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
51 changes: 41 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ The demo runs on **simulated data**: no real facility, customer, or equipment is
- **ISA 18.2 alarm handling** with acknowledge, shelve, return to normal, designed suppression, an alarm journal, and a master alarm database. See [Alarm management](#alarm-management-isa-182).
- **A live one-line diagram** that follows the power path, showing which conductors are carrying load, energized, or dead.
- **Meter detail and trends** for every device, with 1 hour, 24 hour, and 7 day history and CSV export.
- **Alarm triage.** The dashboard raises the alarm flood; the [BMS Alarm Triage Agent](https://github.com/AddisonTech/bms-alarm-triage-agent) cuts it to a short ranked list. After an event, *Triage this event* puts the root cause first with its consequences folded underneath. See [Alarm triage](#alarm-triage).
- **Try it yourself.** Open *About this demo* and trigger a utility outage, voltage sag, PDU overload, or power factor drop. Events also happen on their own every few minutes.

---
Expand Down Expand Up @@ -53,6 +54,31 @@ What else is implemented:

---

## Alarm triage

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.0, 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.
- **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.

After a demo event finishes, a *Triage this event* button appears under the top bar. On the simulated utility outage the journal holds 24 entries (6 alarms, 6 returns, and 12 acknowledgements and suppressions) and the agent returns 1 ranked item, the utility failure, with the other 5 alarms under it. A run takes about 65 ms once the agent is loaded; the first one loads it on demand so startup stays fast on the free tier.

It is read only. The agent gets a copy of the journal and history for the window and has no path back to the alarm manager or any meter.

**The export.** `GET /api/export/journal` returns the alarm journal and trend history for a window as one JSON document in the agent's input format ([bms-triage-journal, version 1](https://github.com/AddisonTech/bms-alarm-triage-agent/blob/main/docs/JOURNAL_FORMAT.md)): every alarm point with its limit, deadband, priority, and meaning, the equipment topology, every journal entry, and the trend samples behind each point. The Triage tab links the export for the triaged window. During an event, history is stored at scan rate rather than every 10 seconds, because a voltage sag lasts a few seconds and the frequency dip about one.

| Endpoint | Purpose |
|---|---|
| `GET /api/export/journal?minutes=60` | Journal and trend history for the last N minutes (or `start` and `end`, ISO 8601), up to 24 hours |
| `GET /api/triage/event` | The latest alarm event: first and last alarm and its window |
| `POST /api/triage` | Run the agent on the latest event (or a `start` and `end` window) and return the ranked list |

---

## Screenshots

| One-line diagram during an outage | Alarm journal |
Expand Down Expand Up @@ -96,18 +122,18 @@ Demo scenarios:
Simulator or Modbus TCP meters
|
v every 2 s
Collector scan loop ------------> SQLite (history every 10 s, alarm journal)
|
+--> Alarm manager (ISA 18.2 state machine)
|
v
WebSocket /ws/live ------------> React front end
REST /api/* <------------ (acknowledge, shelve, history, demo events)
Collector scan loop ------------> SQLite (history every 10 s, every scan during an event, alarm journal)
| |
+--> Alarm manager (ISA 18.2) +--> journal export --> BMS Alarm Triage Agent (read only)
| |
v v
WebSocket /ws/live ------------> React front end <----- POST /api/triage, ranked list
REST /api/* <------------ (acknowledge, shelve, history, demo events, export)
```

- **Backend:** FastAPI. A single collector owns the scan loop, the latest readings, the alarm manager, and the set of WebSocket clients. On startup in simulation mode it generates 24 hours of history (with past events and simulated operator acknowledgements) in well under a second, so trends and the journal are never empty.
- **WebSocket feed:** on connect the server sends a `snapshot` (meters, readings, alarms, summary, demo info). After that it pushes `readings` every scan and `alarms` whenever the alarm list changes or an operator acts. Clients can send `{"type": "ping"}` and get a `pong`.
- **Front end:** React and Vite with hash based routes (`/#/alarms`), so any page can be refreshed or shared on a static host with no rewrite rules. The live feed is a single WebSocket with automatic reconnection and a stale connection watchdog. Chart heavy pages are loaded on demand, so the first load is about 72 kB of gzipped JavaScript.
- **Front end:** React and Vite with hash based routes (`/#/alarms`), so any page can be refreshed or shared on a static host with no rewrite rules. The live feed is a single WebSocket with automatic reconnection and a stale connection watchdog. Chart heavy pages and the Triage tab are loaded on demand, so the first load is about 74 kB of gzipped JavaScript.
- **Cold starts:** the static front end never sleeps. While the API wakes up it shows a progress screen and pings `/api/health` (which is also what wakes the server), then switches to the live view as soon as the first snapshot arrives. If the connection drops later, the page keeps the last data, dims it, and shows "Reconnecting".

### Stack
Expand All @@ -117,6 +143,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.0, 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 Expand Up @@ -157,7 +184,7 @@ cd backend && pip install -r requirements-dev.txt && python -m pytest -q
cd frontend && npm test
```

The backend suite covers the simulator (physical consistency, the outage sequence, every scenario), the alarm state machine (every ISA 18.2 transition, deadband, delays, shelving, suppression, KPIs), the WebSocket feed (snapshot, streaming, ping, live alarm updates after operator actions), the REST API, and Modbus decoding. The front end suite covers the feed reducer and URL handling.
The backend suite covers the simulator (physical consistency, the outage sequence, every scenario), the alarm state machine (every ISA 18.2 transition, deadband, delays, shelving, suppression, KPIs), the WebSocket feed (snapshot, streaming, ping, live alarm updates after operator actions), the REST API, Modbus decoding, and the triage tie-in (the journal export checked against the agent's own parser, every demo scenario triaged from the real simulator, the event window, scan rate history during events, and the export and triage endpoints). The front end suite covers the feed reducer, URL handling, and the Triage tab (root cause first, consequences collapsed, trend evidence, the writer label).

---

Expand All @@ -178,6 +205,7 @@ Backend settings are environment variables (see `backend/config.py`):
| `DEMO_EVENTS_ENABLED` | `true` | Allow visitors to trigger events from the UI |
| `CORS_ORIGINS` | `*` | Comma separated allowed origins |
| `DB_PATH` | `power_dashboard.db` | SQLite file |
| `TRIAGE_ANTHROPIC_KEY` | unset | Optional. When set, Claude Haiku writes the triage text; otherwise the template does. Not set on the hosted demo |

Front end: `VITE_API_URL` is the API base URL used at build time. It is optional. A production build without it points at the live demo API, `https://power-dashboard-api.onrender.com`, and the dev server without it proxies to the local backend.

Expand Down Expand Up @@ -215,11 +243,14 @@ backend/
alarms.py ISA 18.2 alarm state machine
config.py Settings, topology, master alarm database
database.py SQLite history and alarm journal
triage_export.py Alarm journal export in the triage agent's format, event window
triage.py Runs the triage agent on the latest event
modbus_source.py Modbus TCP polling (optional)
tests/
frontend/
src/services/ WebSocket feed, REST client
src/pages/ Overview, One-Line, Meters, Alarms, Trends, System
src/pages/ Overview, One-Line, Meters, Alarms, Triage, Trends, System
src/lib/triage.js Triage tab helpers (evidence text, chart geometry)
src/components/ Wake screen, About panel, alarm banner, badges, charts
render.yaml Render Blueprint
```
Expand Down
12 changes: 11 additions & 1 deletion backend/collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,11 @@ async def scan_once(self, now: Optional[float] = None) -> Dict[str, Dict[str, An

alarms_changed = self.alarms.evaluate(readings, now)

if now - self._last_history >= config.HISTORY_INTERVAL:
# History is stored every HISTORY_INTERVAL, and every scan while
# something is abnormal, the way a historian stores on exception. A
# voltage sag lasts a few seconds and a generator frequency dip about
# one, so the trend behind an alarm has to be at scan rate to show it.
if now - self._last_history >= config.HISTORY_INTERVAL or alarms_changed or self.abnormal():
database.insert_readings(r for r in readings.values() if r.get("voltage") is not None)
self._last_history = now
if now - self._last_prune >= 3600:
Expand All @@ -159,6 +163,12 @@ async def scan_once(self, now: Optional[float] = None) -> Dict[str, Dict[str, An
await self.broadcast_alarms(now)
return readings

def abnormal(self) -> bool:
"""An event is playing out or an alarm condition is present."""
if self.sim is not None and self.sim.scenario is not None:
return True
return any(p.condition for p in self.alarms.points.values())

# ------------------------------------------------------------------
# Views
# ------------------------------------------------------------------
Expand Down
31 changes: 30 additions & 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.1.0"
VERSION = "1.2.0"


def _env_bool(name: str, default: bool) -> bool:
Expand Down Expand Up @@ -69,6 +69,14 @@ def _env_float(name: str, default: float) -> float:
# Nominal system frequency
NOMINAL_FREQUENCY = 60.0

# Triage: the alarm journal export and the triage agent tie-in
TRIAGE_SITE = "SIM-DC" # site label on exported point ids (synthetic)
TRIAGE_EVENT_GAP_S = 300 # alarms closer than this belong to one event
TRIAGE_PRE_S = 300 # trend history kept before the event starts
TRIAGE_POST_S = 300 # and after its last alarm
TRIAGE_MAX_WINDOW_S = 3600 # longest window triaged in one run
EXPORT_MAX_MINUTES = 1440 # longest window the export endpoint returns


# ---------------------------------------------------------------------------
# Facility topology
Expand Down Expand Up @@ -165,13 +173,17 @@ def _env_float(name: str, default: float) -> float:
# on_delay consecutive scans in alarm before annunciating (anti-chatter)
# off_delay consecutive scans normal before returning to normal
# suppress designed (state based) suppression rule, see alarms.py
# signal what the alarm means to the triage agent's electrical rules
# (see triage_export.py); alarms without one are exported as
# plain alarms
# ---------------------------------------------------------------------------

def _pdu_alarms(pdu: str):
name = METERS_BY_ID[pdu]["name"]
return [
{
"tag": f"{pdu}.LOAD.HI", "meter_id": pdu, "parameter": "load_pct", "kind": "HI",
"signal": "pdu_overload",
"setpoint": 80.0, "deadband": 3.0, "priority": 3, "on_delay": 2, "off_delay": 2,
"unit": "%",
"message": f"{name} load high",
Expand All @@ -180,6 +192,7 @@ def _pdu_alarms(pdu: str):
},
{
"tag": f"{pdu}.LOAD.HIHI", "meter_id": pdu, "parameter": "load_pct", "kind": "HIHI",
"signal": "pdu_overload",
"setpoint": 90.0, "deadband": 3.0, "priority": 2, "on_delay": 2, "off_delay": 2,
"unit": "%",
"message": f"{name} load very high",
Expand All @@ -194,6 +207,7 @@ def _ups_alarms(ups: str):
return [
{
"tag": f"{ups}.ONBATT", "meter_id": ups, "parameter": "on_battery", "kind": "DISCRETE",
"signal": "ups_on_battery",
"setpoint": 1, "deadband": 0, "priority": 1, "on_delay": 0, "off_delay": 1,
"unit": "",
"message": f"{name} on battery",
Expand All @@ -202,6 +216,7 @@ def _ups_alarms(ups: str):
},
{
"tag": f"{ups}.LOAD.HI", "meter_id": ups, "parameter": "load_pct", "kind": "HI",
"signal": "ups_load_high",
"setpoint": 50.0, "deadband": 2.0, "priority": 2, "on_delay": 2, "off_delay": 2,
"unit": "%",
"message": f"{name} load above 2N limit",
Expand All @@ -222,6 +237,7 @@ def _ups_alarms(ups: str):
ALARM_POINTS = [
{
"tag": "UTIL-1.FAIL", "meter_id": "UTIL-1", "parameter": "source_failed", "kind": "DISCRETE",
"signal": "utility_failure",
"setpoint": 1, "deadband": 0, "priority": 2, "on_delay": 0, "off_delay": 1,
"unit": "",
"message": "Utility power failure",
Expand All @@ -230,6 +246,7 @@ def _ups_alarms(ups: str):
},
{
"tag": "UTIL-1.V.LO", "meter_id": "UTIL-1", "parameter": "voltage", "kind": "LO",
"signal": "voltage_sag",
"setpoint": 432.0, "deadband": 4.8, "priority": 3, "on_delay": 0, "off_delay": 1,
"unit": "V",
"message": "Utility voltage low (sag)",
Expand All @@ -240,6 +257,7 @@ def _ups_alarms(ups: str):
},
{
"tag": "GEN-1.F.LO", "meter_id": "GEN-1", "parameter": "frequency", "kind": "LO",
"signal": "frequency_low",
"setpoint": 59.5, "deadband": 0.1, "priority": 3, "on_delay": 0, "off_delay": 1,
"unit": "Hz",
"message": "Generator frequency low",
Expand All @@ -248,8 +266,18 @@ def _ups_alarms(ups: str):
"suppress": {"meter_id": "GEN-1", "parameter": "running", "equals": False,
"reason": "Generator not running (state based suppression)"},
},
{
"tag": "GEN-1.RUN", "meter_id": "GEN-1", "parameter": "running", "kind": "DISCRETE",
"signal": "generator_start",
"setpoint": 1, "deadband": 0, "priority": 4, "on_delay": 0, "off_delay": 1,
"unit": "",
"message": "Generator running",
"consequence": "Facility load is on, or about to be on, generator power and fuel.",
"response": "Confirm what called for the generator. Check fuel level and load.",
},
{
"tag": "ATS-1.EMERG", "meter_id": "ATS-1", "parameter": "on_emergency", "kind": "DISCRETE",
"signal": "ats_transfer",
"setpoint": 1, "deadband": 0, "priority": 3, "on_delay": 0, "off_delay": 1,
"unit": "",
"message": "ATS on emergency source",
Expand All @@ -268,6 +296,7 @@ def _ups_alarms(ups: str):
*_ups_alarms("UPS-B"),
{
"tag": "MECH-1.PF.LO", "meter_id": "MECH-1", "parameter": "power_factor", "kind": "LO",
"signal": "power_factor_low",
"setpoint": 0.85, "deadband": 0.01, "priority": 4, "on_delay": 2, "off_delay": 2,
"unit": "",
"message": "Mechanical panel power factor low",
Expand Down
Loading
Loading