diff --git a/CHANGELOG.md b/CHANGELOG.md index d3a13b2..a2db55a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,30 @@ Releases before 1.3.0 are recorded in the git history and their merge commits. +## 1.3.1 - 2026-10-21 + +- **Triage agent 0.3.1.** `backend/requirements.txt` pins the agent to + `7eb8aff`, its main branch just after the 0.3.1 merge (`8e6ce6e`), which + adds the committed Haiku eval results and the scorecard that goes with + them. 0.3.1 fixes two pipeline bugs: a frequency dip with no utility + failure now goes under the ATS transfer whose load step caused it, and a + point that alarms again after it cleared becomes its own episode. +- **One card per episode.** Since 0.3.1 one point can have two escalated + episodes, such as a UPS that goes to battery with an outage and again + later on its own. The Triage tab keyed its cards, consequences and set + aside entries by point alone, so two such cards would have shared a key. + They are keyed by point and episode start now, or by point and place in + the list where an entry has no start. +- **Grading panel.** It reads the new scorecard from the pinned package: + the template writer passes all six graders on all 58 cases. No code + change was needed for that. +- **Fixtures.** Rebuilt with `backend/scripts/make_triage_fixtures.py`. + The outage fixtures differ from 1.3.0 only in the agent version and run + timings: the utility failure first, five consequences collapsed, the same + measured dip (58.88 Hz, 1.12 Hz below 60, back within 0.5 Hz in 2 s). + +Verification: backend 85 tests, front end 49 tests, production build. + ## 1.3.0 - 2026-10-20 - **Triage agent 0.3.0.** `backend/requirements.txt` pins the agent to diff --git a/README.md b/README.md index 441291f..a6d5084 100644 --- a/README.md +++ b/README.md @@ -58,9 +58,9 @@ 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.3.0, 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.3.1, pinned in `backend/requirements.txt` to a commit on its main branch (the 0.3.1 release with its eval results committed), 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. +- **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. With no utility failure, an ATS transfer still carries the frequency dip at its own load step, and a point that alarms again minutes after it cleared, such as a UPS back on battery after an outage, is its own episode and its own card. - **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. @@ -152,7 +152,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.3.0, pinned to its release commit | +| Alarm triage | BMS Alarm Triage Agent 0.3.1, pinned to a 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 eb336b0..49a4d35 100644 --- a/backend/config.py +++ b/backend/config.py @@ -6,7 +6,7 @@ import os -VERSION = "1.3.0" +VERSION = "1.3.1" def _env_bool(name: str, default: bool) -> bool: diff --git a/backend/requirements.txt b/backend/requirements.txt index f5a937a..4f3a975 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@cc5f707f329a20862dbb19318e6d428c8162bcfd +bms-alarm-triage-agent @ git+https://github.com/AddisonTech/bms-alarm-triage-agent@7eb8aff82eb442ba92ece3d15c18bff7dabe904c diff --git a/backend/tests/test_api_websocket.py b/backend/tests/test_api_websocket.py index ac3f567..b21b2ea 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.3.0" + assert body["version"] == "1.3.1" 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 a87aeef..4ed5a64 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.3.0" + assert doc["source"] == "power-dashboard 1.3.1" 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_3(): import bms_alarm_triage - assert bms_alarm_triage.__version__ == "0.3.0" + assert bms_alarm_triage.__version__ == "0.3.1" @pytest.mark.parametrize("zone_name", ["America/New_York", "UTC"]) diff --git a/backend/triage.py b/backend/triage.py index a30c510..e7befdc 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.3.0 release commit. +a commit of its 0.3.1 release. 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 c2052d0..eff5547 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "power-dashboard", - "version": "1.3.0", + "version": "1.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "power-dashboard", - "version": "1.3.0", + "version": "1.3.1", "dependencies": { "lucide-react": "^0.379.0", "react": "^18.3.1", diff --git a/frontend/package.json b/frontend/package.json index 86eec76..becae41 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "power-dashboard", "private": true, - "version": "1.3.0", + "version": "1.3.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/lib/triage.js b/frontend/src/lib/triage.js index 30194eb..8e707c6 100644 --- a/frontend/src/lib/triage.js +++ b/frontend/src/lib/triage.js @@ -156,3 +156,11 @@ export function passRate(rate) { const pct = rate * 100 return `${Number.isInteger(pct) ? pct : pct.toFixed(1)}%` } + +// A React key for one entry. Since agent 0.3.1 one point can have two +// episodes, each its own card, so a point alone is not unique: an entry with +// a start time is keyed by point and start, and one without (the nuisance +// and unresolved lists carry none) by point and its place in the list. +export function episodeKey(entry, index) { + return entry.start ? `${entry.point}@${entry.start}` : `${entry.point}#${index}` +} diff --git a/frontend/src/lib/triage.test.js b/frontend/src/lib/triage.test.js index f9e53de..598b7cc 100644 --- a/frontend/src/lib/triage.test.js +++ b/frontend/src/lib/triage.test.js @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { - chartGeometry, dipText, eventFinished, evidenceText, floodParts, graderTitle, itemWriter, passRate, reduction, ruleLabel, - seconds, valueText, writerLabel, + chartGeometry, dipText, episodeKey, eventFinished, evidenceText, floodParts, graderTitle, itemWriter, passRate, reduction, + ruleLabel, seconds, valueText, writerLabel, } from './triage' import outage from '../test/triage-outage.json' import outageUtc from '../test/triage-outage-utc.json' @@ -111,3 +111,16 @@ describe('grading', () => { }) }) +describe('episodeKey', () => { + it('tells two episodes on one point apart by their start', () => { + const a = episodeKey({ point: 'SIM-DC/UPS-B/ONBATT', start: '2026-09-21T14:18:20+00:00' }, 0) + const b = episodeKey({ point: 'SIM-DC/UPS-B/ONBATT', start: '2026-09-21T14:28:20+00:00' }, 1) + expect(a).not.toBe(b) + expect(a).toBe('SIM-DC/UPS-B/ONBATT@2026-09-21T14:18:20+00:00') + }) + + it('falls back to the place in the list when there is no start', () => { + expect(episodeKey({ point: 'P' }, 0)).not.toBe(episodeKey({ point: 'P' }, 1)) + }) +}) + diff --git a/frontend/src/pages/Triage.jsx b/frontend/src/pages/Triage.jsx index c3d5686..32ce133 100644 --- a/frontend/src/pages/Triage.jsx +++ b/frontend/src/pages/Triage.jsx @@ -5,8 +5,8 @@ import { api, exportJournalUrl } from '../services/api' import { PriorityBadge } from '../components/Badges' import { clockIn, dateTimeIn, viewerZone, zoneLabel } from '../lib/zone' import { - capitalize, chartGeometry, evidenceText, floodParts, graderTitle, itemWriter, passRate, reduction, ruleLabel, - writerLabel, + capitalize, chartGeometry, episodeKey, evidenceText, floodParts, graderTitle, itemWriter, passRate, reduction, + ruleLabel, writerLabel, } from '../lib/triage' const CHART_W = 320 @@ -38,8 +38,8 @@ function Consequences({ items, zone }) { {open && (