mirror of
https://github.com/zvx-echo6/central.git
synced 2026-06-10 11:54:37 +02:00
feat(wzdx): WZDx adapter + CENTRAL_TRAFFIC family bootstrap (v0.9.0)
Opens Phase 4 transportation aggregation (Design B, Central-direct). New
registry-driven wzdx adapter polls the FHWA WZDx Feed Registry, fetches each
eligible v4.x GeoJSON feed concurrently, and emits work_zone events into the new
CENTRAL_TRAFFIC stream. Production code; central-supervisor AND central-gui
restart (new adapter class + stream + ADAPTER_GROUPS). Ships disabled.
First adapter to use the category/subject split: category="work_zone.wzdx" (GUI
event_type "work_zone" via split_part) while the NATS subject is
central.traffic.work_zone.{state}. Subject state from the registry row, geocoder
state as fallback. Severity from vehicle_impact (all-lanes-closed=3,
some-lanes-closed=2, all-lanes-open=1, unknown/missing=1). Feed filter
geojson + active + needapikey=false + version 4.x (21 of 39 feeds). 600s cadence.
Dedup composite <data_source_id>:<feature_id> in the shared cursors.db; stateless
discovery (no conftest isolation entry). enrichment_locations uses the canonical
("latitude","longitude") paths.
Full suite: 739 passed, 1 skipped (central and unprivileged zvx, 3x each).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
de464a08e1
commit
7eab5fc1b1
12 changed files with 571 additions and 1 deletions
1
tests/fixtures/wzdx_iowa_sample.json
vendored
Normal file
1
tests/fixtures/wzdx_iowa_sample.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"road_event_feed_info":{"publisher":"Iowa DOT","version":"4.0","data_sources":[{"data_source_id":"IowaDOT-WZDx","organization_name":"Iowa DOT"}],"update_date":"2026-05-25T19:34:45Z"},"type":"FeatureCollection","features":[{"id":"OpenTMS-Event22920571864-1","type":"Feature","properties":{"core_details":{"event_type":"work-zone","data_source_id":"IowaDOT-WZDx","road_names":["US-65"],"direction":"northbound","description":"Between IA 2 (6 miles south of the Humeston area) and County Road H50 (2 miles north of the Humeston area). Road construction. Intermittent lane closure. Pilot car in operation. Look out for flaggers. From 7:00AM CDT to 5:00PM CDT on weekdays. Starting June 1, 2026 at 7:00AM CDT until June 30, 2026 at about 5:00PM CDT. Comment: Chariton RCE (800-881-5778) - Wayne County","update_date":"2026-05-22T18:17:42Z"},"start_date":"2026-06-01T12:00:00Z","end_date":"2026-06-01T22:00:00Z","start_date_accuracy":"estimated","end_date_accuracy":"estimated","beginning_accuracy":"estimated","ending_accuracy":"estimated","beginning_cross_street":"65N-13.1","ending_cross_street":"65N-22.4","beginning_milepost":13.1,"ending_milepost":22.4,"vehicle_impact":"all-lanes-open","types_of_work":[{"type_name":"surface-work"}],"lanes":[{"order":1,"type":"shoulder","status":"open"},{"order":2,"type":"general","status":"open"},{"order":3,"type":"shoulder","status":"open"}],"location_method":"unknown"},"geometry":{"type":"MultiPoint","coordinates":[[-93.499196,40.764081],[-93.493947,40.897518]]}},{"id":"OpenTMS-Event22735489722","type":"Feature","properties":{"core_details":{"event_type":"work-zone","data_source_id":"IowaDOT-WZDx","road_names":["I-80"],"direction":"eastbound","description":"Between I-35 (Clive) and Exit 127: IA 141 (Urbandale). Road closed due to night time construction work. Detour in operation. Follow the Iowa DOT-recommended detour around the closure. See map for detour(s). Starting May 26, 2026 at 10:00PM CDT until May 27, 2026 at about 4:00AM CDT. Full schedule below: \u2022 May 26, 10:00PM - May 27, 4:00AM Comment: Grimes RCE (800-251-2707) - Polk County","update_date":"2026-05-07T16:52:11Z"},"start_date":"2026-05-27T03:00:00Z","end_date":"2026-05-27T09:00:00Z","start_date_accuracy":"estimated","end_date_accuracy":"estimated","beginning_accuracy":"estimated","ending_accuracy":"estimated","beginning_cross_street":"80E-124.3","ending_cross_street":"80E-127","beginning_milepost":124.3,"ending_milepost":127.0,"vehicle_impact":"some-lanes-closed","types_of_work":[{"type_name":"surface-work"}],"lanes":[{"order":1,"type":"shoulder","status":"closed"},{"order":2,"type":"general","status":"closed"},{"order":3,"type":"general","status":"closed"},{"order":4,"type":"general","status":"closed"},{"order":5,"type":"general","status":"closed"},{"order":6,"type":"shoulder","status":"open"}],"location_method":"unknown"},"geometry":{"type":"MultiPoint","coordinates":[[-93.776735,41.603576],[-93.776897,41.642469]]}}]}
|
||||
1
tests/fixtures/wzdx_utah_sample.json
vendored
Normal file
1
tests/fixtures/wzdx_utah_sample.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"road_event_feed_info":{"publisher":"UDOT","version":"4.0","license":"https://creativecommons.org/publicdomain/zero/1.0/","data_sources":[{"data_source_id":"UDOT-construction","organization_name":"UDOT-TOC","update_date":"2023-03-19T07:03:52.1411634-06:00","update_frequency":900,"contact_name":"Chuck Felice","contact_email":"cfelice@utah.gov"}],"update_date":"2023-03-19T07:04:04.8614897-06:00","update_frequency":900,"contact_name":"Chuck Felice","contact_email":"cfelice@utah.gov"},"type":"FeatureCollection","features":[{"id":"2365_eastbound","type":"Feature","properties":{"core_details":{"event_type":"work-zone","data_source_id":"UDOT-Construction","road_names":["I-80"],"direction":"eastbound","description":"The Utah Department of Transportation (UDOT) will improve I-80 between 1300 East and 2300 East. The pavement will be replaced with new concrete throughout, and a new lane will be added to eastbound I-80 between 1300 East and 2300 East. Expect lane shifts and lane closures between 1300 East and 2300 East and minor delays in the area during the project.","creation_date":"2022-01-10T18:53:49.643Z","update_date":"2022-01-10T18:53:49.643Z"},"start_date":"2021-12-01T07:00:00Z","end_date":"2022-12-31T07:00:00Z","start_date_accuracy":"estimated","end_date_accuracy":"estimated","beginning_accuracy":"estimated","ending_accuracy":"estimated","location_method":"unknown","vehicle_impact":"unknown","beginning_cross_street":"700 E / Salt Lake City","ending_cross_street":"2300 E / Holladay","beginning_milepost":125,"ending_milepost":127,"event_status":"active"},"geometry":{"type":"LineString","coordinates":[[-111.855022,40.719556],[-111.836362,40.717367],[-111.834606,40.716772],[-111.833043,40.716002],[-111.831355,40.715327],[-111.829568,40.714835],[-111.827774,40.714344],[-111.825987,40.713849],[-111.824192,40.713376],[-111.822413,40.712896],[-111.820576,40.712489],[-111.818724,40.712591]]}}]}
|
||||
|
|
@ -1142,6 +1142,7 @@ _SAMPLE_INNER = {
|
|||
"usgs_quake": {"magnitude": 1.009682538298, "place": "17 km W of Searles Valley, CA"},
|
||||
"wfigs_incidents": {"county": "Montezuma", "state": "CO"},
|
||||
"wfigs_perimeters": {"county": "Carbon", "state": "MT"},
|
||||
"wzdx": {"road_names": ["I-80"], "direction": "eastbound"},
|
||||
}
|
||||
|
||||
# Exact expected subjects for the deterministic adapters. swpc_alerts is omitted
|
||||
|
|
@ -1160,6 +1161,7 @@ _EXPECTED_SUBJECT = {
|
|||
"usgs_quake": "Magnitude 1.0 — 17 km W of Searles Valley, CA",
|
||||
"wfigs_incidents": "Wildfire incident — Montezuma, CO",
|
||||
"wfigs_perimeters": "Wildfire perimeter — Carbon, MT",
|
||||
"wzdx": "Work zone on I-80 eastbound",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
152
tests/test_wzdx.py
Normal file
152
tests/test_wzdx.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"""Tests for the WZDx adapter.
|
||||
|
||||
Fixtures are real captures trimmed to representative features:
|
||||
wzdx_utah_sample.json -- curl https://udottraffic.utah.gov/wzdx/udot/v40/data
|
||||
| jq '{road_event_feed_info, type, features: .features[0:1]}'
|
||||
(LineString, vehicle_impact "unknown", has event_status, no lanes)
|
||||
wzdx_iowa_sample.json -- curl https://iowa-atms.cloud-q-free.com/api/rest/dataprism/wzdx/wzdxfeed
|
||||
| jq '{... , features: [<all-lanes-open feature>, <some-lanes-closed feature>]}'
|
||||
(MultiPoint, lanes + types_of_work, no event_status)
|
||||
|
||||
No tests/conftest isolation entry is added: WZDx dedup uses the supervisor-
|
||||
injected cursors.db and registry discovery is stateless, so there is no
|
||||
adapter-owned cache to redirect (unlike nwis's NWIS_CACHE_DB_PATH).
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from central.adapters.wzdx import (
|
||||
_DEFAULT_SEVERITY,
|
||||
_VEHICLE_IMPACT_SEVERITY,
|
||||
WZDxAdapter,
|
||||
_eligible,
|
||||
_flatten_geometry,
|
||||
)
|
||||
from central.config_models import AdapterConfig
|
||||
|
||||
FIX = Path(__file__).parent / "fixtures"
|
||||
UTAH = json.loads((FIX / "wzdx_utah_sample.json").read_text())
|
||||
IOWA = json.loads((FIX / "wzdx_iowa_sample.json").read_text())
|
||||
|
||||
|
||||
def _cfg(settings=None):
|
||||
return AdapterConfig(
|
||||
name="wzdx", enabled=True, cadence_s=600,
|
||||
settings=settings or {}, updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(tmp_path):
|
||||
return WZDxAdapter(_cfg(), MagicMock(), tmp_path / "cursors.db")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("row,keep", [
|
||||
({"format": "geojson", "active": True, "needapikey": False, "version": "4.1"}, True),
|
||||
({"format": "geojson", "active": True, "needapikey": False, "version": "4"}, True),
|
||||
({"format": "json", "active": True, "needapikey": False, "version": "4.1"}, False),
|
||||
({"format": "geojson", "active": False, "needapikey": False, "version": "4.1"}, False),
|
||||
({"format": "geojson", "active": True, "needapikey": True, "version": "4.1"}, False),
|
||||
({"format": "geojson", "active": True, "needapikey": False, "version": "3.1"}, False),
|
||||
({"format": "geojson", "active": True, "needapikey": False, "version": "CWZ 1.0"}, False),
|
||||
])
|
||||
def test_eligible_filter(row, keep):
|
||||
assert _eligible(row) is keep
|
||||
|
||||
|
||||
def test_dedup_key(adapter):
|
||||
eu = adapter._build_event(UTAH["features"][0], {"feedname": "udot", "state": "utah"})
|
||||
ei = adapter._build_event(IOWA["features"][0], {"feedname": "idot", "state": "iowa"})
|
||||
assert eu.id == "UDOT-Construction:2365_eastbound"
|
||||
assert ei.id == "IowaDOT-WZDx:OpenTMS-Event22920571864-1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("vi,sev", [
|
||||
("all-lanes-closed", 3), ("some-lanes-closed", 2), ("all-lanes-open", 1),
|
||||
("unknown", 1), (None, 1),
|
||||
])
|
||||
def test_severity(vi, sev):
|
||||
assert _VEHICLE_IMPACT_SEVERITY.get(vi, _DEFAULT_SEVERITY) == sev
|
||||
|
||||
|
||||
@pytest.mark.parametrize("geom,expect", [
|
||||
({"type": "LineString", "coordinates": [[-111.8, 40.7], [-111.6, 40.6]]}, (40.7, -111.8)),
|
||||
({"type": "MultiPoint", "coordinates": [[-93.5, 40.7]]}, (40.7, -93.5)),
|
||||
({"type": "Point", "coordinates": [-93.5, 40.7]}, (40.7, -93.5)),
|
||||
(None, (None, None)),
|
||||
({"type": "Polygon", "coordinates": []}, (None, None)),
|
||||
])
|
||||
def test_flatten_geometry(geom, expect):
|
||||
assert _flatten_geometry(geom) == expect
|
||||
|
||||
|
||||
def test_build_utah_shape(adapter):
|
||||
e = adapter._build_event(UTAH["features"][0], {"feedname": "udot", "state": "utah"})
|
||||
assert e.category == "work_zone.wzdx"
|
||||
assert e.severity == 1 # vehicle_impact "unknown"
|
||||
assert e.data["latitude"] is not None
|
||||
assert e.data["event_status"] == "active" # Utah carries it
|
||||
|
||||
|
||||
def test_build_iowa_shape(adapter):
|
||||
e0 = adapter._build_event(IOWA["features"][0], {"feedname": "idot", "state": "iowa"})
|
||||
e1 = adapter._build_event(IOWA["features"][1], {"feedname": "idot", "state": "iowa"})
|
||||
assert e0.severity == 1 # all-lanes-open
|
||||
assert e1.severity == 2 # some-lanes-closed
|
||||
assert e0.data["event_status"] is None # Iowa lacks it -> no raise
|
||||
|
||||
|
||||
def test_subject_from_registry(adapter):
|
||||
e = adapter._build_event(UTAH["features"][0], {"feedname": "udot", "state": "utah"})
|
||||
assert adapter.subject_for(e) == "central.traffic.work_zone.ut"
|
||||
|
||||
|
||||
def test_subject_unknown(adapter):
|
||||
e = adapter._build_event(UTAH["features"][0], {"feedname": "x", "state": "n/a"})
|
||||
assert adapter.subject_for(e) == "central.traffic.work_zone.unknown"
|
||||
|
||||
|
||||
def test_subject_geocoder_fallback(adapter):
|
||||
e = adapter._build_event(UTAH["features"][0], {"feedname": "x", "state": "n/a"})
|
||||
e.data["_enriched"] = {"geocoder": {"state": "Idaho"}}
|
||||
assert adapter.subject_for(e) == "central.traffic.work_zone.id"
|
||||
|
||||
|
||||
def test_event_type_split(adapter):
|
||||
# Mirrors routes.py split_part(category, '.', 1) -> GUI event_type.
|
||||
e = adapter._build_event(UTAH["features"][0], {"feedname": "udot", "state": "utah"})
|
||||
assert e.category.split(".")[0] == "work_zone"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_yields_events(adapter):
|
||||
await adapter.startup()
|
||||
registry = [
|
||||
{"format": "geojson", "active": True, "needapikey": False, "version": "4", "feedname": "udot", "state": "utah", "url": {"url": "u"}},
|
||||
{"format": "geojson", "active": True, "needapikey": False, "version": "4", "feedname": "idot", "state": "iowa", "url": {"url": "i"}},
|
||||
{"format": "json", "active": True, "needapikey": False, "version": "4", "feedname": "skip", "state": "ohio", "url": {"url": "s"}},
|
||||
]
|
||||
adapter._fetch_registry = AsyncMock(return_value=registry)
|
||||
|
||||
async def fake_feed(row):
|
||||
return {"udot": UTAH, "idot": IOWA}.get(row["feedname"], {"features": []})["features"]
|
||||
|
||||
adapter._fetch_feed = fake_feed
|
||||
events = [e async for e in adapter.poll()]
|
||||
await adapter.shutdown()
|
||||
# Utah 1 + Iowa 2 = 3; the json feed is dropped by _discover.
|
||||
assert len(events) == 3
|
||||
assert {e.adapter for e in events} == {"wzdx"}
|
||||
|
||||
|
||||
def test_summary_partial_renders_subject():
|
||||
# End-to-end through the real _event_summaries/wzdx.html partial selection.
|
||||
from central.gui.routes import _derive_subject
|
||||
flat = {"road_names": ["I-80"], "direction": "eastbound"}
|
||||
row = {"adapter": "wzdx", "data": {"data": {"data": flat}}}
|
||||
assert _derive_subject(row) == "Work zone on I-80 eastbound"
|
||||
Loading…
Add table
Add a link
Reference in a new issue