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.