diff --git a/DotNet/BuildingOS.IntegrationTest/Tests/OxiGraphImportTest.cs b/DotNet/BuildingOS.IntegrationTest/Tests/OxiGraphImportTest.cs
index 4f1ea9f..94d12d1 100644
--- a/DotNet/BuildingOS.IntegrationTest/Tests/OxiGraphImportTest.cs
+++ b/DotNet/BuildingOS.IntegrationTest/Tests/OxiGraphImportTest.cs
@@ -103,6 +103,9 @@ public async Task ListPointDetails_BuildingScoped_ReturnsPointsJoinedByEquipment
Assert.NotEmpty(details);
Assert.All(details, d => Assert.Equal("floor-1", d.Floor!.Name));
+ // #183: the seed writes sbco:interval "60" on points; the read path must now surface it as
+ // Point.Interval (previously always null because the mapper never projected sbco:interval).
+ Assert.Contains(details, d => d.Point.Interval == 60f);
}
// #181: gateway_id must belong to a single building; import-time validation must reject a duplicate.
diff --git a/DotNet/BuildingOS.Shared.Test/Domain/Configuration/SettingsLogicTest.cs b/DotNet/BuildingOS.Shared.Test/Domain/Configuration/SettingsLogicTest.cs
index 5c29ae5..44f0324 100644
--- a/DotNet/BuildingOS.Shared.Test/Domain/Configuration/SettingsLogicTest.cs
+++ b/DotNet/BuildingOS.Shared.Test/Domain/Configuration/SettingsLogicTest.cs
@@ -103,6 +103,9 @@ public void Registry_FindsSeededKeys_AndNullForUnknown()
{
Assert.NotNull(SettingsRegistry.Find("ui.showExperimentalFeatures"));
Assert.NotNull(SettingsRegistry.Find("telemetry.staleThresholdSeconds"));
+ // telemetry.staleIntervalMultiplier is intentionally NOT registered as an editable setting in
+ // this slice (fixed default 3) — it would be a false affordance until read at runtime (#183).
+ Assert.Null(SettingsRegistry.Find("telemetry.staleIntervalMultiplier"));
Assert.Null(SettingsRegistry.Find("nope"));
}
}
diff --git a/DotNet/BuildingOS.Shared/Domain/Configuration/SettingsRegistry.cs b/DotNet/BuildingOS.Shared/Domain/Configuration/SettingsRegistry.cs
index 30f35b5..cb51c36 100644
--- a/DotNet/BuildingOS.Shared/Domain/Configuration/SettingsRegistry.cs
+++ b/DotNet/BuildingOS.Shared/Domain/Configuration/SettingsRegistry.cs
@@ -19,8 +19,14 @@ public static class SettingsRegistry
Key: "telemetry.staleThresholdSeconds",
Type: SettingType.Number,
DefaultValue: "300",
- Description: "テレメトリを「鮮度切れ」とみなすまでの秒数(閾値)",
+ Description: "テレメトリを「鮮度切れ」とみなすまでの秒数(期待周期が未設定のポイントの既定閾値, #183)",
Category: "telemetry"),
+ // NOTE: the per-point expected-interval multiplier N (threshold = interval × N, #183) is a
+ // fixed default (3, DEFAULT_STALE_INTERVAL_MULTIPLIER on the frontend) in this slice — it is
+ // intentionally NOT exposed as an editable setting here. Editing it would be a false
+ // affordance until the freshness classifier reads it at runtime, which needs an all-role
+ // (non-admin) telemetry-threshold read surface (GET /api/system/settings is admin-only).
+ // Add it back alongside that surface so admin edits actually change stale classification.
};
/// Returns the definition for , or null when it is not allowlisted.
diff --git a/DotNet/BuildingOS.Shared/Infrastructure/OxiGraph/OxiGraphDigitalTwinDatabase.cs b/DotNet/BuildingOS.Shared/Infrastructure/OxiGraph/OxiGraphDigitalTwinDatabase.cs
index 8384eaf..3d5e63f 100644
--- a/DotNet/BuildingOS.Shared/Infrastructure/OxiGraph/OxiGraphDigitalTwinDatabase.cs
+++ b/DotNet/BuildingOS.Shared/Infrastructure/OxiGraph/OxiGraphDigitalTwinDatabase.cs
@@ -189,12 +189,13 @@ public async Task ListDevices(string? spaceDtId)
public async Task GetPoint(string pointId)
{
var sparql = $@"{Prefixes}
-SELECT ?ptDt ?ptId ?ptName ?ptWritable ?devIdBac ?objType ?instNo ?identKey ?identVal ?tagKey ?tagBoolVal WHERE {{
+SELECT ?ptDt ?ptId ?ptName ?ptWritable ?devIdBac ?objType ?instNo ?ptInterval ?identKey ?identVal ?tagKey ?tagBoolVal WHERE {{
?pt a <{Cls_Point}> ; <{Prop_Id}> ?ptId ; <{Prop_Name}> ?ptName .
OPTIONAL {{ ?pt <{Prop_Writable}> ?ptWritable . }}
OPTIONAL {{ ?pt <{Prop_DeviceIdBacnet}> ?devIdBac . }}
OPTIONAL {{ ?pt <{Prop_ObjectTypeBacnet}> ?objType . }}
OPTIONAL {{ ?pt <{Prop_InstanceNoBacnet}> ?instNo . }}
+ OPTIONAL {{ ?pt <{Prop_Interval}> ?ptInterval . }}
FILTER(?ptId = ""{EscapeStringLiteral(pointId)}"")
BIND(?pt AS ?ptDt)
OPTIONAL {{
@@ -263,7 +264,7 @@ public async Task ListPointDetails(string buildingDtId)
// Equipment without sbco:floor or with a mismatched floor literal will not appear.
// SAMPLE aggregates gatewayId across all points of a device for deterministic selection.
var sparql = $@"{Prefixes}
-SELECT ?ptDt ?ptId ?ptName ?ptWritable ?ptSpec ?ptType ?ptGw ?devIdBac ?objType ?instNo
+SELECT ?ptDt ?ptId ?ptName ?ptWritable ?ptSpec ?ptType ?ptGw ?devIdBac ?objType ?instNo ?ptInterval
?floorDt ?floorId ?floorName ?spaceDt ?spaceId ?spaceName ?devDt ?devId ?devName (SAMPLE(?gwRaw) AS ?devGw)
WHERE {{
<{buildingDtId}> <{Prop_HasPart}> ?floor .
@@ -287,8 +288,9 @@ public async Task ListPointDetails(string buildingDtId)
OPTIONAL {{ ?pt <{Prop_DeviceIdBacnet}> ?devIdBac . }}
OPTIONAL {{ ?pt <{Prop_ObjectTypeBacnet}> ?objType . }}
OPTIONAL {{ ?pt <{Prop_InstanceNoBacnet}> ?instNo . }}
+ OPTIONAL {{ ?pt <{Prop_Interval}> ?ptInterval . }}
}}
-GROUP BY ?ptDt ?ptId ?ptName ?ptWritable ?ptSpec ?ptType ?ptGw ?devIdBac ?objType ?instNo
+GROUP BY ?ptDt ?ptId ?ptName ?ptWritable ?ptSpec ?ptType ?ptGw ?devIdBac ?objType ?instNo ?ptInterval
?floorDt ?floorId ?floorName ?spaceDt ?spaceId ?spaceName ?devDt ?devId ?devName";
var rows = await _client.QueryAsync(sparql);
@@ -424,7 +426,7 @@ private async Task CachedQueryAsync(string cacheKey, Func> fac
private static string BuildPointSelect(string? deviceUri) => deviceUri is null
? $@"{Prefixes}
-SELECT ?ptDt ?ptId ?ptName ?ptWritable ?ptSpec ?ptType ?ptGw ?devIdBac ?objType ?instNo
+SELECT ?ptDt ?ptId ?ptName ?ptWritable ?ptSpec ?ptType ?ptGw ?devIdBac ?objType ?instNo ?ptInterval
WHERE {{
?pt a <{Cls_Point}> ; <{Prop_Id}> ?ptId ; <{Prop_Name}> ?ptName .
BIND(?pt AS ?ptDt)
@@ -435,9 +437,10 @@ private static string BuildPointSelect(string? deviceUri) => deviceUri is null
OPTIONAL {{ ?pt <{Prop_DeviceIdBacnet}> ?devIdBac . }}
OPTIONAL {{ ?pt <{Prop_ObjectTypeBacnet}> ?objType . }}
OPTIONAL {{ ?pt <{Prop_InstanceNoBacnet}> ?instNo . }}
+ OPTIONAL {{ ?pt <{Prop_Interval}> ?ptInterval . }}
}}"
: $@"{Prefixes}
-SELECT ?ptDt ?ptId ?ptName ?ptWritable ?ptSpec ?ptType ?ptGw ?devIdBac ?objType ?instNo
+SELECT ?ptDt ?ptId ?ptName ?ptWritable ?ptSpec ?ptType ?ptGw ?devIdBac ?objType ?instNo ?ptInterval
WHERE {{
<{deviceUri}> <{Prop_HasPoint}> ?pt .
?pt a <{Cls_Point}> ; <{Prop_Id}> ?ptId ; <{Prop_Name}> ?ptName .
@@ -449,6 +452,7 @@ private static string BuildPointSelect(string? deviceUri) => deviceUri is null
OPTIONAL {{ ?pt <{Prop_DeviceIdBacnet}> ?devIdBac . }}
OPTIONAL {{ ?pt <{Prop_ObjectTypeBacnet}> ?objType . }}
OPTIONAL {{ ?pt <{Prop_InstanceNoBacnet}> ?instNo . }}
+ OPTIONAL {{ ?pt <{Prop_Interval}> ?ptInterval . }}
}}";
private static BuildingOS.Shared.Point MapPoint(IReadOnlyDictionary r) =>
@@ -464,6 +468,7 @@ private static BuildingOS.Shared.Point MapPoint(IReadOnlyDictionary r) =>
@@ -480,6 +485,14 @@ private static Device MapDevice(IReadOnlyDictionary r) =>
private static int? TryParseNullableInt(string? value)
=> int.TryParse(value, out var parsed) ? parsed : null;
+ // sbco:interval literals are culture-independent (e.g. "60"); parse with the invariant culture so
+ // a comma-decimal locale can't misread them.
+ private static float? TryParseNullableFloat(string? value)
+ => float.TryParse(value, System.Globalization.NumberStyles.Float,
+ System.Globalization.CultureInfo.InvariantCulture, out var parsed)
+ ? parsed
+ : null;
+
// ── Metadata helpers ──────────────────────────────────────────────────────
///
diff --git a/DotNet/BuildingOS.Shared/Infrastructure/OxiGraph/OxiGraphOntology.cs b/DotNet/BuildingOS.Shared/Infrastructure/OxiGraph/OxiGraphOntology.cs
index 035011f..b7c80e1 100644
--- a/DotNet/BuildingOS.Shared/Infrastructure/OxiGraph/OxiGraphOntology.cs
+++ b/DotNet/BuildingOS.Shared/Infrastructure/OxiGraph/OxiGraphOntology.cs
@@ -34,6 +34,9 @@ internal static class OxiGraphOntology
// sbco:floor is a string literal on EquipmentExt used to join equipment to Level by name
internal const string Prop_Floor = SbcoNs + "floor";
+ // Expected telemetry interval (seconds) on PointExt — drives per-point stale detection (#183).
+ internal const string Prop_Interval = SbcoNs + "interval";
+
// Native addressing + unit on PointExt (used by the gateway point-list export, #224).
internal const string Prop_Unit = SbcoNs + "unit";
internal const string Prop_LocalId = SbcoNs + "localId";
diff --git a/docs/oss-sla-freshness.md b/docs/oss-sla-freshness.md
index 49e9bd1..d2d1051 100644
--- a/docs/oss-sla-freshness.md
+++ b/docs/oss-sla-freshness.md
@@ -93,7 +93,55 @@ flush 前の末尾が「過去レンジクエリ」に空くと、直近のチ
---
-## 6. 関連
+## 6. Point 別「鮮度切れ」判定 — 期待周期ベース(#183)
+
+上記 §1–§5 は「イベント→読める」までの**配信**鮮度。本節は運用ダッシュボード(オペレータ ホーム
+`/home`、ポイント詳細)が「そのポイントは**そもそも届いているか**」を判定する **stale 判定**の閾値モデルです。
+
+設備データの期待周期は Point ごとに大きく異なります(室温 1 分 / 電力量 30 分 / 設備状態 5 秒 /
+保守点検値 1 日)。固定 300 秒の一律閾値だと、速いポイントは誤検知し、遅いポイントは検出が遅れます。そこで
+判定閾値を**期待周期から導出**します:
+
+```
+判定閾値 = expectedInterval × N (N = 固定倍率 3。本スライスでは固定既定値で、
+ まだ runtime 設定ではない — 下記フォローアップ参照)
+```
+
+期待周期は以下の階層で解決し、**最初に見つかった値**を使います(most specific first):
+
+```
+point-specific expected interval (Twin / Point metadata の sbco:interval)
+ → device default
+ → gateway default
+ → (無し) ⇒ system default 閾値 telemetry.staleThresholdSeconds(現行の 300s)へフォールバック
+ ※期待周期が無い場合は倍率を掛けず、従来どおり 300s をそのまま用いる
+```
+
+現状 Twin が持つのは **point 単位**(`sbco:interval`, 秒)のみ。device / gateway 既定は Twin に未モデル化
+のため resolver 上は受け口だけ用意し(API 非互換を出さずに後日配線可能)、本スライスでは point + system の
+2 段で稼働します。
+
+### 実装
+
+| レイヤ | 実体 |
+|---|---|
+| 期待周期→閾値(純粋関数) | `web-client/src/lib/telemetry/freshness-threshold.ts`(`resolveExpectedIntervalSeconds` / `resolveStaleThresholdSeconds`, `DEFAULT_STALE_INTERVAL_MULTIPLIER`) |
+| Point 別閾値の適用 | `classifyPointFreshness`(`PointLastSeen.thresholdSeconds` で per-point 上書き)/ `loadPointsFreshness`(期待周期マップ + 倍率から各点の閾値を算出) |
+| 期待周期の供給 | `Point.interval`(aspida `interval`/`sbco:interval`)。Twin seed は既に `sbco:interval` を書き込むが、読み取り経路(OxiGraph mapper / SPARQL projection)が未配線だったのを本スライスで有効化 |
+| 既定値 | 周期未設定時の既定閾値は `SettingsRegistry`(#148)の `telemetry.staleThresholdSeconds`(300, **本スライス以前から存在**)。倍率 N は **固定定数 3**(フロント `DEFAULT_STALE_INTERVAL_MULTIPLIER`)で、editable 設定としては**あえて公開しない**(下記) |
+
+> **フォローアップ**: 倍率 N を editable 設定にしても、鮮度判定が runtime でその値を読むまでは「保存できるが挙動が
+> 変わらない」false affordance になる。反映には非管理者でも読める telemetry 閾値の read サーフェスが必要
+> (`GET /api/system/settings` は admin 限定)。そこで本スライスでは N を `SettingsRegistry` に**登録せず**固定
+> 既定値 3 とし、その read サーフェスを用意する時に (a) `telemetry.staleIntervalMultiplier` をレジストリへ追加し
+> (b) home / ポイント詳細の両方へ同じ値を配線し (c) 倍率を変えると同一 interval/age が stale⇄fresh に変わる回帰を
+> runtime 供給経路で固定する、までをまとめて行う(#148/#176)。`telemetry.staleThresholdSeconds`(既存)の live
+> 反映と device / gateway 既定周期の Twin モデル化も同フォローアップ。現状フロントはこれらを定数
+> (`DEFAULT_STALE_THRESHOLD_SECONDS` / `DEFAULT_STALE_INTERVAL_MULTIPLIER`)で鏡写しにしている。
+
+---
+
+## 7. 関連
- [oss-tier-architecture.md](oss-tier-architecture.md) — Hot/Warm/Cold 階層と Query Router
- [oss-warm-parquet-lake.md](oss-warm-parquet-lake.md) — 既定の Parquet レイク(背景/構成/KPI)
diff --git a/web-client/src/app/(protected)/points/[pointId]/components/telemetry-hot-data.test.tsx b/web-client/src/app/(protected)/points/[pointId]/components/telemetry-hot-data.test.tsx
index 608694c..1d7aea5 100644
--- a/web-client/src/app/(protected)/points/[pointId]/components/telemetry-hot-data.test.tsx
+++ b/web-client/src/app/(protected)/points/[pointId]/components/telemetry-hot-data.test.tsx
@@ -3,7 +3,10 @@ import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { TelemetryHotData } from "./telemetry-hot-data";
-function renderHot(hotData: ValidTelemetryData | null) {
+function renderHot(
+ hotData: ValidTelemetryData | null,
+ expectedIntervalSeconds?: number | null,
+) {
return render(
,
);
}
@@ -35,4 +39,18 @@ describe("TelemetryHotData freshness badge (#158)", () => {
renderHot(null);
expect(screen.queryByTestId(/^freshness-/)).not.toBeInTheDocument();
});
+
+ it("uses the point's expected interval to bucket freshness (#183)", () => {
+ // A fast point (expected 5s → threshold 15s) is stale at 60s old, even though the 300s default
+ // would still call it fresh.
+ renderHot({ datetime: iso(60), value: 21.5 }, 5);
+ expect(screen.getByTestId("freshness-stale")).toBeInTheDocument();
+ });
+
+ it("keeps a slow point fresh past the 300s default when its interval is long (#183)", () => {
+ // Expected daily (86400s → threshold 259200s): a 100000s-old sample is stale by the default but
+ // fresh once the point's own interval is honored.
+ renderHot({ datetime: iso(100_000), value: 21.5 }, 86_400);
+ expect(screen.getByTestId("freshness-fresh")).toBeInTheDocument();
+ });
});
diff --git a/web-client/src/app/(protected)/points/[pointId]/components/telemetry-hot-data.tsx b/web-client/src/app/(protected)/points/[pointId]/components/telemetry-hot-data.tsx
index 840aad9..f3ea69f 100644
--- a/web-client/src/app/(protected)/points/[pointId]/components/telemetry-hot-data.tsx
+++ b/web-client/src/app/(protected)/points/[pointId]/components/telemetry-hot-data.tsx
@@ -4,6 +4,7 @@ import {
DEFAULT_STALE_THRESHOLD_SECONDS,
classifyPointFreshness,
} from "@/lib/telemetry/freshness";
+import { resolveStaleThresholdSeconds } from "@/lib/telemetry/freshness-threshold";
import { unitLabelMap } from "@/lib/utils/helper/telemetry-helper";
import { ArrowPathIcon } from "@heroicons/react/24/outline";
import { useMemo } from "react";
@@ -16,6 +17,7 @@ export function TelemetryHotData({
scale = 1,
unit,
labels,
+ expectedIntervalSeconds,
}: {
hotData: ValidTelemetryData | null;
hotLoading: boolean;
@@ -24,6 +26,8 @@ export function TelemetryHotData({
scale?: number;
unit?: string;
labels?: string;
+ /** Expected telemetry interval (seconds, sbco:interval) driving this point's stale threshold (#183). */
+ expectedIntervalSeconds?: number | null;
}) {
const splitLabels = labels ? labels.split(",") : null;
@@ -38,15 +42,21 @@ export function TelemetryHotData({
return `${hotData.value * scale} ${unit ? (unitLabelMap[unit] ?? unit) : ""}`;
}, [hotData, scale, splitLabels, unit]);
- // Freshness of the latest sample, evaluated against the current time on each render. Uses the
- // registry default threshold (300s); wiring the live telemetry.staleThresholdSeconds setting is a
- // follow-up. (Not wrapped in useMemo: `new Date()` is impure, so memoizing it is neither safe nor
+ // Freshness of the latest sample, evaluated against the current time on each render. The stale
+ // threshold is derived from this point's expected interval (`interval × N`, #183) with N a fixed
+ // default (3), falling back to the default 300s when the twin has no expected interval. Making N /
+ // the default threshold live per-role runtime settings is a follow-up (needs a non-admin read
+ // surface). (Not wrapped in useMemo: `new Date()` is impure, so memoizing it is neither safe nor
// worthwhile — the classify call is a single cheap comparison.)
+ const thresholdSeconds = resolveStaleThresholdSeconds({
+ expected: { point: expectedIntervalSeconds },
+ systemDefaultThresholdSeconds: DEFAULT_STALE_THRESHOLD_SECONDS,
+ });
const freshness = hotData?.datetime
? classifyPointFreshness(
[{ pointId: "", lastSeen: hotData.datetime }],
new Date(),
- DEFAULT_STALE_THRESHOLD_SECONDS,
+ thresholdSeconds,
)[0]
: null;
diff --git a/web-client/src/app/(protected)/points/[pointId]/page-component.tsx b/web-client/src/app/(protected)/points/[pointId]/page-component.tsx
index 89fbb9d..41ecb46 100644
--- a/web-client/src/app/(protected)/points/[pointId]/page-component.tsx
+++ b/web-client/src/app/(protected)/points/[pointId]/page-component.tsx
@@ -209,6 +209,7 @@ export default function PointDetailPageComponent({
scale={pointDetail.point.scale ?? undefined}
unit={pointDetail.point.unit ?? undefined}
labels={pointDetail.point.labels ?? undefined}
+ expectedIntervalSeconds={pointDetail.point.interval ?? undefined}
/>
diff --git a/web-client/src/components/home/operator-home.tsx b/web-client/src/components/home/operator-home.tsx
index 05bb5cf..821fdf5 100644
--- a/web-client/src/components/home/operator-home.tsx
+++ b/web-client/src/components/home/operator-home.tsx
@@ -1,7 +1,7 @@
"use client";
import { fetchGateways as defaultFetchGateways } from "@/lib/admin/gateways";
-import { buildAttentionList } from "@/lib/home/aggregate";
+import { buildAttentionList, type NamedPoint } from "@/lib/home/aggregate";
import type { HomeLoaders } from "@/lib/home/loaders";
import type { ResourceRef } from "@/lib/resources/types";
import {
@@ -34,7 +34,7 @@ export function OperatorHome({
const [floors, setFloors] = useState([]);
const [floorDtId, setFloorDtId] = useState(null);
const [freshness, setFreshness] = useState([]);
- const [named, setNamed] = useState<{ pointId: string; name: string }[]>([]);
+ const [named, setNamed] = useState([]);
const [loadingFloor, setLoadingFloor] = useState(false);
const [error, setError] = useState(null);
@@ -93,7 +93,7 @@ export function OperatorHome({
const points = await loaders.loadFloorPoints(floorDtId);
if (!active) return;
setNamed(points);
- const fresh = await loaders.loadFreshness(points.map((p) => p.pointId));
+ const fresh = await loaders.loadFreshness(points);
if (!active) return;
setFreshness(fresh);
})()
diff --git a/web-client/src/lib/home/aggregate.ts b/web-client/src/lib/home/aggregate.ts
index dc65b88..eeb3901 100644
--- a/web-client/src/lib/home/aggregate.ts
+++ b/web-client/src/lib/home/aggregate.ts
@@ -9,6 +9,8 @@ export type NamedPoint = {
name: string;
deviceName?: string;
spaceName?: string;
+ /** Expected telemetry interval (seconds) for per-point stale detection (#183); undefined = unknown. */
+ expectedIntervalSeconds?: number | null;
};
/** A point needing operator attention: stale (old) or missing (never/unparseable). */
diff --git a/web-client/src/lib/home/loaders.ts b/web-client/src/lib/home/loaders.ts
index f01833c..d4a4c6d 100644
--- a/web-client/src/lib/home/loaders.ts
+++ b/web-client/src/lib/home/loaders.ts
@@ -8,6 +8,7 @@ import {
import type { ResourceRef } from "@/lib/resources/types";
import { DEFAULT_STALE_THRESHOLD_SECONDS, type PointFreshness } from "@/lib/telemetry/freshness";
import { loadPointsFreshness } from "@/lib/telemetry/freshness-loader";
+import { DEFAULT_STALE_INTERVAL_MULTIPLIER } from "@/lib/telemetry/freshness-threshold";
import type { NamedPoint } from "./aggregate";
/**
@@ -21,8 +22,11 @@ export type HomeLoaders = {
loadFloors: (buildingDtId: string) => Promise;
/** All points under a floor (space → device → point traversal), as id+name pairs. */
loadFloorPoints: (floorDtId: string) => Promise;
- /** Per-point freshness for the given point ids (fans out latest-sample fetches). */
- loadFreshness: (pointIds: string[]) => Promise;
+ /**
+ * Per-point freshness for the given points. Takes the {@link NamedPoint}s (not bare ids) so each
+ * point's expected interval drives its own stale threshold (#183).
+ */
+ loadFreshness: (points: NamedPoint[]) => Promise;
};
/**
@@ -48,6 +52,7 @@ export const productionHomeLoaders: HomeLoaders = {
name: p.name,
deviceName: d.name,
spaceName: s.name,
+ expectedIntervalSeconds: p.expectedIntervalSeconds,
}));
}),
);
@@ -56,9 +61,21 @@ export const productionHomeLoaders: HomeLoaders = {
);
return perSpace.flat();
},
- loadFreshness: (pointIds) =>
- loadPointsFreshness(pointIds, {
- now: new Date(),
- thresholdSeconds: DEFAULT_STALE_THRESHOLD_SECONDS,
- }),
+ loadFreshness: (points) =>
+ loadPointsFreshness(
+ points.map((p) => p.pointId),
+ {
+ now: new Date(),
+ // System-default fallback for points with no expected interval. The multiplier is a fixed
+ // constant this slice (not an editable setting — that would be a false affordance until read
+ // at runtime, #183); wiring a live all-role telemetry-threshold surface (both the default
+ // threshold and the multiplier) stays a follow-up (#148/#176). The constants below match the
+ // backend defaults.
+ thresholdSeconds: DEFAULT_STALE_THRESHOLD_SECONDS,
+ intervalMultiplier: DEFAULT_STALE_INTERVAL_MULTIPLIER,
+ expectedIntervalSeconds: new Map(
+ points.map((p) => [p.pointId, p.expectedIntervalSeconds]),
+ ),
+ },
+ ),
};
diff --git a/web-client/src/lib/resources/mapping.test.ts b/web-client/src/lib/resources/mapping.test.ts
index 39eb506..ebec276 100644
--- a/web-client/src/lib/resources/mapping.test.ts
+++ b/web-client/src/lib/resources/mapping.test.ts
@@ -32,6 +32,7 @@ describe("toPointResource", () => {
labels: null,
specification: null,
kind: null,
+ expectedIntervalSeconds: null,
});
});
@@ -46,12 +47,14 @@ describe("toPointResource", () => {
labels: "a,b",
specification: "spec",
type: "analog",
+ interval: 60,
};
const r = toPointResource(p);
expect(r.writable).toBe(true);
expect(r.unit).toBe("°C");
expect(r.scale).toBe(0.1);
expect(r.kind).toBe("analog");
+ expect(r.expectedIntervalSeconds).toBe(60);
});
});
diff --git a/web-client/src/lib/resources/mapping.ts b/web-client/src/lib/resources/mapping.ts
index 02066b0..7e9708b 100644
--- a/web-client/src/lib/resources/mapping.ts
+++ b/web-client/src/lib/resources/mapping.ts
@@ -28,6 +28,7 @@ export function toPointResource(p: Point): PointResource {
labels: p.labels ?? null,
specification: p.specification ?? null,
kind: p.type ?? null,
+ expectedIntervalSeconds: p.interval ?? null,
};
}
diff --git a/web-client/src/lib/resources/types.ts b/web-client/src/lib/resources/types.ts
index 4210f97..dc9aaa6 100644
--- a/web-client/src/lib/resources/types.ts
+++ b/web-client/src/lib/resources/types.ts
@@ -26,6 +26,11 @@ export type PointResource = ResourceRef & {
specification: string | null;
/** The point's measurement kind (aspida `Point.type`), renamed to avoid clashing with `type`. */
kind: string | null;
+ /**
+ * Expected telemetry interval in seconds (aspida `Point.interval`, sbco:interval). Drives per-point
+ * stale detection (#183); null when the twin has no expected interval for this point.
+ */
+ expectedIntervalSeconds: number | null;
};
/** One cross-resource search match. */
diff --git a/web-client/src/lib/telemetry/freshness-loader.test.ts b/web-client/src/lib/telemetry/freshness-loader.test.ts
index 515f0fd..5606032 100644
--- a/web-client/src/lib/telemetry/freshness-loader.test.ts
+++ b/web-client/src/lib/telemetry/freshness-loader.test.ts
@@ -72,6 +72,34 @@ describe("loadPointsFreshness", () => {
expect(fetchLatestBatch).toHaveBeenCalledWith(["A", "B", "C"]);
});
+ it("derives per-point thresholds from expected intervals × multiplier (#183)", async () => {
+ // FAST expects data every 5s (threshold 5×3=15s) → 120s old is stale.
+ // SLOW expects data every 1h (threshold 3600×3=10800s) → 120s old is still fresh.
+ // NOINT has no expected interval → the system default (300s) applies → 120s old is fresh.
+ const fetchLatestBatch = vi.fn().mockResolvedValue([
+ { pointId: "FAST", lastSeen: ago(120) },
+ { pointId: "SLOW", lastSeen: ago(120) },
+ { pointId: "NOINT", lastSeen: ago(120) },
+ ] satisfies PointLastSeen[]);
+
+ const results = await loadPointsFreshness(["FAST", "SLOW", "NOINT"], {
+ now: NOW,
+ thresholdSeconds: 300,
+ intervalMultiplier: 3,
+ expectedIntervalSeconds: new Map([
+ ["FAST", 5],
+ ["SLOW", 3600],
+ ]),
+ fetchLatestBatch,
+ });
+
+ expect(results.map((r) => [r.pointId, r.status])).toEqual([
+ ["FAST", "stale"],
+ ["SLOW", "fresh"],
+ ["NOINT", "fresh"],
+ ]);
+ });
+
it("returns an empty array for no points without calling the fetcher", async () => {
const fetchLatestBatch = vi.fn();
expect(
diff --git a/web-client/src/lib/telemetry/freshness-loader.ts b/web-client/src/lib/telemetry/freshness-loader.ts
index 700d42b..36819c5 100644
--- a/web-client/src/lib/telemetry/freshness-loader.ts
+++ b/web-client/src/lib/telemetry/freshness-loader.ts
@@ -3,6 +3,7 @@ import {
type PointFreshness,
type PointLastSeen,
} from "./freshness";
+import { resolveStaleThresholdSeconds } from "./freshness-threshold";
import { latestTelemetryBatch } from "./repository";
/** Fetches the latest-sample timestamps for many points at once. Injectable so the loader is testable offline. */
@@ -10,7 +11,16 @@ export type LatestBatchFetcher = (pointIds: string[]) => Promise;
+ /** Multiplier N for `expectedInterval → threshold`. Defaults to the registry default (3). */
+ intervalMultiplier?: number;
/** Batch latest-sample fetcher; defaults to the telemetry repository façade (#182). */
fetchLatestBatch?: LatestBatchFetcher;
};
@@ -23,6 +33,10 @@ export type LoadPointsFreshnessOptions = {
* concurrent browser requests. A point the batch *omits* (a non-admin cannot read it) is classified
* `missing`, matching {@link classifyPointFreshness}'s "no sample ⇒ missing" rule.
*
+ * Each point's stale threshold is derived from its expected telemetry interval when known
+ * (`interval × intervalMultiplier`, #183), falling back to `thresholdSeconds`; see
+ * {@link resolveStaleThresholdSeconds}.
+ *
* A *failed* fetch, by contrast, is rethrown rather than masked as all-missing (#182 review): the
* caller (operator home) must be able to tell "the data is unavailable" apart from "the points
* genuinely have no data", so it can show an error instead of a fleet of false 欠測.
@@ -32,6 +46,8 @@ export async function loadPointsFreshness(
{
now,
thresholdSeconds,
+ expectedIntervalSeconds,
+ intervalMultiplier,
fetchLatestBatch = (ids) => latestTelemetryBatch(ids),
}: LoadPointsFreshnessOptions,
): Promise {
@@ -43,6 +59,13 @@ export async function loadPointsFreshness(
const lastSeen: PointLastSeen[] = pointIds.map((pointId) => ({
pointId,
lastSeen: seen.get(pointId) ?? null,
+ thresholdSeconds: expectedIntervalSeconds
+ ? resolveStaleThresholdSeconds({
+ expected: { point: expectedIntervalSeconds.get(pointId) },
+ multiplier: intervalMultiplier,
+ systemDefaultThresholdSeconds: thresholdSeconds,
+ })
+ : undefined,
}));
return classifyPointFreshness(lastSeen, now, thresholdSeconds);
}
diff --git a/web-client/src/lib/telemetry/freshness-threshold.test.ts b/web-client/src/lib/telemetry/freshness-threshold.test.ts
new file mode 100644
index 0000000..51dcf55
--- /dev/null
+++ b/web-client/src/lib/telemetry/freshness-threshold.test.ts
@@ -0,0 +1,95 @@
+import { describe, expect, it } from "vitest";
+import {
+ DEFAULT_STALE_INTERVAL_MULTIPLIER,
+ resolveExpectedIntervalSeconds,
+ resolveStaleThresholdSeconds,
+} from "./freshness-threshold";
+
+describe("resolveExpectedIntervalSeconds", () => {
+ it("prefers the point-specific interval over device/gateway", () => {
+ expect(
+ resolveExpectedIntervalSeconds({ point: 60, device: 120, gateway: 300 }),
+ ).toBe(60);
+ });
+
+ it("falls back point → device → gateway in order", () => {
+ expect(resolveExpectedIntervalSeconds({ device: 120, gateway: 300 })).toBe(
+ 120,
+ );
+ expect(resolveExpectedIntervalSeconds({ gateway: 300 })).toBe(300);
+ });
+
+ it("returns null when no tier supplies a usable interval", () => {
+ expect(resolveExpectedIntervalSeconds({})).toBeNull();
+ expect(
+ resolveExpectedIntervalSeconds({ point: null, device: undefined }),
+ ).toBeNull();
+ });
+
+ it("ignores non-positive, NaN, or non-finite intervals and continues down the hierarchy", () => {
+ // A zero/negative expected interval is meaningless (would make everything stale); skip it.
+ expect(resolveExpectedIntervalSeconds({ point: 0, device: 90 })).toBe(90);
+ expect(resolveExpectedIntervalSeconds({ point: -5, gateway: 30 })).toBe(30);
+ expect(
+ resolveExpectedIntervalSeconds({ point: Number.NaN, device: 45 }),
+ ).toBe(45);
+ expect(
+ resolveExpectedIntervalSeconds({ point: Number.POSITIVE_INFINITY }),
+ ).toBeNull();
+ });
+});
+
+describe("resolveStaleThresholdSeconds", () => {
+ it("multiplies the resolved expected interval by N (default 3)", () => {
+ expect(
+ resolveStaleThresholdSeconds({
+ expected: { point: 60 },
+ systemDefaultThresholdSeconds: 300,
+ }),
+ ).toBe(180);
+ });
+
+ it("honors a custom multiplier", () => {
+ expect(
+ resolveStaleThresholdSeconds({
+ expected: { point: 30 },
+ multiplier: 5,
+ systemDefaultThresholdSeconds: 300,
+ }),
+ ).toBe(150);
+ });
+
+ it("falls back to the system default threshold when no expected interval is known", () => {
+ // No point/device/gateway interval → keep today's behaviour (the 300s registry default),
+ // NOT default×multiplier — the multiplier only applies to a real expected interval.
+ expect(
+ resolveStaleThresholdSeconds({
+ expected: {},
+ systemDefaultThresholdSeconds: 300,
+ }),
+ ).toBe(300);
+ });
+
+ it("uses the resolved tier (device) when point is absent", () => {
+ expect(
+ resolveStaleThresholdSeconds({
+ expected: { device: 600 },
+ systemDefaultThresholdSeconds: 300,
+ }),
+ ).toBe(1800);
+ });
+
+ it("guards against a non-positive multiplier by reverting to the default", () => {
+ expect(
+ resolveStaleThresholdSeconds({
+ expected: { point: 60 },
+ multiplier: 0,
+ systemDefaultThresholdSeconds: 300,
+ }),
+ ).toBe(60 * DEFAULT_STALE_INTERVAL_MULTIPLIER);
+ });
+
+ it("exposes the default multiplier constant (3), mirroring the registry default", () => {
+ expect(DEFAULT_STALE_INTERVAL_MULTIPLIER).toBe(3);
+ });
+});
diff --git a/web-client/src/lib/telemetry/freshness-threshold.ts b/web-client/src/lib/telemetry/freshness-threshold.ts
new file mode 100644
index 0000000..c8c0e66
--- /dev/null
+++ b/web-client/src/lib/telemetry/freshness-threshold.ts
@@ -0,0 +1,85 @@
+/**
+ * Pure resolution of a point's stale threshold from its **expected telemetry interval** (#183).
+ *
+ * Equipment data has wildly different natural cadences (室温 1 分 / 電力量 30 分 / 設備状態 5 秒 /
+ * 保守点検値 1 日), so a single fixed 300s stale threshold either false-alarms fast points or misses
+ * slow ones. Instead we derive the threshold from each point's expected interval:
+ *
+ * threshold = expectedInterval × N (N = {@link DEFAULT_STALE_INTERVAL_MULTIPLIER})
+ *
+ * The expected interval itself is resolved through a hierarchy, most specific first:
+ *
+ * point-specific (Twin / Point metadata, sbco:interval)
+ * → device default
+ * → gateway default
+ * → (none) ⇒ fall back to the system-default stale threshold (the historical 300s registry
+ * value from `telemetry.staleThresholdSeconds`) — the multiplier does NOT apply here
+ * because there is no interval to multiply, preserving today's behaviour for points
+ * that have no expected interval configured.
+ *
+ * Only the point tier is currently populated from the twin; `device` / `gateway` are accepted so the
+ * hierarchy can be wired later (their twin defaults do not exist yet) without an API change.
+ *
+ * Both functions are pure and fully unit-testable.
+ */
+
+/**
+ * Fixed stale-interval multiplier N (`threshold = interval × N`) for this slice (#183). It is a
+ * constant, not yet a runtime setting: making it admin-editable is deferred until an all-role
+ * telemetry-threshold read surface exists, so the setting is not exposed in `SettingsRegistry` (an
+ * editable-but-ignored value would be a false affordance). `resolveStaleThresholdSeconds` still
+ * accepts a `multiplier` param so that surface can supply it later without an API change.
+ */
+export const DEFAULT_STALE_INTERVAL_MULTIPLIER = 3;
+
+/**
+ * The tiers of the expected-interval fallback, in priority order. Each is the point's expected
+ * telemetry interval **in seconds** at that scope, or null/undefined when unset at that scope.
+ */
+export type ExpectedIntervalSources = {
+ point?: number | null;
+ device?: number | null;
+ gateway?: number | null;
+};
+
+/** A usable expected interval is a finite, strictly-positive number of seconds. */
+function isUsableInterval(v: number | null | undefined): v is number {
+ return typeof v === "number" && Number.isFinite(v) && v > 0;
+}
+
+/**
+ * Resolve the effective expected interval (seconds) by walking the point → device → gateway
+ * hierarchy and returning the first tier that supplies a usable (finite, positive) value. Returns
+ * null when no tier does, so the caller can fall back to the system-default threshold.
+ */
+export function resolveExpectedIntervalSeconds(
+ sources: ExpectedIntervalSources,
+): number | null {
+ for (const tier of [sources.point, sources.device, sources.gateway]) {
+ if (isUsableInterval(tier)) return tier;
+ }
+ return null;
+}
+
+/**
+ * Resolve a point's stale threshold (seconds). When an expected interval is known (any tier),
+ * `interval × multiplier`; otherwise the caller's system-default threshold. A non-positive or
+ * non-finite multiplier reverts to {@link DEFAULT_STALE_INTERVAL_MULTIPLIER}.
+ */
+export function resolveStaleThresholdSeconds({
+ expected,
+ multiplier = DEFAULT_STALE_INTERVAL_MULTIPLIER,
+ systemDefaultThresholdSeconds,
+}: {
+ expected: ExpectedIntervalSources;
+ multiplier?: number;
+ systemDefaultThresholdSeconds: number;
+}): number {
+ const interval = resolveExpectedIntervalSeconds(expected);
+ if (interval === null) return systemDefaultThresholdSeconds;
+
+ const n = isUsableInterval(multiplier)
+ ? multiplier
+ : DEFAULT_STALE_INTERVAL_MULTIPLIER;
+ return interval * n;
+}
diff --git a/web-client/src/lib/telemetry/freshness.test.ts b/web-client/src/lib/telemetry/freshness.test.ts
index f0d4e5a..3e85d01 100644
--- a/web-client/src/lib/telemetry/freshness.test.ts
+++ b/web-client/src/lib/telemetry/freshness.test.ts
@@ -113,6 +113,32 @@ describe("classifyPointFreshness", () => {
it("exposes the backend registry default (300s) as a shared constant", () => {
expect(DEFAULT_STALE_THRESHOLD_SECONDS).toBe(300);
});
+
+ it("uses a point's own thresholdSeconds over the default when provided (#183)", () => {
+ // A fast point (expected 5s → threshold 15s) is stale at 120s even though the 300s default
+ // would still call it fresh; a slow point (expected 1d) stays fresh at the same age.
+ const points: PointLastSeen[] = [
+ { pointId: "FAST", lastSeen: ago(120), thresholdSeconds: 15 },
+ { pointId: "SLOW", lastSeen: ago(120), thresholdSeconds: 86_400 },
+ ];
+ const results = classifyPointFreshness(points, NOW, 300);
+ expect(results.map((r) => [r.pointId, r.status])).toEqual([
+ ["FAST", "stale"],
+ ["SLOW", "fresh"],
+ ]);
+ });
+
+ it("falls back to the default threshold for points without their own thresholdSeconds", () => {
+ const points: PointLastSeen[] = [
+ { pointId: "OVERRIDE", lastSeen: ago(120), thresholdSeconds: 60 },
+ { pointId: "DEFAULT", lastSeen: ago(120) },
+ ];
+ const results = classifyPointFreshness(points, NOW, 300);
+ expect(results.map((r) => [r.pointId, r.status])).toEqual([
+ ["OVERRIDE", "stale"],
+ ["DEFAULT", "fresh"],
+ ]);
+ });
});
describe("summarizeFreshness", () => {
diff --git a/web-client/src/lib/telemetry/freshness.ts b/web-client/src/lib/telemetry/freshness.ts
index 3c2f3ed..3699fb9 100644
--- a/web-client/src/lib/telemetry/freshness.ts
+++ b/web-client/src/lib/telemetry/freshness.ts
@@ -19,6 +19,12 @@ export type FreshnessStatus = "fresh" | "stale" | "missing";
export type PointLastSeen = {
pointId: string;
lastSeen: string | null;
+ /**
+ * Per-point stale threshold in seconds (#183). When set, it overrides the `thresholdSeconds`
+ * argument for this point — this is how an expected-interval-derived threshold is applied to each
+ * point individually. Omit to use the caller's shared default.
+ */
+ thresholdSeconds?: number;
};
export type PointFreshness = {
@@ -38,10 +44,13 @@ export type FreshnessSummary = {
/**
* Classify each point's freshness relative to `now`:
* - `missing` — no sample, or an unparseable timestamp,
- * - `stale` — the last sample is strictly older than `thresholdSeconds`,
+ * - `stale` — the last sample is strictly older than the point's threshold,
* - `fresh` — otherwise (the threshold boundary itself, and future timestamps from clock skew,
* count as fresh).
*
+ * `thresholdSeconds` is the shared default; a point may override it with its own
+ * {@link PointLastSeen.thresholdSeconds} (the expected-interval-derived threshold, #183).
+ *
* Input order is preserved. The comparison uses milliseconds so the boundary is exact; the reported
* `ageSeconds` is floored to whole seconds for display.
*/
@@ -51,9 +60,8 @@ export function classifyPointFreshness(
thresholdSeconds: number,
): PointFreshness[] {
const nowMs = now.getTime();
- const thresholdMs = thresholdSeconds * 1000;
- return points.map(({ pointId, lastSeen }) => {
+ return points.map(({ pointId, lastSeen, thresholdSeconds: perPoint }) => {
if (lastSeen === null) {
return { pointId, status: "missing", ageSeconds: null };
}
@@ -63,6 +71,7 @@ export function classifyPointFreshness(
return { pointId, status: "missing", ageSeconds: null };
}
+ const thresholdMs = (perPoint ?? thresholdSeconds) * 1000;
const ageMs = nowMs - lastSeenMs;
const status: FreshnessStatus = ageMs > thresholdMs ? "stale" : "fresh";
// Future timestamps (ageMs < 0) floor toward 0 rather than showing a negative age.