From 382f744c1281b713b8c282d76adf2b1aff92d561 Mon Sep 17 00:00:00 2001 From: Matt Johnson Date: Tue, 26 May 2026 02:58:54 +0000 Subject: [PATCH] feat(wfigs_perimeters): emit true polygons via Geo.geometry (v0.9.8) Plumb the upstream GeoJSON Polygon/MultiPolygon into Geo.geometry so the archive renders the real fire-perimeter shape (ST_GeomFromGeoJSON) instead of the bbox 4-corner fallback. Adapter-local change; the v0.9.3 Geo.geometry framework already carries it end-to-end. Graceful null + dict-or-string coercion via _as_geometry; fall-off removal events stay NULL. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/central/adapters/wfigs_perimeters.py | 15 ++++++ tests/test_wfigs.py | 65 ++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/src/central/adapters/wfigs_perimeters.py b/src/central/adapters/wfigs_perimeters.py index d31540d..246ff77 100644 --- a/src/central/adapters/wfigs_perimeters.py +++ b/src/central/adapters/wfigs_perimeters.py @@ -1,5 +1,6 @@ """WFIGS Perimeters adapter for wildfire perimeter polygons.""" +import json import logging import sqlite3 from collections.abc import AsyncIterator @@ -39,6 +40,19 @@ from central.models import Event, Geo logger = logging.getLogger(__name__) + +def _as_geometry(geometry: Any) -> dict[str, Any] | None: + """Coerce upstream geometry to a GeoJSON dict for Geo.geometry; None if absent/malformed.""" + if isinstance(geometry, dict): + return geometry + if isinstance(geometry, str): + try: + parsed = json.loads(geometry) + return parsed if isinstance(parsed, dict) else None + except (ValueError, TypeError): + return None + return None + LAYER_NAME = "perimeters" @@ -305,6 +319,7 @@ class WFIGSPerimetersAdapter(SourceAdapter): geo = Geo( centroid=centroid, bbox=bbox, + geometry=_as_geometry(geometry), regions=regions, primary_region=primary_region, ) diff --git a/tests/test_wfigs.py b/tests/test_wfigs.py index ae463eb..a45746c 100644 --- a/tests/test_wfigs.py +++ b/tests/test_wfigs.py @@ -450,6 +450,20 @@ class TestWFIGSIncidentsAdapter: assert adapter.region.south == 35.0 +SAMPLE_PERIMETERS_MULTIPOLYGON = { + "type": "FeatureCollection", + "features": [{ + "type": "Feature", + "geometry": {"type": "MultiPolygon", "coordinates": [ + [[[-113.6, 48.4], [-113.4, 48.4], [-113.4, 48.6], [-113.6, 48.4]]], + [[[-114.0, 48.0], [-113.8, 48.0], [-113.8, 48.2], [-114.0, 48.0]]]]}, + "properties": {"attr_IrwinID": "GUID-002-MULTI", "attr_IncidentTypeCategory": "WF", + "attr_POOState": "US-MT", "attr_POOCounty": "Glacier", + "attr_FireDiscoveryDateTime": 1716000000000}, + }], +} + + class TestWFIGSPerimetersAdapter: """Tests for WFIGS Perimeters adapter.""" @@ -532,3 +546,54 @@ class TestWFIGSPerimetersAdapter: subject = adapter.subject_for(event) # Subject uses normalized state: mt.glacier not us-mt.glacier assert subject == "central.fire.perimeter.mt.glacier" + + async def _poll_once(self, adapter, response): + resp = AsyncMock() + resp.raise_for_status = MagicMock() + resp.json = AsyncMock(return_value=response) + with patch.object(adapter._session, "get", return_value=AsyncMock(__aenter__=AsyncMock(return_value=resp), __aexit__=AsyncMock())): + return [e async for e in adapter.poll()] + + @pytest.mark.asyncio + async def test_geometry_field_set_from_upstream_polygon(self, mock_config, mock_config_store, cursor_db_path): + """geo.geometry carries the full upstream Polygon (v0.9.3 framework).""" + from central.adapters.wfigs_perimeters import WFIGSPerimetersAdapter + adapter = WFIGSPerimetersAdapter(mock_config, mock_config_store, cursor_db_path) + await adapter.startup() + events = await self._poll_once(adapter, SAMPLE_PERIMETERS_RESPONSE) + await adapter.shutdown() + geom = events[0].geo.geometry + assert geom is not None and geom["type"] == "Polygon" + assert geom["coordinates"] == SAMPLE_PERIMETERS_RESPONSE["features"][0]["geometry"]["coordinates"] + + @pytest.mark.asyncio + async def test_geometry_field_set_from_multipolygon(self, mock_config, mock_config_store, cursor_db_path): + """geo.geometry carries a MultiPolygon intact (archive ST_GeomFromGeoJSON handles it).""" + from central.adapters.wfigs_perimeters import WFIGSPerimetersAdapter + adapter = WFIGSPerimetersAdapter(mock_config, mock_config_store, cursor_db_path) + await adapter.startup() + events = await self._poll_once(adapter, SAMPLE_PERIMETERS_MULTIPOLYGON) + await adapter.shutdown() + geom = events[0].geo.geometry + assert geom is not None and geom["type"] == "MultiPolygon" + assert len(geom["coordinates"]) == 2 + + @pytest.mark.asyncio + async def test_fall_off_geometry_stays_none(self, mock_config, mock_config_store, cursor_db_path): + """Synthetic removal events carry no geometry.""" + from central.adapters.wfigs_perimeters import WFIGSPerimetersAdapter + adapter = WFIGSPerimetersAdapter(mock_config, mock_config_store, cursor_db_path) + await adapter.startup() + await self._poll_once(adapter, SAMPLE_PERIMETERS_RESPONSE) + events = await self._poll_once(adapter, {"type": "FeatureCollection", "features": []}) + await adapter.shutdown() + removed = [e for e in events if e.category == "fire.perimeter.removed"] + assert removed and removed[0].geo.geometry is None + + def test_geometry_coercion_handles_malformed_and_none(self): + """_as_geometry: dict passthrough, JSON-string coercion, malformed/absent -> None.""" + from central.adapters.wfigs_perimeters import _as_geometry + assert _as_geometry({"type": "Polygon"})["type"] == "Polygon" + assert _as_geometry('{"type": "Point"}')["type"] == "Point" + assert _as_geometry("not json") is None + assert _as_geometry(None) is None