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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import os

VERSION = "1.2.1"
VERSION = "1.2.2"


def _env_bool(name: str, default: bool) -> bool:
Expand Down
2 changes: 1 addition & 1 deletion backend/requirements.txt
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion backend/tests/test_api_websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
4 changes: 2 additions & 2 deletions backend/tests/test_triage_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"])
Expand Down
23 changes: 23 additions & 0 deletions backend/tests/test_triage_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
2 changes: 1 addition & 1 deletion backend/triage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "power-dashboard",
"private": true,
"version": "1.2.1",
"version": "1.2.2",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
23 changes: 22 additions & 1 deletion frontend/src/lib/triage.js
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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)}` : ''
Expand Down
36 changes: 35 additions & 1 deletion frontend/src/lib/triage.test.js
Original file line number Diff line number Diff line change
@@ -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]

Expand Down Expand Up @@ -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))
})
})
4 changes: 2 additions & 2 deletions frontend/src/pages/Triage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ function Consequences({ items, zone }) {
<span className="muted small mono">{clockIn(c.start, zone.name, zone.labels)}</span>
</div>
<div className="muted small mono tri-point">{c.point}</div>
<div className="small tri-cq-why">{evidenceText(c.evidence)}</div>
<div className="small tri-cq-why">{evidenceText(c.evidence, zone)}</div>
</li>
))}
</ul>
Expand Down Expand Up @@ -82,7 +82,7 @@ export function TriageItem({ item, zone }) {
<figure className="tri-evidence">
<figcaption className="tri-say-label">Trend evidence</figcaption>
<EvidenceChart evidence={item.evidence} />
<p className="small muted">{evidenceText(item.evidence)}</p>
<p className="small muted">{evidenceText(item.evidence, zone)}</p>
</figure>

<Consequences items={item.consequences} zone={zone} />
Expand Down
48 changes: 34 additions & 14 deletions frontend/src/test/triage-outage-utc.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
}
},
"triage": {
"agent_version": "0.2.1",
"agent_version": "0.2.2",
"time_zone": {
"name": "UTC",
"label": "UTC"
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -833,7 +834,8 @@
"2026-09-21T14:23:22+00:00",
0.0
]
]
],
"dip": null
}
},
{
Expand Down Expand Up @@ -1107,7 +1109,8 @@
"2026-09-21T14:23:22+00:00",
0.0
]
]
],
"dip": null
}
},
{
Expand Down Expand Up @@ -1381,7 +1384,8 @@
"2026-09-21T14:23:22+00:00",
0.0
]
]
],
"dip": null
}
},
{
Expand Down Expand Up @@ -1655,7 +1659,8 @@
"2026-09-21T14:23:22+00:00",
0.0
]
]
],
"dip": null
}
},
{
Expand All @@ -1674,20 +1679,20 @@
"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",
"limit": 59.5,
"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",
Expand Down Expand Up @@ -1929,15 +1934,30 @@
"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"
}
}
}
]
}
],
"nuisance": [],
"unresolved": [],
"elapsed_s": 0.039,
"elapsed_s": 0.04,
"writer": {
"kind": "template",
"name": "template, no model",
Expand Down
Loading
Loading