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:
Matt Johnson 2026-05-25 20:35:08 +00:00
commit 7eab5fc1b1
12 changed files with 571 additions and 1 deletions

1
tests/fixtures/wzdx_iowa_sample.json vendored Normal file
View 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
View 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]]}}]}

View file

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