diff --git a/backend/routers/project_locations.py b/backend/routers/project_locations.py index 7fade31..54bf833 100644 --- a/backend/routers/project_locations.py +++ b/backend/routers/project_locations.py @@ -776,6 +776,46 @@ async def get_nrl_sessions( }) +@router.get("/vibration_summary", response_class=HTMLResponse) +async def get_project_vibration_summary( + project_id: str, + request: Request, + from_dt: Optional[datetime] = Query(None), + to_dt: Optional[datetime] = Query(None), + db: Session = Depends(get_db), +): + """ + Render a small HTML partial summarising vibration-event activity + across every vibration MonitoringLocation in the project. + + Returned to the Vibration tab of the project detail page via HTMX. + Fans out concurrently across all locations (which in turn fan out + across each location's UnitAssignment windows). Total queries to + SFM = sum of assignments across the project. + + 404 if the project doesn't exist. Empty-state partial if the + project has no vibration locations. + """ + project = db.query(Project).filter_by(id=project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found.") + + from backend.services.sfm_events import vibration_summary_for_project + + summary = await vibration_summary_for_project( + db, project_id, from_dt=from_dt, to_dt=to_dt + ) + + return templates.TemplateResponse( + "partials/projects/vibration_summary.html", + { + "request": request, + "project_id": project_id, + "summary": summary, + }, + ) + + @router.get("/locations/{location_id}/events", response_class=JSONResponse) async def get_location_events( project_id: str, diff --git a/backend/services/sfm_events.py b/backend/services/sfm_events.py index 08da595..3430266 100644 --- a/backend/services/sfm_events.py +++ b/backend/services/sfm_events.py @@ -414,6 +414,125 @@ async def events_for_unit( } +# ── Project-level roll-up (aggregates across all vibration locations) ───────── + + +async def vibration_summary_for_project( + db: Session, + project_id: str, + *, + from_dt: Optional[datetime] = None, + to_dt: Optional[datetime] = None, +) -> dict: + """Aggregate SFM events across every vibration location in a project. + + Returns: + { + "project_id": str, + "total_events": int, + "peak_pvs": float | None, + "peak_pvs_at": ISO timestamp | None, + "peak_pvs_location_id": str | None, + "peak_pvs_location_name": str | None, + "last_event": ISO timestamp | None, + "false_trigger_count": int, + "per_location": [ + {"location_id", "location_name", "event_count", + "peak_pvs", "last_event"}, + ... # sorted by event_count DESC + ], + "vibration_location_count": int, + } + """ + locations = ( + db.query(MonitoringLocation) + .filter(MonitoringLocation.project_id == project_id) + .filter(MonitoringLocation.location_type == "vibration") + .all() + ) + + if not locations: + return { + "project_id": project_id, + "total_events": 0, + "peak_pvs": None, + "peak_pvs_at": None, + "peak_pvs_location_id": None, + "peak_pvs_location_name": None, + "last_event": None, + "false_trigger_count": 0, + "per_location": [], + "vibration_location_count": 0, + } + + # Fan out across locations. Each call internally fans out across that + # location's UnitAssignment rows, so this is a nested fan-out. Both + # tiers happen concurrently because asyncio.gather + httpx pool. + results = await asyncio.gather( + *( + events_for_location( + db, + loc.id, + from_dt=from_dt, + to_dt=to_dt, + false_trigger=None, + limit=1, # We only need stats; events list itself is ignored. + ) + for loc in locations + ), + return_exceptions=False, + ) + + per_location: list[dict] = [] + total_events = 0 + peak_pvs = None + peak_pvs_at = None + peak_pvs_location_id = None + peak_pvs_location_name = None + last_event = None + false_trigger_count = 0 + + for loc, res in zip(locations, results): + st = res.get("stats", {}) or {} + ec = st.get("event_count", 0) or 0 + total_events += ec + false_trigger_count += st.get("false_trigger_count", 0) or 0 + + ev_last = st.get("last_event") + if ev_last and (last_event is None or ev_last > last_event): + last_event = ev_last + + ev_peak = st.get("peak_pvs") + if ev_peak is not None and (peak_pvs is None or ev_peak > peak_pvs): + peak_pvs = ev_peak + peak_pvs_at = st.get("peak_pvs_at") + peak_pvs_location_id = loc.id + peak_pvs_location_name = loc.name + + per_location.append({ + "location_id": loc.id, + "location_name": loc.name, + "event_count": ec, + "peak_pvs": ev_peak, + "last_event": ev_last, + }) + + per_location.sort(key=lambda r: r["event_count"], reverse=True) + + return { + "project_id": project_id, + "total_events": total_events, + "peak_pvs": peak_pvs, + "peak_pvs_at": peak_pvs_at, + "peak_pvs_location_id": peak_pvs_location_id, + "peak_pvs_location_name": peak_pvs_location_name, + "last_event": last_event, + "false_trigger_count": false_trigger_count, + "per_location": per_location, + "vibration_location_count": len(locations), + } + + # ── Stats helpers ───────────────────────────────────────────────────────────── diff --git a/templates/partials/projects/vibration_summary.html b/templates/partials/projects/vibration_summary.html new file mode 100644 index 0000000..fb7b1df --- /dev/null +++ b/templates/partials/projects/vibration_summary.html @@ -0,0 +1,76 @@ +{# Project-wide vibration events roll-up. Loaded via HTMX. #} +{% if summary.vibration_location_count == 0 %} +