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:
2026-05-12 00:09:02 +00:00
parent bc5a151faa
commit 63bd6ad8a2
4 changed files with 246 additions and 0 deletions
+40
View File
@@ -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) @router.get("/locations/{location_id}/events", response_class=JSONResponse)
async def get_location_events( async def get_location_events(
project_id: str, project_id: str,
+119
View File
@@ -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 ───────────────────────────────────────────────────────────── # ── Stats helpers ─────────────────────────────────────────────────────────────
@@ -0,0 +1,76 @@
{# Project-wide vibration events roll-up. Loaded via HTMX. #}
{% if summary.vibration_location_count == 0 %}
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">
No vibration monitoring locations yet. Add one to start collecting events.
</div>
</div>
{% else %}
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Project-wide vibration events
</h3>
<span class="text-xs text-gray-500 dark:text-gray-400">
across {{ summary.vibration_location_count }} location{{ '' if summary.vibration_location_count == 1 else 's' }}
</span>
</div>
<!-- KPI tiles -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Total Events</span>
<span class="text-2xl font-bold text-gray-900 dark:text-white mt-1">{{ "{:,}".format(summary.total_events) }}</span>
</div>
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Peak PVS</span>
{% if summary.peak_pvs is not none %}
<span class="text-2xl font-bold text-gray-900 dark:text-white mt-1">{{ "%.4f"|format(summary.peak_pvs) }} <span class="text-sm font-normal">in/s</span></span>
<a href="/projects/{{ summary.project_id }}/nrl/{{ summary.peak_pvs_location_id }}"
class="text-xs text-seismo-orange hover:text-seismo-navy truncate mt-1"
title="{{ summary.peak_pvs_location_name }}">
{{ summary.peak_pvs_location_name }}
{% if summary.peak_pvs_at %} · {{ summary.peak_pvs_at[:10] }}{% endif %}
</a>
{% else %}
<span class="text-2xl font-bold text-gray-900 dark:text-white mt-1"></span>
{% endif %}
</div>
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Last Event</span>
{% if summary.last_event %}
<span class="text-base font-bold text-gray-900 dark:text-white mt-1">{{ summary.last_event[:19].replace('T', ' ') }}</span>
{% else %}
<span class="text-2xl font-bold text-gray-900 dark:text-white mt-1"></span>
{% endif %}
</div>
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">False Triggers</span>
<span class="text-2xl font-bold {% if summary.false_trigger_count > 0 %}text-amber-600 dark:text-amber-400{% else %}text-gray-900 dark:text-white{% endif %} mt-1">{{ "{:,}".format(summary.false_trigger_count) }}</span>
</div>
</div>
{% if summary.per_location and summary.total_events > 0 %}
<!-- Top locations by activity -->
<div>
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Top locations by activity</h4>
<div class="space-y-1.5">
{% for loc in summary.per_location[:5] %}
<a href="/projects/{{ summary.project_id }}/nrl/{{ loc.location_id }}"
class="flex items-center justify-between py-1.5 px-3 rounded hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
<span class="text-sm font-medium text-gray-900 dark:text-white truncate">
📍 {{ loc.location_name }}
</span>
<span class="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap ml-3">
<span>{{ "{:,}".format(loc.event_count) }} event{{ '' if loc.event_count == 1 else 's' }}</span>
{% if loc.peak_pvs is not none %}
<span class="text-xs text-gray-500">peak {{ "%.4f"|format(loc.peak_pvs) }}</span>
{% endif %}
</span>
</a>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endif %}
+11
View File
@@ -90,6 +90,17 @@
<!-- Vibration Locations sub-panel --> <!-- Vibration Locations sub-panel -->
<div id="vib-sub-locations" class="vib-sub-panel"> <div id="vib-sub-locations" class="vib-sub-panel">
<!-- Project-wide vibration events roll-up -->
<div id="vibration-summary"
hx-get="/api/projects/{{ project_id }}/vibration_summary"
hx-trigger="load"
hx-swap="innerHTML"
class="mb-5">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Loading project summary…</div>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6"> <div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Locations</h2> <h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Locations</h2>