diff --git a/src/central/adapters/eonet.py b/src/central/adapters/eonet.py index ac4d46e..87003c7 100644 --- a/src/central/adapters/eonet.py +++ b/src/central/adapters/eonet.py @@ -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): diff --git a/src/central/adapters/gdacs.py b/src/central/adapters/gdacs.py index 9ff6d6e..cff6801 100644 --- a/src/central/adapters/gdacs.py +++ b/src/central/adapters/gdacs.py @@ -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: diff --git a/src/central/adapters/inciweb.py b/src/central/adapters/inciweb.py index 2ae0634..853b120 100644 --- a/src/central/adapters/inciweb.py +++ b/src/central/adapters/inciweb.py @@ -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, }, ) diff --git a/src/central/adapters/nwis.py b/src/central/adapters/nwis.py index e2a922d..6948a69 100644 --- a/src/central/adapters/nwis.py +++ b/src/central/adapters/nwis.py @@ -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, diff --git a/src/central/adapters/nws.py b/src/central/adapters/nws.py index 8205a1f..e50922e 100644 --- a/src/central/adapters/nws.py +++ b/src/central/adapters/nws.py @@ -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, diff --git a/src/central/adapters/swpc_alerts.py b/src/central/adapters/swpc_alerts.py index 3368824..8bd3317 100644 --- a/src/central/adapters/swpc_alerts.py +++ b/src/central/adapters/swpc_alerts.py @@ -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, diff --git a/src/central/adapters/swpc_kindex.py b/src/central/adapters/swpc_kindex.py index f05bcbb..ab5d29f 100644 --- a/src/central/adapters/swpc_kindex.py +++ b/src/central/adapters/swpc_kindex.py @@ -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, diff --git a/src/central/adapters/swpc_protons.py b/src/central/adapters/swpc_protons.py index 1a3876e..7c1d84c 100644 --- a/src/central/adapters/swpc_protons.py +++ b/src/central/adapters/swpc_protons.py @@ -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, diff --git a/src/central/adapters/usgs_quake.py b/src/central/adapters/usgs_quake.py index 63009ee..4fd8911 100644 --- a/src/central/adapters/usgs_quake.py +++ b/src/central/adapters/usgs_quake.py @@ -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, diff --git a/src/central/adapters/wfigs_incidents.py b/src/central/adapters/wfigs_incidents.py index 660d1de..be31f20 100644 --- a/src/central/adapters/wfigs_incidents.py +++ b/src/central/adapters/wfigs_incidents.py @@ -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, }, ) diff --git a/src/central/adapters/wfigs_perimeters.py b/src/central/adapters/wfigs_perimeters.py index 669d635..1a1d336 100644 --- a/src/central/adapters/wfigs_perimeters.py +++ b/src/central/adapters/wfigs_perimeters.py @@ -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, diff --git a/tests/test_enrichment_locations_coverage.py b/tests/test_enrichment_locations_coverage.py new file mode 100644 index 0000000..2be4c50 --- /dev/null +++ b/tests/test_enrichment_locations_coverage.py @@ -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)