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) <noreply@anthropic.com>
This commit is contained in:
Matt Johnson 2026-05-26 02:58:54 +00:00
commit 382f744c12
2 changed files with 80 additions and 0 deletions

View file

@ -1,5 +1,6 @@
"""WFIGS Perimeters adapter for wildfire perimeter polygons.""" """WFIGS Perimeters adapter for wildfire perimeter polygons."""
import json
import logging import logging
import sqlite3 import sqlite3
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
@ -39,6 +40,19 @@ from central.models import Event, Geo
logger = logging.getLogger(__name__) 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" LAYER_NAME = "perimeters"
@ -305,6 +319,7 @@ class WFIGSPerimetersAdapter(SourceAdapter):
geo = Geo( geo = Geo(
centroid=centroid, centroid=centroid,
bbox=bbox, bbox=bbox,
geometry=_as_geometry(geometry),
regions=regions, regions=regions,
primary_region=primary_region, primary_region=primary_region,
) )

View file

@ -450,6 +450,20 @@ class TestWFIGSIncidentsAdapter:
assert adapter.region.south == 35.0 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: class TestWFIGSPerimetersAdapter:
"""Tests for WFIGS Perimeters adapter.""" """Tests for WFIGS Perimeters adapter."""
@ -532,3 +546,54 @@ class TestWFIGSPerimetersAdapter:
subject = adapter.subject_for(event) subject = adapter.subject_for(event)
# Subject uses normalized state: mt.glacier not us-mt.glacier # Subject uses normalized state: mt.glacier not us-mt.glacier
assert subject == "central.fire.perimeter.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