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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
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.3.0"
VERSION = "1.3.1"


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@cc5f707f329a20862dbb19318e6d428c8162bcfd
bms-alarm-triage-agent @ git+https://github.com/AddisonTech/bms-alarm-triage-agent@7eb8aff82eb442ba92ece3d15c18bff7dabe904c
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.3.0"
assert body["version"] == "1.3.1"


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.3.0"
assert doc["source"] == "power-dashboard 1.3.1"
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_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"])
Expand Down
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.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
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.3.0",
"version": "1.3.1",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/lib/triage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
}
17 changes: 15 additions & 2 deletions frontend/src/lib/triage.test.js
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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))
})
})

18 changes: 9 additions & 9 deletions frontend/src/pages/Triage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -38,8 +38,8 @@ function Consequences({ items, zone }) {
</button>
{open && (
<ul className="tri-cq-list">
{items.map(c => (
<li key={c.point}>
{items.map((c, i) => (
<li key={episodeKey(c, i)}>
<div className="tri-cq-head">
<PriorityBadge priority={c.priority} compact />
<span className="tri-cq-label">{capitalize(c.label)}</span>
Expand Down Expand Up @@ -185,18 +185,18 @@ export function TriageResult({ data }) {
<div className="card empty-state"><p>No item in this window needed escalating.</p></div>
)}
<div className="tri-list">
{result.items.map(item => <TriageItem key={item.point} item={item} zone={zone} />)}
{result.items.map((item, i) => <TriageItem key={episodeKey(item, i)} item={item} zone={zone} />)}
</div>

{(result.nuisance.length > 0 || result.unresolved.length > 0) && (
<div className="card">
<div className="card-head"><h3>Set aside</h3></div>
<ul className="tri-aside">
{result.nuisance.map(n => (
<li key={n.point}><span className="mono small">{n.point}</span> <span className="muted small">{n.finding}</span></li>
{result.nuisance.map((n, i) => (
<li key={episodeKey(n, i)}><span className="mono small">{n.point}</span> <span className="muted small">{n.finding}</span></li>
))}
{result.unresolved.map(u => (
<li key={u.point}><span className="mono small">{u.point}</span> <span className="muted small">{u.reason}</span></li>
{result.unresolved.map((u, i) => (
<li key={episodeKey(u, i)}><span className="mono small">{u.point}</span> <span className="muted small">{u.reason}</span></li>
))}
</ul>
</div>
Expand Down
58 changes: 58 additions & 0 deletions frontend/src/pages/Triage.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,61 @@ describe('How this agent is graded', () => {
})
})

// Since agent 0.3.1 one point can have two episodes, each escalated as its
// own card: a UPS that goes to battery, comes back, and goes again later.
const cause = outage.triage.items[0]
const battery = cause.consequences.find(c => c.point.endsWith('/UPS-B/ONBATT'))
const episode = (start) => ({
...cause, ...battery, rank: 1, rule: 'E-P2', consequences: [], start,
reason: `UPS on battery at UPS-B from ${start}.`, next_step: 'Inspect UPS-B: the input breaker.',
written_by: 'template, no model',
})
const twoEpisodes = {
...outage,
triage: {
...outage.triage,
items: [episode('2026-09-21T14:18:20+00:00'), { ...episode('2026-09-21T14:28:20+00:00'), rank: 2 }],
nuisance: [
{ point: battery.point, signal: 'ups_on_battery', label: 'UPS on battery', rule: 'E-D1', finding: 'first' },
{ point: battery.point, signal: 'ups_on_battery', label: 'UPS on battery', rule: 'E-D1', finding: 'second' },
],
unresolved: [],
},
}

// The keys React is handed for the children of the element with this class
function keysUnder(element, className) {
if (!element || typeof element !== 'object') return null
const props = element.props || {}
if (typeof props.className === 'string' && props.className.split(' ').includes(className)) {
return [props.children].flat(Infinity).filter(c => c && typeof c === 'object').map(c => c.key)
}
for (const child of [props.children].flat(Infinity)) {
const found = keysUnder(child, className)
if (found) return found
}
return null
}

describe('Two episodes on one point', () => {
it('renders both cards', () => {
const html = render(<TriageResult data={twoEpisodes} />)
expect(html.match(/class="tri-item /g)).toHaveLength(2)
expect(text(html)).toContain('from 2026-09-21T14:18:20+00:00')
expect(text(html)).toContain('from 2026-09-21T14:28:20+00:00')
})

it('gives each card its own key, by point and episode start', () => {
const keys = keysUnder(TriageResult({ data: twoEpisodes }), 'tri-list')
expect(keys).toEqual([
`${battery.point}@2026-09-21T14:18:20+00:00`,
`${battery.point}@2026-09-21T14:28:20+00:00`,
])
})

it('keeps set-aside entries on one point apart too', () => {
const keys = keysUnder(TriageResult({ data: twoEpisodes }), 'tri-aside')
expect(new Set(keys).size).toBe(2)
})
})

14 changes: 7 additions & 7 deletions frontend/src/test/triage-grading.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"agent_version": "0.3.0",
"agent_version": "0.3.1",
"writer": "template",
"writer_name": "template, no model",
"date": "2026-10-20",
"date": "2026-10-21",
"cases_run": 58,
"cases_passed": 55,
"cases_passed": 58,
"graders": [
{
"name": "grounding",
Expand All @@ -16,16 +16,16 @@
{
"name": "root_cause",
"what": "The write-up names the right cause, says what is wrong with it from the evidence, and never turns a consequence into the cause. With no cause in the evidence, it names none.",
"passed": 38,
"passed": 40,
"applicable": 40,
"rate": 0.95
"rate": 1.0
},
{
"name": "escalation",
"what": "What should escalate gets a card and nothing else does, and no card talks its own escalation down as nuisance or nothing to act on.",
"passed": 55,
"passed": 58,
"applicable": 58,
"rate": 0.9483
"rate": 1.0
},
{
"name": "uncertainty",
Expand Down
6 changes: 3 additions & 3 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.3.0",
"agent_version": "0.3.1",
"time_zone": {
"name": "UTC",
"label": "UTC"
Expand Down Expand Up @@ -1957,14 +1957,14 @@
],
"nuisance": [],
"unresolved": [],
"elapsed_s": 0.054,
"elapsed_s": 0.037,
"writer": {
"kind": "template",
"name": "template, no model",
"model": null
},
"read_only": true
},
"elapsed_ms": 82.4,
"elapsed_ms": 50.4,
"cached": false
}
6 changes: 3 additions & 3 deletions frontend/src/test/triage-outage.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
}
},
"triage": {
"agent_version": "0.3.0",
"agent_version": "0.3.1",
"time_zone": {
"name": "America/New_York",
"label": "EDT"
Expand Down Expand Up @@ -1957,14 +1957,14 @@
],
"nuisance": [],
"unresolved": [],
"elapsed_s": 0.053,
"elapsed_s": 0.043,
"writer": {
"kind": "template",
"name": "template, no model",
"model": null
},
"read_only": true
},
"elapsed_ms": 87.7,
"elapsed_ms": 61.9,
"cached": false
}
Loading