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."""
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,
)

View file

@ -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