diff --git a/backend/routers/units.py b/backend/routers/units.py index 31ffc07..0b84cec 100644 --- a/backend/routers/units.py +++ b/backend/routers/units.py @@ -1,7 +1,7 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from datetime import datetime -from typing import Dict, Any +from typing import Dict, Any, Optional from backend.database import get_db from backend.services.snapshot import emit_status_snapshot @@ -72,3 +72,67 @@ def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)): "slm_serial_number": unit.slm_serial_number, "deployed_with_modem_id": unit.deployed_with_modem_id } + + +@router.get("/units/{unit_id}/events") +async def get_unit_events( + unit_id: str, + bucket: str = Query("all", regex="^(all|attributed|unattributed)$"), + from_dt: Optional[datetime] = Query(None), + to_dt: Optional[datetime] = Query(None), + false_trigger: Optional[bool] = Query(None), + limit: int = Query(500, ge=1, le=5000), + db: Session = Depends(get_db), +): + """ + Return SFM events for a single unit, annotated with assignment attribution. + + Each event includes an `attribution` object pointing at the project/location + it falls into (or null if outside every assignment window). Unattributed + events also carry a `nearest_assignment` field with `delta_days` so the + operator can see how far off the nearest assignment is — useful for + deciding whether to backdate the assignment to absorb the event. + + Bucket filter: + - all (default): every event + - attributed: only events inside an assignment window + - unattributed: only orphan events (the diagnostic bucket) + + Non-seismograph units return an empty events list. The route does not + 404 for SLMs/modems so the unit detail page can render the section + conditionally without depending on the response shape. + """ + unit = db.query(RosterUnit).filter_by(id=unit_id).first() + if not unit: + raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found") + + if unit.device_type != "seismograph": + return { + "events": [], + "count": 0, + "stats": { + "event_count": 0, + "unattributed_count": 0, + "peak_pvs": None, + "peak_pvs_at": None, + "peak_pvs_serial": None, + "last_event": None, + "false_trigger_count": 0, + }, + "assignments_total": 0, + "device_type": unit.device_type, + } + + from backend.services.sfm_events import events_for_unit + + result = await events_for_unit( + db, + unit_id, + bucket=bucket, + from_dt=from_dt, + to_dt=to_dt, + false_trigger=false_trigger, + limit=limit, + ) + result["device_type"] = unit.device_type + return result diff --git a/backend/services/sfm_events.py b/backend/services/sfm_events.py index 19312ec..08da595 100644 --- a/backend/services/sfm_events.py +++ b/backend/services/sfm_events.py @@ -29,7 +29,7 @@ from typing import Optional import httpx from sqlalchemy.orm import Session -from backend.models import UnitAssignment, RosterUnit +from backend.models import UnitAssignment, RosterUnit, MonitoringLocation, Project log = logging.getLogger("backend.services.sfm_events") @@ -252,6 +252,168 @@ async def events_for_location( } +# ── Per-unit (cross-project) view ───────────────────────────────────────────── + + +async def events_for_unit( + db: Session, + unit_id: str, + *, + bucket: str = "all", # "all" | "attributed" | "unattributed" + from_dt: Optional[datetime] = None, + to_dt: Optional[datetime] = None, + false_trigger: Optional[bool] = None, + limit: int = 500, +) -> dict: + """Return events for a unit annotated with their assignment attribution. + + Unlike events_for_location (which queries SFM per assignment window), this + helper queries SFM for ALL events for the serial within the optional + [from_dt, to_dt] filter, then walks each event against the unit's + UnitAssignment intervals to compute attribution. + + Bucket semantics: + - "all": every event, attributed or not + - "attributed": events that fall inside at least one assignment window + - "unattributed": events with no overlapping assignment (the diagnostic + bucket — operator should fix assignment dates to + attribute these) + + Each event gets an extra `attribution` field: + {assignment_id, location_id, location_name, project_id, project_name, + assigned_at, assigned_until} or None + + Unattributed events also get a `nearest_assignment` field with the + same shape plus `delta_days` (signed; negative = event before assignment). + """ + # 1. Pull all assignments for this unit (any device_type — caller has + # already filtered by seismograph in the route). Order matters: we + # want the earliest-start assignment first so attribution prefers the + # chronologically-first overlap when there are simultaneous active + # assignments at different locations (rare but possible). + assignments = ( + db.query(UnitAssignment) + .filter(UnitAssignment.unit_id == unit_id) + .order_by(UnitAssignment.assigned_at.asc()) + .all() + ) + + # Resolve location + project names once. + loc_ids = {a.location_id for a in assignments} + proj_ids = {a.project_id for a in assignments} + loc_map = { + l.id: l for l in db.query(MonitoringLocation).filter( + MonitoringLocation.id.in_(loc_ids) + ).all() + } if loc_ids else {} + proj_map = { + p.id: p for p in db.query(Project).filter( + Project.id.in_(proj_ids) + ).all() + } if proj_ids else {} + + now = datetime.utcnow() + + def _attr_dict(a: UnitAssignment) -> dict: + loc = loc_map.get(a.location_id) + proj = proj_map.get(a.project_id) + return { + "assignment_id": a.id, + "location_id": a.location_id, + "location_name": loc.name if loc else None, + "project_id": a.project_id, + "project_name": proj.name if proj else None, + "assigned_at": _iso_utc(a.assigned_at), + "assigned_until": _iso_utc(a.assigned_until), + } + + # 2. Fetch all events for this serial in one shot. + async with httpx.AsyncClient(timeout=_SFM_TIMEOUT_SECONDS) as client: + events = await _fetch_events_for_serial( + client, + serial=unit_id, + from_dt=from_dt or datetime(1970, 1, 1), + to_dt=to_dt or now, + false_trigger=false_trigger, + limit=_SFM_FETCH_CEILING, + ) + + # 3. For each event, walk the assignment list and find the first + # overlapping window. O(N * M) but both are small in practice. + for ev in events: + ts_str = ev.get("timestamp") + if not ts_str: + ev["attribution"] = None + continue + try: + # SFM returns ISO with "T" separator; tolerate both. + ts = datetime.fromisoformat(ts_str.replace(" ", "T")) + except ValueError: + ev["attribution"] = None + continue + + matched: Optional[UnitAssignment] = None + for a in assignments: + a_end = a.assigned_until or now + if a.assigned_at <= ts <= a_end: + matched = a + break + + if matched is not None: + ev["attribution"] = _attr_dict(matched) + else: + ev["attribution"] = None + # Find the nearest assignment (chronologically) for diagnostic. + if assignments: + nearest = min( + assignments, + key=lambda a: min( + abs((ts - a.assigned_at).total_seconds()), + abs((ts - (a.assigned_until or now)).total_seconds()), + ), + ) + # Signed delta in days from the nearest boundary + # (negative = event BEFORE that boundary). + if ts < nearest.assigned_at: + delta_seconds = (ts - nearest.assigned_at).total_seconds() + elif ts > (nearest.assigned_until or now): + delta_seconds = (ts - (nearest.assigned_until or now)).total_seconds() + else: + delta_seconds = 0 + ev["nearest_assignment"] = { + **_attr_dict(nearest), + "delta_days": round(delta_seconds / 86400, 1), + } + + # 4. Apply bucket filter. + if bucket == "attributed": + filtered = [e for e in events if e.get("attribution") is not None] + elif bucket == "unattributed": + filtered = [e for e in events if e.get("attribution") is None] + else: + filtered = events + + filtered.sort(key=lambda e: e.get("timestamp") or "", reverse=True) + total_count = len(filtered) + capped = filtered[:limit] + + # 5. Stats: compute over the ENTIRE event set (not the filtered bucket) + # so the unattributed_count tile is always meaningful regardless of + # which bucket the operator has selected. + base_stats = _compute_stats(events) + unattributed_count = sum( + 1 for e in events if e.get("attribution") is None + ) + base_stats["unattributed_count"] = unattributed_count + + return { + "events": capped, + "count": total_count, + "stats": base_stats, + "assignments_total": len(assignments), + } + + # ── Stats helpers ───────────────────────────────────────────────────────────── diff --git a/templates/unit_detail.html b/templates/unit_detail.html index d2fbf73..d33d8dd 100644 --- a/templates/unit_detail.html +++ b/templates/unit_detail.html @@ -294,6 +294,91 @@ + +
+| Timestamp | +Tran | +Vert | +Long | +PVS | +Flags | +Attribution | +
|---|