mirror of
https://github.com/zvx-echo6/central.git
synced 2026-05-21 18:14:44 +02:00
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>
This commit is contained in:
parent
3c27534e9e
commit
c918e8d259
12 changed files with 167 additions and 0 deletions
|
|
@ -148,6 +148,9 @@ class EONETAdapter(SourceAdapter):
|
|||
wizard_order = None
|
||||
default_cadence_s = 1800
|
||||
|
||||
# Event lat/lon mirrored from Geo.centroid into event.data (see poll()).
|
||||
enrichment_locations = [("latitude", "longitude")]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AdapterConfig,
|
||||
|
|
@ -370,6 +373,12 @@ class EONETAdapter(SourceAdapter):
|
|||
"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)
|
||||
|
||||
if self.is_published(dedup_key):
|
||||
|
|
|
|||
|
|
@ -150,6 +150,9 @@ class GDACSAdapter(SourceAdapter):
|
|||
wizard_order = None
|
||||
default_cadence_s = 600
|
||||
|
||||
# Event lat/lon mirrored from Geo.centroid into event.data (see poll()).
|
||||
enrichment_locations = [("latitude", "longitude")]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AdapterConfig,
|
||||
|
|
@ -391,6 +394,12 @@ class GDACSAdapter(SourceAdapter):
|
|||
"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:
|
||||
# Explicit tombstone from upstream. Only emit if we previously observed it.
|
||||
if guid in observed_before:
|
||||
|
|
|
|||
|
|
@ -171,6 +171,9 @@ class InciWebAdapter(SourceAdapter):
|
|||
wizard_order = None # Ships disabled
|
||||
default_cadence_s = 600
|
||||
|
||||
# Coords parsed from the narrative, mirrored from Geo.centroid into event.data.
|
||||
enrichment_locations = [("latitude", "longitude")]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AdapterConfig,
|
||||
|
|
@ -461,6 +464,9 @@ class InciWebAdapter(SourceAdapter):
|
|||
"url": item.get("link", ""),
|
||||
"guid": guid,
|
||||
"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,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -124,6 +124,9 @@ class NWISAdapter(SourceAdapter):
|
|||
wizard_order = None
|
||||
default_cadence_s = 900
|
||||
|
||||
# Site lat/lon mirrored from Geo.centroid into event.data (see _build_event).
|
||||
enrichment_locations = [("latitude", "longitude")]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AdapterConfig,
|
||||
|
|
@ -372,6 +375,12 @@ class NWISAdapter(SourceAdapter):
|
|||
"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(
|
||||
id=f"{monitoring_location_id}:{parameter_code}:{time_iso}",
|
||||
adapter=self.name,
|
||||
|
|
|
|||
|
|
@ -212,6 +212,9 @@ class NWSAdapter(SourceAdapter):
|
|||
wizard_order = 1
|
||||
default_cadence_s = 60
|
||||
|
||||
# Alerts cover forecast zones/counties (polygons), not a single point.
|
||||
enrichment_locations = []
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AdapterConfig,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@ class SWPCAlertsAdapter(SourceAdapter):
|
|||
wizard_order = None
|
||||
default_cadence_s = 300
|
||||
|
||||
# Space weather — no geographic coordinate to enrich.
|
||||
enrichment_locations = []
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AdapterConfig,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@ class SWPCKindexAdapter(SourceAdapter):
|
|||
wizard_order = None
|
||||
default_cadence_s = 600
|
||||
|
||||
# Space weather — no geographic coordinate to enrich.
|
||||
enrichment_locations = []
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AdapterConfig,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ class SWPCProtonsAdapter(SourceAdapter):
|
|||
wizard_order = None
|
||||
default_cadence_s = 600
|
||||
|
||||
# Space weather — no geographic coordinate to enrich.
|
||||
enrichment_locations = []
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AdapterConfig,
|
||||
|
|
|
|||
|
|
@ -79,6 +79,9 @@ class USGSQuakeAdapter(SourceAdapter):
|
|||
wizard_order = 3
|
||||
default_cadence_s = 60
|
||||
|
||||
# Epicenter lat/lon are top-level keys in event.data (see _build_event).
|
||||
enrichment_locations = [("latitude", "longitude")]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AdapterConfig,
|
||||
|
|
|
|||
|
|
@ -60,6 +60,9 @@ class WFIGSIncidentsAdapter(SourceAdapter):
|
|||
wizard_order = None # Not in setup wizard
|
||||
default_cadence_s = 300
|
||||
|
||||
# Incident-point lat/lon mirrored from Geo.centroid into event.data.
|
||||
enrichment_locations = [("latitude", "longitude")]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AdapterConfig,
|
||||
|
|
@ -326,6 +329,9 @@ class WFIGSIncidentsAdapter(SourceAdapter):
|
|||
"POOState_raw": state_raw,
|
||||
"POOCounty": county,
|
||||
"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,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,9 @@ class WFIGSPerimetersAdapter(SourceAdapter):
|
|||
wizard_order = None # Not in setup wizard
|
||||
default_cadence_s = 300
|
||||
|
||||
# Perimeters are polygons, not a single point — no coordinate to enrich.
|
||||
enrichment_locations = []
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AdapterConfig,
|
||||
|
|
|
|||
110
tests/test_enrichment_locations_coverage.py
Normal file
110
tests/test_enrichment_locations_coverage.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue