Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
};

/// <summary>Returns the definition for <paramref name="key"/>, or null when it is not allowlisted.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,12 +189,13 @@ public async Task<Device[]> ListDevices(string? spaceDtId)
public async Task<BuildingOS.Shared.Point?> 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 {{
Expand Down Expand Up @@ -263,7 +264,7 @@ public async Task<PointDetail[]> 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 .
Expand All @@ -287,8 +288,9 @@ public async Task<PointDetail[]> 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);
Expand Down Expand Up @@ -424,7 +426,7 @@ private async Task<T[]> CachedQueryAsync<T>(string cacheKey, Func<Task<T[]>> 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)
Expand All @@ -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 .
Expand All @@ -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<string, string> r) =>
Expand All @@ -464,6 +468,7 @@ private static BuildingOS.Shared.Point MapPoint(IReadOnlyDictionary<string, stri
DeviceIdBacnet = r.GetValueOrDefault("devIdBac"),
ObjectTypeBacnet = r.GetValueOrDefault("objType"),
InstanceNoBacnet = TryParseNullableInt(r.GetValueOrDefault("instNo")),
Interval = TryParseNullableFloat(r.GetValueOrDefault("ptInterval")),
};

private static Device MapDevice(IReadOnlyDictionary<string, string> r) =>
Expand All @@ -480,6 +485,14 @@ private static Device MapDevice(IReadOnlyDictionary<string, string> 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 ──────────────────────────────────────────────────────

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
50 changes: 49 additions & 1 deletion docs/oss-sla-freshness.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,18 @@ 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(
<TelemetryHotData
hotData={hotData}
hotLoading={false}
onRefresh={vi.fn()}
onDownloadClick={vi.fn()}
unit="degC"
expectedIntervalSeconds={expectedIntervalSeconds}
/>,
);
}
Expand All @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -16,6 +17,7 @@ export function TelemetryHotData({
scale = 1,
unit,
labels,
expectedIntervalSeconds,
}: {
hotData: ValidTelemetryData | null;
hotLoading: boolean;
Expand All @@ -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;

Expand All @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
</div>
</div>
Expand Down
6 changes: 3 additions & 3 deletions web-client/src/components/home/operator-home.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -34,7 +34,7 @@ export function OperatorHome({
const [floors, setFloors] = useState<ResourceRef[]>([]);
const [floorDtId, setFloorDtId] = useState<string | null>(null);
const [freshness, setFreshness] = useState<PointFreshness[]>([]);
const [named, setNamed] = useState<{ pointId: string; name: string }[]>([]);
const [named, setNamed] = useState<NamedPoint[]>([]);
const [loadingFloor, setLoadingFloor] = useState(false);
const [error, setError] = useState<string | null>(null);

Expand Down Expand Up @@ -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);
})()
Expand Down
2 changes: 2 additions & 0 deletions web-client/src/lib/home/aggregate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
Loading
Loading