From cea2357b558dcb5bb31ff41485a8b9db1ea756d5 Mon Sep 17 00:00:00 2001 From: goodnight Date: Sat, 18 Jul 2026 20:33:48 +0100 Subject: [PATCH] fix: add trend/faults-per-machine endpoints, real diagnosis-latency metric, varied simulate --- src/torq/api/routes.py | 12 ++++++++++ src/torq/db/models.py | 45 +++++++++++++++++++++++++++++++++++- tests/test_metrics_charts.py | 41 ++++++++++++++++++++++++++++++++ web/src/pages/Dashboard.jsx | 18 +++++++++++---- 4 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 tests/test_metrics_charts.py diff --git a/src/torq/api/routes.py b/src/torq/api/routes.py index c44e3a4..231e81c 100644 --- a/src/torq/api/routes.py +++ b/src/torq/api/routes.py @@ -130,6 +130,18 @@ def metrics(): return models.metrics() +@router.get("/metrics/trend") +def metrics_trend(): + """Per-day diagnosis latency + MTTR for the trend chart.""" + return models.trend() + + +@router.get("/metrics/faults-per-machine") +def metrics_faults_per_machine(): + """Work-order count per machine for the bar chart.""" + return models.faults_per_machine() + + @router.get("/events/stream") async def event_stream(request: Request): """SSE endpoint: streams incoming MachineFaultEvent in real-time.""" diff --git a/src/torq/db/models.py b/src/torq/db/models.py index 706f29a..0d43b81 100644 --- a/src/torq/db/models.py +++ b/src/torq/db/models.py @@ -146,7 +146,9 @@ def metrics() -> dict: for w in wos: by_status[w.status] = by_status.get(w.status, 0) + 1 - ttd = [s for w in wos if (s := _secs(w.fault_arrived_at, w.dispatched_at)) is not None] + # Diagnosis latency = fault arrival -> work order created (the AI step). + # Not -> dispatched_at, which also includes the human approval wait. + ttd = [s for w in wos if (s := _secs(w.fault_arrived_at, w.created_at)) is not None] resolved = [w for w in wos if w.status == "resolved"] ttf = [ float(w.outcome["time_to_fix_min"]) @@ -194,3 +196,44 @@ def metrics() -> dict: "resolution_rate": round(len(resolved) / len(wos), 2) if wos else None, "machine_downtime": machine_downtime, } + + +def trend(days: int = 7) -> list[dict]: + """Per-day avg diagnosis latency (min) and MTTR (min) for the recent window.""" + buckets: dict[str, dict[str, list[float]]] = {} + for w in list_all(): + if not w.created_at: + continue + day = w.created_at[:10] + b = buckets.setdefault(day, {"diag": [], "mttr": []}) + d = _secs(w.fault_arrived_at, w.created_at) + if d is not None: + b["diag"].append(d / 60) + if w.status == "resolved": + fix = _downtime_min(w) + if fix is not None: + b["mttr"].append(fix) + + rows = [] + for day in sorted(buckets)[-days:]: + b = buckets[day] + rows.append( + { + "label": day[5:], # MM-DD + "diagnosis": round(sum(b["diag"]) / len(b["diag"]), 1) if b["diag"] else 0, + "mttr": round(sum(b["mttr"]) / len(b["mttr"]), 1) if b["mttr"] else 0, + } + ) + return rows + + +def faults_per_machine() -> list[dict]: + """Count of work orders per machine, most faults first.""" + counts: dict[str, int] = {} + for w in list_all(): + if w.machine: + counts[w.machine] = counts.get(w.machine, 0) + 1 + return [ + {"machine": m, "count": c} + for m, c in sorted(counts.items(), key=lambda kv: -kv[1]) + ] diff --git a/tests/test_metrics_charts.py b/tests/test_metrics_charts.py new file mode 100644 index 0000000..69c6e24 --- /dev/null +++ b/tests/test_metrics_charts.py @@ -0,0 +1,41 @@ +"""Trend + faults-per-machine aggregation, and diagnosis latency semantics.""" + +from unittest.mock import patch + +from torq.agent.schemas import WorkOrder +from torq.db import models + + +def _wo(**kw): + base = dict(id=kw.pop("id", "x"), fault_code="E-201", machine="CM-350", root_cause="rc") + base.update(kw) + return WorkOrder(**base) + + +SAMPLE = [ + _wo(id="a", machine="CM-350", fault_arrived_at="2026-07-18T10:00:00+00:00", + created_at="2026-07-18T10:00:20+00:00", status="resolved", + outcome={"time_to_fix_min": 30}), + _wo(id="b", machine="Pump 3", fault_arrived_at="2026-07-18T11:00:00+00:00", + created_at="2026-07-18T11:00:40+00:00", status="pending"), + _wo(id="c", machine="CM-350", fault_arrived_at="2026-07-19T09:00:00+00:00", + created_at="2026-07-19T09:00:10+00:00", status="resolved", + outcome={"time_to_fix_min": 50}), +] + + +@patch("torq.db.models.list_all", return_value=SAMPLE) +def test_faults_per_machine_counts_and_orders(_): + fpm = models.faults_per_machine() + assert fpm[0] == {"machine": "CM-350", "count": 2} # most first + assert {"machine": "Pump 3", "count": 1} in fpm + + +@patch("torq.db.models.list_all", return_value=SAMPLE) +def test_trend_buckets_by_day(_): + rows = models.trend() + assert [r["label"] for r in rows] == ["07-18", "07-19"] + # diagnosis is fault->created latency in minutes (20s/40s avg = 0.5 on day 1) + assert rows[0]["diagnosis"] == 0.5 + assert rows[0]["mttr"] == 30.0 # only the resolved one counts + assert rows[1]["mttr"] == 50.0 diff --git a/web/src/pages/Dashboard.jsx b/web/src/pages/Dashboard.jsx index 76e94ce..ef3b5e6 100644 --- a/web/src/pages/Dashboard.jsx +++ b/web/src/pages/Dashboard.jsx @@ -17,6 +17,18 @@ const FIX = { time_to_fix_min: 30, }; +// Varied demo faults across skills (E=electromechanical, J=packaging, C/P/A=general) +// so repeated clicks exercise different machines and route to different techs. +const SIM_FAULTS = [ + { fault_code: "E-471", machine: "CM-350 Line 2", context: "Motor tripped after hours running." }, + { fault_code: "E-201", machine: "CM-350 Line 1", context: "Overcurrent on start, grinding noise." }, + { fault_code: "J-108", machine: "PK-9 Line 3", context: "Film feed jammed at the roller nip." }, + { fault_code: "J-233", machine: "PK-9 Line 3", context: "Seal temperature low, weak seals." }, + { fault_code: "C-207", machine: "Chiller 6", context: "Condenser water flow dropping, efficiency down." }, + { fault_code: "P-410", machine: "Pump 3", context: "High vibration and bearing noise." }, + { fault_code: "A-120", machine: "AHU 2", context: "Filter differential pressure over setpoint." }, +]; + function Tile({ label, value }) { return (
@@ -600,11 +612,7 @@ export default function Dashboard() { const simulate = () => act( - () => api.reportFault({ - fault_code: "E-471", - machine: "CM-350 Line 2", - context: "Motor tripped after hours running.", - }), + () => api.reportFault(SIM_FAULTS[Math.floor(Math.random() * SIM_FAULTS.length)]), t("dashboard.toast_simulated") );