update main to v0.10.0 #48

Merged
serversdown merged 32 commits from feature/sfm-integration into main 2026-05-14 16:56:43 -04:00
4 changed files with 246 additions and 0 deletions
Showing only changes of commit 63bd6ad8a2 - Show all commits
+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>