diff --git a/README.md b/README.md index 7be4926..ebe47c1 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 | @@ -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 | diff --git a/backend/config.py b/backend/config.py index 9b86a00..8c31e4b 100644 --- a/backend/config.py +++ b/backend/config.py @@ -6,7 +6,7 @@ import os -VERSION = "1.2.0" +VERSION = "1.2.1" def _env_bool(name: str, default: bool) -> bool: diff --git a/backend/main.py b/backend/main.py index 9a9393f..9e1f57e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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"]), @@ -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") @@ -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: diff --git a/backend/requirements.txt b/backend/requirements.txt index ddc0218..55ec024 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,4 @@ fastapi==0.111.0 uvicorn[standard]==0.29.0 pydantic==2.14.1 -bms-alarm-triage-agent @ git+https://github.com/AddisonTech/bms-alarm-triage-agent@2060b45eaf1e24b446313663ac45d6c86117c165 +bms-alarm-triage-agent @ git+https://github.com/AddisonTech/bms-alarm-triage-agent@76357a837ad98eafb1df2352696d9a3739ff1bc1 diff --git a/backend/tests/test_api_websocket.py b/backend/tests/test_api_websocket.py index c7ce603..1d019fd 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.2.0" + assert body["version"] == "1.2.1" def test_websocket_sends_snapshot_on_connect(client): diff --git a/backend/tests/test_triage_api.py b/backend/tests/test_triage_api.py index 160925f..7273800 100644 --- a/backend/tests/test_triage_api.py +++ b/backend/tests/test_triage_api.py @@ -21,7 +21,7 @@ def test_export_returns_the_journal_format(client): assert res.headers["content-disposition"].startswith('attachment; filename="alarm-journal-') doc = res.json() assert doc["format"] == "bms-triage-journal" and doc["version"] == 1 - assert doc["source"] == "power-dashboard 1.2.0" + assert doc["source"] == "power-dashboard 1.2.1" parsed = agent_journal.parse(doc) assert parsed.points and parsed.series @@ -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 diff --git a/backend/tests/test_triage_export.py b/backend/tests/test_triage_export.py index 8f48923..4d42732 100644 --- a/backend/tests/test_triage_export.py +++ b/backend/tests/test_triage_export.py @@ -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" diff --git a/backend/triage.py b/backend/triage.py index 0ffe779..ae9ba7a 100644 --- a/backend/triage.py +++ b/backend/triage.py @@ -4,7 +4,7 @@ list. This module finds the window around the most recent alarm event, exports the alarm journal and trend history for it (triage_export.py), and hands that document to the agent, which is installed as a package pinned to -its 0.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 @@ -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 @@ -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: @@ -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} @@ -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"], diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a29c22a..a8880f4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "power-dashboard", - "version": "1.2.0", + "version": "1.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "power-dashboard", - "version": "1.2.0", + "version": "1.2.1", "dependencies": { "lucide-react": "^0.379.0", "react": "^18.3.1", diff --git a/frontend/package.json b/frontend/package.json index ab80b44..9c5b0e0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "power-dashboard", "private": true, - "version": "1.2.0", + "version": "1.2.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index f1c5596..dca78dd 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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); } diff --git a/frontend/src/lib/format.js b/frontend/src/lib/format.js index 14acf8b..d12500a 100644 --- a/frontend/src/lib/format.js +++ b/frontend/src/lib/format.js @@ -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) } @@ -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()) { diff --git a/frontend/src/lib/zone.js b/frontend/src/lib/zone.js new file mode 100644 index 0000000..1630205 --- /dev/null +++ b/frontend/src/lib/zone.js @@ -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)}` +} diff --git a/frontend/src/lib/zone.test.js b/frontend/src/lib/zone.test.js new file mode 100644 index 0000000..c8189c4 --- /dev/null +++ b/frontend/src/lib/zone.test.js @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { clockIn, dateTimeIn, offsetOf, zoneLabel } from './zone' +import { dateTimeOf, timeOf } from './format' + +const SUMMER = '2026-10-16T19:28:45.000+00:00' +const WINTER = '2026-01-16T19:28:45.000+00:00' + +describe('zone helpers', () => { + it('converts to the zone and labels it', () => { + expect(clockIn(SUMMER, 'America/New_York')).toBe('15:28:45 EDT') + expect(clockIn(WINTER, 'America/New_York')).toBe('14:28:45 EST') + expect(clockIn(SUMMER, 'UTC')).toBe('19:28:45 UTC') + expect(dateTimeIn(SUMMER, 'America/New_York')).toBe('Oct 16 15:28:45 EDT') + }) + + it('reads the offset the server keys its labels by', () => { + expect(offsetOf(SUMMER, 'America/New_York')).toBe('-04:00') + expect(offsetOf(SUMMER, 'UTC')).toBe('+00:00') + expect(offsetOf(SUMMER, 'Asia/Kolkata')).toBe('+05:30') + }) + + it('takes the label from the server when it has one', () => { + expect(zoneLabel(SUMMER, 'Europe/Berlin', { '+02:00': 'CEST' })).toBe('CEST') + expect(clockIn(SUMMER, 'Europe/Berlin', { '+02:00': 'CEST' })).toBe('21:28:45 CEST') + expect(zoneLabel(SUMMER, 'Europe/Berlin')).toBe('UTC+2') + }) + + it('labels the shared formatters too', () => { + expect(timeOf(SUMMER, 'America/New_York')).toBe('15:28:45 EDT') + expect(dateTimeOf(SUMMER, 'UTC')).toBe('Oct 16 19:28:45 UTC') + expect(timeOf(null)).toBe('--') + }) +}) diff --git a/frontend/src/pages/Triage.jsx b/frontend/src/pages/Triage.jsx index 21c6556..fd4c06e 100644 --- a/frontend/src/pages/Triage.jsx +++ b/frontend/src/pages/Triage.jsx @@ -3,7 +3,7 @@ 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 { clockIn, dateTimeIn, viewerZone, zoneLabel } from '../lib/zone' import { capitalize, chartGeometry, evidenceText, floodParts, itemWriter, reduction, ruleLabel, writerLabel, } from '../lib/triage' @@ -24,7 +24,7 @@ export function EvidenceChart({ evidence }) { ) } -function Consequences({ items }) { +function Consequences({ items, zone }) { const [open, setOpen] = useState(false) if (!items.length) { return

Nothing else in this window depends on it.

@@ -42,7 +42,7 @@ function Consequences({ items }) {
{capitalize(c.label)} - {timeOf(c.start)} + {clockIn(c.start, zone.name, zone.labels)}
{c.point}
{evidenceText(c.evidence)}
@@ -54,7 +54,7 @@ function Consequences({ items }) { ) } -export function TriageItem({ item }) { +export function TriageItem({ item, zone }) { return (
@@ -85,20 +85,36 @@ export function TriageItem({ item }) {

{evidenceText(item.evidence)}

- +
) } +// The zone the agent wrote its text in, with the labels for its offsets +function zoneOf(data) { + return { name: data.triage.time_zone?.name || data.zone?.name || 'UTC', labels: data.zone?.labels || {} } +} + +export function ZoneNote({ zone, at }) { + if (!zone) return null + const label = at ? zoneLabel(at, zone.name, zone.labels) : 'UTC' + return ( + + Times in {zone.name}{label === zone.name ? '' : ` (${label})`} + + ) +} + export function TriageResult({ data }) { const result = data.triage + const zone = zoneOf(data) const counts = result.counts const r = reduction(counts) return ( @@ -132,7 +148,7 @@ export function TriageResult({ data }) {

No item in this window needed escalating.

)}
- {result.items.map(item => )} + {result.items.map(item => )}
{(result.nuisance.length > 0 || result.unresolved.length > 0) && ( @@ -161,29 +177,34 @@ export function TriageResult({ data }) { export default function Triage() { const location = useLocation() const navigate = useNavigate() + const [timeZone] = useState(viewerZone) const [event, setEvent] = useState(undefined) + const [zone, setZone] = useState({ name: timeZone, labels: {} }) 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)) - }, []) + api.getTriageEvent(timeZone) + .then(r => { setEvent(r.event); if (r.zone) setZone(r.zone) }) + .catch(() => setEvent(null)) + }, [timeZone]) const run = useCallback(async () => { setBusy(true) setError(null) try { - const res = await api.runTriage() + const res = await api.runTriage({ time_zone: timeZone }) setData(res) setEvent(e => ({ ...(e || {}), ...res.event })) + if (res.zone) setZone(res.zone) } catch (err) { setError(err.message) } finally { setBusy(false) } - }, []) + }, [timeZone]) useEffect(() => { loadEvent() }, [loadEvent]) @@ -212,8 +233,9 @@ export default function Triage() { {event === null && No alarm event yet. Trigger one from About this demo.} {event && ( - Latest event: first alarm {dateTimeOf(event.first_alarm)} + Latest event: first alarm {dateTimeIn(event.first_alarm, zone.name, zone.labels)} {event.alarms ? , {event.alarms} alarm{event.alarms === 1 ? '' : 's'} : null} +
)} diff --git a/frontend/src/pages/Triage.test.jsx b/frontend/src/pages/Triage.test.jsx index 3976267..8c34d3b 100644 --- a/frontend/src/pages/Triage.test.jsx +++ b/frontend/src/pages/Triage.test.jsx @@ -2,8 +2,10 @@ 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 Triage, { EvidenceChart, TriageItem, TriageResult, ZoneNote } from './Triage' +import { clockIn, dateTimeIn } from '../lib/zone' import outage from '../test/triage-outage.json' +import outageUtc from '../test/triage-outage-utc.json' const render = (el) => renderToStaticMarkup(el) const text = (html) => html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ') @@ -47,7 +49,7 @@ describe('Triage tab', () => { it('shows the next step and the trend evidence', () => { const item = outage.triage.items[0] - const html = render() + 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"') @@ -69,3 +71,43 @@ describe('Triage tab', () => { expect(text(html)).toContain('cuts the alarm flood') }) }) + +describe('Triage tab times', () => { + const cases = [ + ['America/New_York', 'EDT', outage], + ['UTC', 'UTC', outageUtc], + ] + + it.each(cases)('states the card times in %s, labeled', (zoneName, label, data) => { + const item = data.triage.items[0] + const labels = data.zone.labels + // what the browser formats for the header and the card footer + const first = clockIn(item.evidence.first_past_limit, zoneName, labels) + const last = clockIn(item.evidence.last_past_limit, zoneName, labels) + expect(first.endsWith(` ${label}`)).toBe(true) + // what the agent wrote in the card's sentence + expect(item.reason).toContain(`from ${first} to ${last}`) + const html = render() + const t = text(html) + expect(t).toContain(dateTimeIn(item.start, zoneName, labels)) + // no clock anywhere on the card without its label + expect(t.match(/\b\d\d:\d\d:\d\d(?! [A-Z])/g)).toBeNull() + const labelsSeen = new Set([...t.matchAll(/\b\d\d:\d\d:\d\d ([A-Z]+)/g)].map(m => m[1])) + expect([...labelsSeen]).toEqual([label]) + }) + + it('names the zone it is showing', () => { + const zone = outage.zone + const t = text(render()) + expect(t).toContain('Times in America/New_York (EDT)') + const u = text(render()) + expect(u.trim()).toBe('Times in UTC') + }) + + it('shows New York and UTC an exact conversion apart', () => { + const ny = outage.triage.items[0].reason.match(/(\d\d):(\d\d:\d\d) EDT/) + const utc = outageUtc.triage.items[0].reason.match(/(\d\d):(\d\d:\d\d) UTC/) + expect((Number(utc[1]) - Number(ny[1]) + 24) % 24).toBe(4) + expect(ny[2]).toBe(utc[2]) + }) +}) diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 4504a4c..0aa140a 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -31,7 +31,7 @@ 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'), + getTriageEvent: (timeZone) => request(`/triage/event?time_zone=${encodeURIComponent(timeZone || 'UTC')}`), // The first run imports the agent on the server, so allow longer than usual runTriage: (window) => request('/triage', { method: 'POST', body: window || {}, timeout: 60000 }), } diff --git a/frontend/src/test/triage-outage-utc.json b/frontend/src/test/triage-outage-utc.json new file mode 100644 index 0000000..a29fb38 --- /dev/null +++ b/frontend/src/test/triage-outage-utc.json @@ -0,0 +1,1950 @@ +{ + "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 + }, + "zone": { + "name": "UTC", + "labels": { + "+00:00": "UTC" + } + }, + "triage": { + "agent_version": "0.2.1", + "time_zone": { + "name": "UTC", + "label": "UTC" + }, + "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 UTC to 14:20:08 UTC, 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 UTC to 14:20:08 UTC (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 UTC, 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 UTC, 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 UTC, 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 UTC, 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 UTC, 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.039, + "writer": { + "kind": "template", + "name": "template, no model", + "model": null + }, + "read_only": true + }, + "elapsed_ms": 64.0, + "cached": false +} diff --git a/frontend/src/test/triage-outage.json b/frontend/src/test/triage-outage.json index d0b458e..cc096f6 100644 --- a/frontend/src/test/triage-outage.json +++ b/frontend/src/test/triage-outage.json @@ -6,8 +6,18 @@ "last_alarm": "2026-09-21T14:18:32.000+00:00", "settled": true }, + "zone": { + "name": "America/New_York", + "labels": { + "-04:00": "EDT" + } + }, "triage": { - "agent_version": "0.2.0", + "agent_version": "0.2.1", + "time_zone": { + "name": "America/New_York", + "label": "EDT" + }, "window": { "start": "2026-09-21T14:13:19+00:00", "end": "2026-09-21T14:23:33+00:00" @@ -49,7 +59,7 @@ "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", + "finding": "the trend shows it past its limit in 55 of 67 samples around the alarm, 10:18:20 EDT to 10:20:08 EDT, worst value 1, confirming the utility power failure; it ranks as the cause", "preliminary_score": 0.088148, "evidence": { "kind": "discrete", @@ -547,7 +557,7 @@ ] }, "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.", + "reason": "Utility power failure at UTIL-1 is confirmed in the trend from 10:18:20 EDT to 10:20:08 EDT (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, @@ -568,7 +578,7 @@ "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", + "finding": "starts at 10:18:20 EDT, 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", @@ -842,7 +852,7 @@ "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", + "finding": "starts at 10:18:20 EDT, 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", @@ -1116,7 +1126,7 @@ "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", + "finding": "starts at 10:18:28 EDT, 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", @@ -1390,7 +1400,7 @@ "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", + "finding": "starts at 10:18:32 EDT, 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", @@ -1664,7 +1674,7 @@ "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", + "finding": "starts at 10:18:32 EDT, 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", @@ -1927,7 +1937,7 @@ ], "nuisance": [], "unresolved": [], - "elapsed_s": 0.075, + "elapsed_s": 0.056, "writer": { "kind": "template", "name": "template, no model",