mirror of
https://github.com/zvx-echo6/central.git
synced 2026-08-26 17:31:39 +00:00
v0.14.4: propagate WFIGS perimeter updates (dedup key includes attr_ModifiedOnDateTime_dt) (#110)
Sibling fix to #109/v0.14.3 for wfigs_perimeters. Dedup key was bare attr_IrwinID, silencing every geometry refinement after first publish. Fix: id = f"{irwin_id}:{attr_ModifiedOnDateTime_dt}" (prefixed field; no recency filter so None path needs no monkeypatch). No new field/event-type/subject/migration. Closes the v0.14.3 deferred follow-up.
This commit is contained in:
parent
18f0dd6df9
commit
c1c3d2576d
2 changed files with 123 additions and 3 deletions
|
|
@ -287,9 +287,15 @@ class WFIGSPerimetersAdapter(SourceAdapter):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build event with geometry in data
|
# Build event with geometry in data
|
||||||
# Use normalized field names in event data for consistency
|
# Use normalized field names in event data for consistency.
|
||||||
|
# v0.14.4: the dedup key includes attr_ModifiedOnDateTime_dt so each
|
||||||
|
# genuine upstream perimeter refinement mints a new id and
|
||||||
|
# republishes, while identical re-polls (same modified time) still
|
||||||
|
# dedup. The bare IrwinID published a perimeter once and silenced
|
||||||
|
# every later geometry refinement (same fix as v0.14.3 for the
|
||||||
|
# sibling wfigs_incidents adapter).
|
||||||
event = Event(
|
event = Event(
|
||||||
id=irwin_id,
|
id=f"{irwin_id}:{props.get('attr_ModifiedOnDateTime_dt')}",
|
||||||
adapter=self.name,
|
adapter=self.name,
|
||||||
category=f"fire.perimeter.{incident_type}",
|
category=f"fire.perimeter.{incident_type}",
|
||||||
time=discovery_time or datetime.now(timezone.utc),
|
time=discovery_time or datetime.now(timezone.utc),
|
||||||
|
|
|
||||||
|
|
@ -690,7 +690,9 @@ class TestWFIGSPerimetersAdapter:
|
||||||
assert len(events) == 1
|
assert len(events) == 1
|
||||||
|
|
||||||
event = events[0]
|
event = events[0]
|
||||||
assert event.id == "GUID-001-GLACIER"
|
# v0.14.4: dedup key is IrwinID + attr_ModifiedOnDateTime_dt so perimeter
|
||||||
|
# refinements republish (see TestWFIGSPerimetersUpdatePropagation below).
|
||||||
|
assert event.id == "GUID-001-GLACIER:1716100000000"
|
||||||
assert event.adapter == "wfigs_perimeters"
|
assert event.adapter == "wfigs_perimeters"
|
||||||
# Category uses normalized incident type
|
# Category uses normalized incident type
|
||||||
assert event.category == "fire.perimeter.wildfire" # NOT fire.perimeter.wf
|
assert event.category == "fire.perimeter.wildfire" # NOT fire.perimeter.wf
|
||||||
|
|
@ -864,6 +866,33 @@ def _incident_response(irwin: str, mod_dt, county: str = "Ada", name: str = "Tes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _perimeter_response(irwin: str, mod_dt, county: str = "Ada", name: str = "Test Fire"):
|
||||||
|
"""Single perimeter feature (attr_*/poly_* prefixed) with a controllable
|
||||||
|
attr_ModifiedOnDateTime_dt and a Polygon intersecting the test region bbox."""
|
||||||
|
return {
|
||||||
|
"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],
|
||||||
|
]]}, # Idaho
|
||||||
|
"properties": {
|
||||||
|
"attr_IrwinID": irwin,
|
||||||
|
"attr_IncidentName": name,
|
||||||
|
"attr_IncidentTypeCategory": "WF",
|
||||||
|
"attr_IncidentSize": 100,
|
||||||
|
"poly_GISAcres": 98.5,
|
||||||
|
"attr_PercentContained": 0,
|
||||||
|
"attr_FireDiscoveryDateTime": 1716000000000,
|
||||||
|
"attr_ModifiedOnDateTime_dt": mod_dt,
|
||||||
|
"attr_POOState": "US-ID",
|
||||||
|
"attr_POOCounty": county,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _poll_once(adapter, response):
|
async def _poll_once(adapter, response):
|
||||||
mr = AsyncMock()
|
mr = AsyncMock()
|
||||||
mr.raise_for_status = MagicMock()
|
mr.raise_for_status = MagicMock()
|
||||||
|
|
@ -968,3 +997,88 @@ class TestWFIGSIncidentsUpdatePropagation:
|
||||||
assert len(pub1) == 1
|
assert len(pub1) == 1
|
||||||
assert pub1[0].id == "GUID-A:None"
|
assert pub1[0].id == "GUID-A:None"
|
||||||
assert len(pub2) == 0 # still dedups consistently on the ':None' key
|
assert len(pub2) == 0 # still dedups consistently on the ':None' key
|
||||||
|
|
||||||
|
|
||||||
|
# --- v0.14.4: perimeter UPDATE propagation ------------------------------------
|
||||||
|
# Same fix as v0.14.3 applied to the sibling wfigs_perimeters adapter: the dedup
|
||||||
|
# key is now attr_IrwinID + attr_ModifiedOnDateTime_dt (was bare attr_IrwinID,
|
||||||
|
# which published a perimeter once and silenced every later geometry refinement).
|
||||||
|
# Note: wfigs_perimeters has NO client-side recency filter, so the None path
|
||||||
|
# needs no monkeypatch (unlike incidents).
|
||||||
|
|
||||||
|
class TestWFIGSPerimetersUpdatePropagation:
|
||||||
|
"""v0.14.4: dedup key includes attr_ModifiedOnDateTime_dt."""
|
||||||
|
|
||||||
|
def _adapter(self, tmp_path: Path):
|
||||||
|
from central.adapters.wfigs_perimeters import WFIGSPerimetersAdapter
|
||||||
|
config = 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),
|
||||||
|
)
|
||||||
|
return WFIGSPerimetersAdapter(config, MagicMock(), tmp_path / "cursors.db")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unchanged_perimeter_dedups_across_polls(self, tmp_path: Path):
|
||||||
|
"""Same IrwinID + same attr_ModifiedOnDateTime_dt: first publishes, second deduped."""
|
||||||
|
adapter = self._adapter(tmp_path)
|
||||||
|
await adapter.startup()
|
||||||
|
resp = _perimeter_response("GUID-P", 1_716_100_000_000)
|
||||||
|
|
||||||
|
pub1 = _dedup_publish(adapter, await _poll_once(adapter, resp))
|
||||||
|
pub2 = _dedup_publish(adapter, await _poll_once(adapter, resp))
|
||||||
|
|
||||||
|
await adapter.shutdown()
|
||||||
|
assert len(pub1) == 1
|
||||||
|
assert len(pub2) == 0 # collapsed via published_ids on the identical key
|
||||||
|
assert pub1[0].id == "GUID-P:1716100000000"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_modified_perimeter_republishes_with_distinct_id(self, tmp_path: Path):
|
||||||
|
"""Same IrwinID, DIFFERENT attr_ModifiedOnDateTime_dt: BOTH publish, distinct ids.
|
||||||
|
|
||||||
|
This is the load-bearing test: it proves the perimeter silencing is fixed."""
|
||||||
|
adapter = self._adapter(tmp_path)
|
||||||
|
await adapter.startup()
|
||||||
|
resp_t1 = _perimeter_response("GUID-P", 1_716_100_000_000)
|
||||||
|
resp_t2 = _perimeter_response("GUID-P", 1_716_100_300_000) # perimeter refined upstream
|
||||||
|
|
||||||
|
pub1 = _dedup_publish(adapter, await _poll_once(adapter, resp_t1))
|
||||||
|
pub2 = _dedup_publish(adapter, await _poll_once(adapter, resp_t2))
|
||||||
|
|
||||||
|
await adapter.shutdown()
|
||||||
|
assert len(pub1) == 1
|
||||||
|
assert len(pub2) == 1 # the refinement propagated, not silenced
|
||||||
|
assert pub1[0].id == "GUID-P:1716100000000"
|
||||||
|
assert pub2[0].id == "GUID-P:1716100300000"
|
||||||
|
assert pub1[0].id != pub2[0].id
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_subject_derivation_unchanged(self, tmp_path: Path):
|
||||||
|
"""The new id does not affect subject_for: still central.fire.perimeter.<state>.<county>."""
|
||||||
|
adapter = self._adapter(tmp_path)
|
||||||
|
await adapter.startup()
|
||||||
|
events = await _poll_once(
|
||||||
|
adapter, _perimeter_response("GUID-P", 1_716_100_300_000, county="Ada")
|
||||||
|
)
|
||||||
|
await adapter.shutdown()
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].id == "GUID-P:1716100300000"
|
||||||
|
assert adapter.subject_for(events[0]) == "central.fire.perimeter.id.ada"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_none_modified_time_is_deterministic(self, tmp_path: Path):
|
||||||
|
"""Defensive: a None attr_ModifiedOnDateTime_dt yields a deterministic
|
||||||
|
':None' id without raising. No recency filter on perimeters, so this
|
||||||
|
reaches the construction path directly (no monkeypatch needed)."""
|
||||||
|
adapter = self._adapter(tmp_path)
|
||||||
|
await adapter.startup()
|
||||||
|
resp = _perimeter_response("GUID-P", None)
|
||||||
|
|
||||||
|
pub1 = _dedup_publish(adapter, await _poll_once(adapter, resp))
|
||||||
|
pub2 = _dedup_publish(adapter, await _poll_once(adapter, resp))
|
||||||
|
|
||||||
|
await adapter.shutdown()
|
||||||
|
assert len(pub1) == 1
|
||||||
|
assert pub1[0].id == "GUID-P:None"
|
||||||
|
assert len(pub2) == 0 # still dedups consistently on the ':None' key
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue