update main to v0.10.0 #48
@@ -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,
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user