diff --git a/README.md b/README.md index ebe47c1..cb2f30f 100644 --- a/README.md +++ b/README.md @@ -58,10 +58,10 @@ What else is implemented: ISA 18.2 alarm handling keeps the list honest, but a utility outage still annunciates six alarms in twelve seconds: utility failure, both UPS systems on battery, the generator running, the transfer switch on the emergency source, and the generator frequency dip when the load lands. Only one of those is the thing that happened. -The **Triage** tab hands the alarm journal and trend history for the window around the latest event to the [BMS Alarm Triage Agent](https://github.com/AddisonTech/bms-alarm-triage-agent), version 0.2.1, pinned in `backend/requirements.txt` to the commit that release was merged as, and shows what comes back: +The **Triage** tab hands the alarm journal and trend history for the window around the latest event to the [BMS Alarm Triage Agent](https://github.com/AddisonTech/bms-alarm-triage-agent), version 0.2.2, pinned in `backend/requirements.txt` to the commit that release was merged as, and shows what comes back: - **Root cause first.** The agent's electrical rule set ranks the cause and folds its consequences under it. A utility failure explains UPS on battery, the generator start, the ATS transfer, and the frequency dip; a voltage sag explains the UPS ride through; a PDU overload explains its own lower stage and the load alarm on the UPS that feeds it; low power factor stands on its own. -- **Trend evidence** for every item, the stretch it spent past its limit, and the rule that ranked it. +- **Trend evidence** for every item, the stretch it spent past its limit, and the rule that ranked it. The generator frequency dip is measured from the load step at the transfer, once the generator is at rated frequency, never across its run up from standstill: worst frequency, how far below 60 Hz, and how long until it was back within 0.5 Hz. - **A next step**, always a diagnostic one: a verb from a closed list, the item's own equipment as the target, never a control action. - **Who wrote the text.** A fixed template writes it from each item's own evidence. Claude Haiku writes it only when the server has `TRIAGE_ANTHROPIC_KEY` set, and the same checks apply to every reply either way. The hosted demo has no key, so the demo shows the template. @@ -150,7 +150,7 @@ Demo scenarios: | API and WebSocket | Python 3.13, FastAPI 0.111, Uvicorn | | Storage | SQLite (WAL mode) | | Field protocol | Modbus TCP via pymodbus (optional) | -| Alarm triage | BMS Alarm Triage Agent 0.2.1, pinned to its release commit | +| Alarm triage | BMS Alarm Triage Agent 0.2.2, pinned to its release commit | | Front end | React 18, Vite 5, React Router 6 | | Charts and icons | Recharts (trend pages), inline SVG, Lucide | | Tests | pytest, FastAPI TestClient, Vitest | diff --git a/backend/config.py b/backend/config.py index 8c31e4b..a853b35 100644 --- a/backend/config.py +++ b/backend/config.py @@ -6,7 +6,7 @@ import os -VERSION = "1.2.1" +VERSION = "1.2.2" def _env_bool(name: str, default: bool) -> bool: diff --git a/backend/requirements.txt b/backend/requirements.txt index 55ec024..a90ef99 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,4 @@ fastapi==0.111.0 uvicorn[standard]==0.29.0 pydantic==2.14.1 -bms-alarm-triage-agent @ git+https://github.com/AddisonTech/bms-alarm-triage-agent@76357a837ad98eafb1df2352696d9a3739ff1bc1 +bms-alarm-triage-agent @ git+https://github.com/AddisonTech/bms-alarm-triage-agent@1c32b20747dd204f96ae25e3006151f8b178bb80 diff --git a/backend/tests/test_api_websocket.py b/backend/tests/test_api_websocket.py index 1d019fd..d4216ba 100644 --- a/backend/tests/test_api_websocket.py +++ b/backend/tests/test_api_websocket.py @@ -26,7 +26,7 @@ def test_health(client): body = client.get("/api/health").json() assert body["status"] == "ok" assert body["data_source"] == "simulator" - assert body["version"] == "1.2.1" + assert body["version"] == "1.2.2" def test_websocket_sends_snapshot_on_connect(client): diff --git a/backend/tests/test_triage_api.py b/backend/tests/test_triage_api.py index 7273800..3b5dbc3 100644 --- a/backend/tests/test_triage_api.py +++ b/backend/tests/test_triage_api.py @@ -21,7 +21,7 @@ def test_export_returns_the_journal_format(client): assert res.headers["content-disposition"].startswith('attachment; filename="alarm-journal-') doc = res.json() assert doc["format"] == "bms-triage-journal" and doc["version"] == 1 - assert doc["source"] == "power-dashboard 1.2.1" + assert doc["source"] == "power-dashboard 1.2.2" parsed = agent_journal.parse(doc) assert parsed.points and parsed.series @@ -129,7 +129,7 @@ def test_the_agent_is_pinned_to_one_release(): def test_the_installed_agent_is_0_2(): import bms_alarm_triage - assert bms_alarm_triage.__version__ == "0.2.1" + assert bms_alarm_triage.__version__ == "0.2.2" @pytest.mark.parametrize("zone_name", ["America/New_York", "UTC"]) diff --git a/backend/tests/test_triage_export.py b/backend/tests/test_triage_export.py index 4d42732..fcc5852 100644 --- a/backend/tests/test_triage_export.py +++ b/backend/tests/test_triage_export.py @@ -261,3 +261,26 @@ def test_a_bad_zone_is_refused(): triage.check_zone("Mars/Olympus") assert triage.check_zone(None) == "UTC" assert triage.check_zone("") == "UTC" + + +# ------------------------------------------------- the generator frequency dip + +def test_the_outage_dip_is_the_load_step_not_the_crank(): + """The simulator's generator cranks through 0, 20 and 40 Hz before it is + at rated. The dip reported is the one at the transfer, after rated.""" + from datetime import datetime + + _c, now = drive("utility_outage", duration=110) + out = triage.run(now, time_zone="UTC") + item = out["triage"]["items"][0] + dip_item = next(c for c in item["consequences"] if c["signal"] == "frequency_low") + ats = next(c for c in item["consequences"] if c["signal"] == "ats_transfer") + dip = dip_item["evidence"]["dip"] + assert dip["measured"] and dip["basis"] == "ats_transfer" + assert datetime.fromisoformat(dip["step_at"]) == datetime.fromisoformat(ats["start"]) + assert datetime.fromisoformat(dip["rated_at"]) <= datetime.fromisoformat(dip["step_at"]) + assert 58.0 < dip["worst_hz"] < 59.5 + assert 0.5 < dip["below_nominal_hz"] < 2.0 + assert 0 < dip["recovery_s"] <= 6 + assert dip_item["evidence"]["worst_value"] == dip["worst_hz"] + assert "Hz below the 60 Hz nominal, back within 0.5 Hz at " in dip_item["finding"] diff --git a/backend/triage.py b/backend/triage.py index ae9ba7a..c5b76aa 100644 --- a/backend/triage.py +++ b/backend/triage.py @@ -4,7 +4,7 @@ list. This module finds the window around the most recent alarm event, exports the alarm journal and trend history for it (triage_export.py), and hands that document to the agent, which is installed as a package pinned to -its 0.2.1 release commit. +its 0.2.2 release commit. Light enough for a free hosting tier: the agent is imported on first use rather than at startup, one run happens at a time, a window that has not diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a8880f4..f2dc6c7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "power-dashboard", - "version": "1.2.1", + "version": "1.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "power-dashboard", - "version": "1.2.1", + "version": "1.2.2", "dependencies": { "lucide-react": "^0.379.0", "react": "^18.3.1", diff --git a/frontend/package.json b/frontend/package.json index 9c5b0e0..23de880 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "power-dashboard", "private": true, - "version": "1.2.1", + "version": "1.2.2", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/lib/triage.js b/frontend/src/lib/triage.js index 942ad51..d86ff16 100644 --- a/frontend/src/lib/triage.js +++ b/frontend/src/lib/triage.js @@ -1,5 +1,7 @@ // Helpers for the Triage tab. Pure functions, so they test without a browser. +import { clockIn } from './zone' + export const RULES = { 'E-P1': 'Cause confirmed by trend', 'E-P2': 'Not explained by any cause', @@ -52,7 +54,26 @@ export function limitText(evidence) { } // One line on what the trend shows, for under the chart -export function evidenceText(evidence) { +// A generator frequency dip, measured by the agent from its load step. +// Times are shown in the viewer's zone with the server's labels. +export function dipText(dip, zone = { name: 'UTC', labels: {} }) { + if (!dip) return null + if (!dip.measured) return `No dip measurement: ${dip.reason}.` + const at = iso => clockIn(iso, zone.name, zone.labels) + const step = dip.basis === 'ats_transfer' ? 'at the transfer' : 'with the generator first at rated frequency' + if (dip.below_nominal_hz <= dip.tolerance_hz) { + return `Load step ${at(dip.step_at)} (${step}): stayed within ${dip.tolerance_hz} Hz of ${dip.nominal_hz} Hz, lowest ${dip.worst_hz.toFixed(2)} Hz.` + } + const head = `Load step ${at(dip.step_at)} (${step}): worst ${dip.worst_hz.toFixed(2)} Hz, ` + + `${dip.below_nominal_hz.toFixed(2)} Hz below the ${dip.nominal_hz} Hz nominal` + if (dip.recovered_at) { + return `${head}, back within ${dip.tolerance_hz} Hz at ${at(dip.recovered_at)}, ${seconds(dip.recovery_s)} after the step.` + } + return `${head}, not back within ${dip.tolerance_hz} Hz by ${at(dip.window_end)}.` +} + +export function evidenceText(evidence, zone) { + if (evidence?.dip) return dipText(evidence.dip, zone) if (!evidence || !evidence.samples_past_limit) return 'The trend never shows this point past its limit.' const past = evidence.samples_past_limit const span = evidence.seconds_past_limit > 0 ? ` for ${seconds(evidence.seconds_past_limit)}` : '' diff --git a/frontend/src/lib/triage.test.js b/frontend/src/lib/triage.test.js index bc8923a..50354ea 100644 --- a/frontend/src/lib/triage.test.js +++ b/frontend/src/lib/triage.test.js @@ -1,8 +1,9 @@ import { describe, expect, it } from 'vitest' import { - chartGeometry, eventFinished, evidenceText, floodParts, itemWriter, reduction, ruleLabel, seconds, valueText, writerLabel, + chartGeometry, dipText, eventFinished, evidenceText, floodParts, itemWriter, reduction, ruleLabel, seconds, valueText, writerLabel, } from './triage' import outage from '../test/triage-outage.json' +import outageUtc from '../test/triage-outage-utc.json' const cause = outage.triage.items[0] @@ -61,3 +62,36 @@ describe('triage helpers', () => { expect(eventFinished(null, null)).toBe(false) }) }) + +describe('frequency dip', () => { + const dipOf = data => data.triage.items[0].consequences.find(c => c.signal === 'frequency_low').evidence.dip + + it('reports the load step dip in the viewer zone, labeled', () => { + const ny = dipText(dipOf(outage), outage.zone) + expect(ny).toMatch(/^Load step \d\d:\d\d:\d\d EDT \(at the transfer\): worst 58\.\d\d Hz, 1\.\d\d Hz below the 60 Hz nominal, back within 0\.5 Hz at \d\d:\d\d:\d\d EDT, 2 s after the step\.$/) + const utc = dipText(dipOf(outageUtc), outageUtc.zone) + expect(utc).toMatch(/^Load step \d\d:\d\d:\d\d UTC .* \d\d:\d\d:\d\d UTC, 2 s after the step\.$/) + }) + + it('never shows a crank value', () => { + const text = dipText(dipOf(outage), outage.zone) + expect(text).not.toMatch(/(^|[^\d.])(0|20|40)\.00 Hz/) + }) + + it('shows no number when nothing was measured', () => { + const none = { measured: false, reason: 'no transfer to the emergency source and the generator never reached its rated 60 Hz in this export' } + const t = dipText(none) + expect(t).toBe('No dip measurement: no transfer to the emergency source and the generator never reached its rated 60 Hz in this export.') + expect(t).not.toMatch(/\d+\.\d\d Hz/) + }) + + it('says when the frequency never came back', () => { + const dip = { ...dipOf(outageUtc), recovered_at: null, recovery_s: null } + expect(dipText(dip, outageUtc.zone)).toMatch(/not back within 0\.5 Hz by \d\d:\d\d:\d\d UTC\.$/) + }) + + it('is what the consequence line shows', () => { + const c = outage.triage.items[0].consequences.find(x => x.signal === 'frequency_low') + expect(evidenceText(c.evidence, outage.zone)).toBe(dipText(c.evidence.dip, outage.zone)) + }) +}) diff --git a/frontend/src/pages/Triage.jsx b/frontend/src/pages/Triage.jsx index fd4c06e..2062b8a 100644 --- a/frontend/src/pages/Triage.jsx +++ b/frontend/src/pages/Triage.jsx @@ -45,7 +45,7 @@ function Consequences({ items, zone }) { {clockIn(c.start, zone.name, zone.labels)}
{c.point}
-
{evidenceText(c.evidence)}
+
{evidenceText(c.evidence, zone)}
))} @@ -82,7 +82,7 @@ export function TriageItem({ item, zone }) {
Trend evidence
-

{evidenceText(item.evidence)}

+

{evidenceText(item.evidence, zone)}

diff --git a/frontend/src/test/triage-outage-utc.json b/frontend/src/test/triage-outage-utc.json index a29fb38..bb3409d 100644 --- a/frontend/src/test/triage-outage-utc.json +++ b/frontend/src/test/triage-outage-utc.json @@ -13,7 +13,7 @@ } }, "triage": { - "agent_version": "0.2.1", + "agent_version": "0.2.2", "time_zone": { "name": "UTC", "label": "UTC" @@ -554,7 +554,8 @@ "2026-09-21T14:23:28+00:00", 0.0 ] - ] + ], + "dip": null }, "rank": 1, "reason": "Utility power failure at UTIL-1 is confirmed in the trend from 14:18:20 UTC to 14:20:08 UTC (about 108 seconds), and it accounts for 5 other alarms: UPS on battery at UPS-A, UPS on battery at UPS-B, generator start at GEN-1, ATS transfer to the emergency source at ATS-1, generator frequency dip at GEN-1.", @@ -833,7 +834,8 @@ "2026-09-21T14:23:22+00:00", 0.0 ] - ] + ], + "dip": null } }, { @@ -1107,7 +1109,8 @@ "2026-09-21T14:23:22+00:00", 0.0 ] - ] + ], + "dip": null } }, { @@ -1381,7 +1384,8 @@ "2026-09-21T14:23:22+00:00", 0.0 ] - ] + ], + "dip": null } }, { @@ -1655,7 +1659,8 @@ "2026-09-21T14:23:22+00:00", 0.0 ] - ] + ], + "dip": null } }, { @@ -1674,7 +1679,7 @@ "band": "EXPLAINED", "rule": "E-C1", "rule_name": "EXPLAINED_BY_CAUSE", - "finding": "starts at 14:18:32 UTC, inside the UTILITY_FAILURE window on SIM-DC/UTIL-1/FAIL, so it is a consequence of that cause and collapses under it", + "finding": "starts at 14:18:32 UTC, inside the UTILITY_FAILURE window on SIM-DC/UTIL-1/FAIL, so it is a consequence of that cause and collapses under it; load step at 14:18:32 UTC (the transfer to the emergency source): worst 58.88 Hz at 14:18:32 UTC, 1.12 Hz below the 60 Hz nominal, back within 0.5 Hz at 14:18:34 UTC, 2 s after the step", "preliminary_score": 2.3e-05, "evidence": { "kind": "low", @@ -1682,12 +1687,12 @@ "deadband": 0.1, "direction": "LOW", "units": "Hz", - "samples": 13, - "samples_past_limit": 4, - "first_past_limit": "2026-09-21T14:18:22+00:00", + "samples": 8, + "samples_past_limit": 1, + "first_past_limit": "2026-09-21T14:18:32+00:00", "last_past_limit": "2026-09-21T14:18:32+00:00", - "seconds_past_limit": 10.0, - "worst_value": 0.0, + "seconds_past_limit": 0.0, + "worst_value": 58.88, "trend": [ [ "2026-09-21T14:13:20+00:00", @@ -1929,7 +1934,22 @@ "2026-09-21T14:23:22+00:00", 0.0 ] - ] + ], + "dip": { + "measured": true, + "reason": null, + "basis": "ats_transfer", + "step_at": "2026-09-21T14:18:32+00:00", + "rated_at": "2026-09-21T14:18:28+00:00", + "worst_hz": 58.88, + "worst_at": "2026-09-21T14:18:32+00:00", + "nominal_hz": 60.0, + "below_nominal_hz": 1.12, + "tolerance_hz": 0.5, + "recovered_at": "2026-09-21T14:18:34+00:00", + "recovery_s": 2.0, + "window_end": "2026-09-21T14:18:46+00:00" + } } } ] @@ -1937,7 +1957,7 @@ ], "nuisance": [], "unresolved": [], - "elapsed_s": 0.039, + "elapsed_s": 0.04, "writer": { "kind": "template", "name": "template, no model", diff --git a/frontend/src/test/triage-outage.json b/frontend/src/test/triage-outage.json index cc096f6..2a0dace 100644 --- a/frontend/src/test/triage-outage.json +++ b/frontend/src/test/triage-outage.json @@ -13,7 +13,7 @@ } }, "triage": { - "agent_version": "0.2.1", + "agent_version": "0.2.2", "time_zone": { "name": "America/New_York", "label": "EDT" @@ -554,7 +554,8 @@ "2026-09-21T14:23:28+00:00", 0.0 ] - ] + ], + "dip": null }, "rank": 1, "reason": "Utility power failure at UTIL-1 is confirmed in the trend from 10:18:20 EDT to 10:20:08 EDT (about 108 seconds), and it accounts for 5 other alarms: UPS on battery at UPS-A, UPS on battery at UPS-B, generator start at GEN-1, ATS transfer to the emergency source at ATS-1, generator frequency dip at GEN-1.", @@ -833,7 +834,8 @@ "2026-09-21T14:23:22+00:00", 0.0 ] - ] + ], + "dip": null } }, { @@ -1107,7 +1109,8 @@ "2026-09-21T14:23:22+00:00", 0.0 ] - ] + ], + "dip": null } }, { @@ -1381,7 +1384,8 @@ "2026-09-21T14:23:22+00:00", 0.0 ] - ] + ], + "dip": null } }, { @@ -1655,7 +1659,8 @@ "2026-09-21T14:23:22+00:00", 0.0 ] - ] + ], + "dip": null } }, { @@ -1674,7 +1679,7 @@ "band": "EXPLAINED", "rule": "E-C1", "rule_name": "EXPLAINED_BY_CAUSE", - "finding": "starts at 10:18:32 EDT, inside the UTILITY_FAILURE window on SIM-DC/UTIL-1/FAIL, so it is a consequence of that cause and collapses under it", + "finding": "starts at 10:18:32 EDT, inside the UTILITY_FAILURE window on SIM-DC/UTIL-1/FAIL, so it is a consequence of that cause and collapses under it; load step at 10:18:32 EDT (the transfer to the emergency source): worst 58.88 Hz at 10:18:32 EDT, 1.12 Hz below the 60 Hz nominal, back within 0.5 Hz at 10:18:34 EDT, 2 s after the step", "preliminary_score": 2.3e-05, "evidence": { "kind": "low", @@ -1682,12 +1687,12 @@ "deadband": 0.1, "direction": "LOW", "units": "Hz", - "samples": 13, - "samples_past_limit": 4, - "first_past_limit": "2026-09-21T14:18:22+00:00", + "samples": 8, + "samples_past_limit": 1, + "first_past_limit": "2026-09-21T14:18:32+00:00", "last_past_limit": "2026-09-21T14:18:32+00:00", - "seconds_past_limit": 10.0, - "worst_value": 0.0, + "seconds_past_limit": 0.0, + "worst_value": 58.88, "trend": [ [ "2026-09-21T14:13:20+00:00", @@ -1929,7 +1934,22 @@ "2026-09-21T14:23:22+00:00", 0.0 ] - ] + ], + "dip": { + "measured": true, + "reason": null, + "basis": "ats_transfer", + "step_at": "2026-09-21T14:18:32+00:00", + "rated_at": "2026-09-21T14:18:28+00:00", + "worst_hz": 58.88, + "worst_at": "2026-09-21T14:18:32+00:00", + "nominal_hz": 60.0, + "below_nominal_hz": 1.12, + "tolerance_hz": 0.5, + "recovered_at": "2026-09-21T14:18:34+00:00", + "recovery_s": 2.0, + "window_end": "2026-09-21T14:18:46+00:00" + } } } ] @@ -1937,7 +1957,7 @@ ], "nuisance": [], "unresolved": [], - "elapsed_s": 0.056, + "elapsed_s": 0.046, "writer": { "kind": "template", "name": "template, no model",