Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ What else is implemented:

ISA 18.2 alarm handling keeps the list honest, but a utility outage still annunciates six alarms in twelve seconds: utility failure, both UPS systems on battery, the generator running, the transfer switch on the emergency source, and the generator frequency dip when the load lands. Only one of those is the thing that happened.

The **Triage** tab hands the alarm journal and trend history for the window around the latest event to the [BMS Alarm Triage Agent](https://github.com/AddisonTech/bms-alarm-triage-agent), version 0.2.0, pinned in `backend/requirements.txt` to the commit that release was merged as, and shows what comes back:
The **Triage** tab hands the alarm journal and trend history for the window around the latest event to the [BMS Alarm Triage Agent](https://github.com/AddisonTech/bms-alarm-triage-agent), version 0.2.1, 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.
Expand All @@ -69,6 +69,13 @@ After a demo event finishes, a *Triage this event* button appears under the top

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.

**Time zones.** The server keeps every time in UTC with an explicit offset. The Triage tab sends the browser's time zone with each run, the agent writes every time in its text in that zone, and the tab shows every time (header, cards, consequences) in the same zone with a short label, such as `15:28:45 EDT`. The labels come from the server for each UTC offset the event touches, so the header the browser formats and the sentence the agent wrote always carry the same label. The alarm list and journal show times in the viewer's zone, labeled the same way.

| Endpoint | Time zone |
|---|---|
| `GET /api/triage/event?time_zone=America/New_York` | Returns the zone's label for each offset in the event's window |
| `POST /api/triage` with `{"time_zone": "America/New_York"}` | The agent's text uses that zone; default UTC. An unknown name is a 400 |

**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 |
Expand Down Expand Up @@ -143,7 +150,7 @@ Demo scenarios:
| API and WebSocket | Python 3.13, FastAPI 0.111, Uvicorn |
| Storage | SQLite (WAL mode) |
| Field protocol | Modbus TCP via pymodbus (optional) |
| Alarm triage | BMS Alarm Triage Agent 0.2.0, pinned to its release commit |
| Alarm triage | BMS Alarm Triage Agent 0.2.1, pinned to its release commit |
| Front end | React 18, Vite 5, React Router 6 |
| Charts and icons | Recharts (trend pages), inline SVG, Lucide |
| Tests | pytest, FastAPI TestClient, Vitest |
Expand Down
2 changes: 1 addition & 1 deletion backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import os

VERSION = "1.2.0"
VERSION = "1.2.1"


def _env_bool(name: str, default: bool) -> bool:
Expand Down
24 changes: 19 additions & 5 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,12 +258,21 @@ def export_journal(request: Request, start: Optional[str] = None, end: Optional[


@app.get("/api/triage/event")
def triage_event(request: Request):
"""The most recent alarm event, if any. Cheap; used to offer the triage button."""
def triage_event(request: Request, time_zone: Optional[str] = None):
"""The most recent alarm event, if any. Cheap; used to offer the triage button.

With time_zone, also the label for each UTC offset the event touches in
that zone, so the header is labeled exactly as the agent's text will be.
"""
try:
zone_name = triage.check_zone(time_zone)
except triage.BadZone as exc:
raise HTTPException(status_code=400, detail=str(exc))
event = triage_export.latest_event(_collector(request).clock())
if event is None:
return {"event": None}
return {"event": {
return {"event": None, "zone": triage.zone_labels(zone_name, [])}
zone = triage.zone_labels(zone_name, [event["start"], event["first_alarm"], event["last_alarm"], event["end"]])
return {"zone": zone, "event": {
"first_alarm": triage_export.iso(event["first_alarm"]),
"last_alarm": triage_export.iso(event["last_alarm"]),
"start": triage_export.iso(event["start"]),
Expand All @@ -276,6 +285,9 @@ def triage_event(request: Request):
class TriageRequest(BaseModel):
start: Optional[str] = None
end: Optional[str] = None
# The viewer's IANA zone, from the browser. Every time in the agent's
# text is written in it, labeled. Default UTC.
time_zone: Optional[str] = Field(None, max_length=64)


@app.post("/api/triage")
Expand All @@ -286,7 +298,9 @@ def run_triage(request: Request, body: Optional[TriageRequest] = 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)
return triage.run(now, start, end, time_zone=body.time_zone if body else None)
except triage.BadZone as exc:
raise HTTPException(status_code=400, detail=str(exc))
except triage.NoEvent as exc:
raise HTTPException(status_code=404, detail=str(exc))
except triage.AgentUnavailable as exc:
Expand Down
2 changes: 1 addition & 1 deletion backend/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
fastapi==0.111.0
uvicorn[standard]==0.29.0
pydantic==2.14.1
bms-alarm-triage-agent @ git+https://github.com/AddisonTech/bms-alarm-triage-agent@2060b45eaf1e24b446313663ac45d6c86117c165
bms-alarm-triage-agent @ git+https://github.com/AddisonTech/bms-alarm-triage-agent@76357a837ad98eafb1df2352696d9a3739ff1bc1
2 changes: 1 addition & 1 deletion backend/tests/test_api_websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def test_health(client):
body = client.get("/api/health").json()
assert body["status"] == "ok"
assert body["data_source"] == "simulator"
assert body["version"] == "1.2.0"
assert body["version"] == "1.2.1"


def test_websocket_sends_snapshot_on_connect(client):
Expand Down
32 changes: 30 additions & 2 deletions backend/tests/test_triage_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def test_export_returns_the_journal_format(client):
assert res.headers["content-disposition"].startswith('attachment; filename="alarm-journal-')
doc = res.json()
assert doc["format"] == "bms-triage-journal" and doc["version"] == 1
assert doc["source"] == "power-dashboard 1.2.0"
assert doc["source"] == "power-dashboard 1.2.1"
parsed = agent_journal.parse(doc)
assert parsed.points and parsed.series

Expand Down Expand Up @@ -129,4 +129,32 @@ def test_the_agent_is_pinned_to_one_release():
def test_the_installed_agent_is_0_2():
import bms_alarm_triage

assert bms_alarm_triage.__version__.startswith("0.2.")
assert bms_alarm_triage.__version__ == "0.2.1"


@pytest.mark.parametrize("zone_name", ["America/New_York", "UTC"])
def test_the_event_and_the_text_use_the_viewers_zone(client, zone_name):
import re
from datetime import datetime
from zoneinfo import ZoneInfo

triage.clear_cache()
event = client.get("/api/triage/event", params={"time_zone": zone_name}).json()
assert event["zone"]["name"] == zone_name
labels = event["zone"]["labels"]
res = client.post("/api/triage", json={"time_zone": zone_name})
assert res.status_code == 200, res.text
body = res.json()
assert body["triage"]["time_zone"]["name"] == zone_name
assert body["zone"]["labels"] == labels # header and text labeled from one map
for item in body["triage"]["items"]:
for clock, label in re.findall(r"\b(\d\d:\d\d:\d\d) ([A-Z][A-Za-z0-9+:-]*)", item["reason"] + item["finding"]):
assert label in body["zone"]["labels"].values()
first = datetime.fromisoformat(body["event"]["first_alarm"]).astimezone(ZoneInfo(zone_name))
offset = first.strftime("%z")
assert f"{offset[:3]}:{offset[3:]}" in body["zone"]["labels"]


def test_a_bad_zone_is_a_400(client):
assert client.post("/api/triage", json={"time_zone": "Mars/Olympus"}).status_code == 400
assert client.get("/api/triage/event", params={"time_zone": "Mars/Olympus"}).status_code == 400
41 changes: 41 additions & 0 deletions backend/tests/test_triage_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,44 @@ def test_a_1_1_database_gains_the_status_columns(tmp_path, monkeypatch):
assert set(database.STATE_FIELDS) <= columns
finally:
database.close()


# ------------------------------------------------------------ time zones

@pytest.mark.parametrize("zone_name, label", [("America/New_York", "EDT"), ("UTC", "UTC")])
def test_header_and_card_text_agree_in_the_viewers_zone(zone_name, label):
"""The header's first alarm, converted to the viewer's zone, is the time
the card's sentence states, with the same label."""
import re
from datetime import datetime
from zoneinfo import ZoneInfo

_c, now = drive("utility_outage", duration=110)
out = triage.run(now, time_zone=zone_name)
assert out["zone"]["name"] == zone_name
assert set(out["zone"]["labels"].values()) == {label}
header = datetime.fromisoformat(out["event"]["first_alarm"]).astimezone(ZoneInfo(zone_name))
item = out["triage"]["items"][0]
stated = re.findall(r"\b(\d\d:\d\d:\d\d) ([A-Z]+)\b", item["reason"])
assert stated[0] == (header.strftime("%H:%M:%S"), label)
assert {lab for _t, lab in stated} == {label}
for consequence in item["consequences"]:
start = datetime.fromisoformat(consequence["start"]).astimezone(ZoneInfo(zone_name))
assert f"{start:%H:%M:%S} {label}" in consequence["finding"]
assert out["event"]["first_alarm"].endswith("+00:00")


def test_each_zone_is_cached_on_its_own():
_c, now = drive("pf_drop", event_scans=120, duration=60)
ny = triage.run(now, time_zone="America/New_York")
utc = triage.run(now, time_zone="UTC")
assert ny["cached"] is False and utc["cached"] is False
assert "EDT" in ny["triage"]["items"][0]["reason"]
assert "UTC" in utc["triage"]["items"][0]["reason"]


def test_a_bad_zone_is_refused():
with pytest.raises(triage.BadZone):
triage.check_zone("Mars/Olympus")
assert triage.check_zone(None) == "UTC"
assert triage.check_zone("") == "UTC"
55 changes: 49 additions & 6 deletions backend/triage.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
list. This module finds the window around the most recent alarm event,
exports the alarm journal and trend history for it (triage_export.py), and
hands that document to the agent, which is installed as a package pinned to
its 0.2.0 release commit.
its 0.2.1 release commit.

Light enough for a free hosting tier: the agent is imported on first use
rather than at startup, one run happens at a time, a window that has not
Expand Down Expand Up @@ -43,6 +43,45 @@ class BadInput(Exception):
"""The agent refused the export. Its message names the entry."""


class BadZone(Exception):
"""The time zone is not an IANA name."""


def check_zone(name: Optional[str]) -> str:
"""The viewer's IANA zone, or UTC. Refuses anything else."""
from bms_alarm_triage import display

name = (name or "").strip() or display.DEFAULT_ZONE
try:
display.zone(name)
except display.UnknownTimeZone as exc:
raise BadZone(str(exc)) from exc
return name


def zone_labels(name: str, epochs) -> Dict[str, Any]:
"""The zone's label for every UTC offset the window touches.

The browser converts each time itself and looks its label up here by
offset, so a header the browser formats and a sentence the agent wrote
carry the same label, EDT next to EDT, even across a daylight saving
change and in zones a browser would only call GMT+2.
"""
from datetime import datetime, timezone

from bms_alarm_triage import display

tz = display.zone(name)
labels = {}
for epoch in epochs:
if epoch is None:
continue
moment = datetime.fromtimestamp(epoch, timezone.utc)
offset = moment.astimezone(tz).strftime("%z")
labels[f"{offset[:3]}:{offset[3:]}"] = display.label(moment, tz)
return {"name": name, "labels": labels}


def _service():
try:
import bms_triage_service
Expand All @@ -58,8 +97,10 @@ def _key(journal: Dict[str, Any]) -> tuple:
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."""
def run(now: float, start: Optional[float] = None, end: Optional[float] = None,
time_zone: Optional[str] = None) -> Dict[str, Any]:
"""Triage the latest event, or an explicit window, with its text in one zone."""
zone_name = check_zone(time_zone)
if start is None or end is None:
event = triage_export.latest_event(now)
if event is None:
Expand All @@ -79,7 +120,8 @@ def run(now: float, start: Optional[float] = None, end: Optional[float] = None)
if not any(e["event"] == "ALARM" for e in journal["journal"]):
raise NoEvent("No alarms in that window.")

key = _key(journal)
zone = zone_labels(zone_name, [event["start"], event["first_alarm"], event["last_alarm"], event["end"]])
key = (_key(journal), zone_name)
with _lock:
if _cache["key"] == key:
return {**_cache["value"], "cached": True}
Expand All @@ -88,11 +130,12 @@ def run(now: float, start: Optional[float] = None, end: Optional[float] = None)

started = time.perf_counter()
try:
result = service.triage(journal)
result = service.triage(journal, time_zone=zone_name)
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}
value = {"event": window, "zone": zone, "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"],
Expand Down
4 changes: 2 additions & 2 deletions frontend/package-lock.json

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

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "power-dashboard",
"private": true,
"version": "1.2.0",
"version": "1.2.1",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,7 @@ select:focus, input[type="text"]:focus { border-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-zone { font-size: 11.5px; }
.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); }

Expand Down
14 changes: 7 additions & 7 deletions frontend/src/lib/format.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { clockIn, dateTimeIn, viewerZone } from './zone'

export function num(v, digits = 1) {
return v == null || Number.isNaN(v) ? '--' : Number(v).toFixed(digits)
}
Expand All @@ -7,15 +9,13 @@ export function kw(v) {
return v >= 1000 ? `${(v / 1000).toFixed(2)} MW` : `${v.toFixed(0)} kW`
}

export function timeOf(iso) {
if (!iso) return '--'
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' })
// Clock times are shown in the viewer's zone, labeled ("15:28:45 EDT")
export function timeOf(iso, zone = viewerZone(), labels) {
return clockIn(iso, zone, labels)
}

export function dateTimeOf(iso) {
if (!iso) return '--'
const d = new Date(iso)
return `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })} ${timeOf(iso)}`
export function dateTimeOf(iso, zone = viewerZone(), labels) {
return dateTimeIn(iso, zone, labels)
}

export function ago(iso, now = Date.now()) {
Expand Down
53 changes: 53 additions & 0 deletions frontend/src/lib/zone.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Times shown to a viewer: one zone, always labeled.
//
// The server keeps every time as UTC ISO 8601 with an offset. The browser
// converts each one to the viewer's IANA zone. The label comes from the
// server's map of UTC offset to label for that zone (the same labels the
// triage agent writes into its text), so a header formatted here and a
// sentence written there say "15:28:45 EDT" alike. Without a map, the
// browser's own short name is used.

export function viewerZone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
} catch {
return 'UTC'
}
}

function parts(iso, zone, options) {
const fmt = new Intl.DateTimeFormat('en-US', { timeZone: zone, ...options })
const out = {}
for (const p of fmt.formatToParts(new Date(iso))) out[p.type] = p.value
return out
}

// "-04:00" for a moment in New York in summer, "+00:00" for UTC
export function offsetOf(iso, zone) {
const name = parts(iso, zone, { timeZoneName: 'longOffset' }).timeZoneName || 'GMT'
const m = /GMT([+-]\d{2}):?(\d{2})?/.exec(name)
return m ? `${m[1]}:${m[2] || '00'}` : '+00:00'
}

export function zoneLabel(iso, zone, labels) {
const fromServer = labels && labels[offsetOf(iso, zone)]
if (fromServer) return fromServer
const short = parts(iso, zone, { timeZoneName: 'short' }).timeZoneName || 'UTC'
return short === 'GMT' && offsetOf(iso, zone) === '+00:00' ? 'UTC' : short.replace(/^GMT/, 'UTC')
}

const CLOCK = { hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }

// "15:28:45 EDT"
export function clockIn(iso, zone = viewerZone(), labels) {
if (!iso) return '--'
const p = parts(iso, zone, CLOCK)
return `${p.hour}:${p.minute}:${p.second} ${zoneLabel(iso, zone, labels)}`
}

// "Oct 16 15:28:45 EDT"
export function dateTimeIn(iso, zone = viewerZone(), labels) {
if (!iso) return '--'
const p = parts(iso, zone, { month: 'short', day: 'numeric', ...CLOCK })
return `${p.month} ${p.day} ${p.hour}:${p.minute}:${p.second} ${zoneLabel(iso, zone, labels)}`
}
Loading
Loading