mirror of
https://github.com/zvx-echo6/central.git
synced 2026-05-21 18:14:44 +02:00
fix(2-B): normalize WFIGS field formats
WFIGS returns ISO 3166-2 state codes (US-MT) and 2-letter incident type codes (WF, RX). Normalize at parse boundary: - normalize_state: strips US- prefix (US-MT -> MT) - normalize_incident_type: maps codes to names (WF -> wildfire) Fixes: - category was fire.incident.wf, now fire.incident.wildfire - region was US-US-MT-GLACIER, now US-MT-GLACIER Both raw and normalized values stored in event.data. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e0ffe686ec
commit
dfad7ef45d
4 changed files with 202 additions and 61 deletions
|
|
@ -17,6 +17,35 @@ WFIGS_PERIMETERS_URL = (
|
||||||
# Fall-off sweep window: 14 days (matches WFIGS's longest fall-off: large fires)
|
# Fall-off sweep window: 14 days (matches WFIGS's longest fall-off: large fires)
|
||||||
FALLOFF_WINDOW_DAYS = 14
|
FALLOFF_WINDOW_DAYS = 14
|
||||||
|
|
||||||
|
# Incident type code mappings (WFIGS uses 2-letter codes)
|
||||||
|
INCIDENT_TYPE_MAP = {
|
||||||
|
"WF": "wildfire",
|
||||||
|
"RX": "prescribed_fire",
|
||||||
|
"CX": "complex",
|
||||||
|
"FA": "false_alarm",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_state(state: str | None) -> str | None:
|
||||||
|
"""Strip 'US-' prefix from POOState (ISO 3166-2 -> 2-letter)."""
|
||||||
|
if not state:
|
||||||
|
return None
|
||||||
|
if state.startswith("US-") and len(state) == 5:
|
||||||
|
return state[3:]
|
||||||
|
if len(state) == 2:
|
||||||
|
return state
|
||||||
|
return state # unknown shape, pass through
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_incident_type(code: str | None) -> str:
|
||||||
|
"""Map IncidentTypeCategory code to a readable name."""
|
||||||
|
if not code:
|
||||||
|
return "unknown"
|
||||||
|
upper = code.upper()
|
||||||
|
if upper in INCIDENT_TYPE_MAP:
|
||||||
|
return INCIDENT_TYPE_MAP[upper]
|
||||||
|
return code.lower()
|
||||||
|
|
||||||
|
|
||||||
def severity_from_acres(acres: float | None) -> int:
|
def severity_from_acres(acres: float | None) -> int:
|
||||||
"""Map DailyAcres to severity level 0-4."""
|
"""Map DailyAcres to severity level 0-4."""
|
||||||
|
|
@ -42,6 +71,7 @@ def build_regions(state: str | None, county: str | None) -> tuple[list[str], str
|
||||||
"""
|
"""
|
||||||
Build geo.regions list and primary_region from POOState and POOCounty.
|
Build geo.regions list and primary_region from POOState and POOCounty.
|
||||||
|
|
||||||
|
Expects normalized 2-letter state codes (e.g., "MT" not "US-MT").
|
||||||
Returns (regions, primary_region).
|
Returns (regions, primary_region).
|
||||||
"""
|
"""
|
||||||
if not state:
|
if not state:
|
||||||
|
|
@ -62,6 +92,7 @@ def subject_suffix(state: str | None, county: str | None) -> str:
|
||||||
"""
|
"""
|
||||||
Build subject suffix from state and county.
|
Build subject suffix from state and county.
|
||||||
|
|
||||||
|
Expects normalized 2-letter state codes.
|
||||||
Returns lowercase state.county (county with spaces→underscores).
|
Returns lowercase state.county (county with spaces→underscores).
|
||||||
Falls back to "unknown" if state is not available.
|
Falls back to "unknown" if state is not available.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ from central.adapters.wfigs_common import (
|
||||||
extract_centroid,
|
extract_centroid,
|
||||||
get_observed_guids,
|
get_observed_guids,
|
||||||
init_observed_table,
|
init_observed_table,
|
||||||
|
normalize_incident_type,
|
||||||
|
normalize_state,
|
||||||
parse_wfigs_timestamp,
|
parse_wfigs_timestamp,
|
||||||
point_in_bbox,
|
point_in_bbox,
|
||||||
severity_from_acres,
|
severity_from_acres,
|
||||||
|
|
@ -185,6 +187,7 @@ class WFIGSIncidentsAdapter(SourceAdapter):
|
||||||
return f"central.fire.incident.removed.{state}"
|
return f"central.fire.incident.removed.{state}"
|
||||||
|
|
||||||
# Regular incidents: central.fire.incident.<state>.<county>
|
# Regular incidents: central.fire.incident.<state>.<county>
|
||||||
|
# POOState is already normalized (2-letter code)
|
||||||
state = event.data.get("POOState")
|
state = event.data.get("POOState")
|
||||||
county = event.data.get("POOCounty")
|
county = event.data.get("POOCounty")
|
||||||
suffix = subject_suffix(state, county)
|
suffix = subject_suffix(state, county)
|
||||||
|
|
@ -273,17 +276,22 @@ class WFIGSIncidentsAdapter(SourceAdapter):
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Track this GUID as observed (for fall-off detection)
|
# Normalize at parse boundary
|
||||||
state = props.get("POOState")
|
state_raw = props.get("POOState")
|
||||||
|
state = normalize_state(state_raw)
|
||||||
county = props.get("POOCounty")
|
county = props.get("POOCounty")
|
||||||
|
incident_type_raw = props.get("IncidentTypeCategory")
|
||||||
|
incident_type = normalize_incident_type(incident_type_raw)
|
||||||
|
|
||||||
|
# Track this GUID as observed (for fall-off detection)
|
||||||
|
# Store normalized state for consistency
|
||||||
current_guids[irwin_id] = (state, county)
|
current_guids[irwin_id] = (state, county)
|
||||||
|
|
||||||
# Parse fields
|
# Parse fields
|
||||||
incident_type = props.get("IncidentTypeCategory", "unknown").lower()
|
|
||||||
discovery_time = parse_wfigs_timestamp(props.get("FireDiscoveryDateTime"))
|
discovery_time = parse_wfigs_timestamp(props.get("FireDiscoveryDateTime"))
|
||||||
daily_acres = props.get("DailyAcres")
|
daily_acres = props.get("DailyAcres")
|
||||||
|
|
||||||
# Build regions
|
# Build regions (expects normalized 2-letter state code)
|
||||||
regions, primary_region = build_regions(state, county)
|
regions, primary_region = build_regions(state, county)
|
||||||
|
|
||||||
# Build geo
|
# Build geo
|
||||||
|
|
@ -297,7 +305,7 @@ class WFIGSIncidentsAdapter(SourceAdapter):
|
||||||
else:
|
else:
|
||||||
geo = Geo(regions=regions, primary_region=primary_region)
|
geo = Geo(regions=regions, primary_region=primary_region)
|
||||||
|
|
||||||
# Build event
|
# Build event with normalized values in data
|
||||||
event = Event(
|
event = Event(
|
||||||
id=irwin_id,
|
id=irwin_id,
|
||||||
adapter=self.name,
|
adapter=self.name,
|
||||||
|
|
@ -308,12 +316,14 @@ class WFIGSIncidentsAdapter(SourceAdapter):
|
||||||
data={
|
data={
|
||||||
"IrwinID": irwin_id,
|
"IrwinID": irwin_id,
|
||||||
"IncidentName": props.get("IncidentName"),
|
"IncidentName": props.get("IncidentName"),
|
||||||
"IncidentTypeCategory": props.get("IncidentTypeCategory"),
|
"IncidentTypeCategory": incident_type,
|
||||||
|
"IncidentTypeCategory_raw": incident_type_raw,
|
||||||
"DailyAcres": daily_acres,
|
"DailyAcres": daily_acres,
|
||||||
"PercentContained": props.get("PercentContained"),
|
"PercentContained": props.get("PercentContained"),
|
||||||
"FireDiscoveryDateTime": props.get("FireDiscoveryDateTime"),
|
"FireDiscoveryDateTime": props.get("FireDiscoveryDateTime"),
|
||||||
"ModifiedOnDateTime": props.get("ModifiedOnDateTime"),
|
"ModifiedOnDateTime": props.get("ModifiedOnDateTime"),
|
||||||
"POOState": state,
|
"POOState": state,
|
||||||
|
"POOState_raw": state_raw,
|
||||||
"POOCounty": county,
|
"POOCounty": county,
|
||||||
"raw": props,
|
"raw": props,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ from central.adapters.wfigs_common import (
|
||||||
extract_centroid,
|
extract_centroid,
|
||||||
get_observed_guids,
|
get_observed_guids,
|
||||||
init_observed_table,
|
init_observed_table,
|
||||||
|
normalize_incident_type,
|
||||||
|
normalize_state,
|
||||||
parse_wfigs_timestamp,
|
parse_wfigs_timestamp,
|
||||||
polygon_intersects_bbox,
|
polygon_intersects_bbox,
|
||||||
severity_from_acres,
|
severity_from_acres,
|
||||||
|
|
@ -185,6 +187,7 @@ class WFIGSPerimetersAdapter(SourceAdapter):
|
||||||
return f"central.fire.perimeter.removed.{state}"
|
return f"central.fire.perimeter.removed.{state}"
|
||||||
|
|
||||||
# Regular perimeters: central.fire.perimeter.<state>.<county>
|
# Regular perimeters: central.fire.perimeter.<state>.<county>
|
||||||
|
# POOState is already normalized (2-letter code)
|
||||||
state = event.data.get("POOState")
|
state = event.data.get("POOState")
|
||||||
county = event.data.get("POOCounty")
|
county = event.data.get("POOCounty")
|
||||||
suffix = subject_suffix(state, county)
|
suffix = subject_suffix(state, county)
|
||||||
|
|
@ -271,18 +274,23 @@ class WFIGSPerimetersAdapter(SourceAdapter):
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Track this GUID as observed (for fall-off detection)
|
# Normalize at parse boundary
|
||||||
state = props.get("attr_POOState")
|
state_raw = props.get("attr_POOState")
|
||||||
|
state = normalize_state(state_raw)
|
||||||
county = props.get("attr_POOCounty")
|
county = props.get("attr_POOCounty")
|
||||||
|
incident_type_raw = props.get("attr_IncidentTypeCategory")
|
||||||
|
incident_type = normalize_incident_type(incident_type_raw)
|
||||||
|
|
||||||
|
# Track this GUID as observed (for fall-off detection)
|
||||||
|
# Store normalized state for consistency
|
||||||
current_guids[irwin_id] = (state, county)
|
current_guids[irwin_id] = (state, county)
|
||||||
|
|
||||||
# Parse fields using prefixed names
|
# Parse fields using prefixed names
|
||||||
incident_type = props.get("attr_IncidentTypeCategory", "unknown").lower()
|
|
||||||
discovery_time = parse_wfigs_timestamp(props.get("attr_FireDiscoveryDateTime"))
|
discovery_time = parse_wfigs_timestamp(props.get("attr_FireDiscoveryDateTime"))
|
||||||
# Use poly_GISAcres or attr_IncidentSize for acreage
|
# Use poly_GISAcres or attr_IncidentSize for acreage
|
||||||
daily_acres = props.get("attr_IncidentSize") or props.get("poly_GISAcres")
|
daily_acres = props.get("attr_IncidentSize") or props.get("poly_GISAcres")
|
||||||
|
|
||||||
# Build regions
|
# Build regions (expects normalized 2-letter state code)
|
||||||
regions, primary_region = build_regions(state, county)
|
regions, primary_region = build_regions(state, county)
|
||||||
|
|
||||||
# Extract centroid for geo
|
# Extract centroid for geo
|
||||||
|
|
@ -320,13 +328,15 @@ class WFIGSPerimetersAdapter(SourceAdapter):
|
||||||
data={
|
data={
|
||||||
"IrwinID": irwin_id,
|
"IrwinID": irwin_id,
|
||||||
"IncidentName": props.get("attr_IncidentName") or props.get("poly_IncidentName"),
|
"IncidentName": props.get("attr_IncidentName") or props.get("poly_IncidentName"),
|
||||||
"IncidentTypeCategory": props.get("attr_IncidentTypeCategory"),
|
"IncidentTypeCategory": incident_type,
|
||||||
|
"IncidentTypeCategory_raw": incident_type_raw,
|
||||||
"DailyAcres": props.get("attr_IncidentSize"),
|
"DailyAcres": props.get("attr_IncidentSize"),
|
||||||
"GISAcres": props.get("poly_GISAcres"),
|
"GISAcres": props.get("poly_GISAcres"),
|
||||||
"PercentContained": props.get("attr_PercentContained"),
|
"PercentContained": props.get("attr_PercentContained"),
|
||||||
"FireDiscoveryDateTime": props.get("attr_FireDiscoveryDateTime"),
|
"FireDiscoveryDateTime": props.get("attr_FireDiscoveryDateTime"),
|
||||||
"ModifiedOnDateTime": props.get("attr_ModifiedOnDateTime_dt"),
|
"ModifiedOnDateTime": props.get("attr_ModifiedOnDateTime_dt"),
|
||||||
"POOState": state,
|
"POOState": state,
|
||||||
|
"POOState_raw": state_raw,
|
||||||
"POOCounty": county,
|
"POOCounty": county,
|
||||||
"geometry": geometry, # Full GeoJSON polygon
|
"geometry": geometry, # Full GeoJSON polygon
|
||||||
"raw": props,
|
"raw": props,
|
||||||
|
|
|
||||||
|
|
@ -11,38 +11,39 @@ from central.config_models import AdapterConfig, RegionConfig
|
||||||
from central.models import Event, Geo
|
from central.models import Event, Geo
|
||||||
|
|
||||||
|
|
||||||
# Sample GeoJSON response with incidents
|
# Sample GeoJSON response with incidents using real WFIGS format
|
||||||
|
# Note: POOState comes as ISO 3166-2 ("US-MT"), IncidentTypeCategory as codes ("WF")
|
||||||
SAMPLE_INCIDENTS_RESPONSE = {
|
SAMPLE_INCIDENTS_RESPONSE = {
|
||||||
"type": "FeatureCollection",
|
"type": "FeatureCollection",
|
||||||
"features": [
|
"features": [
|
||||||
{
|
{
|
||||||
"type": "Feature",
|
"type": "Feature",
|
||||||
"geometry": {"type": "Point", "coordinates": [-116.5, 43.5]},
|
"geometry": {"type": "Point", "coordinates": [-113.5, 48.5]},
|
||||||
"properties": {
|
"properties": {
|
||||||
"IrwinID": "GUID-001-BOISE",
|
"IrwinID": "GUID-001-GLACIER",
|
||||||
"IncidentName": "Test Fire 1",
|
"IncidentName": "Glacier Fire",
|
||||||
"IncidentTypeCategory": "Wildfire",
|
"IncidentTypeCategory": "WF", # Real format: 2-letter code
|
||||||
"DailyAcres": 150,
|
"DailyAcres": 150,
|
||||||
"PercentContained": 25,
|
"PercentContained": 25,
|
||||||
"FireDiscoveryDateTime": 1716000000000,
|
"FireDiscoveryDateTime": 1716000000000,
|
||||||
"ModifiedOnDateTime": 1716100000000,
|
"ModifiedOnDateTime": 1716100000000,
|
||||||
"POOState": "ID",
|
"POOState": "US-MT", # Real format: ISO 3166-2
|
||||||
"POOCounty": "Ada",
|
"POOCounty": "Glacier",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "Feature",
|
"type": "Feature",
|
||||||
"geometry": {"type": "Point", "coordinates": [-117.0, 44.0]},
|
"geometry": {"type": "Point", "coordinates": [-116.5, 43.5]},
|
||||||
"properties": {
|
"properties": {
|
||||||
"IrwinID": "GUID-002-CANYON",
|
"IrwinID": "GUID-002-OWYHEE",
|
||||||
"IncidentName": "Test Fire 2",
|
"IncidentName": "Owyhee Rx",
|
||||||
"IncidentTypeCategory": "PrescribedFire",
|
"IncidentTypeCategory": "RX", # Prescribed fire
|
||||||
"DailyAcres": 5,
|
"DailyAcres": 5,
|
||||||
"PercentContained": 100,
|
"PercentContained": 100,
|
||||||
"FireDiscoveryDateTime": 1716200000000,
|
"FireDiscoveryDateTime": 1716200000000,
|
||||||
"ModifiedOnDateTime": 1716300000000,
|
"ModifiedOnDateTime": 1716300000000,
|
||||||
"POOState": "ID",
|
"POOState": "US-ID",
|
||||||
"POOCounty": "Canyon",
|
"POOCounty": "Owyhee",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -51,12 +52,12 @@ SAMPLE_INCIDENTS_RESPONSE = {
|
||||||
"properties": {
|
"properties": {
|
||||||
"IrwinID": "GUID-003-FLORIDA",
|
"IrwinID": "GUID-003-FLORIDA",
|
||||||
"IncidentName": "Florida Fire",
|
"IncidentName": "Florida Fire",
|
||||||
"IncidentTypeCategory": "Wildfire",
|
"IncidentTypeCategory": "WF",
|
||||||
"DailyAcres": 50,
|
"DailyAcres": 50,
|
||||||
"PercentContained": 0,
|
"PercentContained": 0,
|
||||||
"FireDiscoveryDateTime": 1716400000000,
|
"FireDiscoveryDateTime": 1716400000000,
|
||||||
"ModifiedOnDateTime": 1716500000000,
|
"ModifiedOnDateTime": 1716500000000,
|
||||||
"POOState": "FL",
|
"POOState": "US-FL",
|
||||||
"POOCounty": "Miami-Dade",
|
"POOCounty": "Miami-Dade",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -72,24 +73,24 @@ SAMPLE_PERIMETERS_RESPONSE = {
|
||||||
"geometry": {
|
"geometry": {
|
||||||
"type": "Polygon",
|
"type": "Polygon",
|
||||||
"coordinates": [[
|
"coordinates": [[
|
||||||
[-116.6, 43.4],
|
[-113.6, 48.4],
|
||||||
[-116.4, 43.4],
|
[-113.4, 48.4],
|
||||||
[-116.4, 43.6],
|
[-113.4, 48.6],
|
||||||
[-116.6, 43.6],
|
[-113.6, 48.6],
|
||||||
[-116.6, 43.4],
|
[-113.6, 48.4],
|
||||||
]],
|
]],
|
||||||
},
|
},
|
||||||
"properties": {
|
"properties": {
|
||||||
"attr_IrwinID": "GUID-001-BOISE",
|
"attr_IrwinID": "GUID-001-GLACIER",
|
||||||
"attr_IncidentName": "Test Fire 1",
|
"attr_IncidentName": "Glacier Fire",
|
||||||
"attr_IncidentTypeCategory": "Wildfire",
|
"attr_IncidentTypeCategory": "WF", # Real format: 2-letter code
|
||||||
"attr_IncidentSize": 150,
|
"attr_IncidentSize": 150,
|
||||||
"poly_GISAcres": 148.5,
|
"poly_GISAcres": 148.5,
|
||||||
"attr_PercentContained": 25,
|
"attr_PercentContained": 25,
|
||||||
"attr_FireDiscoveryDateTime": 1716000000000,
|
"attr_FireDiscoveryDateTime": 1716000000000,
|
||||||
"attr_ModifiedOnDateTime_dt": 1716100000000,
|
"attr_ModifiedOnDateTime_dt": 1716100000000,
|
||||||
"attr_POOState": "ID",
|
"attr_POOState": "US-MT", # Real format: ISO 3166-2
|
||||||
"attr_POOCounty": "Ada",
|
"attr_POOCounty": "Glacier",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -137,15 +138,16 @@ class TestWFIGSCommon:
|
||||||
|
|
||||||
def test_build_regions_full(self):
|
def test_build_regions_full(self):
|
||||||
from central.adapters.wfigs_common import build_regions
|
from central.adapters.wfigs_common import build_regions
|
||||||
regions, primary = build_regions("ID", "Ada")
|
# Expects normalized 2-letter state code
|
||||||
assert regions == ["US-ID-ADA"]
|
regions, primary = build_regions("MT", "Glacier")
|
||||||
assert primary == "US-ID-ADA"
|
assert regions == ["US-MT-GLACIER"]
|
||||||
|
assert primary == "US-MT-GLACIER"
|
||||||
|
|
||||||
def test_build_regions_state_only(self):
|
def test_build_regions_state_only(self):
|
||||||
from central.adapters.wfigs_common import build_regions
|
from central.adapters.wfigs_common import build_regions
|
||||||
regions, primary = build_regions("ID", None)
|
regions, primary = build_regions("MT", None)
|
||||||
assert regions == ["US-ID"]
|
assert regions == ["US-MT"]
|
||||||
assert primary == "US-ID"
|
assert primary == "US-MT"
|
||||||
|
|
||||||
def test_build_regions_none(self):
|
def test_build_regions_none(self):
|
||||||
from central.adapters.wfigs_common import build_regions
|
from central.adapters.wfigs_common import build_regions
|
||||||
|
|
@ -155,7 +157,8 @@ class TestWFIGSCommon:
|
||||||
|
|
||||||
def test_subject_suffix(self):
|
def test_subject_suffix(self):
|
||||||
from central.adapters.wfigs_common import subject_suffix
|
from central.adapters.wfigs_common import subject_suffix
|
||||||
assert subject_suffix("ID", "Ada") == "id.ada"
|
# Expects normalized 2-letter state code
|
||||||
|
assert subject_suffix("MT", "Glacier") == "mt.glacier"
|
||||||
assert subject_suffix("ID", "Ada County") == "id.ada_county"
|
assert subject_suffix("ID", "Ada County") == "id.ada_county"
|
||||||
assert subject_suffix("ID", None) == "id"
|
assert subject_suffix("ID", None) == "id"
|
||||||
assert subject_suffix(None, None) == "unknown"
|
assert subject_suffix(None, None) == "unknown"
|
||||||
|
|
@ -165,6 +168,66 @@ class TestWFIGSCommon:
|
||||||
assert point_in_bbox(-116.5, 43.5, -124, 31, -102, 49) is True
|
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
|
assert point_in_bbox(-80.0, 26.0, -124, 31, -102, 49) is False
|
||||||
|
|
||||||
|
# Normalization tests
|
||||||
|
def test_normalize_state_iso_3166(self):
|
||||||
|
"""normalize_state strips US- prefix from ISO 3166-2 codes."""
|
||||||
|
from central.adapters.wfigs_common import normalize_state
|
||||||
|
assert normalize_state("US-MT") == "MT"
|
||||||
|
assert normalize_state("US-ID") == "ID"
|
||||||
|
assert normalize_state("US-CA") == "CA"
|
||||||
|
|
||||||
|
def test_normalize_state_already_2letter(self):
|
||||||
|
"""normalize_state passes through 2-letter codes."""
|
||||||
|
from central.adapters.wfigs_common import normalize_state
|
||||||
|
assert normalize_state("MT") == "MT"
|
||||||
|
assert normalize_state("ID") == "ID"
|
||||||
|
|
||||||
|
def test_normalize_state_none_empty(self):
|
||||||
|
"""normalize_state handles None and empty strings."""
|
||||||
|
from central.adapters.wfigs_common import normalize_state
|
||||||
|
assert normalize_state(None) is None
|
||||||
|
assert normalize_state("") is None
|
||||||
|
|
||||||
|
def test_normalize_state_unknown_format(self):
|
||||||
|
"""normalize_state passes through unknown formats."""
|
||||||
|
from central.adapters.wfigs_common import normalize_state
|
||||||
|
assert normalize_state("Montana") == "Montana"
|
||||||
|
assert normalize_state("US-MONTANA") == "US-MONTANA"
|
||||||
|
|
||||||
|
def test_normalize_incident_type_wf(self):
|
||||||
|
"""normalize_incident_type maps WF to wildfire."""
|
||||||
|
from central.adapters.wfigs_common import normalize_incident_type
|
||||||
|
assert normalize_incident_type("WF") == "wildfire"
|
||||||
|
assert normalize_incident_type("wf") == "wildfire"
|
||||||
|
|
||||||
|
def test_normalize_incident_type_rx(self):
|
||||||
|
"""normalize_incident_type maps RX to prescribed_fire."""
|
||||||
|
from central.adapters.wfigs_common import normalize_incident_type
|
||||||
|
assert normalize_incident_type("RX") == "prescribed_fire"
|
||||||
|
assert normalize_incident_type("rx") == "prescribed_fire"
|
||||||
|
|
||||||
|
def test_normalize_incident_type_cx(self):
|
||||||
|
"""normalize_incident_type maps CX to complex."""
|
||||||
|
from central.adapters.wfigs_common import normalize_incident_type
|
||||||
|
assert normalize_incident_type("CX") == "complex"
|
||||||
|
|
||||||
|
def test_normalize_incident_type_fa(self):
|
||||||
|
"""normalize_incident_type maps FA to false_alarm."""
|
||||||
|
from central.adapters.wfigs_common import normalize_incident_type
|
||||||
|
assert normalize_incident_type("FA") == "false_alarm"
|
||||||
|
|
||||||
|
def test_normalize_incident_type_unknown_code(self):
|
||||||
|
"""normalize_incident_type lowercases unknown codes."""
|
||||||
|
from central.adapters.wfigs_common import normalize_incident_type
|
||||||
|
assert normalize_incident_type("UNKNOWN_CODE") == "unknown_code"
|
||||||
|
assert normalize_incident_type("Wildfire") == "wildfire"
|
||||||
|
|
||||||
|
def test_normalize_incident_type_none(self):
|
||||||
|
"""normalize_incident_type returns unknown for None."""
|
||||||
|
from central.adapters.wfigs_common import normalize_incident_type
|
||||||
|
assert normalize_incident_type(None) == "unknown"
|
||||||
|
assert normalize_incident_type("") == "unknown"
|
||||||
|
|
||||||
|
|
||||||
class TestWFIGSIncidentsAdapter:
|
class TestWFIGSIncidentsAdapter:
|
||||||
"""Tests for WFIGS Incidents adapter."""
|
"""Tests for WFIGS Incidents adapter."""
|
||||||
|
|
@ -211,13 +274,26 @@ class TestWFIGSIncidentsAdapter:
|
||||||
# Should have 2 events (Florida filtered out by bbox)
|
# Should have 2 events (Florida filtered out by bbox)
|
||||||
assert len(events) == 2
|
assert len(events) == 2
|
||||||
|
|
||||||
|
# First event: Glacier Fire
|
||||||
event = events[0]
|
event = events[0]
|
||||||
assert event.id == "GUID-001-BOISE"
|
assert event.id == "GUID-001-GLACIER"
|
||||||
assert event.adapter == "wfigs_incidents"
|
assert event.adapter == "wfigs_incidents"
|
||||||
assert event.category == "fire.incident.wildfire"
|
# Category uses normalized incident type
|
||||||
|
assert event.category == "fire.incident.wildfire" # NOT fire.incident.wf
|
||||||
assert event.severity == 3 # 150 acres = severity 3 (100-999 range)
|
assert event.severity == 3 # 150 acres = severity 3 (100-999 range)
|
||||||
assert event.geo.primary_region == "US-ID-ADA"
|
# Region uses normalized state (no double US-)
|
||||||
assert event.data["IrwinID"] == "GUID-001-BOISE"
|
assert event.geo.primary_region == "US-MT-GLACIER" # NOT US-US-MT-GLACIER
|
||||||
|
# Data contains both normalized and raw values
|
||||||
|
assert event.data["POOState"] == "MT" # normalized
|
||||||
|
assert event.data["POOState_raw"] == "US-MT" # raw
|
||||||
|
assert event.data["IncidentTypeCategory"] == "wildfire" # normalized
|
||||||
|
assert event.data["IncidentTypeCategory_raw"] == "WF" # raw
|
||||||
|
|
||||||
|
# Second event: Owyhee Rx
|
||||||
|
event2 = events[1]
|
||||||
|
assert event2.category == "fire.incident.prescribed_fire" # NOT fire.incident.rx
|
||||||
|
assert event2.data["POOState"] == "ID"
|
||||||
|
assert event2.data["POOState_raw"] == "US-ID"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_is_published_dedup(
|
async def test_is_published_dedup(
|
||||||
|
|
@ -280,27 +356,30 @@ class TestWFIGSIncidentsAdapter:
|
||||||
# The removal is yielded for GUID-002
|
# The removal is yielded for GUID-002
|
||||||
removal_events = [e for e in events2 if e.category == "fire.incident.removed"]
|
removal_events = [e for e in events2 if e.category == "fire.incident.removed"]
|
||||||
assert len(removal_events) == 1
|
assert len(removal_events) == 1
|
||||||
assert removal_events[0].data["irwin_id"] == "GUID-002-CANYON"
|
assert removal_events[0].data["irwin_id"] == "GUID-002-OWYHEE"
|
||||||
|
|
||||||
def test_subject_for_incidents(
|
def test_subject_for_incidents_normalized(
|
||||||
self, mock_config: AdapterConfig, mock_config_store: MagicMock, cursor_db_path: Path
|
self, mock_config: AdapterConfig, mock_config_store: MagicMock, cursor_db_path: Path
|
||||||
):
|
):
|
||||||
|
"""subject_for uses normalized state codes."""
|
||||||
from central.adapters.wfigs_incidents import WFIGSIncidentsAdapter
|
from central.adapters.wfigs_incidents import WFIGSIncidentsAdapter
|
||||||
|
|
||||||
adapter = WFIGSIncidentsAdapter(mock_config, mock_config_store, cursor_db_path)
|
adapter = WFIGSIncidentsAdapter(mock_config, mock_config_store, cursor_db_path)
|
||||||
|
|
||||||
|
# Event data contains normalized state (MT not US-MT)
|
||||||
event = Event(
|
event = Event(
|
||||||
id="test-id",
|
id="test-id",
|
||||||
adapter="wfigs_incidents",
|
adapter="wfigs_incidents",
|
||||||
category="fire.incident.wildfire",
|
category="fire.incident.wildfire",
|
||||||
time=datetime.now(timezone.utc),
|
time=datetime.now(timezone.utc),
|
||||||
severity=2,
|
severity=2,
|
||||||
geo=Geo(primary_region="US-ID-ADA"),
|
geo=Geo(primary_region="US-MT-GLACIER"),
|
||||||
data={"POOState": "ID", "POOCounty": "Ada"},
|
data={"POOState": "MT", "POOCounty": "Glacier"},
|
||||||
)
|
)
|
||||||
|
|
||||||
subject = adapter.subject_for(event)
|
subject = adapter.subject_for(event)
|
||||||
assert subject == "central.fire.incident.id.ada"
|
# Subject uses normalized state: mt.glacier not us-mt.glacier
|
||||||
|
assert subject == "central.fire.incident.mt.glacier"
|
||||||
|
|
||||||
def test_subject_for_removal(
|
def test_subject_for_removal(
|
||||||
self, mock_config: AdapterConfig, mock_config_store: MagicMock, cursor_db_path: Path
|
self, mock_config: AdapterConfig, mock_config_store: MagicMock, cursor_db_path: Path
|
||||||
|
|
@ -316,11 +395,11 @@ class TestWFIGSIncidentsAdapter:
|
||||||
time=datetime.now(timezone.utc),
|
time=datetime.now(timezone.utc),
|
||||||
severity=0,
|
severity=0,
|
||||||
geo=Geo(),
|
geo=Geo(),
|
||||||
data={"irwin_id": "test-id", "state": "ID"},
|
data={"irwin_id": "test-id", "state": "MT"},
|
||||||
)
|
)
|
||||||
|
|
||||||
subject = adapter.subject_for(event)
|
subject = adapter.subject_for(event)
|
||||||
assert subject == "central.fire.incident.removed.id"
|
assert subject == "central.fire.incident.removed.mt"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_bbox_post_filter(
|
async def test_bbox_post_filter(
|
||||||
|
|
@ -416,29 +495,40 @@ class TestWFIGSPerimetersAdapter:
|
||||||
assert len(events) == 1
|
assert len(events) == 1
|
||||||
|
|
||||||
event = events[0]
|
event = events[0]
|
||||||
assert event.id == "GUID-001-BOISE"
|
assert event.id == "GUID-001-GLACIER"
|
||||||
assert event.adapter == "wfigs_perimeters"
|
assert event.adapter == "wfigs_perimeters"
|
||||||
assert event.category == "fire.perimeter.wildfire"
|
# Category uses normalized incident type
|
||||||
assert event.geo.primary_region == "US-ID-ADA"
|
assert event.category == "fire.perimeter.wildfire" # NOT fire.perimeter.wf
|
||||||
|
# Region uses normalized state (no double US-)
|
||||||
|
assert event.geo.primary_region == "US-MT-GLACIER" # NOT US-US-MT-GLACIER
|
||||||
|
# Data contains both normalized and raw values
|
||||||
|
assert event.data["POOState"] == "MT" # normalized
|
||||||
|
assert event.data["POOState_raw"] == "US-MT" # raw
|
||||||
|
assert event.data["IncidentTypeCategory"] == "wildfire" # normalized
|
||||||
|
assert event.data["IncidentTypeCategory_raw"] == "WF" # raw
|
||||||
|
# Geometry is included
|
||||||
assert "geometry" in event.data
|
assert "geometry" in event.data
|
||||||
assert event.data["geometry"]["type"] == "Polygon"
|
assert event.data["geometry"]["type"] == "Polygon"
|
||||||
|
|
||||||
def test_subject_for_perimeters(
|
def test_subject_for_perimeters_normalized(
|
||||||
self, mock_config: AdapterConfig, mock_config_store: MagicMock, cursor_db_path: Path
|
self, mock_config: AdapterConfig, mock_config_store: MagicMock, cursor_db_path: Path
|
||||||
):
|
):
|
||||||
|
"""subject_for uses normalized state codes."""
|
||||||
from central.adapters.wfigs_perimeters import WFIGSPerimetersAdapter
|
from central.adapters.wfigs_perimeters import WFIGSPerimetersAdapter
|
||||||
|
|
||||||
adapter = WFIGSPerimetersAdapter(mock_config, mock_config_store, cursor_db_path)
|
adapter = WFIGSPerimetersAdapter(mock_config, mock_config_store, cursor_db_path)
|
||||||
|
|
||||||
|
# Event data contains normalized state (MT not US-MT)
|
||||||
event = Event(
|
event = Event(
|
||||||
id="test-id",
|
id="test-id",
|
||||||
adapter="wfigs_perimeters",
|
adapter="wfigs_perimeters",
|
||||||
category="fire.perimeter.wildfire",
|
category="fire.perimeter.wildfire",
|
||||||
time=datetime.now(timezone.utc),
|
time=datetime.now(timezone.utc),
|
||||||
severity=2,
|
severity=2,
|
||||||
geo=Geo(primary_region="US-ID-ADA"),
|
geo=Geo(primary_region="US-MT-GLACIER"),
|
||||||
data={"POOState": "ID", "POOCounty": "Ada", "geometry": {}},
|
data={"POOState": "MT", "POOCounty": "Glacier", "geometry": {}},
|
||||||
)
|
)
|
||||||
|
|
||||||
subject = adapter.subject_for(event)
|
subject = adapter.subject_for(event)
|
||||||
assert subject == "central.fire.perimeter.id.ada"
|
# Subject uses normalized state: mt.glacier not us-mt.glacier
|
||||||
|
assert subject == "central.fire.perimeter.mt.glacier"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue