mirror of
https://github.com/zvx-echo6/central.git
synced 2026-05-21 18:14:44 +02:00
Two new adapters for wildfire data from NIFC WFIGS: - wfigs_incidents: Active fire incident locations - wfigs_perimeters: Active fire perimeter polygons Features: - IRWIN GUID dedup via is_published/mark_published - Fall-off detection with removal events when fires exit current - Bbox post-filtering with shapely polygon intersection - Severity mapping from DailyAcres (0-4 scale) - Subject hierarchy: central.fire.<layer>.<state>.<county> Ships disabled by default; operators enable via GUI. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
444 lines
16 KiB
Python
444 lines
16 KiB
Python
"""Tests for WFIGS adapters."""
|
|
|
|
import sqlite3
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from central.config_models import AdapterConfig, RegionConfig
|
|
from central.models import Event, Geo
|
|
|
|
|
|
# Sample GeoJSON response with incidents
|
|
SAMPLE_INCIDENTS_RESPONSE = {
|
|
"type": "FeatureCollection",
|
|
"features": [
|
|
{
|
|
"type": "Feature",
|
|
"geometry": {"type": "Point", "coordinates": [-116.5, 43.5]},
|
|
"properties": {
|
|
"IrwinID": "GUID-001-BOISE",
|
|
"IncidentName": "Test Fire 1",
|
|
"IncidentTypeCategory": "Wildfire",
|
|
"DailyAcres": 150,
|
|
"PercentContained": 25,
|
|
"FireDiscoveryDateTime": 1716000000000,
|
|
"ModifiedOnDateTime": 1716100000000,
|
|
"POOState": "ID",
|
|
"POOCounty": "Ada",
|
|
},
|
|
},
|
|
{
|
|
"type": "Feature",
|
|
"geometry": {"type": "Point", "coordinates": [-117.0, 44.0]},
|
|
"properties": {
|
|
"IrwinID": "GUID-002-CANYON",
|
|
"IncidentName": "Test Fire 2",
|
|
"IncidentTypeCategory": "PrescribedFire",
|
|
"DailyAcres": 5,
|
|
"PercentContained": 100,
|
|
"FireDiscoveryDateTime": 1716200000000,
|
|
"ModifiedOnDateTime": 1716300000000,
|
|
"POOState": "ID",
|
|
"POOCounty": "Canyon",
|
|
},
|
|
},
|
|
{
|
|
"type": "Feature",
|
|
"geometry": {"type": "Point", "coordinates": [-80.0, 26.0]},
|
|
"properties": {
|
|
"IrwinID": "GUID-003-FLORIDA",
|
|
"IncidentName": "Florida Fire",
|
|
"IncidentTypeCategory": "Wildfire",
|
|
"DailyAcres": 50,
|
|
"PercentContained": 0,
|
|
"FireDiscoveryDateTime": 1716400000000,
|
|
"ModifiedOnDateTime": 1716500000000,
|
|
"POOState": "FL",
|
|
"POOCounty": "Miami-Dade",
|
|
},
|
|
},
|
|
],
|
|
}
|
|
|
|
# Perimeters API uses prefixed field names (attr_*, poly_*)
|
|
SAMPLE_PERIMETERS_RESPONSE = {
|
|
"type": "FeatureCollection",
|
|
"features": [
|
|
{
|
|
"type": "Feature",
|
|
"geometry": {
|
|
"type": "Polygon",
|
|
"coordinates": [[
|
|
[-116.6, 43.4],
|
|
[-116.4, 43.4],
|
|
[-116.4, 43.6],
|
|
[-116.6, 43.6],
|
|
[-116.6, 43.4],
|
|
]],
|
|
},
|
|
"properties": {
|
|
"attr_IrwinID": "GUID-001-BOISE",
|
|
"attr_IncidentName": "Test Fire 1",
|
|
"attr_IncidentTypeCategory": "Wildfire",
|
|
"attr_IncidentSize": 150,
|
|
"poly_GISAcres": 148.5,
|
|
"attr_PercentContained": 25,
|
|
"attr_FireDiscoveryDateTime": 1716000000000,
|
|
"attr_ModifiedOnDateTime_dt": 1716100000000,
|
|
"attr_POOState": "ID",
|
|
"attr_POOCounty": "Ada",
|
|
},
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
class TestWFIGSCommon:
|
|
"""Tests for WFIGS common utilities."""
|
|
|
|
def test_severity_from_acres_none(self):
|
|
from central.adapters.wfigs_common import severity_from_acres
|
|
assert severity_from_acres(None) == 0
|
|
assert severity_from_acres(0) == 0
|
|
|
|
def test_severity_from_acres_small(self):
|
|
from central.adapters.wfigs_common import severity_from_acres
|
|
assert severity_from_acres(5) == 1
|
|
assert severity_from_acres(9.9) == 1
|
|
|
|
def test_severity_from_acres_medium(self):
|
|
from central.adapters.wfigs_common import severity_from_acres
|
|
assert severity_from_acres(10) == 2
|
|
assert severity_from_acres(99) == 2
|
|
|
|
def test_severity_from_acres_large(self):
|
|
from central.adapters.wfigs_common import severity_from_acres
|
|
assert severity_from_acres(100) == 3
|
|
assert severity_from_acres(999) == 3
|
|
|
|
def test_severity_from_acres_very_large(self):
|
|
from central.adapters.wfigs_common import severity_from_acres
|
|
assert severity_from_acres(1000) == 4
|
|
assert severity_from_acres(100000) == 4
|
|
|
|
def test_parse_wfigs_timestamp(self):
|
|
from central.adapters.wfigs_common import parse_wfigs_timestamp
|
|
ts = parse_wfigs_timestamp(1716000000000)
|
|
assert ts is not None
|
|
assert ts.tzinfo == timezone.utc
|
|
assert ts.year == 2024
|
|
|
|
def test_parse_wfigs_timestamp_none(self):
|
|
from central.adapters.wfigs_common import parse_wfigs_timestamp
|
|
assert parse_wfigs_timestamp(None) is None
|
|
|
|
def test_build_regions_full(self):
|
|
from central.adapters.wfigs_common import build_regions
|
|
regions, primary = build_regions("ID", "Ada")
|
|
assert regions == ["US-ID-ADA"]
|
|
assert primary == "US-ID-ADA"
|
|
|
|
def test_build_regions_state_only(self):
|
|
from central.adapters.wfigs_common import build_regions
|
|
regions, primary = build_regions("ID", None)
|
|
assert regions == ["US-ID"]
|
|
assert primary == "US-ID"
|
|
|
|
def test_build_regions_none(self):
|
|
from central.adapters.wfigs_common import build_regions
|
|
regions, primary = build_regions(None, None)
|
|
assert regions == []
|
|
assert primary is None
|
|
|
|
def test_subject_suffix(self):
|
|
from central.adapters.wfigs_common import subject_suffix
|
|
assert subject_suffix("ID", "Ada") == "id.ada"
|
|
assert subject_suffix("ID", "Ada County") == "id.ada_county"
|
|
assert subject_suffix("ID", None) == "id"
|
|
assert subject_suffix(None, None) == "unknown"
|
|
|
|
def test_point_in_bbox(self):
|
|
from central.adapters.wfigs_common import point_in_bbox
|
|
assert point_in_bbox(-116.5, 43.5, -124, 31, -102, 49) is True
|
|
assert point_in_bbox(-80.0, 26.0, -124, 31, -102, 49) is False
|
|
|
|
|
|
class TestWFIGSIncidentsAdapter:
|
|
"""Tests for WFIGS Incidents adapter."""
|
|
|
|
@pytest.fixture
|
|
def mock_config(self) -> AdapterConfig:
|
|
return AdapterConfig(
|
|
name="wfigs_incidents",
|
|
enabled=True,
|
|
cadence_s=300,
|
|
settings={
|
|
"region": {"north": 49.0, "south": 31.0, "east": -102.0, "west": -124.0}
|
|
},
|
|
updated_at=datetime.now(timezone.utc),
|
|
)
|
|
|
|
@pytest.fixture
|
|
def mock_config_store(self) -> MagicMock:
|
|
return MagicMock()
|
|
|
|
@pytest.fixture
|
|
def cursor_db_path(self, tmp_path: Path) -> Path:
|
|
return tmp_path / "cursors.db"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_normalization_incidents(
|
|
self, mock_config: AdapterConfig, mock_config_store: MagicMock, cursor_db_path: Path
|
|
):
|
|
"""Incidents are correctly normalized to Events."""
|
|
from central.adapters.wfigs_incidents import WFIGSIncidentsAdapter
|
|
|
|
adapter = WFIGSIncidentsAdapter(mock_config, mock_config_store, cursor_db_path)
|
|
await adapter.startup()
|
|
|
|
mock_response = AsyncMock()
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_response.json = AsyncMock(return_value=SAMPLE_INCIDENTS_RESPONSE)
|
|
|
|
with patch.object(adapter._session, "get", return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_response), __aexit__=AsyncMock())):
|
|
events = [e async for e in adapter.poll()]
|
|
|
|
await adapter.shutdown()
|
|
|
|
# Should have 2 events (Florida filtered out by bbox)
|
|
assert len(events) == 2
|
|
|
|
event = events[0]
|
|
assert event.id == "GUID-001-BOISE"
|
|
assert event.adapter == "wfigs_incidents"
|
|
assert event.category == "fire.incident.wildfire"
|
|
assert event.severity == 3 # 150 acres = severity 3 (100-999 range)
|
|
assert event.geo.primary_region == "US-ID-ADA"
|
|
assert event.data["IrwinID"] == "GUID-001-BOISE"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_is_published_dedup(
|
|
self, mock_config: AdapterConfig, mock_config_store: MagicMock, cursor_db_path: Path
|
|
):
|
|
"""is_published/mark_published provides dedup functionality."""
|
|
from central.adapters.wfigs_incidents import WFIGSIncidentsAdapter
|
|
|
|
adapter = WFIGSIncidentsAdapter(mock_config, mock_config_store, cursor_db_path)
|
|
await adapter.startup()
|
|
|
|
# Initially not published
|
|
assert adapter.is_published("test-id") is False
|
|
|
|
# Mark as published
|
|
adapter.mark_published("test-id")
|
|
|
|
# Now it should be published
|
|
assert adapter.is_published("test-id") is True
|
|
|
|
await adapter.shutdown()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fall_off_emits_removal(
|
|
self, mock_config: AdapterConfig, mock_config_store: MagicMock, cursor_db_path: Path
|
|
):
|
|
"""Fall-off detection emits removal events."""
|
|
from central.adapters.wfigs_incidents import WFIGSIncidentsAdapter
|
|
|
|
adapter = WFIGSIncidentsAdapter(mock_config, mock_config_store, cursor_db_path)
|
|
await adapter.startup()
|
|
|
|
# First poll with 2 incidents
|
|
mock_response1 = AsyncMock()
|
|
mock_response1.raise_for_status = MagicMock()
|
|
mock_response1.json = AsyncMock(return_value=SAMPLE_INCIDENTS_RESPONSE)
|
|
|
|
# Second poll with only 1 incident (GUID-002 fell off)
|
|
reduced_response = {
|
|
"type": "FeatureCollection",
|
|
"features": [SAMPLE_INCIDENTS_RESPONSE["features"][0]],
|
|
}
|
|
mock_response2 = AsyncMock()
|
|
mock_response2.raise_for_status = MagicMock()
|
|
mock_response2.json = AsyncMock(return_value=reduced_response)
|
|
|
|
with patch.object(adapter._session, "get", return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_response1), __aexit__=AsyncMock())):
|
|
events1 = [e async for e in adapter.poll()]
|
|
|
|
with patch.object(adapter._session, "get", return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_response2), __aexit__=AsyncMock())):
|
|
events2 = [e async for e in adapter.poll()]
|
|
|
|
await adapter.shutdown()
|
|
|
|
# First poll: 2 incident events
|
|
assert len(events1) == 2
|
|
|
|
# Second poll: 1 incident (seen again) + 1 removal for GUID-002
|
|
# The incident event is yielded (supervisor does dedup via is_published)
|
|
# The removal is yielded for GUID-002
|
|
removal_events = [e for e in events2 if e.category == "fire.incident.removed"]
|
|
assert len(removal_events) == 1
|
|
assert removal_events[0].data["irwin_id"] == "GUID-002-CANYON"
|
|
|
|
def test_subject_for_incidents(
|
|
self, mock_config: AdapterConfig, mock_config_store: MagicMock, cursor_db_path: Path
|
|
):
|
|
from central.adapters.wfigs_incidents import WFIGSIncidentsAdapter
|
|
|
|
adapter = WFIGSIncidentsAdapter(mock_config, mock_config_store, cursor_db_path)
|
|
|
|
event = Event(
|
|
id="test-id",
|
|
adapter="wfigs_incidents",
|
|
category="fire.incident.wildfire",
|
|
time=datetime.now(timezone.utc),
|
|
severity=2,
|
|
geo=Geo(primary_region="US-ID-ADA"),
|
|
data={"POOState": "ID", "POOCounty": "Ada"},
|
|
)
|
|
|
|
subject = adapter.subject_for(event)
|
|
assert subject == "central.fire.incident.id.ada"
|
|
|
|
def test_subject_for_removal(
|
|
self, mock_config: AdapterConfig, mock_config_store: MagicMock, cursor_db_path: Path
|
|
):
|
|
from central.adapters.wfigs_incidents import WFIGSIncidentsAdapter
|
|
|
|
adapter = WFIGSIncidentsAdapter(mock_config, mock_config_store, cursor_db_path)
|
|
|
|
event = Event(
|
|
id="test-id:removed:2024-01-01",
|
|
adapter="wfigs_incidents",
|
|
category="fire.incident.removed",
|
|
time=datetime.now(timezone.utc),
|
|
severity=0,
|
|
geo=Geo(),
|
|
data={"irwin_id": "test-id", "state": "ID"},
|
|
)
|
|
|
|
subject = adapter.subject_for(event)
|
|
assert subject == "central.fire.incident.removed.id"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bbox_post_filter(
|
|
self, mock_config: AdapterConfig, mock_config_store: MagicMock, cursor_db_path: Path
|
|
):
|
|
"""Features outside bbox are filtered out."""
|
|
from central.adapters.wfigs_incidents import WFIGSIncidentsAdapter
|
|
|
|
adapter = WFIGSIncidentsAdapter(mock_config, mock_config_store, cursor_db_path)
|
|
await adapter.startup()
|
|
|
|
mock_response = AsyncMock()
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_response.json = AsyncMock(return_value=SAMPLE_INCIDENTS_RESPONSE)
|
|
|
|
with patch.object(adapter._session, "get", return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_response), __aexit__=AsyncMock())):
|
|
events = [e async for e in adapter.poll()]
|
|
|
|
await adapter.shutdown()
|
|
|
|
# Florida incident should be filtered out
|
|
assert len(events) == 2
|
|
irwin_ids = {e.id for e in events}
|
|
assert "GUID-003-FLORIDA" not in irwin_ids
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_config_region_change(
|
|
self, mock_config: AdapterConfig, mock_config_store: MagicMock, cursor_db_path: Path
|
|
):
|
|
from central.adapters.wfigs_incidents import WFIGSIncidentsAdapter
|
|
|
|
adapter = WFIGSIncidentsAdapter(mock_config, mock_config_store, cursor_db_path)
|
|
|
|
assert adapter.region.north == 49.0
|
|
|
|
new_config = AdapterConfig(
|
|
name="wfigs_incidents",
|
|
enabled=True,
|
|
cadence_s=300,
|
|
settings={
|
|
"region": {"north": 50.0, "south": 35.0, "east": -100.0, "west": -120.0}
|
|
},
|
|
updated_at=datetime.now(timezone.utc),
|
|
)
|
|
await adapter.apply_config(new_config)
|
|
|
|
assert adapter.region.north == 50.0
|
|
assert adapter.region.south == 35.0
|
|
|
|
|
|
class TestWFIGSPerimetersAdapter:
|
|
"""Tests for WFIGS Perimeters adapter."""
|
|
|
|
@pytest.fixture
|
|
def mock_config(self) -> AdapterConfig:
|
|
return AdapterConfig(
|
|
name="wfigs_perimeters",
|
|
enabled=True,
|
|
cadence_s=300,
|
|
settings={
|
|
"region": {"north": 49.0, "south": 31.0, "east": -102.0, "west": -124.0}
|
|
},
|
|
updated_at=datetime.now(timezone.utc),
|
|
)
|
|
|
|
@pytest.fixture
|
|
def mock_config_store(self) -> MagicMock:
|
|
return MagicMock()
|
|
|
|
@pytest.fixture
|
|
def cursor_db_path(self, tmp_path: Path) -> Path:
|
|
return tmp_path / "cursors.db"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_normalization_perimeters(
|
|
self, mock_config: AdapterConfig, mock_config_store: MagicMock, cursor_db_path: Path
|
|
):
|
|
"""Perimeters are correctly normalized to Events with geometry."""
|
|
from central.adapters.wfigs_perimeters import WFIGSPerimetersAdapter
|
|
|
|
adapter = WFIGSPerimetersAdapter(mock_config, mock_config_store, cursor_db_path)
|
|
await adapter.startup()
|
|
|
|
mock_response = AsyncMock()
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_response.json = AsyncMock(return_value=SAMPLE_PERIMETERS_RESPONSE)
|
|
|
|
with patch.object(adapter._session, "get", return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_response), __aexit__=AsyncMock())):
|
|
events = [e async for e in adapter.poll()]
|
|
|
|
await adapter.shutdown()
|
|
|
|
assert len(events) == 1
|
|
|
|
event = events[0]
|
|
assert event.id == "GUID-001-BOISE"
|
|
assert event.adapter == "wfigs_perimeters"
|
|
assert event.category == "fire.perimeter.wildfire"
|
|
assert event.geo.primary_region == "US-ID-ADA"
|
|
assert "geometry" in event.data
|
|
assert event.data["geometry"]["type"] == "Polygon"
|
|
|
|
def test_subject_for_perimeters(
|
|
self, mock_config: AdapterConfig, mock_config_store: MagicMock, cursor_db_path: Path
|
|
):
|
|
from central.adapters.wfigs_perimeters import WFIGSPerimetersAdapter
|
|
|
|
adapter = WFIGSPerimetersAdapter(mock_config, mock_config_store, cursor_db_path)
|
|
|
|
event = Event(
|
|
id="test-id",
|
|
adapter="wfigs_perimeters",
|
|
category="fire.perimeter.wildfire",
|
|
time=datetime.now(timezone.utc),
|
|
severity=2,
|
|
geo=Geo(primary_region="US-ID-ADA"),
|
|
data={"POOState": "ID", "POOCounty": "Ada", "geometry": {}},
|
|
)
|
|
|
|
subject = adapter.subject_for(event)
|
|
assert subject == "central.fire.perimeter.id.ada"
|