feat(sfm): project-level vibration events roll-up
Phase 3 of the SFM integration. Adds a "Project-wide vibration events"
KPI card to the Vibration tab of every project detail page, summarising
event activity across all of that project's vibration MonitoringLocations.
Backend:
- backend/services/sfm_events.py: vibration_summary_for_project() helper.
Concurrently fans out events_for_location() across every vibration
location in the project; aggregates total events, peak PVS (with the
location it occurred at), last-event timestamp, false-trigger count;
and produces a per-location breakdown sorted by event count.
- backend/routers/project_locations.py: new GET /api/projects/{p}/
vibration_summary endpoint returning an HTML partial (HTMX-friendly,
matches the locations-list HTMX pattern already used on this page).
Frontend:
- templates/partials/projects/vibration_summary.html: new partial with
four KPI tiles (total, peak PVS + linked location + date, last event,
false triggers) and a "Top locations by activity" mini-list showing
the top 5 by event count. Empty-state copy when the project has no
vibration locations yet.
- templates/projects/detail.html: HTMX-load the new summary above the
locations list inside the Vibration tab.
Verified against terra-view-alpha: 24 events across "Loc 1 - 78 poop
street", peak PVS 14.1351 in/s.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user