Compare commits

..

8 commits

Author SHA1 Message Date
496dd1626f
Merge pull request #48 from zvx-echo6/feat/l-c-events-table-readable
feat(L-c): operator /events table polish — readable Time, Location, Subject, Adapter columns; sortable; plain-language summaries
2026-05-21 01:06:00 -06:00
zvx
5d4320bc73 feat(L-c): operator /events table polish — readable Time, Location, Subject, Adapter columns; sortable; plain-language summaries
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 07:05:20 +00:00
339c980c9a
Merge pull request #47 from zvx-echo6/feat/l-b-events-tab-polish
feat(L-b): operator /events tab polish — registry-derived filter, all-adapter map, per-adapter row partials
2026-05-21 00:08:01 -06:00
zvx
49d85021e8 feat(L-b): operator /events tab polish — registry-derived filter, all-adapter map, per-adapter row partials
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:45:15 +00:00
1cf1eabb1c
Merge pull request #46 from zvx-echo6/feature/3-mb-apply-enrichment-coordless
fix(3-M.b): apply_enrichment always attaches _enriched (coordless events)
2026-05-20 22:09:33 -06:00
Matt Johnson
f0c044505f fix(3-M.b): apply_enrichment always attaches _enriched for declared adapters
Coordless events such as removal tombstones with null lat/lon, from adapters that declare enrichment_locations, previously fell off the loop without writing _enriched and carried no geocoder bundle at all, violating the every-event-carries-_enriched design rule. Add a post-loop fallback that resolves the null location to an all-null bundle per enricher. Adapters with no enrichment_locations remain skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:04:25 +00:00
bb6db03a07
Merge pull request #45 from zvx-echo6/feature/3-la-enrichment-locations-finishing
feat(3-L.a): finish enrichment_locations across adapters
2026-05-20 20:51:34 -06:00
Matt Johnson
c918e8d259 feat(3-L.a): finish enrichment_locations across adapters
First half of the split PR L (events-tab + map deferred to L-b). Only FIRMS
declared enrichment_locations, so apply_enrichment silently bypassed every
other adapter. This declares it for all 12.

Pre-read finding (resolved per PM): apply_enrichment is a FLAT lookup
(event.data.get(lat_path)); FIRMS/usgs_quake already carry top-level
latitude/longitude in event.data, but the other point adapters kept coords
only in Geo.centroid where the flat path can't reach them. Per PM (option b),
the 5 centroid-only adapters now also write top-level latitude/longitude into
event.data, mirroring their existing Geo.centroid (lon, lat) — 2-3 lines each,
no framework refactor. Geo retained for existing rendering uses.

Declarations (verbatim):
  firms              [("latitude","longitude")]   (unchanged)
  usgs_quake         [("latitude","longitude")]   (already top-level in data)
  nwis               [("latitude","longitude")]   + centroid mirror
  eonet              [("latitude","longitude")]   + centroid mirror
  gdacs              [("latitude","longitude")]   + centroid mirror
  wfigs_incidents    [("latitude","longitude")]   + centroid mirror (inline data)
  inciweb            [("latitude","longitude")]   + centroid mirror (inline data)
  wfigs_perimeters   []   # polygons, no point
  nws                []   # forecast zones/counties, no point
  swpc_alerts        []   # space weather, no coordinate
  swpc_kindex        []   # space weather, no coordinate
  swpc_protons       []   # space weather, no coordinate

Centroid mirror is `latitude = centroid[1]; longitude = centroid[0]` (centroid
is GeoJSON (lon, lat)); guarded on centroid presence so coordinate-less events
get no lat/lon keys (apply_enrichment then skips them).

map_render_kind concept dropped — the existing /events map is already
geometry-kind-agnostic (renders any row's data-geometry via L.geoJSON), so it
was unnecessary. Events-tab enhancements are PR L-b.

Tests (test_enrichment_locations_coverage.py, 6, all registry-derived):
- every adapter explicitly declares enrichment_locations in its own class body
- declarations are valid list[(str,str)]
- point adapters all use the canonical ("latitude","longitude") paths
- >=5 point adapters are non-empty (regression guard)
- synthetic-event builders prove the keys resolve: usgs_quake._feature_to_event
  and nwis._build_event (the two adapters with isolated builders; the four
  inline-build adapters are covered by the post-merge live smoke).

Verification: full pytest 552 passed, 1 skipped (was 546; +6). grep
subject_for_event/_ADAPTER_REGISTRY and grep 100.64.0./192.168.1. in src empty.

Follow-ups (NOT here): consumer-doc per-adapter _enriched.geocoder notes for
the newly-enriched adapters belong in L-b's doc pass; live end-to-end smoke
runs post-merge (USGS quake + one other) per the acceptance bar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 01:48:23 +00:00
44 changed files with 969 additions and 53 deletions

View file

@ -148,6 +148,9 @@ class EONETAdapter(SourceAdapter):
wizard_order = None wizard_order = None
default_cadence_s = 1800 default_cadence_s = 1800
# Event lat/lon mirrored from Geo.centroid into event.data (see poll()).
enrichment_locations = [("latitude", "longitude")]
def __init__( def __init__(
self, self,
config: AdapterConfig, config: AdapterConfig,
@ -370,6 +373,12 @@ class EONETAdapter(SourceAdapter):
"latest_geometry_date": latest_date_iso or None, "latest_geometry_date": latest_date_iso or None,
} }
# Mirror centroid (lon, lat) into top-level data keys for the flat
# enrichment path (see enrichment_locations).
if centroid is not None:
data["latitude"] = centroid[1]
data["longitude"] = centroid[0]
dedup_key = _dedup_key(event_id, latest_date_iso) dedup_key = _dedup_key(event_id, latest_date_iso)
if self.is_published(dedup_key): if self.is_published(dedup_key):

View file

@ -150,6 +150,9 @@ class GDACSAdapter(SourceAdapter):
wizard_order = None wizard_order = None
default_cadence_s = 600 default_cadence_s = 600
# Event lat/lon mirrored from Geo.centroid into event.data (see poll()).
enrichment_locations = [("latitude", "longitude")]
def __init__( def __init__(
self, self,
config: AdapterConfig, config: AdapterConfig,
@ -391,6 +394,12 @@ class GDACSAdapter(SourceAdapter):
"iscurrent": iscurrent, "iscurrent": iscurrent,
} }
# Mirror centroid (lon, lat) into top-level data keys for the flat
# enrichment path (see enrichment_locations).
if centroid is not None:
data["latitude"] = centroid[1]
data["longitude"] = centroid[0]
if not iscurrent: if not iscurrent:
# Explicit tombstone from upstream. Only emit if we previously observed it. # Explicit tombstone from upstream. Only emit if we previously observed it.
if guid in observed_before: if guid in observed_before:

View file

@ -171,6 +171,9 @@ class InciWebAdapter(SourceAdapter):
wizard_order = None # Ships disabled wizard_order = None # Ships disabled
default_cadence_s = 600 default_cadence_s = 600
# Coords parsed from the narrative, mirrored from Geo.centroid into event.data.
enrichment_locations = [("latitude", "longitude")]
def __init__( def __init__(
self, self,
config: AdapterConfig, config: AdapterConfig,
@ -461,6 +464,9 @@ class InciWebAdapter(SourceAdapter):
"url": item.get("link", ""), "url": item.get("link", ""),
"guid": guid, "guid": guid,
"raw": item, "raw": item,
# Mirror centroid (lon, lat) for the flat enrichment path.
"latitude": centroid[1] if centroid else None,
"longitude": centroid[0] if centroid else None,
}, },
) )

View file

@ -124,6 +124,9 @@ class NWISAdapter(SourceAdapter):
wizard_order = None wizard_order = None
default_cadence_s = 900 default_cadence_s = 900
# Site lat/lon mirrored from Geo.centroid into event.data (see _build_event).
enrichment_locations = [("latitude", "longitude")]
def __init__( def __init__(
self, self,
config: AdapterConfig, config: AdapterConfig,
@ -372,6 +375,12 @@ class NWISAdapter(SourceAdapter):
"last_modified": props.get("last_modified"), "last_modified": props.get("last_modified"),
} }
# Mirror centroid (lon, lat) into top-level data keys so the flat
# enrichment path can reach them (see enrichment_locations).
if centroid is not None:
data["latitude"] = centroid[1]
data["longitude"] = centroid[0]
return Event( return Event(
id=f"{monitoring_location_id}:{parameter_code}:{time_iso}", id=f"{monitoring_location_id}:{parameter_code}:{time_iso}",
adapter=self.name, adapter=self.name,

View file

@ -212,6 +212,9 @@ class NWSAdapter(SourceAdapter):
wizard_order = 1 wizard_order = 1
default_cadence_s = 60 default_cadence_s = 60
# Alerts cover forecast zones/counties (polygons), not a single point.
enrichment_locations = []
def __init__( def __init__(
self, self,
config: AdapterConfig, config: AdapterConfig,

View file

@ -41,6 +41,9 @@ class SWPCAlertsAdapter(SourceAdapter):
wizard_order = None wizard_order = None
default_cadence_s = 300 default_cadence_s = 300
# Space weather — no geographic coordinate to enrich.
enrichment_locations = []
def __init__( def __init__(
self, self,
config: AdapterConfig, config: AdapterConfig,

View file

@ -41,6 +41,9 @@ class SWPCKindexAdapter(SourceAdapter):
wizard_order = None wizard_order = None
default_cadence_s = 600 default_cadence_s = 600
# Space weather — no geographic coordinate to enrich.
enrichment_locations = []
def __init__( def __init__(
self, self,
config: AdapterConfig, config: AdapterConfig,

View file

@ -40,6 +40,9 @@ class SWPCProtonsAdapter(SourceAdapter):
wizard_order = None wizard_order = None
default_cadence_s = 600 default_cadence_s = 600
# Space weather — no geographic coordinate to enrich.
enrichment_locations = []
def __init__( def __init__(
self, self,
config: AdapterConfig, config: AdapterConfig,

View file

@ -79,6 +79,9 @@ class USGSQuakeAdapter(SourceAdapter):
wizard_order = 3 wizard_order = 3
default_cadence_s = 60 default_cadence_s = 60
# Epicenter lat/lon are top-level keys in event.data (see _build_event).
enrichment_locations = [("latitude", "longitude")]
def __init__( def __init__(
self, self,
config: AdapterConfig, config: AdapterConfig,

View file

@ -60,6 +60,9 @@ class WFIGSIncidentsAdapter(SourceAdapter):
wizard_order = None # Not in setup wizard wizard_order = None # Not in setup wizard
default_cadence_s = 300 default_cadence_s = 300
# Incident-point lat/lon mirrored from Geo.centroid into event.data.
enrichment_locations = [("latitude", "longitude")]
def __init__( def __init__(
self, self,
config: AdapterConfig, config: AdapterConfig,
@ -326,6 +329,9 @@ class WFIGSIncidentsAdapter(SourceAdapter):
"POOState_raw": state_raw, "POOState_raw": state_raw,
"POOCounty": county, "POOCounty": county,
"raw": props, "raw": props,
# Mirror centroid (lon, lat) for the flat enrichment path.
"latitude": centroid[1] if centroid else None,
"longitude": centroid[0] if centroid else None,
}, },
) )

View file

@ -60,6 +60,9 @@ class WFIGSPerimetersAdapter(SourceAdapter):
wizard_order = None # Not in setup wizard wizard_order = None # Not in setup wizard
default_cadence_s = 300 default_cadence_s = 300
# Perimeters are polygons, not a single point — no coordinate to enrich.
enrichment_locations = []
def __init__( def __init__(
self, self,
config: AdapterConfig, config: AdapterConfig,

View file

@ -4,7 +4,7 @@ import base64
import json import json
import logging import logging
import re import re
from datetime import datetime from datetime import datetime, timezone
from typing import Any from typing import Any
logger = logging.getLogger("central.gui.routes") logger = logging.getLogger("central.gui.routes")
@ -2870,6 +2870,31 @@ def _geometry_summary(geometry: dict | None) -> str:
return geom_type return geom_type
def _format_event_time(iso: str | None) -> str:
"""Format an ISO-8601 timestamp as 'MM-DD-YYYY HH:MM UTC' (24h, no seconds)."""
if not iso:
return ""
try:
dt = datetime.fromisoformat(iso).astimezone(timezone.utc)
except (ValueError, TypeError):
return iso
return dt.strftime("%m-%d-%Y %H:%M") + " UTC"
def _decorate_table_events(events: list[dict]) -> None:
"""Add display-only fields used by the HTML events table (in place).
These are for the table chrome only and are deliberately NOT added in
_fetch_events, so the /events.json payload is unchanged. adapter_display
is sourced from the registry (display_name), with the bare name as fallback.
"""
display = {cls.name: cls.display_name for cls in discover_adapters().values()}
for event in events:
event["geometry_summary"] = _geometry_summary(event.get("geometry"))
event["time_human"] = _format_event_time(event.get("time"))
event["adapter_display"] = display.get(event.get("adapter"), event.get("adapter"))
@router.get("/events.json") @router.get("/events.json")
async def events_json(request: Request): async def events_json(request: Request):
@ -2958,9 +2983,15 @@ async def events_list(request: Request) -> HTMLResponse:
events = result.events events = result.events
next_cursor = result.next_cursor next_cursor = result.next_cursor
# Add geometry summary to each event # Add table-only display fields (time_human, adapter_display, geometry_summary)
for event in events: _decorate_table_events(events)
event["geometry_summary"] = _geometry_summary(event.get("geometry"))
# Registry-derived adapter list for the filter <select> and map legend.
# Sorted by name for stable ordering; index drives the legend color palette.
adapters = [
{"name": cls.name, "display_name": cls.display_name}
for cls in sorted(discover_adapters().values(), key=lambda c: c.name)
]
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
@ -2974,6 +3005,7 @@ async def events_list(request: Request) -> HTMLResponse:
"filter_error": error, "filter_error": error,
"tile_url": tile_url, "tile_url": tile_url,
"tile_attribution": tile_attribution, "tile_attribution": tile_attribution,
"adapters": adapters,
}, },
) )
@ -3014,9 +3046,8 @@ async def events_rows(request: Request) -> HTMLResponse:
events = result.events events = result.events
next_cursor = result.next_cursor next_cursor = result.next_cursor
# Add geometry summary to each event # Add table-only display fields (time_human, adapter_display, geometry_summary)
for event in events: _decorate_table_events(events)
event["geometry_summary"] = _geometry_summary(event.get("geometry"))
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,

View file

@ -0,0 +1,2 @@
{# Fallback for adapters without a bespoke partial. No curated fields — the
full payload is shown in the "Data" block rendered by _events_rows.html. #}

View file

@ -0,0 +1,6 @@
{# EONET natural-event tracker. Fields from payload->data->data. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% if d.get('title') is not none %}<dt>Title</dt><dd>{{ d.title }}</dd>{% endif %}
{% if d.get('category_id') is not none %}<dt>Category</dt><dd>{{ d.category_id }}</dd>{% endif %}
{% if d.get('magnitudeValue') is not none %}<dt>Magnitude</dt><dd>{{ d.magnitudeValue }} {{ d.get('magnitudeUnit', '') }}</dd>{% endif %}
{% if d.get('url') is not none %}<dt>Source</dt><dd><a href="{{ d.url }}" target="_blank" rel="noopener">{{ d.url }}</a></dd>{% endif %}

View file

@ -0,0 +1,6 @@
{# NASA FIRMS fire hotspots. Fields from payload->data->data. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% if d.get('confidence') is not none %}<dt>Confidence</dt><dd>{{ d.confidence }}</dd>{% endif %}
{% if d.get('frp') is not none %}<dt>FRP (MW)</dt><dd>{{ d.frp }}</dd>{% endif %}
{% if d.get('bright_ti4') is not none %}<dt>Brightness (TI4)</dt><dd>{{ d.bright_ti4 }}</dd>{% endif %}
{% if d.get('satellite') is not none %}<dt>Satellite</dt><dd>{{ d.satellite }}{% if d.get('daynight') is not none %} ({{ d.daynight }}){% endif %}</dd>{% endif %}

View file

@ -0,0 +1,6 @@
{# GDACS global disaster alerts. Fields from payload->data->data. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% if d.get('title') is not none %}<dt>Title</dt><dd>{{ d.title }}</dd>{% endif %}
{% if d.get('eventtype') is not none %}<dt>Event type</dt><dd>{{ d.eventtype }}</dd>{% endif %}
{% if d.get('alertlevel') is not none %}<dt>Alert level</dt><dd>{{ d.alertlevel }}{% if d.get('alertscore') is not none %} ({{ d.alertscore }}){% endif %}</dd>{% endif %}
{% if d.get('country') is not none %}<dt>Country</dt><dd>{{ d.country }}</dd>{% endif %}

View file

@ -0,0 +1,5 @@
{# NIFC InciWeb wildfire narrative. Fields from payload->data->data. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% if d.get('title') is not none %}<dt>Title</dt><dd>{{ d.title }}</dd>{% endif %}
{% if d.get('description') is not none %}<dt>Description</dt><dd>{{ d.description | truncate(200) }}</dd>{% endif %}
{% if d.get('url') is not none %}<dt>Source</dt><dd><a href="{{ d.url }}" target="_blank" rel="noopener">{{ d.url }}</a></dd>{% endif %}

View file

@ -0,0 +1,6 @@
{# USGS NWIS water observations. Fields from payload->data->data. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% if d.get('parameter_code') is not none %}<dt>Parameter</dt><dd>{{ d.parameter_code }}</dd>{% endif %}
{% if d.get('value') is not none %}<dt>Value</dt><dd>{{ d.value }} {{ d.get('unit_of_measure', '') }}</dd>{% endif %}
{% if d.get('monitoring_location_id') is not none %}<dt>Site</dt><dd><code>{{ d.monitoring_location_id }}</code></dd>{% endif %}
{% if d.get('statistic_id') is not none %}<dt>Statistic</dt><dd>{{ d.statistic_id }}</dd>{% endif %}

View file

@ -0,0 +1,6 @@
{# NWS weather alerts. Fields from payload->data->data. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% if d.get('event') is not none %}<dt>Event</dt><dd>{{ d.event }}</dd>{% endif %}
{% if d.get('severity') is not none %}<dt>Severity</dt><dd>{{ d.severity }}{% if d.get('certainty') is not none %} / {{ d.certainty }}{% endif %}</dd>{% endif %}
{% if d.get('headline') is not none %}<dt>Headline</dt><dd>{{ d.headline }}</dd>{% endif %}
{% if d.get('areaDesc') is not none %}<dt>Area</dt><dd>{{ d.areaDesc | truncate(200) }}</dd>{% endif %}

View file

@ -0,0 +1,5 @@
{# NOAA SWPC space-weather alerts. Fields from payload->data->data. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% if d.get('product_id') is not none %}<dt>Product</dt><dd><code>{{ d.product_id }}</code></dd>{% endif %}
{% if d.get('issue_datetime') is not none %}<dt>Issued</dt><dd>{{ d.issue_datetime }}</dd>{% endif %}
{% if d.get('message') is not none %}<dt>Message</dt><dd>{{ d.message | truncate(240) }}</dd>{% endif %}

View file

@ -0,0 +1,6 @@
{# NOAA SWPC planetary K-index. Fields from payload->data->data. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% if d.get('Kp') is not none %}<dt>Kp</dt><dd>{{ d.Kp }}</dd>{% endif %}
{% if d.get('a_running') is not none %}<dt>A-running</dt><dd>{{ d.a_running }}</dd>{% endif %}
{% if d.get('station_count') is not none %}<dt>Stations</dt><dd>{{ d.station_count }}</dd>{% endif %}
{% if d.get('time_tag') is not none %}<dt>Time tag</dt><dd>{{ d.time_tag }}</dd>{% endif %}

View file

@ -0,0 +1,6 @@
{# NOAA SWPC GOES proton flux. Fields from payload->data->data. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% if d.get('flux') is not none %}<dt>Flux</dt><dd>{{ d.flux }}</dd>{% endif %}
{% if d.get('energy') is not none %}<dt>Energy</dt><dd>{{ d.energy }}</dd>{% endif %}
{% if d.get('satellite') is not none %}<dt>Satellite</dt><dd>{{ d.satellite }}</dd>{% endif %}
{% if d.get('time_tag') is not none %}<dt>Time tag</dt><dd>{{ d.time_tag }}</dd>{% endif %}

View file

@ -0,0 +1,6 @@
{# USGS earthquakes. Fields from payload->data->data. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% if d.get('magnitude') is not none %}<dt>Magnitude</dt><dd>{{ d.magnitude }}{% if d.get('magType') is not none %} {{ d.magType }}{% endif %}</dd>{% endif %}
{% if d.get('place') is not none %}<dt>Place</dt><dd>{{ d.place }}</dd>{% endif %}
{% if d.get('depth') is not none %}<dt>Depth (km)</dt><dd>{{ d.depth }}</dd>{% endif %}
{% if d.get('url') is not none %}<dt>Source</dt><dd><a href="{{ d.url }}" target="_blank" rel="noopener">{{ d.url }}</a></dd>{% endif %}

View file

@ -0,0 +1,6 @@
{# NIFC WFIGS wildfire incidents. Fields from payload->data->data. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% if d.get('state') is not none %}<dt>State</dt><dd>{{ d.state }}</dd>{% endif %}
{% if d.get('county') is not none %}<dt>County</dt><dd>{{ d.county }}</dd>{% endif %}
{% if d.get('reason') is not none %}<dt>Reason</dt><dd>{{ d.reason }}</dd>{% endif %}
{% if d.get('irwin_id') is not none %}<dt>IRWIN ID</dt><dd><code>{{ d.irwin_id }}</code></dd>{% endif %}

View file

@ -0,0 +1,6 @@
{# NIFC WFIGS wildfire perimeters. Fields from payload->data->data. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% if d.get('state') is not none %}<dt>State</dt><dd>{{ d.state }}</dd>{% endif %}
{% if d.get('county') is not none %}<dt>County</dt><dd>{{ d.county }}</dd>{% endif %}
{% if d.get('reason') is not none %}<dt>Reason</dt><dd>{{ d.reason }}</dd>{% endif %}
{% if d.get('irwin_id') is not none %}<dt>IRWIN ID</dt><dd><code>{{ d.irwin_id }}</code></dd>{% endif %}

View file

@ -0,0 +1,2 @@
{# No per-adapter summary; the Subject cell falls back to "—" and the map
popup omits the subject line. #}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('title') %}{{ d.title }}{% endif -%}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('frp') is not none or d.get('confidence') %}Fire detected{% if d.get('frp') is not none %} — {{ d.frp }} MW radiative power{% endif %}{% if d.get('confidence') == 'high' %} (high confidence){% endif %}{% endif -%}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('title') %}{{ d.title }}{% endif %}{% if d.get('alertlevel') %} — {{ d.alertlevel }} alert{% endif -%}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('title') %}{{ d.title }}{% endif -%}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('value') is not none %}Water reading: {{ d.value }}{% if d.get('unit_of_measure') %} {{ d.unit_of_measure }}{% endif %}{% endif -%}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('event') %}{{ d.event }}{% endif %}{% if d.get('severity') %} — {{ d.severity }}{% endif -%}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('product_id') or d.get('message') %}Space weather alert{% if d.get('product_id') %} {{ d.product_id }}{% endif %}{% if d.get('message') %}: {{ d.message | replace('\r', ' ') | replace('\n', ' ') | truncate(80) }}{% endif %}{% endif -%}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('Kp') is not none %}Geomagnetic activity (Kp index): {{ d.Kp }}{% endif -%}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('flux') is not none %}Solar proton flux: {{ d.flux | round(2) }} pfu{% if d.get('energy') %} at {{ d.energy }}{% endif %}{% endif -%}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('magnitude') is not none %}Magnitude {{ d.magnitude | round(1) }}{% if d.get('place') %} — {{ d.place }}{% endif %}{% endif -%}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('county') or d.get('state') %}Wildfire incident — {% if d.get('county') %}{{ d.county }}{% if d.get('state') %}, {% endif %}{% endif %}{% if d.get('state') %}{{ d.state }}{% endif %}{% endif -%}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('county') or d.get('state') %}Wildfire perimeter — {% if d.get('county') %}{{ d.county }}{% if d.get('state') %}, {% endif %}{% endif %}{% if d.get('state') %}{{ d.state }}{% endif %}{% endif -%}

View file

@ -10,30 +10,40 @@
<tr> <tr>
<th style="width: 2rem;"></th> <th style="width: 2rem;"></th>
<th>Time</th> <th>Time</th>
<th>Adapter</th> <th>Location</th>
<th>Category</th>
<th>Geometry</th>
<th>Subject</th> <th>Subject</th>
<th>Adapter</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for event in events %} {% for event in events %}
{# Per-adapter one-line summary, dispatched by adapter name with a generic
fallback (no hardcoded list). Captured once so it serves both the
Subject cell and the map popup (via data-subject). #}
{% set subject_summary %}{% include ["_event_summaries/" ~ event.adapter ~ ".html", "_event_summaries/_default.html"] %}{% endset %}
{# Location: generic _enriched.geocoder reader, then top-level named
fields, then coordinates. No adapter-specific logic. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% set gc = (d.get('_enriched') or {}).get('geocoder') or {} %}
{% set loc_local = gc.get('city') or d.get('city') or gc.get('county') or d.get('county') %}
{% set loc_state = gc.get('state') or d.get('state') %}
{% set loc_country = gc.get('country') or d.get('country') %}
{% set loc_parts = [loc_local, loc_state, loc_country] | select | list %}
<tr class="event-row" data-row-idx="{{ loop.index0 }}" <tr class="event-row" data-row-idx="{{ loop.index0 }}"
data-event-id="{{ event.id }}" data-event-id="{{ event.id }}"
data-adapter="{{ event.adapter }}" data-adapter="{{ event.adapter }}"
data-category="{{ event.category }}" data-category="{{ event.category }}"
data-time="{{ event.time }}" data-time="{{ event.time }}"
data-subject="{{ event.subject or '' }}" data-subject="{{ subject_summary | trim }}"
{% if event.geometry %}data-geometry='{{ event.geometry | tojson }}'{% endif %}> {% if event.geometry %}data-geometry='{{ event.geometry | tojson }}'{% endif %}>
<td><button type="button" class="expand-row" aria-label="Expand">&#9656;</button></td> <td><button type="button" class="expand-row" aria-label="Expand">&#9656;</button></td>
<td>{{ event.time }}</td> <td title="{{ event.time }}">{{ event.time_human }}</td>
<td>{{ event.adapter }}</td> <td>{% if loc_parts %}{{ loc_parts | join(', ') }}{% elif gc.get('landclass') %}{{ gc.landclass }}{% else %}—{% endif %}</td>
<td>{{ event.category }}</td> <td>{{ subject_summary | trim or '—' }}</td>
<td>{{ event.geometry_summary }}</td> <td>{{ event.adapter_display }}</td>
<td>{{ event.subject or '—' }}</td>
</tr> </tr>
<tr class="event-detail" hidden> <tr class="event-detail" hidden>
<td colspan="6"> <td colspan="5">
<dl class="event-detail-list"> <dl class="event-detail-list">
<dt>Event ID</dt> <dt>Event ID</dt>
<dd><code>{{ event.id }}</code></dd> <dd><code>{{ event.id }}</code></dd>
@ -43,6 +53,11 @@
<dt>Regions</dt> <dt>Regions</dt>
<dd>{{ event.regions | join(", ") }}</dd> <dd>{{ event.regions | join(", ") }}</dd>
{% endif %} {% endif %}
{# Per-adapter curated fields. Dispatch by adapter name with a
generic fallback; the chosen template is whichever exists
first, so new adapters fall through to _default.html with
no hardcoded adapter list here. #}
{% include ["_event_rows/" ~ event.adapter ~ ".html", "_event_rows/_default.html"] %}
<dt>Data</dt> <dt>Data</dt>
<dd><pre class="event-data-pre">{{ event.data | tojson(indent=2) }}</pre></dd> <dd><pre class="event-data-pre">{{ event.data | tojson(indent=2) }}</pre></dd>
</dl> </dl>

View file

@ -18,7 +18,8 @@
} }
.map-legend { .map-legend {
display: flex; display: flex;
gap: 1rem; flex-wrap: wrap;
gap: 0.5rem 1rem;
font-size: 0.85rem; font-size: 0.85rem;
} }
.map-legend-item { .map-legend-item {
@ -105,6 +106,12 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{# 12-slot legend/marker palette. Adapters map to colors by their sorted index
(loop.index0 % palette length) — no per-adapter color is hardcoded. #}
{% set palette = [
"#f59e0b", "#dc2626", "#7c3aed", "#2563eb", "#059669", "#db2777",
"#0891b2", "#65a30d", "#ea580c", "#4f46e5", "#9333ea", "#0d9488"
] %}
<h1>Events</h1> <h1>Events</h1>
{% if filter_error %} {% if filter_error %}
@ -123,9 +130,9 @@
<label for="adapter">Adapter</label> <label for="adapter">Adapter</label>
<select id="adapter" name="adapter"> <select id="adapter" name="adapter">
<option value="">All</option> <option value="">All</option>
<option value="nws" {% if filter_values.adapter == 'nws' %}selected{% endif %}>nws</option> {% for a in adapters %}
<option value="firms" {% if filter_values.adapter == 'firms' %}selected{% endif %}>firms</option> <option value="{{ a.name }}" {% if filter_values.adapter == a.name %}selected{% endif %}>{{ a.display_name }}</option>
<option value="usgs_quake" {% if filter_values.adapter == 'usgs_quake' %}selected{% endif %}>usgs_quake</option> {% endfor %}
</select> </select>
</div> </div>
<div> <div>
@ -163,18 +170,12 @@
<div id="events-map"></div> <div id="events-map"></div>
<div class="map-controls"> <div class="map-controls">
<div class="map-legend"> <div class="map-legend">
{% for a in adapters %}
<div class="map-legend-item"> <div class="map-legend-item">
<div class="map-legend-swatch" style="background-color: #f59e0b;"></div> <div class="map-legend-swatch" style="background-color: {{ palette[loop.index0 % palette|length] }};"></div>
<span>NWS (Weather)</span> <span>{{ a.display_name }}</span>
</div>
<div class="map-legend-item">
<div class="map-legend-swatch" style="background-color: #dc2626;"></div>
<span>FIRMS (Fire)</span>
</div>
<div class="map-legend-item">
<div class="map-legend-swatch" style="background-color: #7c3aed;"></div>
<span>USGS (Quake)</span>
</div> </div>
{% endfor %}
</div> </div>
<button type="button" id="fit-to-results" class="outline secondary">Fit map to results</button> <button type="button" id="fit-to-results" class="outline secondary">Fit map to results</button>
</div> </div>
@ -189,17 +190,57 @@
var tileUrl = {{ tile_url | tojson }}; var tileUrl = {{ tile_url | tojson }};
var tileAttr = {{ tile_attribution | tojson }}; var tileAttr = {{ tile_attribution | tojson }};
// Adapter color mapping // Adapter color mapping — built from the registry-derived adapter list and
// the same palette the legend uses, keyed by sorted index. No adapter name
// or color is hardcoded here.
var PALETTE = {{ palette | tojson }};
var ADAPTER_COLORS = { var ADAPTER_COLORS = {
"nws": "#f59e0b", {% for a in adapters %}{{ a.name | tojson }}: PALETTE[{{ loop.index0 }} % PALETTE.length]{{ "," if not loop.last }}
"firms": "#dc2626", {% endfor %}
"usgs_quake": "#7c3aed"
}; };
function getAdapterColor(adapter) { function getAdapterColor(adapter) {
return ADAPTER_COLORS[adapter] || "#3388ff"; return ADAPTER_COLORS[adapter] || "#3388ff";
} }
// Flatten arbitrarily-nested GeoJSON coordinates into a flat [lng, lat] list.
function flattenCoords(coords, out) {
if (coords.length && typeof coords[0] === "number") {
out.push(coords);
return;
}
for (var i = 0; i < coords.length; i++) {
flattenCoords(coords[i], out);
}
}
// A geometry whose vertices all collapse to a single point (zero extent in
// both dimensions). Enrichment stores some point-like sources as degenerate
// polygons; Leaflet would draw these invisibly, so we plot a marker instead.
function isDegenerate(geom) {
if (!geom || !geom.coordinates) return false;
var pts = [];
flattenCoords(geom.coordinates, pts);
if (pts.length === 0) return false;
var minX = pts[0][0], maxX = pts[0][0], minY = pts[0][1], maxY = pts[0][1];
for (var i = 1; i < pts.length; i++) {
if (pts[i][0] < minX) minX = pts[i][0];
if (pts[i][0] > maxX) maxX = pts[i][0];
if (pts[i][1] < minY) minY = pts[i][1];
if (pts[i][1] > maxY) maxY = pts[i][1];
}
return (maxX - minX) < 1e-9 && (maxY - minY) < 1e-9;
}
// Mean of all vertices, returned as Leaflet [lat, lng].
function centroidLatLng(geom) {
var pts = [];
flattenCoords(geom.coordinates, pts);
var sx = 0, sy = 0;
for (var i = 0; i < pts.length; i++) { sx += pts[i][0]; sy += pts[i][1]; }
return [sy / pts.length, sx / pts.length];
}
// Initialize map // Initialize map
var map = L.map("events-map").setView([39, -98], 4); var map = L.map("events-map").setView([39, -98], 4);
@ -275,7 +316,22 @@
var adapter = row.dataset.adapter || ""; var adapter = row.dataset.adapter || "";
var color = getAdapterColor(adapter); var color = getAdapterColor(adapter);
var layer = L.geoJSON(geom, { var markerStyle = {
radius: 8,
color: color,
weight: 2,
fillColor: color,
fillOpacity: 0.25
};
// Real polygons/lines render as geometry; zero-extent geometries
// (degenerate polygons from enrichment) render as a point marker
// so every non-null geometry is actually visible on the map.
var layer;
if (isDegenerate(geom)) {
layer = L.circleMarker(centroidLatLng(geom), markerStyle);
} else {
layer = L.geoJSON(geom, {
style: { style: {
color: color, color: color,
weight: 2, weight: 2,
@ -283,15 +339,10 @@
fillOpacity: 0.25 fillOpacity: 0.25
}, },
pointToLayer: function(feature, latlng) { pointToLayer: function(feature, latlng) {
return L.circleMarker(latlng, { return L.circleMarker(latlng, markerStyle);
radius: 8,
color: color,
weight: 2,
fillColor: color,
fillOpacity: 0.25
});
} }
}); });
}
layer.bindPopup(buildPopup(row)); layer.bindPopup(buildPopup(row));
layer.on("click", function() { layer.on("click", function() {
@ -414,17 +465,80 @@
// Fit to results button // Fit to results button
document.getElementById("fit-to-results").addEventListener("click", fitToAllLayers); document.getElementById("fit-to-results").addEventListener("click", fitToAllLayers);
// Client-side sort of the displayed rows; state persists across HTMX swaps.
var sortState = { col: null, dir: 1 }; // dir: 1 asc, -1 desc
function sortKey(row, col) {
// Time sorts on the ISO timestamp for true chronological order.
if (col === 1) return row.dataset.time || "";
var cell = row.children[col];
return cell ? cell.textContent.trim().toLowerCase() : "";
}
function applySort() {
if (sortState.col === null) return;
var tbody = document.querySelector("#events-rows table.events-table tbody");
if (!tbody) return;
var pairs = [];
tbody.querySelectorAll("tr.event-row").forEach(function(r) {
pairs.push([r, r.nextElementSibling]); // main row + its detail row
});
// Array.prototype.sort is stable (ES2019+), so equal keys keep order.
pairs.sort(function(a, b) {
var ka = sortKey(a[0], sortState.col), kb = sortKey(b[0], sortState.col);
if (ka < kb) return -sortState.dir;
if (ka > kb) return sortState.dir;
return 0;
});
pairs.forEach(function(p) {
tbody.appendChild(p[0]);
if (p[1]) tbody.appendChild(p[1]);
});
}
function updateSortIndicators(ths) {
ths.forEach(function(th, idx) {
th.querySelectorAll(".sort-ind").forEach(function(s) { s.remove(); });
if (idx === sortState.col) {
var s = document.createElement("span");
s.className = "sort-ind";
s.textContent = sortState.dir === 1 ? " ▲" : " ▼";
th.appendChild(s);
}
});
}
function bindSortHandlers() {
var ths = document.querySelectorAll("#events-rows table.events-table thead th");
ths.forEach(function(th, idx) {
if (idx === 0) return; // expand column is not sortable
th.style.cursor = "pointer";
th.onclick = function() {
if (sortState.col === idx) { sortState.dir *= -1; }
else { sortState.col = idx; sortState.dir = 1; }
updateSortIndicators(ths);
applySort();
};
});
updateSortIndicators(ths);
}
// Initial load - bind layers and fit bounds // Initial load - bind layers and fit bounds
rebindEventLayers(); // Initial load only rebindEventLayers(); // Initial load only
bindSortHandlers();
if (false) { // DISABLED: map never auto-fits if (false) { // DISABLED: map never auto-fits
fitToAllLayers(); fitToAllLayers();
isInitialLoad = false; isInitialLoad = false;
} }
// Re-bind layers after HTMX swap (but do NOT fit bounds) // Re-bind layers after HTMX swap so the map tracks the current (filtered /
// paginated) result set. Viewport is preserved — we never auto-fit here.
document.body.addEventListener("htmx:afterSwap", function(evt) { document.body.addEventListener("htmx:afterSwap", function(evt) {
if (evt.detail.target.id === "events-rows") { if (evt.detail.target.id === "events-rows") {
// rebindEventLayers(); // DISABLED: map shows all events, only table filters rebindEventLayers();
// Re-bind sort handlers to the new rows and re-apply the active sort.
bindSortHandlers();
applySort();
// Do NOT call fitToAllLayers - preserve user viewport // Do NOT call fitToAllLayers - preserve user viewport
} }
}); });

View file

@ -76,9 +76,12 @@ async def apply_enrichment(
No-op when the adapter declares no enrichment_locations or no enrichers No-op when the adapter declares no enrichment_locations or no enrichers
are registered. Uses the first (lat_path, lon_path) tuple that resolves to are registered. Uses the first (lat_path, lon_path) tuple that resolves to
a non-null coordinate pair in event.data. Each enricher's result is keyed a non-null coordinate pair in event.data. If no declared pair resolves to
by enricher.name. Mutates the data dict in place (Event is frozen, but its coordinates, still attaches an all-null bundle so that every event from an
data dict is not this avoids a model_copy on every published event). enriched adapter carries _enriched (consumers get a stable field set).
Each enricher's result is keyed by enricher.name. Mutates the data dict in
place (Event is frozen, but its data dict is not this avoids a
model_copy on every published event).
""" """
if not enrichment_locations or not enrichers: if not enrichment_locations or not enrichers:
return return
@ -93,6 +96,15 @@ async def apply_enrichment(
enriched[enricher.name] = await enricher.enrich(location) enriched[enricher.name] = await enricher.enrich(location)
event.data["_enriched"] = enriched event.data["_enriched"] = enriched
return return
# No declared pair resolved to coordinates. Still attach _enriched: each
# enricher resolves the null location to its own all-null bundle (per the
# never-raise contract), so coordless events (e.g. removal tombstones)
# carry the same shape as enriched ones.
null_location = {"lat": None, "lon": None}
enriched = {}
for enricher in enrichers:
enriched[enricher.name] = await enricher.enrich(null_location)
event.data["_enriched"] = enriched
# Stream subject mappings -- derived from the registry; every stream is included # Stream subject mappings -- derived from the registry; every stream is included
# (META too: supervisor must create it in JetStream even though archive skips it). # (META too: supervisor must create it in JetStream even though archive skips it).

View file

@ -0,0 +1,78 @@
"""Regression tests for apply_enrichment's coordless path.
Design principle: every event from an adapter that declares enrichment_locations
must carry data["_enriched"] populated when coordinates resolve, an all-null
bundle when they don't (e.g. removal tombstones with no lat/lon). Adapters that
declare no enrichment_locations are still skipped entirely.
"""
from datetime import datetime, timezone
from typing import Any
import pytest
from central.config_models import EnrichmentConfig
from central.enrichment.cache import EnrichmentCache
from central.enrichment.geocoder import GeocoderEnricher, all_null_bundle
from central.models import Event, Geo
from central.supervisor import apply_enrichment, build_enrichers
def _make_event(data: dict[str, Any]) -> Event:
return Event(
id="evt-1",
adapter="usgs_quake",
category="quake.event.test",
time=datetime(2026, 1, 1, tzinfo=timezone.utc),
geo=Geo(),
data=data,
)
class _PopulatingBackend:
"""Deterministic backend that resolves any real coords to a fixed place."""
async def reverse(self, lat: float, lon: float) -> dict[str, Any]:
return {**all_null_bundle(), "city": "Boise", "state": "ID"}
@pytest.mark.asyncio
async def test_coordless_event_with_declared_locations_gets_null_bundle(tmp_path):
"""An event whose declared coord paths are all None still gets _enriched."""
cache = EnrichmentCache(tmp_path / "enrichment_cache.db")
enrichers = build_enrichers(EnrichmentConfig(), cache)
event = _make_event(
{"latitude": None, "longitude": None, "reason": "fallen_off_current_service"}
)
assert "_enriched" not in event.data
await apply_enrichment(event, [("latitude", "longitude")], enrichers)
assert event.data["_enriched"]["geocoder"] == all_null_bundle()
@pytest.mark.asyncio
async def test_event_with_coords_still_enriches_normally(tmp_path):
"""The coord-bearing path is unchanged: the backend is consulted and its
resolved fields land in the bundle."""
cache = EnrichmentCache(tmp_path / "enrichment_cache.db")
enricher = GeocoderEnricher(_PopulatingBackend(), cache=cache)
event = _make_event({"latitude": 43.0, "longitude": -116.0})
await apply_enrichment(event, [("latitude", "longitude")], [enricher])
bundle = event.data["_enriched"]["geocoder"]
assert bundle["state"] == "ID"
assert bundle["city"] == "Boise"
@pytest.mark.asyncio
async def test_adapter_with_no_enrichment_locations_still_skipped(tmp_path):
"""Adapters declaring no enrichment_locations are skipped — no _enriched."""
cache = EnrichmentCache(tmp_path / "enrichment_cache.db")
enrichers = build_enrichers(EnrichmentConfig(), cache)
event = _make_event({"latitude": 43.0, "longitude": -116.0})
await apply_enrichment(event, [], enrichers)
assert "_enriched" not in event.data

View file

@ -0,0 +1,110 @@
"""Coverage tests for adapter enrichment_locations declarations (PR L-a).
Every adapter must make a conscious enrichment_locations declaration a
non-empty [(lat_field, lon_field)] for point adapters, or an explicit [] for
adapters with no point coordinate. Registry-derived (iterates
discover_adapters()), no hardcoded adapter lists.
Plus synthetic-event tests for the two adapters with isolated event builders
(usgs_quake._feature_to_event, nwis._build_event) proving the declared
latitude/longitude paths actually resolve on event.data. The four inline-build
point adapters (eonet, gdacs, wfigs_incidents, inciweb) construct events only
inside poll() and are covered by the live end-to-end smoke instead.
"""
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock
from central.adapter_discovery import discover_adapters
from central.config_models import AdapterConfig
def test_every_adapter_explicitly_declares_enrichment_locations():
"""Each adapter declares enrichment_locations in its OWN class body (not
just inheriting the SourceAdapter default) a conscious choice per adapter."""
missing = [
name for name, cls in discover_adapters().items()
if "enrichment_locations" not in cls.__dict__
]
assert not missing, f"adapters missing an explicit enrichment_locations: {missing}"
def test_enrichment_locations_shape_is_valid():
"""Each declaration is a list of (str, str) tuples (possibly empty)."""
for name, cls in discover_adapters().items():
locs = cls.enrichment_locations
assert isinstance(locs, list), f"{name}: not a list"
for tup in locs:
assert isinstance(tup, tuple) and len(tup) == 2, f"{name}: bad tuple {tup!r}"
assert all(isinstance(p, str) for p in tup), f"{name}: non-str path {tup!r}"
def test_point_adapters_use_canonical_lat_lon_paths():
"""Every point adapter (non-empty declaration) uses the same protocol keys
('latitude', 'longitude') the convention FIRMS established."""
for name, cls in discover_adapters().items():
if cls.enrichment_locations:
assert cls.enrichment_locations == [("latitude", "longitude")], (
f"{name} uses non-canonical paths: {cls.enrichment_locations}"
)
def test_at_least_the_known_point_adapters_are_non_empty():
"""Registry-derived sanity: the adapters that carry a point coordinate have
a non-empty declaration. Derived by probing enrichment_locations, not a
hardcoded list guards against a regression that blanks them all."""
non_empty = {
name for name, cls in discover_adapters().items() if cls.enrichment_locations
}
# firms is the original; there must be several point adapters now.
assert "firms" in non_empty
assert len(non_empty) >= 5, f"expected several point adapters, got {sorted(non_empty)}"
# --- synthetic-event tests for the two isolated builders --------------------
def test_usgs_quake_event_exposes_top_level_latlon():
from central.adapters.usgs_quake import USGSQuakeAdapter
config = AdapterConfig(
name="usgs_quake", enabled=True, cadence_s=60,
settings={}, updated_at=datetime.now(timezone.utc),
)
adapter = USGSQuakeAdapter(config, MagicMock(), Path("/tmp/never_used.db"))
feature = {
"type": "Feature", "id": "test_q1",
"properties": {"mag": 2.5, "place": "X", "time": 1715000000000,
"updated": 1715000000000},
"geometry": {"type": "Point", "coordinates": [-116.2, 43.7, 10.5]},
}
event = adapter._feature_to_event(feature)
assert event is not None
assert event.data["latitude"] == 43.7
assert event.data["longitude"] == -116.2
def test_nwis_event_mirrors_centroid_into_data():
from central.adapters.nwis import NWISAdapter
config = AdapterConfig(
name="nwis", enabled=True, cadence_s=900,
settings={}, updated_at=datetime.now(timezone.utc),
)
adapter = NWISAdapter(config, MagicMock(), Path("/tmp/never_used.db"))
feature = {
"geometry": {"type": "Point", "coordinates": [-90.25, 41.78]}, # (lon, lat)
"properties": {
"monitoring_location_id": "USGS-05420500",
"time": "2026-05-20T00:00:00Z",
"value": "123.0",
"unit_of_measure": "ft",
},
}
event = adapter._build_event(feature, "00060")
assert event is not None
# latitude = centroid[1], longitude = centroid[0]; no axis swap.
assert event.data["latitude"] == 41.78
assert event.data["longitude"] == -90.25
# Geo.centroid retained for existing rendering uses.
assert event.geo.centroid == (-90.25, 41.78)

View file

@ -684,3 +684,404 @@ class TestEventRowDataAttributes:
assert context["events"][0]["adapter"] == "usgs_quake" assert context["events"][0]["adapter"] == "usgs_quake"
assert context["events"][0]["category"] == "quake.event" assert context["events"][0]["category"] == "quake.event"
assert context["events"][0]["subject"] == "M4.2 Earthquake" assert context["events"][0]["subject"] == "M4.2 Earthquake"
# --- PR L-b: operator /events tab polish ---------------------------------
def _events_context(events):
"""Minimal context for rendering _events_rows.html as a standalone fragment."""
return {
"events": events,
"next_cursor": None,
"filter_error": None,
"filter_values": {
"adapter": "", "category": "", "since": "", "until": "",
"region_north": "", "region_south": "", "region_east": "",
"region_west": "", "limit": "50",
},
}
def _event(adapter, inner=None, geometry=None):
"""Build an event dict matching _fetch_events output shape.
`inner` populates payload->data->data (the adapter-specific payload) at
event["data"]["data"]["data"], which the per-adapter partials read.
"""
return {
"id": "evt-" + adapter,
"time": "2026-05-17T12:00:00+00:00",
"received": "2026-05-17T12:00:00+00:00",
"adapter": adapter,
"category": adapter + ".test",
"subject": "subject",
"geometry": geometry,
"geometry_summary": "",
"data": {"data": {"data": inner or {}}},
"regions": [],
}
def _render_rows(events):
"""Render _events_rows.html through the real Jinja environment."""
from central.gui import templates as gui_templates
return gui_templates.env.get_template("_events_rows.html").render(
**_events_context(events)
)
class TestRegistryDrivenAdapterFilter:
"""(A) Adapter filter <select> is driven by discover_adapters(), no hardcoded list."""
@pytest.mark.asyncio
async def test_filter_options_cover_every_discovered_adapter(self):
from central.adapter_discovery import discover_adapters
registry = discover_adapters()
mock_request = MagicMock()
mock_request.state.operator = MagicMock(id=1, username="admin")
mock_request.state.csrf_token = "test_csrf"
mock_request.query_params = {}
mock_conn = AsyncMock()
mock_conn.fetch.return_value = []
mock_conn.fetchrow.return_value = {
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
"map_attribution": "OpenStreetMap",
}
mock_pool = MagicMock()
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
mock_templates = MagicMock()
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
with patch("central.gui.routes._get_templates", return_value=mock_templates):
with patch("central.gui.routes.get_pool", return_value=mock_pool):
await events_list(mock_request)
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
# The list is exactly the registry, sorted by name (stable), no extras.
assert [a["name"] for a in context["adapters"]] == sorted(registry.keys())
# Each entry carries name + display_name straight from the adapter class.
for cls in registry.values():
assert {"name": cls.name, "display_name": cls.display_name} in context["adapters"]
class TestPerAdapterRowPartials:
"""(C) Per-adapter row partials with registry-derived dispatch + _default fallback."""
def test_every_discovered_adapter_has_a_partial(self):
from central.adapter_discovery import discover_adapters
from central.gui import templates as gui_templates
# get_template raises TemplateNotFound if a per-adapter file is missing.
for name in discover_adapters():
gui_templates.env.get_template("_event_rows/%s.html" % name)
def test_default_fallback_partial_exists(self):
from central.gui import templates as gui_templates
gui_templates.env.get_template("_event_rows/_default.html")
def test_every_discovered_adapter_renders_without_error(self):
from central.adapter_discovery import discover_adapters
for name in discover_adapters():
html = _render_rows([_event(name)])
assert 'data-adapter="%s"' % name in html
def test_unknown_adapter_falls_back_to_default(self):
# No bespoke partial -> dispatch resolves to _default.html (no crash),
# and the raw payload block is still rendered.
html = _render_rows([_event("not_a_real_adapter", inner={"foo": "bar"})])
assert 'data-adapter="not_a_real_adapter"' in html
assert "event-data-pre" in html
def test_usgs_quake_partial_surfaces_curated_fields(self):
html = _render_rows([_event(
"usgs_quake",
inner={"magnitude": 4.2, "magType": "mb", "place": "10km N of Town", "depth": 5.0},
)])
assert "Magnitude" in html
assert "4.2" in html
assert "10km N of Town" in html
def test_nws_partial_surfaces_curated_fields(self):
html = _render_rows([_event(
"nws",
inner={"event": "Tornado Warning", "headline": "TORNADO", "severity": "Extreme"},
)])
assert "Tornado Warning" in html
assert "Extreme" in html
class TestMapAllAdapterGeometry:
"""(B) Every non-null geometry reaches the map; rebind-on-swap is enabled."""
def test_polygon_geometry_emitted_as_data_geometry(self):
poly = {
"type": "Polygon",
"coordinates": [[[-104.18, 37.14], [-103.66, 37.14],
[-103.66, 37.43], [-104.18, 37.43], [-104.18, 37.14]]],
}
html = _render_rows([_event("nws", inner={"event": "x"}, geometry=poly)])
assert "data-geometry=" in html
assert "Polygon" in html
def test_event_without_geometry_omits_data_geometry(self):
html = _render_rows([_event("swpc_protons", inner={"flux": 1.0})])
assert "data-geometry=" not in html
def test_map_rebinds_on_swap_and_handles_degenerate_geometry(self):
# Regression guard for the NWIS-only map: rebind must fire on HTMX swap
# and the degenerate-geometry fallback must exist.
import pathlib
from central.gui import templates as gui_templates
src = pathlib.Path(
gui_templates.env.loader.searchpath[0], "events_list.html"
).read_text()
assert "// rebindEventLayers(); // DISABLED" not in src
assert "isDegenerate" in src
# --- PR L-c: readable Time / Location / Subject / Adapter columns ---------
def _first_row_cells(html):
"""Visible <td> text of the first event-row (before its detail row).
Splits on the detail row's class attribute (not the bare string
'event-detail', which also appears in the page's <style> block).
"""
import re
body = html.split('class="event-detail"')[0]
return [
re.sub(r"<[^>]+>", "", c).strip()
for c in re.findall(r"<td[^>]*>(.*?)</td>", body, re.S)
]
class TestEventTimeFormat:
"""(A) Server-side 'MM-DD-YYYY HH:MM UTC' formatting (24h, no seconds)."""
def test_format_basic_utc(self):
from central.gui.routes import _format_event_time
assert _format_event_time("2026-05-21T06:00:00+00:00") == "05-21-2026 06:00 UTC"
def test_format_converts_offset_to_utc(self):
from central.gui.routes import _format_event_time
# 19:30 at -06:00 is 01:30 UTC the next day.
assert _format_event_time("2026-05-20T19:30:00-06:00") == "05-21-2026 01:30 UTC"
def test_format_empty_and_none(self):
from central.gui.routes import _format_event_time
assert _format_event_time("") == ""
assert _format_event_time(None) == ""
def test_format_no_seconds_no_offset_suffix(self):
from central.gui.routes import _format_event_time
out = _format_event_time("2026-01-02T03:04:59+00:00")
assert out == "01-02-2026 03:04 UTC"
assert ":59" not in out and "+00" not in out
class TestTableDisplayDecoration:
"""(A)/(D) _decorate_table_events adds display fields; /events.json unaffected."""
def test_adapter_display_matches_registry(self):
from central.adapter_discovery import discover_adapters
from central.gui.routes import _decorate_table_events, _format_event_time
registry = discover_adapters()
events = [_event(name) for name in registry]
_decorate_table_events(events)
for ev in events:
cls = registry[ev["adapter"]]
assert ev["adapter_display"] == cls.display_name
assert ev["time_human"] == _format_event_time(ev["time"])
def test_unknown_adapter_display_falls_back_to_name(self):
from central.gui.routes import _decorate_table_events
events = [_event("not_a_real_adapter")]
_decorate_table_events(events)
assert events[0]["adapter_display"] == "not_a_real_adapter"
@pytest.mark.asyncio
async def test_events_json_has_no_table_only_fields(self):
# No /events.json schema change: display fields must not leak into JSON.
mock_request = MagicMock()
mock_request.state.operator = MagicMock(id=1, username="admin")
mock_request.query_params = {}
mock_conn = AsyncMock()
mock_conn.fetch.return_value = [{
"id": "e1",
"time": datetime(2026, 5, 21, 6, 0, tzinfo=timezone.utc),
"received": datetime(2026, 5, 21, 6, 0, tzinfo=timezone.utc),
"adapter": "nwis",
"category": "hydro",
"subject": None,
"geometry": None,
"data": {},
"regions": [],
}]
mock_pool = MagicMock()
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
with patch("central.gui.routes.get_pool", return_value=mock_pool):
resp = await events_json(mock_request)
ev = json.loads(resp.body)["events"][0]
assert "time_human" not in ev
assert "adapter_display" not in ev
class TestLocationColumn:
"""(B) Generic _enriched/top-level location reader — no adapter logic."""
def _location(self, inner):
return _first_row_cells(_render_rows([_event("usgs_quake", inner=inner)]))[2]
def test_city_state_country(self):
loc = self._location({"_enriched": {"geocoder": {
"city": "Trinidad", "state": "Colorado", "country": "United States"}}})
assert loc == "Trinidad, Colorado, United States"
def test_state_country_when_city_null(self):
loc = self._location({"_enriched": {"geocoder": {
"city": None, "state": "Missouri", "country": "United States"}}})
assert loc == "Missouri, United States"
def test_top_level_country_fallback(self):
# gdacs-style: no geocoder, country at top level.
assert self._location({"country": "Austria"}) == "Austria"
def test_top_level_state_only_fallback(self):
# wfigs-style: state code at top level, no country.
assert self._location({"state": "CO"}) == "CO"
def test_landclass_fallback(self):
loc = self._location({"_enriched": {"geocoder": {
"city": None, "state": None, "country": None,
"landclass": "Ridgecrest Field Office"}}})
assert loc == "Ridgecrest Field Office"
def test_coordinates_alone_are_not_a_location(self):
# Bare lat/lon is a position, not a place name -> "—".
assert self._location({"latitude": 35.3603324, "longitude": -117.7854995}) == ""
def test_none_when_nothing_available(self):
assert self._location({}) == ""
class TestSubjectColumn:
"""(C) Per-adapter one-line summaries, registry-derived dispatch + fallback."""
def test_every_adapter_has_a_summary_partial(self):
from central.adapter_discovery import discover_adapters
from central.gui import templates as gui_templates
for name in discover_adapters():
gui_templates.env.get_template("_event_summaries/%s.html" % name)
def test_default_summary_partial_exists(self):
from central.gui import templates as gui_templates
gui_templates.env.get_template("_event_summaries/_default.html")
def test_every_adapter_summary_renders_without_error(self):
from central.adapter_discovery import discover_adapters
for name in discover_adapters():
_render_rows([_event(name)]) # must not raise
def test_usgs_quake_summary_is_plain_language(self):
cells = _first_row_cells(_render_rows([_event(
"usgs_quake", inner={"magnitude": 1.347, "magType": "ml",
"place": "14 km W of Johannesburg, CA"})]))
assert cells[3] == "Magnitude 1.3 — 14 km W of Johannesburg, CA"
assert "ml" not in cells[3] # scale code dropped
def test_nwis_summary_drops_parameter_code(self):
cells = _first_row_cells(_render_rows([_event(
"nwis", inner={"parameter_code": "00060", "value": 111.0,
"unit_of_measure": "ft^3/s"})]))
assert cells[3] == "Water reading: 111.0 ft^3/s"
assert "00060" not in cells[3] # opaque pcode dropped
def test_unknown_adapter_summary_is_dash(self):
cells = _first_row_cells(_render_rows([_event("not_a_real_adapter", inner={"x": 1})]))
assert cells[3] == ""
def test_summary_populates_data_subject_for_popup(self):
html = _render_rows([_event("usgs_quake", inner={"magnitude": 1.347,
"place": "near Town"})])
assert 'data-subject="Magnitude 1.3 — near Town"' in html
class TestTableRendersThroughHTTP:
"""End-to-end HTTP render: Time and Adapter cells must be populated in the
real response, guarding the route->template binding (not the helper alone)."""
def _mock_pool(self):
now = datetime(2026, 5, 21, 6, 0, tzinfo=timezone.utc)
def _fetchrow(query, *args):
q = " ".join(str(query).split())
if "config.sessions" in q:
return {"id": 1, "username": "admin", "created_at": now,
"password_changed_at": now, "csrf_token": "csrf"}
if "map_tile_url" in q:
return {"map_tile_url": "https://t/{z}/{x}/{y}.png",
"map_attribution": "OSM"}
return None
rows = [
{"id": "evt-q", "time": now, "received": now, "adapter": "usgs_quake",
"category": "quake", "subject": None, "geometry": None,
"data": {"data": {"data": {"magnitude": 1.3, "place": "near Town"}}},
"regions": []},
{"id": "evt-w", "time": now, "received": now, "adapter": "nwis",
"category": "hydro", "subject": None, "geometry": None,
"data": {"data": {"data": {"value": 5.0, "unit_of_measure": "ft"}}},
"regions": []},
]
mock_conn = MagicMock()
mock_conn.fetchrow = AsyncMock(side_effect=_fetchrow)
mock_conn.fetch = AsyncMock(return_value=rows)
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
mock_conn.__aexit__ = AsyncMock()
mock_pool = MagicMock()
mock_pool.acquire = MagicMock(return_value=mock_conn)
return mock_pool
def _client(self):
from fastapi import FastAPI
from starlette.testclient import TestClient
from central.gui.middleware import SessionMiddleware
from central.gui.routes import router
app = FastAPI()
app.include_router(router)
app.add_middleware(SessionMiddleware)
return TestClient(app, cookies={"central_session": "valid"})
def _expected_adapter_display(self):
from central.adapter_discovery import discover_adapters
return discover_adapters()["usgs_quake"].display_name
def test_events_page_time_and_adapter_cells_populated(self):
mock_pool = self._mock_pool()
with patch("central.gui.middleware.get_pool", return_value=mock_pool), \
patch("central.gui.routes.get_pool", return_value=mock_pool):
resp = self._client().get("/events")
assert resp.status_code == 200
cells = _first_row_cells(resp.text)
assert cells[1].endswith("UTC") and "2026" in cells[1] # Time non-empty
assert cells[4] == self._expected_adapter_display() # Adapter display_name
def test_events_rows_fragment_time_and_adapter_cells_populated(self):
mock_pool = self._mock_pool()
with patch("central.gui.middleware.get_pool", return_value=mock_pool), \
patch("central.gui.routes.get_pool", return_value=mock_pool):
resp = self._client().get("/events/rows")
assert resp.status_code == 200
cells = _first_row_cells(resp.text)
assert cells[1].endswith("UTC") and "2026" in cells[1] # Time non-empty
assert cells[4] == self._expected_adapter_display() # Adapter display_name