diff --git a/README.md b/README.md index c48e24b..7be4926 100644 --- a/README.md +++ b/README.md @@ -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. --- @@ -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 | @@ -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 @@ -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 | @@ -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). --- @@ -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. @@ -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 ``` diff --git a/backend/collector.py b/backend/collector.py index 2e95fc4..1e19cd5 100644 --- a/backend/collector.py +++ b/backend/collector.py @@ -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: @@ -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 # ------------------------------------------------------------------ diff --git a/backend/config.py b/backend/config.py index dbb2b36..9b86a00 100644 --- a/backend/config.py +++ b/backend/config.py @@ -6,7 +6,7 @@ import os -VERSION = "1.1.0" +VERSION = "1.2.0" def _env_bool(name: str, default: bool) -> bool: @@ -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 @@ -165,6 +173,9 @@ 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): @@ -172,6 +183,7 @@ def _pdu_alarms(pdu: str): 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", @@ -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", @@ -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", @@ -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", @@ -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", @@ -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)", @@ -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", @@ -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", @@ -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", diff --git a/backend/database.py b/backend/database.py index cc67d2b..13bb210 100644 --- a/backend/database.py +++ b/backend/database.py @@ -10,6 +10,12 @@ HISTORY_FIELDS = ("voltage", "current", "power_kw", "power_factor", "frequency", "energy_kwh", "demand_kw", "load_pct") +# Status values the discrete alarms are evaluated on. Stored beside the +# analog history so an alarm journal export carries trend evidence for every +# alarm point, not only the analog ones. NULL on meters that have no such value. +STATE_FIELDS = ("source_failed", "on_battery", "on_emergency", "running", "battery_soc") +STORED_FIELDS = HISTORY_FIELDS + STATE_FIELDS + PERIODS = {"1h": 3600, "24h": 86400, "7d": 7 * 86400} HISTORY_POINTS = 360 # target points per trend after downsampling @@ -55,7 +61,12 @@ def init_db(reset: bool = False): frequency REAL, energy_kwh REAL, demand_kw REAL, - load_pct REAL + load_pct REAL, + source_failed REAL, + on_battery REAL, + on_emergency REAL, + running REAL, + battery_soc REAL ); CREATE INDEX IF NOT EXISTS idx_meter_readings_meter_epoch ON meter_readings(meter_id, epoch); @@ -79,24 +90,33 @@ def init_db(reset: bool = False): ); CREATE INDEX IF NOT EXISTS idx_alarm_events_epoch ON alarm_events(epoch); """) + # A database written by 1.1.0 has no status columns. Add them in place. + have = {row["name"] for row in db.execute("PRAGMA table_info(meter_readings)")} + for name in STATE_FIELDS: + if name not in have: + db.execute(f"ALTER TABLE meter_readings ADD COLUMN {name} REAL") db.commit() def insert_readings(readings: Iterable[Dict[str, Any]]): rows = [ - (r["meter_id"], r["epoch"], r["timestamp"], *(r.get(f) for f in HISTORY_FIELDS)) + (r["meter_id"], r["epoch"], r["timestamp"], *(_stored(r.get(f)) for f in STORED_FIELDS)) for r in readings ] with _lock: db = _db() db.executemany( - f"INSERT INTO meter_readings (meter_id, epoch, timestamp, {', '.join(HISTORY_FIELDS)}) " - f"VALUES (?, ?, ?, {', '.join('?' * len(HISTORY_FIELDS))})", + f"INSERT INTO meter_readings (meter_id, epoch, timestamp, {', '.join(STORED_FIELDS)}) " + f"VALUES (?, ?, ?, {', '.join('?' * len(STORED_FIELDS))})", rows, ) db.commit() +def _stored(value): + return float(value) if isinstance(value, bool) else value + + def get_meter_history(meter_id: str, period: str = "1h", now: Optional[float] = None) -> List[Dict]: """Return averaged history buckets for one meter, oldest first.""" span = PERIODS[period] @@ -149,6 +169,39 @@ def get_alarm_events(limit: int = 200, tag: Optional[str] = None) -> List[Dict]: return [dict(r) for r in rows] +def get_readings_window(start: float, end: float, fields: Iterable[str]) -> List[Dict]: + """Raw stored readings between two epochs, oldest first, for the given fields.""" + fields = [f for f in fields if f in STORED_FIELDS] + with _lock: + rows = _db().execute( + f"SELECT meter_id, epoch, timestamp, {', '.join(fields)} FROM meter_readings " + "WHERE epoch >= ? AND epoch <= ? ORDER BY epoch ASC, id ASC", + (start, end), + ).fetchall() + return [dict(r) for r in rows] + + +def get_alarm_events_window(start: float, end: float) -> List[Dict]: + """Journal entries between two epochs, oldest first.""" + with _lock: + rows = _db().execute( + "SELECT * FROM alarm_events WHERE epoch >= ? AND epoch <= ? ORDER BY epoch ASC, id ASC", + (start, end), + ).fetchall() + return [dict(r) for r in rows] + + +def get_alarm_epochs(since: float, until: float) -> List[float]: + """Times of every annunciated alarm in a span, newest first.""" + with _lock: + rows = _db().execute( + "SELECT epoch FROM alarm_events WHERE event = 'ALARM' AND epoch >= ? AND epoch <= ? " + "ORDER BY epoch DESC", + (since, until), + ).fetchall() + return [r["epoch"] for r in rows] + + def prune(days: int, now: Optional[float] = None): cutoff = (time.time() if now is None else now) - days * 86400 with _lock: diff --git a/backend/main.py b/backend/main.py index f69b25b..9a9393f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3,14 +3,18 @@ import json import logging from contextlib import asynccontextmanager +from datetime import datetime, timezone from typing import Optional from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse from pydantic import BaseModel, Field import config import database +import triage +import triage_export from alarms import AlarmActionError from collector import Collector @@ -204,6 +208,93 @@ async def trigger_event(name: str, request: Request): return info +# --------------------------------------------------------------------------- +# Alarm journal export and triage +# +# The export is the alarm journal and trend history for a window, in the +# triage agent's input format (bms-triage-journal, version 1). POST +# /api/triage runs the agent on the window around the most recent event and +# returns the ranked list: root cause first, its consequences collapsed +# under it, trend evidence, the next step, and which writer wrote the text. +# --------------------------------------------------------------------------- + +def _epoch(text: Optional[str], name: str) -> Optional[float]: + if text in (None, ""): + return None + try: + moment = datetime.fromisoformat(text) + except ValueError: + raise HTTPException(status_code=400, detail=f"{name} must be an ISO 8601 time") + if moment.tzinfo is None: + raise HTTPException(status_code=400, detail=f"{name} needs a timezone offset") + return moment.timestamp() + + +def _window(request: Request, start: Optional[str], end: Optional[str], minutes: Optional[float]): + now = _collector(request).clock() + t0, t1 = _epoch(start, "start"), _epoch(end, "end") + if t0 is None and t1 is None: + span = (minutes or 60) * 60 + t1, t0 = now, now - span + elif t0 is None or t1 is None: + raise HTTPException(status_code=400, detail="Give both start and end, or neither") + if t1 <= t0: + raise HTTPException(status_code=400, detail="end must be after start") + if t1 - t0 > config.EXPORT_MAX_MINUTES * 60: + raise HTTPException(status_code=400, detail=f"The window is limited to {config.EXPORT_MAX_MINUTES} minutes") + return t0, t1 + + +@app.get("/api/export/journal") +def export_journal(request: Request, start: Optional[str] = None, end: Optional[str] = None, + minutes: Optional[float] = None): + """Alarm journal and trend history for a window, in the triage agent's format.""" + if minutes is not None and not 0 < minutes <= config.EXPORT_MAX_MINUTES: + raise HTTPException(status_code=400, detail=f"minutes must be 1 to {config.EXPORT_MAX_MINUTES}") + t0, t1 = _window(request, start, end, minutes) + doc = triage_export.build_journal(t0, t1) + name = "alarm-journal-" + datetime.fromtimestamp(t1, timezone.utc).strftime("%Y%m%d-%H%M%S") + ".json" + return JSONResponse(doc, headers={"Content-Disposition": f'attachment; filename="{name}"'}) + + +@app.get("/api/triage/event") +def triage_event(request: Request): + """The most recent alarm event, if any. Cheap; used to offer the triage button.""" + event = triage_export.latest_event(_collector(request).clock()) + if event is None: + return {"event": None} + return {"event": { + "first_alarm": triage_export.iso(event["first_alarm"]), + "last_alarm": triage_export.iso(event["last_alarm"]), + "start": triage_export.iso(event["start"]), + "end": triage_export.iso(event["end"]), + "alarms": event["alarms"], + "settled": event["settled"], + }} + + +class TriageRequest(BaseModel): + start: Optional[str] = None + end: Optional[str] = None + + +@app.post("/api/triage") +def run_triage(request: Request, body: Optional[TriageRequest] = None): + """Run the triage agent on the latest event (or a given window).""" + now = _collector(request).clock() + start = end = None + if body and (body.start or body.end): + start, end = _window(request, body.start, body.end, None) + try: + return triage.run(now, start, end) + except triage.NoEvent as exc: + raise HTTPException(status_code=404, detail=str(exc)) + except triage.AgentUnavailable as exc: + raise HTTPException(status_code=503, detail=str(exc)) + except triage.BadInput as exc: + raise HTTPException(status_code=422, detail=str(exc)) + + # --------------------------------------------------------------------------- # WebSocket live feed # diff --git a/backend/requirements.txt b/backend/requirements.txt index 1eab1e7..ddc0218 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,3 +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@2060b45eaf1e24b446313663ac45d6c86117c165 diff --git a/backend/tests/test_api_websocket.py b/backend/tests/test_api_websocket.py index 5ee387e..c7ce603 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.1.0" + assert body["version"] == "1.2.0" def test_websocket_sends_snapshot_on_connect(client): diff --git a/backend/tests/test_triage_api.py b/backend/tests/test_triage_api.py new file mode 100644 index 0000000..160925f --- /dev/null +++ b/backend/tests/test_triage_api.py @@ -0,0 +1,132 @@ +"""The export and triage endpoints, on the running app with its backfilled history.""" +import pytest +from fastapi.testclient import TestClient + +import triage +import triage_export +from bms_alarm_triage import journal as agent_journal +from main import app + + +@pytest.fixture(scope="module") +def client(): + triage.clear_cache() + with TestClient(app) as c: + yield c + + +def test_export_returns_the_journal_format(client): + res = client.get("/api/export/journal?minutes=60") + assert res.status_code == 200 + 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.0" + parsed = agent_journal.parse(doc) + assert parsed.points and parsed.series + + +def test_export_takes_an_explicit_window(client): + event = client.get("/api/triage/event").json()["event"] + res = client.get("/api/export/journal", params={"start": event["start"], "end": event["end"]}) + assert res.status_code == 200 + doc = res.json() + assert any(e["event"] == "ALARM" for e in doc["journal"]) + + +@pytest.mark.parametrize("query", [ + "minutes=0", + "minutes=5000", + "start=yesterday&end=today", + "start=2026-07-14T10:00:00", + "start=2026-07-14T10:00:00%2B00:00", + "start=2026-07-14T11:00:00%2B00:00&end=2026-07-14T10:00:00%2B00:00", + "start=2026-07-01T10:00:00%2B00:00&end=2026-07-14T10:00:00%2B00:00", +]) +def test_export_refuses_a_bad_window(client, query): + assert client.get(f"/api/export/journal?{query}").status_code == 400 + + +def test_the_latest_event_is_offered(client): + event = client.get("/api/triage/event").json()["event"] + assert event is not None # the backfill always contains past events + assert event["start"] < event["first_alarm"] <= event["last_alarm"] <= event["end"] + assert event["alarms"] >= 1 + + +def test_triage_returns_the_ranked_list(client, monkeypatch): + monkeypatch.delenv("TRIAGE_ANTHROPIC_KEY", raising=False) + triage.clear_cache() + res = client.post("/api/triage") + assert res.status_code == 200, res.text + body = res.json() + result = body["triage"] + assert result["writer"]["kind"] == "template" + assert result["read_only"] is True + assert result["counts"]["ranked_items"] >= 1 + assert result["counts"]["ranked_items"] <= result["counts"]["alarms_annunciated"] + for item in result["items"]: + assert item["reason"] and item["next_step"] + assert item["evidence"]["trend"] + for consequence in item["consequences"]: + assert consequence["band"] == "EXPLAINED" + assert body["event"]["first_alarm"] + + +def test_triage_runs_on_an_explicit_window(client): + event = client.get("/api/triage/event").json()["event"] + res = client.post("/api/triage", json={"start": event["start"], "end": event["end"]}) + assert res.status_code == 200 + assert res.json()["triage"]["counts"]["ranked_items"] >= 1 + + +def test_triage_with_no_event_is_a_404(client, monkeypatch): + monkeypatch.setattr(triage_export, "latest_event", lambda now, lookback_s=0: None) + res = client.post("/api/triage") + assert res.status_code == 404 + assert "No alarm event" in res.json()["detail"] + + +def test_triage_refuses_a_bad_window(client): + assert client.post("/api/triage", json={"start": "2026-07-14T10:00:00+00:00"}).status_code == 400 + + +def test_an_agent_input_error_is_a_422(client, monkeypatch): + def broken(start, end, journal_from=None): + doc = original(start, end, journal_from) + doc["journal"].append({"time": doc["journal"][0]["time"], "point": "SIM-DC/NOPE/X", + "event": "ALARM", "value": 1}) + return doc + + original = triage_export.build_journal + monkeypatch.setattr(triage_export, "build_journal", broken) + triage.clear_cache() + res = client.post("/api/triage") + assert res.status_code == 422 + assert "not defined in points" in res.json()["detail"] + + +def test_the_hosted_demo_sets_no_api_key(): + from pathlib import Path + + blueprint = (Path(__file__).resolve().parents[2] / "render.yaml").read_text() + assert "TRIAGE_ANTHROPIC_KEY" not in blueprint + assert "ANTHROPIC" not in blueprint + + +def test_the_agent_is_pinned_to_one_release(): + """A tag or a full commit, never a branch that can move under the server.""" + import re + from pathlib import Path + + requirements = (Path(__file__).resolve().parents[1] / "requirements.txt").read_text() + line = next(l for l in requirements.splitlines() if l.startswith("bms-alarm-triage-agent")) + ref = line.rsplit("@", 1)[1] + assert line.startswith("bms-alarm-triage-agent @ git+https://github.com/AddisonTech/bms-alarm-triage-agent@") + assert re.fullmatch(r"v\d+\.\d+\.\d+|[0-9a-f]{40}", ref) + + +def test_the_installed_agent_is_0_2(): + import bms_alarm_triage + + assert bms_alarm_triage.__version__.startswith("0.2.") diff --git a/backend/tests/test_triage_export.py b/backend/tests/test_triage_export.py new file mode 100644 index 0000000..8f48923 --- /dev/null +++ b/backend/tests/test_triage_export.py @@ -0,0 +1,222 @@ +"""The alarm journal export and the triage agent, driven by the real simulator. + +Each scenario runs the simulator, the alarm manager, and the collector's +storage with a fixed clock, then exports and triages the window around the +event. No server is running here, so the history is exactly what the scans +wrote. +""" +import asyncio + +import pytest + +import config +import database +import triage +import triage_export +from bms_alarm_triage import journal as agent_journal +from collector import Collector + +T0 = 1_790_000_000.0 + + +def drive(scenario, quiet_scans=150, event_scans=300, ack_after_s=40, **params): + """Run quiet, trigger the scenario, run it out. Returns (collector, now).""" + database.init_db(reset=True) + triage.clear_cache() + clock = [T0] + c = Collector(clock=lambda: clock[0]) + c.sim.auto_events = False + + async def go(): + for _ in range(quiet_scans): + await c.scan_once(clock[0]) + clock[0] += 2 + if scenario: + c.sim.trigger(scenario, clock[0], **params) + for _ in range(event_scans): + await c.scan_once(clock[0]) + clock[0] += 2 + for p in c.alarms.points.values(): + if p.state.value in ("UNACK", "RTN_UNACK") and p.activated_at and clock[0] - p.activated_at > ack_after_s: + c.alarms.acknowledge(p.tag, clock[0], "operator (simulated)") + + asyncio.run(go()) + return c, clock[0] + + +@pytest.fixture(scope="module") +def outage(): + c, now = drive("utility_outage", duration=110) + event = triage_export.latest_event(now) + doc = triage_export.build_journal(event["start"], event["end"], journal_from=event["first_alarm"]) + return {"collector": c, "now": now, "event": event, "doc": doc, "result": triage.run(now)} + + +# ------------------------------------------------------------- the export + +def test_the_export_is_valid_agent_input(outage): + parsed = agent_journal.parse(outage["doc"]) + assert parsed.counts()["journal_entries"] == len(outage["doc"]["journal"]) + + +def test_the_export_carries_every_alarm_point(outage): + points = {p["id"]: p for p in outage["doc"]["points"]} + assert len(points) == len(config.ALARM_POINTS) + assert points["SIM-DC/UPS-A/ONBATT"]["kind"] == "discrete" + assert points["SIM-DC/UPS-A/ONBATT"]["signal"] == "ups_on_battery" + assert points["SIM-DC/UTIL-1/V_LO"]["kind"] == "low" + assert points["SIM-DC/UTIL-1/V_LO"]["deadband"] == 4.8 + assert points["SIM-DC/PDU-A1/LOAD_HIHI"]["series"] == points["SIM-DC/PDU-A1/LOAD_HI"]["series"] + + +def test_the_export_carries_the_topology(outage): + feeds = {e["id"]: e["fed_from"] for e in outage["doc"]["equipment"]} + assert feeds["PDU-A1"] == "UPS-A" + assert feeds["UPS-B"] == "MSB-1" + assert feeds["UTIL-1"] is None + + +def test_discrete_alarms_have_trend_evidence(outage): + series = {s["id"]: s["samples"] for s in outage["doc"]["series"]} + for key in ("SIM-DC/UTIL-1/source_failed", "SIM-DC/UPS-A/on_battery", "SIM-DC/ATS-1/on_emergency", + "SIM-DC/GEN-1/running"): + values = {v for _t, v in series[key]} + assert values == {0.0, 1.0}, key + + +def test_history_is_stored_at_scan_rate_during_an_event(outage): + """A one second frequency dip is only visible at scan rate.""" + series = {s["id"]: s["samples"] for s in outage["doc"]["series"]} + frequency = series["SIM-DC/GEN-1/frequency"] + running = [v for _t, v in frequency if v > 50] + assert min(running) < 59.5 # the dip after the block load lands + stamps = [t for t, _v in series["SIM-DC/UTIL-1/source_failed"]] + assert len(stamps) > 300 // 10 + 60 # far more than one per HISTORY_INTERVAL + + +def test_the_journal_starts_at_the_event(outage): + first = triage_export.iso(outage["event"]["first_alarm"]) + assert all(e["time"] >= first for e in outage["doc"]["journal"]) + assert outage["doc"]["journal"][0]["event"] in ("ALARM", "SUPPRESS") + + +def test_every_entry_and_sample_is_inside_the_window(outage): + window = outage["doc"]["window"] + for entry in outage["doc"]["journal"]: + assert window["start"] <= entry["time"] <= window["end"] + for series in outage["doc"]["series"]: + for stamp, _value in series["samples"]: + assert window["start"] <= stamp <= window["end"] + + +def test_the_export_uses_no_real_names_or_addresses(outage): + import json + import re + + text = json.dumps(outage["doc"]) + assert not re.search(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", text) + assert outage["doc"]["site"] == "SIM-DC" + + +# ------------------------------------------------------- the event window + +def test_the_event_window_brackets_the_alarms(outage): + event = outage["event"] + assert event["start"] == event["first_alarm"] - config.TRIAGE_PRE_S + assert event["end"] <= outage["now"] + assert event["alarms"] == 6 + + +def test_alarms_far_apart_are_separate_events(): + c, now = drive("pf_drop", event_scans=120, duration=60) + first = triage_export.latest_event(now) + + async def later(): + t = now + for _ in range(600): # twenty quiet minutes, scanned, not skipped + await c.scan_once(t) + t += 2 + c.sim.trigger("voltage_sag", t, duration=6, depth=0.85) + for _ in range(60): + await c.scan_once(t) + t += 2 + return t + + end = asyncio.run(later()) + second = triage_export.latest_event(end) + assert second["first_alarm"] > first["last_alarm"] + config.TRIAGE_EVENT_GAP_S + assert second["alarms"] == 3 + + +def test_no_alarms_means_no_event(): + _c, now = drive(None, event_scans=10) + assert triage_export.latest_event(now) is None + with pytest.raises(triage.NoEvent): + triage.run(now) + + +# ----------------------------------------------------------- triage + +def test_a_utility_outage_comes_down_to_one_item(outage): + result = outage["result"]["triage"] + assert result["counts"]["journal_entries"] == 24 + assert result["counts"]["alarm_events"] == 12 + assert result["counts"]["ranked_items"] == 1 + item = result["items"][0] + assert item["point"] == "SIM-DC/UTIL-1/FAIL" + assert item["rule"] == "E-P1" + assert sorted(c["point"] for c in item["consequences"]) == sorted([ + "SIM-DC/UPS-A/ONBATT", "SIM-DC/UPS-B/ONBATT", "SIM-DC/GEN-1/RUN", + "SIM-DC/ATS-1/EMERG", "SIM-DC/GEN-1/F_LO", + ]) + assert result["writer"]["kind"] == "template" + assert item["next_step"].startswith("Review UTIL-1:") + + +@pytest.mark.parametrize("scenario, params, cause, consequences", [ + ("voltage_sag", {"duration": 6, "depth": 0.85}, "SIM-DC/UTIL-1/V_LO", + ["SIM-DC/UPS-A/ONBATT", "SIM-DC/UPS-B/ONBATT"]), + ("pdu_overload", {"pdu": "PDU-A2", "target_pct": 93, "hold": 70}, "SIM-DC/PDU-A2/LOAD_HIHI", + ["SIM-DC/PDU-A2/LOAD_HI", "SIM-DC/UPS-A/LOAD_HI"]), + ("pf_drop", {"duration": 100, "pf": 0.8}, "SIM-DC/MECH-1/PF_LO", []), +]) +def test_each_demo_scenario_comes_down_to_one_item(scenario, params, cause, consequences): + _c, now = drive(scenario, **params) + result = triage.run(now)["triage"] + assert [i["point"] for i in result["items"]] == [cause] + assert sorted(c["point"] for c in result["items"][0]["consequences"]) == sorted(consequences) + + +def test_an_unchanged_window_is_not_run_twice(): + _c, now = drive("pf_drop", event_scans=120, duration=60) + first = triage.run(now) + again = triage.run(now) + assert first["cached"] is False and again["cached"] is True + assert again["triage"] == first["triage"] + + +def test_the_run_is_quick(outage): + assert outage["result"]["elapsed_ms"] < 2000 + + +# ----------------------------------------------------------- storage + +def test_a_1_1_database_gains_the_status_columns(tmp_path, monkeypatch): + import sqlite3 + + path = str(tmp_path / "old.db") + conn = sqlite3.connect(path) + conn.executescript(""" + CREATE TABLE meter_readings (id INTEGER PRIMARY KEY AUTOINCREMENT, meter_id TEXT NOT NULL, + epoch REAL NOT NULL, timestamp TEXT NOT NULL, voltage REAL, current REAL, power_kw REAL, + power_factor REAL, frequency REAL, energy_kwh REAL, demand_kw REAL, load_pct REAL); + """) + conn.close() + database.close() + monkeypatch.setattr(config, "DB_PATH", path) + try: + database.init_db() + columns = {r["name"] for r in database._db().execute("PRAGMA table_info(meter_readings)")} + assert set(database.STATE_FIELDS) <= columns + finally: + database.close() diff --git a/backend/triage.py b/backend/triage.py new file mode 100644 index 0000000..0ffe779 --- /dev/null +++ b/backend/triage.py @@ -0,0 +1,105 @@ +"""The triage tie-in: run the BMS Alarm Triage Agent on the latest event. + +The dashboard raises the alarm flood; the agent cuts it to a short ranked +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.0 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 +changed since the last run returns the stored result, and a run over one +event takes tens of milliseconds once imported. + +The agent writes its explanations with its template writer. Claude Haiku +writes them only if TRIAGE_ANTHROPIC_KEY is set in the environment, and the +hosted demo does not set it. Strictly read only: the agent receives a copy of +the journal and history and has no path back to the alarm manager or any +meter. +""" + +import logging +import threading +import time +from typing import Any, Dict, Optional + +import triage_export + +logger = logging.getLogger(__name__) + +_lock = threading.Lock() +_cache: Dict[str, Any] = {"key": None, "value": None} + + +class NoEvent(Exception): + """There is no alarm event in the journal to triage.""" + + +class AgentUnavailable(Exception): + """The triage agent package is not installed.""" + + +class BadInput(Exception): + """The agent refused the export. Its message names the entry.""" + + +def _service(): + try: + import bms_triage_service + except ImportError as exc: # pragma: no cover - only without the dependency + raise AgentUnavailable("The triage agent is not installed on this server") from exc + return bms_triage_service + + +def _key(journal: Dict[str, Any]) -> tuple: + entries = journal["journal"] + samples = sum(len(s["samples"]) for s in journal["series"]) + return (journal["window"]["start"], journal["window"]["end"], len(entries), + entries[-1].get("id") if entries else None, samples) + + +def run(now: float, start: Optional[float] = None, end: Optional[float] = None) -> Dict[str, Any]: + """Triage the latest event, or an explicit window.""" + if start is None or end is None: + event = triage_export.latest_event(now) + if event is None: + raise NoEvent("No alarm event in the journal yet. Trigger a demo event first.") + journal = triage_export.build_journal(event["start"], event["end"], journal_from=event["first_alarm"]) + else: + event = {"first_alarm": None, "last_alarm": None, "start": start, "end": end, "settled": now >= end} + journal = triage_export.build_journal(start, end) + + window = { + "start": triage_export.iso(event["start"]), + "end": triage_export.iso(event["end"]), + "first_alarm": triage_export.iso(event["first_alarm"]) if event["first_alarm"] else None, + "last_alarm": triage_export.iso(event["last_alarm"]) if event["last_alarm"] else None, + "settled": event["settled"], + } + if not any(e["event"] == "ALARM" for e in journal["journal"]): + raise NoEvent("No alarms in that window.") + + key = _key(journal) + with _lock: + if _cache["key"] == key: + return {**_cache["value"], "cached": True} + service = _service() + from bms_alarm_triage.errors import TriageError + + started = time.perf_counter() + try: + result = service.triage(journal) + except TriageError as exc: + raise BadInput(str(exc)) from exc + elapsed = time.perf_counter() - started + value = {"event": window, "triage": result, "elapsed_ms": round(elapsed * 1000, 1), "cached": False} + _cache["key"], _cache["value"] = key, value + logger.info("Triage: %d journal entries to %d ranked item(s) in %.0f ms (%s)", + result["counts"].get("journal_entries", 0), result["counts"]["ranked_items"], + elapsed * 1000, result["writer"]["name"]) + return value + + +def clear_cache(): + with _lock: + _cache["key"] = _cache["value"] = None diff --git a/backend/triage_export.py b/backend/triage_export.py new file mode 100644 index 0000000..01021d4 --- /dev/null +++ b/backend/triage_export.py @@ -0,0 +1,145 @@ +"""Alarm journal and trend history export, in the triage agent's input format. + +The BMS Alarm Triage Agent reads one vendor-neutral JSON document per run +(format "bms-triage-journal", version 1): the alarm journal for a time +window, every alarm point with its limit, priority and meaning, the +equipment topology, and the trend history behind each point. This module +builds that document straight from the SQLite history and journal, so the +same export a person can download is what the triage endpoint hands the +agent. + +Point ids are "//", for example SIM-DC/UPS-A/ONBATT, and +each point names the series it is measured on, "//", +so the load high and load very high alarms on one PDU share one series. +""" + +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +import config +import database + +FORMAT = "bms-triage-journal" +FORMAT_VERSION = 1 + +KINDS = {"HI": "high", "HIHI": "high", "LO": "low", "LOLO": "low", "DISCRETE": "discrete"} + + +def iso(epoch: float) -> str: + return datetime.fromtimestamp(epoch, timezone.utc).isoformat(timespec="milliseconds") + + +def point_id(tag: str) -> str: + meter, _, alarm = tag.partition(".") + return f"{config.TRIAGE_SITE}/{meter}/{alarm.replace('.', '_')}" + + +def series_id(meter_id: str, parameter: str) -> str: + return f"{config.TRIAGE_SITE}/{meter_id}/{parameter}" + + +def _point(d: Dict[str, Any]) -> Dict[str, Any]: + return { + "id": point_id(d["tag"]), + "equipment": d["meter_id"], + "signal": d.get("signal", ""), + "kind": KINDS[d["kind"]], + "limit": d["setpoint"], + "deadband": d.get("deadband", 0), + "priority": d["priority"], + "units": d.get("unit", ""), + "series": series_id(d["meter_id"], d["parameter"]), + "description": d["message"], + } + + +def _units(parameter: str) -> str: + return {"voltage": "V", "frequency": "Hz", "load_pct": "%", "battery_soc": "%", "current": "A"}.get(parameter, "") + + +def build_journal(start: float, end: float, journal_from: Optional[float] = None) -> Dict[str, Any]: + """The journal document for one window. + + journal_from, when given, starts the journal entries later than the + trend history, so the history shows the plant before an event without + pulling in the tail of an earlier one. + """ + definitions = config.ALARM_POINTS + by_tag = {d["tag"]: d for d in definitions} + + entries = [] + for row in database.get_alarm_events_window(journal_from if journal_from is not None else start, end): + if row["tag"] not in by_tag: + continue # an alarm since removed from the database + entries.append({ + "id": f"J{row['id']:07d}", + "time": row["timestamp"], + "point": point_id(row["tag"]), + "event": row["event"], + "value": row["value"], + "operator": row["operator"], + "note": row["note"], + }) + + wanted = {(d["meter_id"], d["parameter"]) for d in definitions} + parameters = sorted({parameter for _meter, parameter in wanted}) + samples: Dict[str, List[list]] = {} + for row in database.get_readings_window(start, end, parameters): + for meter_id, parameter in ((row["meter_id"], p) for p in parameters): + if (meter_id, parameter) not in wanted: + continue + value = row.get(parameter) + if value is None: + continue + samples.setdefault(series_id(meter_id, parameter), []).append([row["timestamp"], value]) + + series = [ + {"id": key, "units": _units(key.rsplit("/", 1)[-1]), "samples": values} + for key, values in sorted(samples.items()) + ] + + return { + "format": FORMAT, + "version": FORMAT_VERSION, + "source": f"power-dashboard {config.VERSION}", + "site": config.TRIAGE_SITE, + # Padded by a second so millisecond rounding never puts a sample outside it + "window": {"start": iso(start - 1), "end": iso(end + 1)}, + "equipment": [ + {"id": m["id"], "type": m["type"], "fed_from": m["parent_id"]} for m in config.METERS + ], + "points": [_point(d) for d in definitions], + "journal": entries, + "series": series, + } + + +def latest_event(now: float, lookback_s: float = 7 * 86400) -> Optional[Dict[str, float]]: + """The most recent alarm event: its first alarm, last alarm, and window. + + Alarms closer together than TRIAGE_EVENT_GAP_S are one event, so an + outage's dozen alarms over two minutes are one event and the PDU + overload an hour earlier is another. The window runs from TRIAGE_PRE_S + before the first alarm to TRIAGE_POST_S after the last, or to now if + that is sooner, and never longer than TRIAGE_MAX_WINDOW_S. + """ + epochs = database.get_alarm_epochs(now - lookback_s, now) + if not epochs: + return None + last = epochs[0] + first = last + for epoch in epochs[1:]: + if first - epoch > config.TRIAGE_EVENT_GAP_S: + break + first = epoch + first = max(first, last - config.TRIAGE_MAX_WINDOW_S + config.TRIAGE_PRE_S) + start = first - config.TRIAGE_PRE_S + end = min(now, last + config.TRIAGE_POST_S) + return { + "first_alarm": first, + "last_alarm": last, + "start": start, + "end": end, + "alarms": sum(1 for e in epochs if e >= first), + "settled": now >= last + config.TRIAGE_POST_S, + } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1a3e166..a29c22a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "power-dashboard", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "power-dashboard", - "version": "1.1.0", + "version": "1.2.0", "dependencies": { "lucide-react": "^0.379.0", "react": "^18.3.1", diff --git a/frontend/package.json b/frontend/package.json index 9b5f64b..ab80b44 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "power-dashboard", "private": true, - "version": "1.1.0", + "version": "1.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 1c10069..f1c5596 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -621,6 +621,101 @@ select:focus, input[type="text"]:focus { border-color: var(--accent); } .wake-preview-title { font-size: 11px; font-weight: 650; text-transform: uppercase; letter-spacing: 0.07em; color: var(--text-muted); margin-bottom: 8px; } .wake-preview ul { padding-left: 18px; color: var(--text-secondary); display: flex; flex-direction: column; gap: 4px; font-size: 13px; } +/* ===================================================================== */ +/* Triage */ +/* ===================================================================== */ + +.triage-offer { justify-content: flex-start; } +.triage-offer-msg { flex: 1; min-width: 0; } +.triage-offer-close { width: 26px; height: 26px; color: var(--accent); } + +.tri-controls { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } +.tri-event { display: flex; align-items: center; gap: 10px; min-width: 0; font-size: 13px; } +.tri-actions { display: flex; gap: 8px; flex-wrap: wrap; } +.tri-intro { display: flex; flex-direction: column; gap: 10px; max-width: 80ch; color: var(--text-secondary); } + +.tri-list { display: flex; flex-direction: column; gap: 14px; } +.tri-item { + background: var(--bg-card); + border: 1px solid var(--border); + border-left: 4px solid var(--p4); + border-radius: var(--r-lg); + padding: 16px 18px; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + grid-template-areas: + "head head" + "why evid" + "step evid" + "cq cq" + "foot foot"; + column-gap: 22px; row-gap: 12px; + min-width: 0; +} +.tri-item.p1 { border-left-color: var(--p1); } +.tri-item.p2 { border-left-color: var(--p2); } +.tri-item.p3 { border-left-color: var(--p3); } +.tri-item-head { grid-area: head; display: flex; gap: 14px; align-items: flex-start; min-width: 0; } +.tri-rank { + flex-shrink: 0; width: 34px; height: 34px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + background: var(--accent-dim); border: 1px solid var(--accent-border); color: var(--accent); + font-family: var(--font-mono); font-weight: 700; +} +.tri-title { display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.tri-label { font-size: 16px; font-weight: 650; } +.tri-point { overflow-wrap: anywhere; } +.tri-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 2px; } +.tri-chip { + display: inline-flex; align-items: center; gap: 5px; + padding: 2px 8px; border-radius: 999px; + font-size: 11px; font-weight: 600; + background: var(--bg-elevated); border: 1px solid var(--border-bright); color: var(--text-secondary); +} +.tri-chip b { font-family: var(--font-mono); color: var(--text-primary); } +.tri-chip-cause { background: var(--accent-dim); border-color: var(--accent-border); color: var(--accent); } + +.tri-say { display: flex; flex-direction: column; gap: 3px; max-width: 70ch; } +.tri-say:not(.tri-say-step) { grid-area: why; } +.tri-say-step { grid-area: step; } +.tri-say-label { font-size: 11px; font-weight: 650; text-transform: uppercase; letter-spacing: 0.07em; color: var(--text-muted); } +.tri-say-step .tri-say-label { color: var(--accent); } + +.tri-evidence { grid-area: evid; display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.tri-chart { width: 100%; height: 72px; display: block; background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r-md); } +.tri-chart-empty { display: flex; align-items: center; justify-content: center; color: var(--text-muted); font-size: 12px; } +.tri-line { fill: none; stroke: var(--accent); stroke-width: 1.6; } +.tri-limit { stroke: var(--p2); stroke-width: 1; stroke-dasharray: 4 3; } +.tri-past { fill: var(--p2-dim); } + +.tri-cq { grid-area: cq; border-top: 1px solid var(--border); padding-top: 10px; } +.tri-alone { grid-area: cq; } +.tri-cq-toggle { + display: inline-flex; align-items: center; gap: 6px; + background: none; border: none; padding: 2px 0; + color: var(--text-secondary); font-weight: 600; font-size: 13px; text-align: left; +} +.tri-cq-toggle:hover { color: var(--text-primary); } +.tri-cq-list { list-style: none; display: flex; flex-direction: column; gap: 10px; margin: 10px 0 0 21px; } +.tri-cq-list li { display: flex; flex-direction: column; gap: 2px; min-width: 0; padding-left: 10px; border-left: 2px solid var(--border-bright); } +.tri-cq-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.tri-cq-label { font-weight: 600; } +.tri-cq-why { color: var(--text-secondary); } + +.tri-foot { grid-area: foot; display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; } +.tri-writer { + display: inline-flex; align-items: center; gap: 6px; + padding: 2px 9px; border-radius: 999px; font-size: 11px; font-weight: 650; + border: 1px dashed var(--border-bright); color: var(--text-secondary); +} +.tri-writer::before { content: "Written by"; font-weight: 500; color: var(--text-muted); } +.tri-writer.haiku { border-style: solid; border-color: var(--accent-border); color: var(--accent); } + +.tri-aside { list-style: none; display: flex; flex-direction: column; gap: 6px; } +.tri-aside li { display: flex; flex-direction: column; gap: 1px; overflow-wrap: anywhere; } +.tri-note { display: flex; align-items: flex-start; gap: 6px; max-width: 90ch; } +.tri-note svg { flex-shrink: 0; margin-top: 2px; } + /* ===================================================================== */ /* Responsive */ /* ===================================================================== */ @@ -700,8 +795,17 @@ select:focus, input[type="text"]:focus { border-color: var(--accent); } .alarm-details, .shelve-form { margin-left: 0; } .state-list-row { grid-template-columns: 1fr; gap: 4px; } + .tri-item { + grid-template-columns: minmax(0, 1fr); + grid-template-areas: "head" "why" "evid" "step" "cq" "foot"; + padding: 14px; + } + .tri-controls { align-items: stretch; } + .tri-actions .btn { flex: 1; } + .tri-cq-list { margin-left: 4px; } + .bottom-nav { - display: grid; grid-template-columns: repeat(6, 1fr); + display: grid; grid-template-columns: repeat(7, 1fr); position: fixed; left: 0; right: 0; bottom: 0; z-index: 40; height: calc(var(--bottom-nav-h) + env(safe-area-inset-bottom)); padding-bottom: env(safe-area-inset-bottom); @@ -710,7 +814,7 @@ select:focus, input[type="text"]:focus { border-color: var(--accent); } } .bottom-item { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; - font-size: 10.5px; font-weight: 550; color: var(--text-muted); + font-size: 10px; font-weight: 550; color: var(--text-muted); } .bottom-item.active { color: var(--accent); } .bottom-icon { position: relative; display: inline-flex; } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index bddc2f7..7d483dc 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,6 @@ import React, { Suspense, lazy, useCallback, useState } from 'react' import { HashRouter, Routes, Route, NavLink, Navigate } from 'react-router-dom' -import { LayoutDashboard, GitBranch, Gauge, Bell, TrendingUp, Settings, Zap, Info, FlaskConical } from 'lucide-react' +import { LayoutDashboard, GitBranch, Gauge, Bell, ListOrdered, TrendingUp, Settings, Zap, Info, FlaskConical } from 'lucide-react' import { LiveFeedProvider, useFeed } from './services/LiveFeed' import WakeScreen from './components/WakeScreen' import AboutPanel from './components/AboutPanel' @@ -14,12 +14,14 @@ import './App.css' // Chart heavy pages load on demand to keep the first load small const Meters = lazy(() => import('./pages/Meters')) const Trending = lazy(() => import('./pages/Trending')) +const Triage = lazy(() => import('./pages/Triage')) const NAV = [ { to: '/', icon: LayoutDashboard, label: 'Overview' }, { to: '/one-line', icon: GitBranch, label: 'One-Line' }, { to: '/meters', icon: Gauge, label: 'Meters' }, { to: '/alarms', icon: Bell, label: 'Alarms', alarms: true }, + { to: '/triage', icon: ListOrdered, label: 'Triage' }, { to: '/trending', icon: TrendingUp, label: 'Trends' }, { to: '/settings', icon: Settings, label: 'System' }, ] @@ -94,6 +96,7 @@ function Shell() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/AlarmBanner.jsx b/frontend/src/components/AlarmBanner.jsx index 6416fff..2bb783a 100644 --- a/frontend/src/components/AlarmBanner.jsx +++ b/frontend/src/components/AlarmBanner.jsx @@ -1,12 +1,52 @@ -import React, { useEffect, useState } from 'react' -import { Link } from 'react-router-dom' -import { Activity } from 'lucide-react' +import React, { useEffect, useRef, useState } from 'react' +import { Link, useLocation, useNavigate } from 'react-router-dom' +import { Activity, ListOrdered, X } from 'lucide-react' import { useFeed } from '../services/LiveFeed' import { api } from '../services/api' import { getOperator } from '../lib/operator' import { ago } from '../lib/format' +import { eventFinished } from '../lib/triage' import { PriorityBadge, StateBadge } from './Badges' +const OFFER_MS = 10 * 60 * 1000 // offer triage for ten minutes after an event + +// After a demo event finishes, offer to run the triage agent on it +function TriageOffer({ scenario }) { + const navigate = useNavigate() + const location = useLocation() + const previous = useRef(scenario) + const [offer, setOffer] = useState(null) + + useEffect(() => { + if (eventFinished(previous.current, scenario)) { + setOffer({ label: previous.current.label, at: Date.now() }) + } + if (scenario) setOffer(null) + previous.current = scenario + }, [scenario]) + + useEffect(() => { + if (!offer) return undefined + const t = setTimeout(() => setOffer(null), OFFER_MS - (Date.now() - offer.at)) + return () => clearTimeout(t) + }, [offer]) + + if (!offer || location.pathname === '/triage') return null + return ( +
+ + {offer.label} finished. + + +
+ ) +} + // Highest priority unacknowledged alarm, always visible under the top bar export default function AlarmBanner() { const { summary, system } = useFeed() @@ -21,6 +61,7 @@ export default function AlarmBanner() { return ( <> + {scenario && (
diff --git a/frontend/src/lib/triage.js b/frontend/src/lib/triage.js new file mode 100644 index 0000000..942ad51 --- /dev/null +++ b/frontend/src/lib/triage.js @@ -0,0 +1,117 @@ +// Helpers for the Triage tab. Pure functions, so they test without a browser. + +export const RULES = { + 'E-P1': 'Cause confirmed by trend', + 'E-P2': 'Not explained by any cause', + 'E-C1': 'Explained by the cause above', + 'E-D1': 'Not seen in the trend', +} + +export function ruleLabel(rule) { + if (!rule) return 'No rule fired' + if (RULES[rule]) return RULES[rule] + return rule +} + +export function writerLabel(writer) { + if (!writer) return '' + if (writer.kind === 'haiku') return 'Written by Claude Haiku' + if (writer.kind === 'template') return 'Written by the template, no model' + return `Written by ${writer.name}` +} + +// The per item label: an item can fall back to the template even when Haiku is on +export function itemWriter(name) { + if (!name) return '' + return /haiku/i.test(name) ? 'Claude Haiku' : 'Template, no model' +} + +export function capitalize(text) { + return text ? text[0].toUpperCase() + text.slice(1) : '' +} + +export function seconds(s) { + if (s == null) return '--' + if (s < 1) return 'one sample' + if (s < 120) return `${Math.round(s)} s` + if (s < 7200) return `${Math.round(s / 60)} min` + return `${(s / 3600).toFixed(1)} h` +} + +export function valueText(evidence, v) { + if (v == null) return '--' + if (evidence.kind === 'discrete') return v >= evidence.limit ? 'Alarm' : 'Normal' + const digits = evidence.units === 'Hz' ? 2 : evidence.units === '' ? 3 : 1 + return `${Number(v).toFixed(digits)}${evidence.units ? ` ${evidence.units}` : ''}` +} + +export function limitText(evidence) { + if (evidence.kind === 'discrete') return 'status alarm' + const op = evidence.direction === 'HIGH' ? '>' : '<' + return `limit ${op} ${valueText(evidence, evidence.limit)}` +} + +// One line on what the trend shows, for under the chart +export function evidenceText(evidence) { + 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)}` : '' + if (evidence.kind === 'discrete') { + return `In the alarm state in ${past} of ${evidence.samples} samples${span}.` + } + const worst = `, worst ${valueText(evidence, evidence.worst_value)}` + return `Past its ${limitText(evidence)} in ${past} of ${evidence.samples} samples${span}${worst}.` +} + +export function reduction(counts) { + if (!counts) return null + const flood = counts.journal_entries ?? counts.alarm_events + const items = counts.ranked_items + return { flood, items, ratio: items ? flood / items : null } +} + +// What the flood was made of, in the journal's own terms +export function floodParts(counts) { + const alarms = counts.alarms_annunciated ?? 0 + const returns = Math.max((counts.alarm_transitions ?? counts.alarm_events ?? 0) - alarms, 0) + const records = counts.operator_records ?? 0 + return `${alarms} alarms, ${returns} returns, ${records} acknowledgements and suppressions` +} + +// Geometry for the evidence chart: a line, the alarm limit, and the stretch +// the point spent past it. Returns null when there is nothing to draw. +export function chartGeometry(evidence, width, height, pad = { left: 6, right: 6, top: 8, bottom: 8 }) { + const pts = (evidence?.trend || []).map(([t, v]) => [new Date(t).getTime(), v]) + if (pts.length < 2) return null + const t0 = pts[0][0] + const t1 = pts[pts.length - 1][0] + const values = pts.map(p => p[1]).concat([evidence.limit]) + let lo = Math.min(...values) + let hi = Math.max(...values) + if (evidence.kind === 'discrete') { lo = 0; hi = 1 } + const room = (hi - lo) * 0.12 || 1 + lo -= room + hi += room + const x = t => pad.left + ((t - t0) / (t1 - t0 || 1)) * (width - pad.left - pad.right) + const y = v => pad.top + (1 - (v - lo) / (hi - lo)) * (height - pad.top - pad.bottom) + const step = evidence.kind === 'discrete' + let path = '' + pts.forEach(([t, v], i) => { + if (i === 0) path += `M${x(t).toFixed(1)},${y(v).toFixed(1)}` + else if (step) path += `H${x(t).toFixed(1)}V${y(v).toFixed(1)}` + else path += `L${x(t).toFixed(1)},${y(v).toFixed(1)}` + }) + let shade = null + if (evidence.first_past_limit && evidence.last_past_limit) { + const a = x(new Date(evidence.first_past_limit).getTime()) + const b = x(new Date(evidence.last_past_limit).getTime()) + shade = { x: a, width: Math.max(b - a, 2) } + } + return { path, limitY: y(evidence.limit), shade, t0, t1 } +} + +// Decide when to offer the button: an event just finished and the +// operator has not triaged it yet. +export function eventFinished(previous, current) { + return !!(previous && !current) +} diff --git a/frontend/src/lib/triage.test.js b/frontend/src/lib/triage.test.js new file mode 100644 index 0000000..bc8923a --- /dev/null +++ b/frontend/src/lib/triage.test.js @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { + chartGeometry, eventFinished, evidenceText, floodParts, itemWriter, reduction, ruleLabel, seconds, valueText, writerLabel, +} from './triage' +import outage from '../test/triage-outage.json' + +const cause = outage.triage.items[0] + +describe('triage helpers', () => { + it('names the rules and the writer', () => { + expect(ruleLabel('E-P1')).toBe('Cause confirmed by trend') + expect(ruleLabel('R-P3')).toBe('R-P3') + expect(writerLabel({ kind: 'template' })).toBe('Written by the template, no model') + expect(writerLabel({ kind: 'haiku', name: 'Claude Haiku' })).toBe('Written by Claude Haiku') + expect(itemWriter('Claude Haiku')).toBe('Claude Haiku') + expect(itemWriter('template, no model')).toBe('Template, no model') + }) + + it('formats durations and values', () => { + expect(seconds(0.4)).toBe('one sample') + expect(seconds(108)).toBe('108 s') + expect(seconds(600)).toBe('10 min') + expect(valueText({ kind: 'discrete', limit: 0.5 }, 1)).toBe('Alarm') + expect(valueText({ kind: 'low', units: 'Hz' }, 58.8)).toBe('58.80 Hz') + }) + + it('states the trend evidence in one line', () => { + expect(evidenceText(cause.evidence)).toMatch(/^In the alarm state in \d+ of \d+ samples for /) + expect(evidenceText({ samples_past_limit: 0 })).toMatch(/never shows/) + const sag = { kind: 'low', direction: 'LOW', limit: 432, units: 'V', samples: 12, samples_past_limit: 3, + seconds_past_limit: 6, worst_value: 410.3 } + expect(evidenceText(sag)).toBe('Past its limit < 432.0 V in 3 of 12 samples for 6 s, worst 410.3 V.') + }) + + it('breaks the flood into its parts', () => { + expect(floodParts(outage.triage.counts)).toBe('6 alarms, 6 returns, 12 acknowledgements and suppressions') + }) + + it('measures the reduction from the flood', () => { + const r = reduction(outage.triage.counts) + expect(r.flood).toBe(24) + expect(r.items).toBe(1) + expect(r.ratio).toBe(24) + }) + + it('draws the trend, the limit, and the time past it', () => { + const g = chartGeometry(cause.evidence, 320, 72) + expect(g.path.startsWith('M')).toBe(true) + expect(g.path).toContain('H') // a status point is drawn as steps + expect(g.limitY).toBeGreaterThan(0) + expect(g.limitY).toBeLessThan(72) + expect(g.shade.width).toBeGreaterThan(2) + expect(chartGeometry({ trend: [] }, 320, 72)).toBeNull() + }) + + it('offers triage only when an event has just finished', () => { + const sc = { name: 'utility_outage', label: 'Utility outage' } + expect(eventFinished(sc, null)).toBe(true) + expect(eventFinished(null, sc)).toBe(false) + expect(eventFinished(sc, sc)).toBe(false) + expect(eventFinished(null, null)).toBe(false) + }) +}) diff --git a/frontend/src/pages/Triage.jsx b/frontend/src/pages/Triage.jsx new file mode 100644 index 0000000..21c6556 --- /dev/null +++ b/frontend/src/pages/Triage.jsx @@ -0,0 +1,254 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' +import { ChevronDown, ChevronRight, Download, ListOrdered, Play, RefreshCw, ShieldCheck } from 'lucide-react' +import { api, exportJournalUrl } from '../services/api' +import { PriorityBadge } from '../components/Badges' +import { dateTimeOf, timeOf } from '../lib/format' +import { + capitalize, chartGeometry, evidenceText, floodParts, itemWriter, reduction, ruleLabel, writerLabel, +} from '../lib/triage' + +const CHART_W = 320 +const CHART_H = 72 + +export function EvidenceChart({ evidence }) { + const g = chartGeometry(evidence, CHART_W, CHART_H) + if (!g) return
No trend samples
+ return ( + + {g.shade && } + + + + ) +} + +function Consequences({ items }) { + const [open, setOpen] = useState(false) + if (!items.length) { + return

Nothing else in this window depends on it.

+ } + return ( +
+ + {open && ( +
    + {items.map(c => ( +
  • +
    + + {capitalize(c.label)} + {timeOf(c.start)} +
    +
    {c.point}
    +
    {evidenceText(c.evidence)}
    +
  • + ))} +
+ )} +
+ ) +} + +export function TriageItem({ item }) { + return ( +
+
+
{item.rank}
+
+
{capitalize(item.label)}
+
{item.point}
+
+ + {item.rule} {ruleLabel(item.rule)} + {item.consequences.length > 0 && Root cause} +
+
+
+ +
+

Why it matters

+

{item.reason}

+
+
+

Next step

+

{item.next_step}

+
+ +
+
Trend evidence
+ +

{evidenceText(item.evidence)}

+
+ + + +
+ + {itemWriter(item.written_by)} + + {dateTimeOf(item.start)} +
+
+ ) +} + +export function TriageResult({ data }) { + const result = data.triage + const counts = result.counts + const r = reduction(counts) + return ( + <> +
+
+
Alarm flood
+
{r.flood}
+
journal entries: {floodParts(counts)}
+
+
+
Ranked items
+
{r.items}
+
+ {counts.consequences_collapsed} collapsed under their cause{counts.nuisance ? `, ${counts.nuisance} nuisance` : ''}{counts.unresolved ? `, ${counts.unresolved} unresolved` : ''} +
+
+
+
Reduction
+
{r.ratio ? `${r.ratio.toFixed(0)}x` : '--'}
+
{r.flood} to read down to {r.items}
+
+
+
Writer
+
{result.writer.kind === 'haiku' ? 'Claude Haiku' : 'Template'}
+
{writerLabel(result.writer)}. Same guardrails either way.
+
+
+ + {result.items.length === 0 && ( +

No item in this window needed escalating.

+ )} +
+ {result.items.map(item => )} +
+ + {(result.nuisance.length > 0 || result.unresolved.length > 0) && ( +
+

Set aside

+
    + {result.nuisance.map(n => ( +
  • {n.point} {n.finding}
  • + ))} + {result.unresolved.map(u => ( +
  • {u.point} {u.reason}
  • + ))} +
+
+ )} + +

+ Read only. The agent gets a copy of the alarm journal and trend history for this + window and has no path back to the alarm manager or any meter. Agent {result.agent_version}, run in{' '} + {data.elapsed_ms} ms{data.cached ? ' (unchanged window, stored result)' : ''}. +

+ + ) +} + +export default function Triage() { + const location = useLocation() + const navigate = useNavigate() + const [event, setEvent] = useState(undefined) + const [data, setData] = useState(null) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const autoRan = useRef(false) + + const loadEvent = useCallback(() => { + api.getTriageEvent().then(r => setEvent(r.event)).catch(() => setEvent(null)) + }, []) + + const run = useCallback(async () => { + setBusy(true) + setError(null) + try { + const res = await api.runTriage() + setData(res) + setEvent(e => ({ ...(e || {}), ...res.event })) + } catch (err) { + setError(err.message) + } finally { + setBusy(false) + } + }, []) + + useEffect(() => { loadEvent() }, [loadEvent]) + + // Arriving from the "Triage this event" button runs straight away + useEffect(() => { + if (location.state?.run && !autoRan.current) { + autoRan.current = true + navigate(location.pathname, { replace: true, state: null }) + run() + } + }, [location.state, location.pathname, navigate, run]) + + return ( +
+
+
+

Triage

+

The BMS Alarm Triage Agent cuts the alarm flood from the latest event to a short ranked list

+
+
+ +
+
+ + {event === undefined && Looking for the latest event...} + {event === null && No alarm event yet. Trigger one from About this demo.} + {event && ( + + Latest event: first alarm {dateTimeOf(event.first_alarm)} + {event.alarms ? , {event.alarms} alarm{event.alarms === 1 ? '' : 's'} : null} + + )} +
+
+ + {event && ( + + Journal export + + )} +
+ {error &&

{error}

} +
+ + {data && } + + {!data && !busy && ( +
+

+ An outage raises a flood: the utility fails, both UPS systems go to battery, the generator starts, + the transfer switch moves, and the generator frequency dips when the load lands. The agent reads the + alarm journal and trend history for the window around the event, ranks the root cause first, and + folds its consequences underneath it, with the trend evidence and a next step for each item. +

+

+ The text is written by a fixed template from each item's own evidence. Claude Haiku writes it only + when the server has an API key set, and the hosted demo has none. Every reply passes the same checks + either way: a diagnostic verb from a closed list, the item's own equipment as the target, and no + control action. +

+
+ )} +
+ ) +} diff --git a/frontend/src/pages/Triage.test.jsx b/frontend/src/pages/Triage.test.jsx new file mode 100644 index 0000000..3976267 --- /dev/null +++ b/frontend/src/pages/Triage.test.jsx @@ -0,0 +1,71 @@ +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { MemoryRouter } from 'react-router-dom' +import { describe, expect, it } from 'vitest' +import Triage, { EvidenceChart, TriageItem, TriageResult } from './Triage' +import outage from '../test/triage-outage.json' + +const render = (el) => renderToStaticMarkup(el) +const text = (html) => html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ') + +describe('Triage tab', () => { + it('puts the root cause first with its consequences folded under it', () => { + const html = render() + const items = html.match(/class="tri-item /g) || [] + expect(items).toHaveLength(1) + expect(text(html)).toContain('Utility power failure') + expect(text(html)).toContain('Root cause') + expect(text(html)).toContain('Explains 5 more alarms, collapsed under this cause') + // collapsed by default: the consequence list is not rendered until opened + expect(html).not.toContain('tri-cq-list') + }) + + it('shows the flood against the ranked list', () => { + const t = text(render()) + expect(t).toContain('Alarm flood 24') + expect(t).toContain('Ranked items 1') + expect(t).toContain('5 collapsed under their cause') + expect(t).toContain('24x') + }) + + it('says which writer wrote the text', () => { + const t = text(render()) + expect(t).toContain('Written by the template, no model') + expect(t).toContain('Template, no model') + const haiku = { + ...outage, + triage: { + ...outage.triage, + writer: { kind: 'haiku', name: 'Claude Haiku', model: 'claude-haiku-5-5' }, + items: [{ ...outage.triage.items[0], written_by: 'Claude Haiku' }], + }, + } + const h = render() + expect(text(h)).toContain('Written by Claude Haiku') + expect(h).toContain('tri-writer haiku') + }) + + it('shows the next step and the trend evidence', () => { + const item = outage.triage.items[0] + const html = render() + expect(text(html)).toContain('Next step') + expect(text(html)).toContain(item.next_step.slice(0, 40)) + expect(html).toContain('class="tri-chart"') + expect(html).toContain('class="tri-limit"') + expect(html).toContain('class="tri-past"') + }) + + it('draws nothing for an empty trend', () => { + expect(render()).toContain('No trend samples') + }) + + it('says it is read only', () => { + expect(text(render())).toContain('Read only.') + }) + + it('renders the page shell with the triage button', () => { + const html = render() + expect(text(html)).toContain('Triage this event') + expect(text(html)).toContain('cuts the alarm flood') + }) +}) diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 0988699..4504a4c 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -31,4 +31,13 @@ export const api = { request(`/alarms/${encodeURIComponent(tag)}/shelve`, { method: 'POST', body: { minutes, reason, operator } }), unshelve: (tag, operator) => request(`/alarms/${encodeURIComponent(tag)}/unshelve`, { method: 'POST', body: { operator } }), triggerEvent: (name) => request(`/demo/events/${encodeURIComponent(name)}`, { method: 'POST' }), + getTriageEvent: () => request('/triage/event'), + // The first run imports the agent on the server, so allow longer than usual + runTriage: (window) => request('/triage', { method: 'POST', body: window || {}, timeout: 60000 }), +} + +// Download link for the alarm journal and trend history, in the triage agent's format +export function exportJournalUrl(start, end) { + const q = new URLSearchParams({ start, end }) + return `${BASE}/export/journal?${q}` } diff --git a/frontend/src/test/triage-outage.json b/frontend/src/test/triage-outage.json new file mode 100644 index 0000000..d0b458e --- /dev/null +++ b/frontend/src/test/triage-outage.json @@ -0,0 +1,1940 @@ +{ + "event": { + "start": "2026-09-21T14:13:20.000+00:00", + "end": "2026-09-21T14:23:32.000+00:00", + "first_alarm": "2026-09-21T14:18:20.000+00:00", + "last_alarm": "2026-09-21T14:18:32.000+00:00", + "settled": true + }, + "triage": { + "agent_version": "0.2.0", + "window": { + "start": "2026-09-21T14:13:19+00:00", + "end": "2026-09-21T14:23:33+00:00" + }, + "counts": { + "alarm_events": 12, + "distinct_conditions": 6, + "ranked_items": 1, + "consequences_collapsed": 5, + "nuisance": 0, + "unresolved": 0, + "model_call_failures": 0, + "journal_entries": 24, + "alarm_transitions": 12, + "alarms_annunciated": 6, + "operator_records": 12, + "points_in_alarm": 6, + "trend_samples": 5526, + "event_ack": 6, + "event_alarm": 6, + "event_rtn": 6, + "event_suppress": 3, + "event_unsuppress": 3 + }, + "items": [ + { + "point": "SIM-DC/UTIL-1/FAIL", + "equipment": "UTIL-1", + "signal": "utility_failure", + "label": "utility power failure", + "domain": "electrical", + "priority": 2, + "start": "2026-09-21T14:18:20+00:00", + "end": "2026-09-21T14:20:12+00:00", + "events": 2, + "alarms": 1, + "returns": 1, + "seconds_in_alarm": 112.0, + "band": "PROMOTED", + "rule": "E-P1", + "rule_name": "CAUSE_CONFIRMED_BY_TREND", + "finding": "the trend shows it past its limit in 55 of 67 samples around the alarm, 14:18:20 to 14:20:08, worst value 1, confirming the utility power failure; it ranks as the cause", + "preliminary_score": 0.088148, + "evidence": { + "kind": "discrete", + "limit": 0.5, + "deadband": 0.5, + "direction": "HIGH", + "units": "", + "samples": 67, + "samples_past_limit": 55, + "first_past_limit": "2026-09-21T14:18:20+00:00", + "last_past_limit": "2026-09-21T14:20:08+00:00", + "seconds_past_limit": 108.0, + "worst_value": 1.0, + "trend": [ + [ + "2026-09-21T14:13:20+00:00", + 0.0 + ], + [ + "2026-09-21T14:13:24+00:00", + 0.0 + ], + [ + "2026-09-21T14:13:30+00:00", + 0.0 + ], + [ + "2026-09-21T14:13:34+00:00", + 0.0 + ], + [ + "2026-09-21T14:13:40+00:00", + 0.0 + ], + [ + "2026-09-21T14:13:44+00:00", + 0.0 + ], + [ + "2026-09-21T14:13:50+00:00", + 0.0 + ], + [ + "2026-09-21T14:13:54+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:00+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:06+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:10+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:16+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:20+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:26+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:30+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:36+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:40+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:46+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:52+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:56+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:02+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:06+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:12+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:16+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:22+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:26+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:32+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:38+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:42+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:48+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:52+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:58+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:02+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:08+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:12+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:18+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:24+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:28+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:34+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:38+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:44+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:48+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:54+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:00+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:04+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:10+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:14+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:20+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:24+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:30+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:34+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:40+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:46+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:50+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:56+00:00", + 0.0 + ], + [ + "2026-09-21T14:18:00+00:00", + 0.0 + ], + [ + "2026-09-21T14:18:06+00:00", + 0.0 + ], + [ + "2026-09-21T14:18:10+00:00", + 0.0 + ], + [ + "2026-09-21T14:18:16+00:00", + 0.0 + ], + [ + "2026-09-21T14:18:20+00:00", + 1.0 + ], + [ + "2026-09-21T14:18:26+00:00", + 1.0 + ], + [ + "2026-09-21T14:18:32+00:00", + 1.0 + ], + [ + "2026-09-21T14:18:36+00:00", + 1.0 + ], + [ + "2026-09-21T14:18:42+00:00", + 1.0 + ], + [ + "2026-09-21T14:18:46+00:00", + 1.0 + ], + [ + "2026-09-21T14:18:52+00:00", + 1.0 + ], + [ + "2026-09-21T14:18:56+00:00", + 1.0 + ], + [ + "2026-09-21T14:19:02+00:00", + 1.0 + ], + [ + "2026-09-21T14:19:06+00:00", + 1.0 + ], + [ + "2026-09-21T14:19:12+00:00", + 1.0 + ], + [ + "2026-09-21T14:19:18+00:00", + 1.0 + ], + [ + "2026-09-21T14:19:22+00:00", + 1.0 + ], + [ + "2026-09-21T14:19:28+00:00", + 1.0 + ], + [ + "2026-09-21T14:19:32+00:00", + 1.0 + ], + [ + "2026-09-21T14:19:38+00:00", + 1.0 + ], + [ + "2026-09-21T14:19:42+00:00", + 1.0 + ], + [ + "2026-09-21T14:19:48+00:00", + 1.0 + ], + [ + "2026-09-21T14:19:52+00:00", + 1.0 + ], + [ + "2026-09-21T14:19:58+00:00", + 1.0 + ], + [ + "2026-09-21T14:20:04+00:00", + 1.0 + ], + [ + "2026-09-21T14:20:08+00:00", + 1.0 + ], + [ + "2026-09-21T14:20:14+00:00", + 0.0 + ], + [ + "2026-09-21T14:20:18+00:00", + 0.0 + ], + [ + "2026-09-21T14:20:24+00:00", + 0.0 + ], + [ + "2026-09-21T14:20:28+00:00", + 0.0 + ], + [ + "2026-09-21T14:20:34+00:00", + 0.0 + ], + [ + "2026-09-21T14:20:40+00:00", + 0.0 + ], + [ + "2026-09-21T14:20:44+00:00", + 0.0 + ], + [ + "2026-09-21T14:20:50+00:00", + 0.0 + ], + [ + "2026-09-21T14:20:54+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:00+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:04+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:10+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:14+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:20+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:26+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:30+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:36+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:40+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:46+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:50+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:56+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:00+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:06+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:12+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:16+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:22+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:26+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:32+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:36+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:42+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:46+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:52+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:58+00:00", + 0.0 + ], + [ + "2026-09-21T14:23:02+00:00", + 0.0 + ], + [ + "2026-09-21T14:23:08+00:00", + 0.0 + ], + [ + "2026-09-21T14:23:12+00:00", + 0.0 + ], + [ + "2026-09-21T14:23:18+00:00", + 0.0 + ], + [ + "2026-09-21T14:23:22+00:00", + 0.0 + ], + [ + "2026-09-21T14:23:28+00:00", + 0.0 + ] + ] + }, + "rank": 1, + "reason": "Utility power failure at UTIL-1 is confirmed in the trend from 14:18:20 to 14:20:08 (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.", + "next_step": "Review UTIL-1: the utility voltage trend from loss to return, confirm the generator carried the load and both UPS batteries recovered their charge, and note the outage times for the utility report.", + "written_by": "template, no model", + "attempts": 1, + "consequences": [ + { + "point": "SIM-DC/UPS-A/ONBATT", + "equipment": "UPS-A", + "signal": "ups_on_battery", + "label": "UPS on battery", + "domain": "electrical", + "priority": 1, + "start": "2026-09-21T14:18:20+00:00", + "end": "2026-09-21T14:18:38+00:00", + "events": 2, + "alarms": 1, + "returns": 1, + "seconds_in_alarm": 18.0, + "band": "EXPLAINED", + "rule": "E-C1", + "rule_name": "EXPLAINED_BY_CAUSE", + "finding": "starts at 14:18:20, inside the UTILITY_FAILURE window on SIM-DC/UTIL-1/FAIL, so it is a consequence of that cause and collapses under it", + "preliminary_score": 0.175104, + "evidence": { + "kind": "discrete", + "limit": 0.5, + "deadband": 0.5, + "direction": "HIGH", + "units": "", + "samples": 20, + "samples_past_limit": 8, + "first_past_limit": "2026-09-21T14:18:20+00:00", + "last_past_limit": "2026-09-21T14:18:34+00:00", + "seconds_past_limit": 14.0, + "worst_value": 1.0, + "trend": [ + [ + "2026-09-21T14:13:20+00:00", + 0.0 + ], + [ + "2026-09-21T14:13:30+00:00", + 0.0 + ], + [ + "2026-09-21T14:13:40+00:00", + 0.0 + ], + [ + "2026-09-21T14:13:50+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:00+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:10+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:20+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:30+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:40+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:52+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:02+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:12+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:22+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:32+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:42+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:52+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:02+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:12+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:24+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:34+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:44+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:54+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:04+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:14+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:24+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:34+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:46+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:56+00:00", + 0.0 + ], + [ + "2026-09-21T14:18:06+00:00", + 0.0 + ], + [ + "2026-09-21T14:18:20+00:00", + 1.0 + ], + [ + "2026-09-21T14:18:26+00:00", + 1.0 + ], + [ + "2026-09-21T14:18:36+00:00", + 0.0 + ], + [ + "2026-09-21T14:18:46+00:00", + 0.0 + ], + [ + "2026-09-21T14:18:56+00:00", + 0.0 + ], + [ + "2026-09-21T14:19:06+00:00", + 0.0 + ], + [ + "2026-09-21T14:19:18+00:00", + 0.0 + ], + [ + "2026-09-21T14:19:28+00:00", + 0.0 + ], + [ + "2026-09-21T14:19:38+00:00", + 0.0 + ], + [ + "2026-09-21T14:19:48+00:00", + 0.0 + ], + [ + "2026-09-21T14:19:58+00:00", + 0.0 + ], + [ + "2026-09-21T14:20:08+00:00", + 0.0 + ], + [ + "2026-09-21T14:20:18+00:00", + 0.0 + ], + [ + "2026-09-21T14:20:28+00:00", + 0.0 + ], + [ + "2026-09-21T14:20:40+00:00", + 0.0 + ], + [ + "2026-09-21T14:20:50+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:00+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:10+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:20+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:30+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:40+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:50+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:00+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:12+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:22+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:32+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:42+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:52+00:00", + 0.0 + ], + [ + "2026-09-21T14:23:02+00:00", + 0.0 + ], + [ + "2026-09-21T14:23:12+00:00", + 0.0 + ], + [ + "2026-09-21T14:23:22+00:00", + 0.0 + ] + ] + } + }, + { + "point": "SIM-DC/UPS-B/ONBATT", + "equipment": "UPS-B", + "signal": "ups_on_battery", + "label": "UPS on battery", + "domain": "electrical", + "priority": 1, + "start": "2026-09-21T14:18:20+00:00", + "end": "2026-09-21T14:18:38+00:00", + "events": 2, + "alarms": 1, + "returns": 1, + "seconds_in_alarm": 18.0, + "band": "EXPLAINED", + "rule": "E-C1", + "rule_name": "EXPLAINED_BY_CAUSE", + "finding": "starts at 14:18:20, inside the UTILITY_FAILURE window on SIM-DC/UTIL-1/FAIL, so it is a consequence of that cause and collapses under it", + "preliminary_score": 0.175104, + "evidence": { + "kind": "discrete", + "limit": 0.5, + "deadband": 0.5, + "direction": "HIGH", + "units": "", + "samples": 20, + "samples_past_limit": 8, + "first_past_limit": "2026-09-21T14:18:20+00:00", + "last_past_limit": "2026-09-21T14:18:34+00:00", + "seconds_past_limit": 14.0, + "worst_value": 1.0, + "trend": [ + [ + "2026-09-21T14:13:20+00:00", + 0.0 + ], + [ + "2026-09-21T14:13:30+00:00", + 0.0 + ], + [ + "2026-09-21T14:13:40+00:00", + 0.0 + ], + [ + "2026-09-21T14:13:50+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:00+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:10+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:20+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:30+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:40+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:52+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:02+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:12+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:22+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:32+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:42+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:52+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:02+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:12+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:24+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:34+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:44+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:54+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:04+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:14+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:24+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:34+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:46+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:56+00:00", + 0.0 + ], + [ + "2026-09-21T14:18:06+00:00", + 0.0 + ], + [ + "2026-09-21T14:18:20+00:00", + 1.0 + ], + [ + "2026-09-21T14:18:26+00:00", + 1.0 + ], + [ + "2026-09-21T14:18:36+00:00", + 0.0 + ], + [ + "2026-09-21T14:18:46+00:00", + 0.0 + ], + [ + "2026-09-21T14:18:56+00:00", + 0.0 + ], + [ + "2026-09-21T14:19:06+00:00", + 0.0 + ], + [ + "2026-09-21T14:19:18+00:00", + 0.0 + ], + [ + "2026-09-21T14:19:28+00:00", + 0.0 + ], + [ + "2026-09-21T14:19:38+00:00", + 0.0 + ], + [ + "2026-09-21T14:19:48+00:00", + 0.0 + ], + [ + "2026-09-21T14:19:58+00:00", + 0.0 + ], + [ + "2026-09-21T14:20:08+00:00", + 0.0 + ], + [ + "2026-09-21T14:20:18+00:00", + 0.0 + ], + [ + "2026-09-21T14:20:28+00:00", + 0.0 + ], + [ + "2026-09-21T14:20:40+00:00", + 0.0 + ], + [ + "2026-09-21T14:20:50+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:00+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:10+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:20+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:30+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:40+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:50+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:00+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:12+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:22+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:32+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:42+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:52+00:00", + 0.0 + ], + [ + "2026-09-21T14:23:02+00:00", + 0.0 + ], + [ + "2026-09-21T14:23:12+00:00", + 0.0 + ], + [ + "2026-09-21T14:23:22+00:00", + 0.0 + ] + ] + } + }, + { + "point": "SIM-DC/GEN-1/RUN", + "equipment": "GEN-1", + "signal": "generator_start", + "label": "generator start", + "domain": "electrical", + "priority": 4, + "start": "2026-09-21T14:18:28+00:00", + "end": "2026-09-21T14:20:44+00:00", + "events": 2, + "alarms": 1, + "returns": 1, + "seconds_in_alarm": 136.0, + "band": "EXPLAINED", + "rule": "E-C1", + "rule_name": "EXPLAINED_BY_CAUSE", + "finding": "starts at 14:18:28, inside the UTILITY_FAILURE window on SIM-DC/UTIL-1/FAIL, so it is a consequence of that cause and collapses under it", + "preliminary_score": -0.086713, + "evidence": { + "kind": "discrete", + "limit": 0.5, + "deadband": 0.5, + "direction": "HIGH", + "units": "", + "samples": 79, + "samples_past_limit": 67, + "first_past_limit": "2026-09-21T14:18:28+00:00", + "last_past_limit": "2026-09-21T14:20:40+00:00", + "seconds_past_limit": 132.0, + "worst_value": 1.0, + "trend": [ + [ + "2026-09-21T14:13:20+00:00", + 0.0 + ], + [ + "2026-09-21T14:13:30+00:00", + 0.0 + ], + [ + "2026-09-21T14:13:40+00:00", + 0.0 + ], + [ + "2026-09-21T14:13:50+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:00+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:10+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:20+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:30+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:40+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:52+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:02+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:12+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:22+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:32+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:42+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:52+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:02+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:12+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:24+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:34+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:44+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:54+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:04+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:14+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:24+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:34+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:46+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:56+00:00", + 0.0 + ], + [ + "2026-09-21T14:18:06+00:00", + 0.0 + ], + [ + "2026-09-21T14:18:16+00:00", + 0.0 + ], + [ + "2026-09-21T14:18:28+00:00", + 1.0 + ], + [ + "2026-09-21T14:18:36+00:00", + 1.0 + ], + [ + "2026-09-21T14:18:46+00:00", + 1.0 + ], + [ + "2026-09-21T14:18:56+00:00", + 1.0 + ], + [ + "2026-09-21T14:19:06+00:00", + 1.0 + ], + [ + "2026-09-21T14:19:18+00:00", + 1.0 + ], + [ + "2026-09-21T14:19:28+00:00", + 1.0 + ], + [ + "2026-09-21T14:19:38+00:00", + 1.0 + ], + [ + "2026-09-21T14:19:48+00:00", + 1.0 + ], + [ + "2026-09-21T14:19:58+00:00", + 1.0 + ], + [ + "2026-09-21T14:20:08+00:00", + 1.0 + ], + [ + "2026-09-21T14:20:18+00:00", + 1.0 + ], + [ + "2026-09-21T14:20:28+00:00", + 1.0 + ], + [ + "2026-09-21T14:20:40+00:00", + 1.0 + ], + [ + "2026-09-21T14:20:50+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:00+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:10+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:20+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:30+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:40+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:50+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:00+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:12+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:22+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:32+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:42+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:52+00:00", + 0.0 + ], + [ + "2026-09-21T14:23:02+00:00", + 0.0 + ], + [ + "2026-09-21T14:23:12+00:00", + 0.0 + ], + [ + "2026-09-21T14:23:22+00:00", + 0.0 + ] + ] + } + }, + { + "point": "SIM-DC/ATS-1/EMERG", + "equipment": "ATS-1", + "signal": "ats_transfer", + "label": "ATS transfer to the emergency source", + "domain": "electrical", + "priority": 3, + "start": "2026-09-21T14:18:32+00:00", + "end": "2026-09-21T14:20:42+00:00", + "events": 2, + "alarms": 1, + "returns": 1, + "seconds_in_alarm": 130.0, + "band": "EXPLAINED", + "rule": "E-C1", + "rule_name": "EXPLAINED_BY_CAUSE", + "finding": "starts at 14:18:32, inside the UTILITY_FAILURE window on SIM-DC/UTIL-1/FAIL, so it is a consequence of that cause and collapses under it", + "preliminary_score": 0.000752, + "evidence": { + "kind": "discrete", + "limit": 0.5, + "deadband": 0.5, + "direction": "HIGH", + "units": "", + "samples": 76, + "samples_past_limit": 64, + "first_past_limit": "2026-09-21T14:18:32+00:00", + "last_past_limit": "2026-09-21T14:20:38+00:00", + "seconds_past_limit": 126.0, + "worst_value": 1.0, + "trend": [ + [ + "2026-09-21T14:13:20+00:00", + 0.0 + ], + [ + "2026-09-21T14:13:30+00:00", + 0.0 + ], + [ + "2026-09-21T14:13:40+00:00", + 0.0 + ], + [ + "2026-09-21T14:13:50+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:00+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:10+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:20+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:30+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:40+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:52+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:02+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:12+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:22+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:32+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:42+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:52+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:02+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:12+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:24+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:34+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:44+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:54+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:04+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:14+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:24+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:34+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:46+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:56+00:00", + 0.0 + ], + [ + "2026-09-21T14:18:06+00:00", + 0.0 + ], + [ + "2026-09-21T14:18:16+00:00", + 0.0 + ], + [ + "2026-09-21T14:18:32+00:00", + 1.0 + ], + [ + "2026-09-21T14:18:36+00:00", + 1.0 + ], + [ + "2026-09-21T14:18:46+00:00", + 1.0 + ], + [ + "2026-09-21T14:18:56+00:00", + 1.0 + ], + [ + "2026-09-21T14:19:06+00:00", + 1.0 + ], + [ + "2026-09-21T14:19:18+00:00", + 1.0 + ], + [ + "2026-09-21T14:19:28+00:00", + 1.0 + ], + [ + "2026-09-21T14:19:38+00:00", + 1.0 + ], + [ + "2026-09-21T14:19:48+00:00", + 1.0 + ], + [ + "2026-09-21T14:19:58+00:00", + 1.0 + ], + [ + "2026-09-21T14:20:08+00:00", + 1.0 + ], + [ + "2026-09-21T14:20:18+00:00", + 1.0 + ], + [ + "2026-09-21T14:20:28+00:00", + 1.0 + ], + [ + "2026-09-21T14:20:40+00:00", + 0.0 + ], + [ + "2026-09-21T14:20:50+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:00+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:10+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:20+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:30+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:40+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:50+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:00+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:12+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:22+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:32+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:42+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:52+00:00", + 0.0 + ], + [ + "2026-09-21T14:23:02+00:00", + 0.0 + ], + [ + "2026-09-21T14:23:12+00:00", + 0.0 + ], + [ + "2026-09-21T14:23:22+00:00", + 0.0 + ] + ] + } + }, + { + "point": "SIM-DC/GEN-1/F_LO", + "equipment": "GEN-1", + "signal": "frequency_low", + "label": "generator frequency dip", + "domain": "electrical", + "priority": 3, + "start": "2026-09-21T14:18:32+00:00", + "end": "2026-09-21T14:18:36+00:00", + "events": 2, + "alarms": 1, + "returns": 1, + "seconds_in_alarm": 4.0, + "band": "EXPLAINED", + "rule": "E-C1", + "rule_name": "EXPLAINED_BY_CAUSE", + "finding": "starts at 14:18:32, inside the UTILITY_FAILURE window on SIM-DC/UTIL-1/FAIL, so it is a consequence of that cause and collapses under it", + "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", + "last_past_limit": "2026-09-21T14:18:32+00:00", + "seconds_past_limit": 10.0, + "worst_value": 0.0, + "trend": [ + [ + "2026-09-21T14:13:20+00:00", + 0.0 + ], + [ + "2026-09-21T14:13:30+00:00", + 0.0 + ], + [ + "2026-09-21T14:13:40+00:00", + 0.0 + ], + [ + "2026-09-21T14:13:50+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:00+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:10+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:20+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:30+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:40+00:00", + 0.0 + ], + [ + "2026-09-21T14:14:52+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:02+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:12+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:22+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:32+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:42+00:00", + 0.0 + ], + [ + "2026-09-21T14:15:52+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:02+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:12+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:24+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:34+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:44+00:00", + 0.0 + ], + [ + "2026-09-21T14:16:54+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:04+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:14+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:24+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:34+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:46+00:00", + 0.0 + ], + [ + "2026-09-21T14:17:56+00:00", + 0.0 + ], + [ + "2026-09-21T14:18:06+00:00", + 0.0 + ], + [ + "2026-09-21T14:18:16+00:00", + 0.0 + ], + [ + "2026-09-21T14:18:26+00:00", + 40.0 + ], + [ + "2026-09-21T14:18:40+00:00", + 59.92 + ], + [ + "2026-09-21T14:18:50+00:00", + 59.99 + ], + [ + "2026-09-21T14:19:00+00:00", + 59.97 + ], + [ + "2026-09-21T14:19:06+00:00", + 59.94 + ], + [ + "2026-09-21T14:19:26+00:00", + 59.96 + ], + [ + "2026-09-21T14:19:30+00:00", + 59.95 + ], + [ + "2026-09-21T14:19:38+00:00", + 59.92 + ], + [ + "2026-09-21T14:19:50+00:00", + 59.99 + ], + [ + "2026-09-21T14:20:02+00:00", + 59.97 + ], + [ + "2026-09-21T14:20:16+00:00", + 59.93 + ], + [ + "2026-09-21T14:20:20+00:00", + 59.93 + ], + [ + "2026-09-21T14:20:28+00:00", + 59.92 + ], + [ + "2026-09-21T14:20:48+00:00", + 59.96 + ], + [ + "2026-09-21T14:20:56+00:00", + 59.96 + ], + [ + "2026-09-21T14:21:08+00:00", + 59.95 + ], + [ + "2026-09-21T14:21:12+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:20+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:30+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:40+00:00", + 0.0 + ], + [ + "2026-09-21T14:21:50+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:00+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:12+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:22+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:32+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:42+00:00", + 0.0 + ], + [ + "2026-09-21T14:22:52+00:00", + 0.0 + ], + [ + "2026-09-21T14:23:02+00:00", + 0.0 + ], + [ + "2026-09-21T14:23:12+00:00", + 0.0 + ], + [ + "2026-09-21T14:23:22+00:00", + 0.0 + ] + ] + } + } + ] + } + ], + "nuisance": [], + "unresolved": [], + "elapsed_s": 0.075, + "writer": { + "kind": "template", + "name": "template, no model", + "model": null + }, + "read_only": true + }, + "elapsed_ms": 64.0, + "cached": false +}