Runbook C: NDIC dopravní detektory
Cíle kapitoly
- Získat NDIC API klíč a uložit ho bezpečně mimo kód
- Parsovat DATEX II XML do plochých eventů a publikovat je s retry na 429
- Detekovat zpomalení na dálnicích porovnáním s baseline v KQL
Úvod
Pattern: Custom Endpoint ← Notebook poller s API auth · Auth: API key· Risk: medium Fabric capacity: F2 OK
Cíl
Real-time stream traffic counts ze smyčkových detektorů na D1, D5, D8 atd. NDIC (Národní dopravní informační centrum) publikuje data ve formátu DATEX II (XML standard).
Pedagogická hodnota:
- Auth API pattern — registrace, API klíč, retry-on-429 handling
- XML parsing — DATEX II je verbose XML, real-world ETL challenge
- Rate limit awareness — NDIC free tier ~60 req/min, design poller k tomu
- Real CZ infrastructure data — D1, D5, D8 highway sensor loops
Reuse mimo kurz:
- Klientské POCs s vlastními auth API (TOTP, OAuth, api-key — pattern stejný)
- Konferenční talk: "Real-world streaming nad evropskou DATEX II infrastrukturou"
Architektura
NDIC DATEX II API
https://api.dopravniinfo.cz/v1/datex2/measurement/...
│ auth: Authorization: ApiKey <KEY>
│ HTTP GET, every 1 min
▼
┌──────────────────────────────────┐
│ Fabric Notebook │
│ nb_c_ndic_publisher │
│ • download XML │
│ • parse DATEX II → events │
│ • POST batch NDJSON → ES │
└──────────┬───────────────────────┘
▼
┌──────────────────────────────────┐
│ Eventstream es_c_ndic │
│ Custom Endpoint → KQL DB │
└──────────┬───────────────────────┘
▼
┌──────────────────────────────────┐
│ KQL DB │
│ c_ndic_raw → c_ndic_clean → MV │
└──────────────────────────────────┘Pre-requisites
A. NDIC API klíč (jednou)
- Registrace na https://www.dopravniinfo.cz/registrace
- Email confirmation
- V profilu: API přístup → Generate key (free tier dostačí)
- Read API docs — endpoint URLs se mění mezi v1/v2 verzemi:
- Ulož API key do KeyVault jako
ndic-api-key
B. Endpoint výběr
NDIC_URL a parser v publisheru.NDIC publikuje multiple datasets. Pro RTI demo použij Measurement Site Tables (smyčkové detektory):
- Endpoint:
https://api.dopravniinfo.cz/v1/datex2/measurement/recent - Refresh: ~1 min latency
- Data volume: ~500 measurement sites × 1 record/min = 500 events/min
Alternativní endpoints (pokud chceš jiné datasets):
traffic-events— incidents, dopravní nehody, uzavírky (sparse)vms— variable message signs (texty na proměnných tabulích) (sparse)
C. KQL setup
Spusť KQL setup skript (sekce níže) v kql_rti_demo.
D. Eventstream es_c_ndic
Stejný pattern jako B: Custom App source → KQL DB destination = c_ndic_raw.
Schema (DATEX II measurement)
DATEX II XML je verbose, příklad zjednodušeně:
<measurementSiteRecord id="MS-D1-KM-022"> <measurementSiteName>D1 km 22 směr Brno</measurementSiteName> <location> <pointCoordinates> <latitude>50.0532</latitude> <longitude>14.6745</longitude> </pointCoordinates> </location> <measuredValue> <basicData> <vehicleFlow> <vehicleFlowRate>1245</vehicleFlowRate> <!-- vehicles/h --> <averageVehicleSpeed>87.3</averageVehicleSpeed> <!-- km/h --> </vehicleFlow> </basicData> </measuredValue> <measurementTimeDefault>2026-05-07T10:30:00+02:00</measurementTimeDefault> </measurementSiteRecord>
Po parse v Notebooku → JSON event:
{
"site_id": "MS-D1-KM-022",
"site_name": "D1 km 22 směr Brno",
"lat": 50.0532,
"lon": 14.6745,
"measurement_ts": "2026-05-07T10:30:00+02:00",
"vehicle_flow_rate": 1245,
"avg_speed_kmh": 87.3,
"publisher_run_id": "uuid",
"publisher_ts": "2026-05-07T10:30:15Z"
}Story arc (samostatně)
- Auth pattern — proč rate limit, proč retry. NDIC docs ukázat.
- Notebook walkthrough — DATEX II XML parsing, batch publish.
- KQL queries — top 10 nejvytíženějších úseků, průměrná rychlost.
- "Co dál" — Activator alert na slow speed (incident detection).
Live demo execution
Pre-flight
- API klíč v KeyVault, accessible z Notebooku
- 1× test run:
run_once()→ check že events došly do KQL - KQL:
c_ndic_raw | count> 0
Live run
- Otevři Notebook v Fabricu, run cell
run_once() - Přepni do KQL DB query
c_ndic_clean | top 10 by ingest_ts descc_ndic_clean | summarize avg_speed=avg(avg_speed_kmh) by site_name | top 10 by avg_speed asc- Show map visualization v Real-Time Dashboard
KQL queries cheat sheet
Top 10 nejvytíženějších úseků (last hour)
c_ndic_clean | where measurement_ts > ago(1h) | summarize avg_flow = avg(vehicle_flow_rate), avg_speed = avg(avg_speed_kmh) by site_id, site_name | top 10 by avg_flow desc
Detekce zpomalení (potenciální incident)
c_ndic_clean | where measurement_ts > ago(15m) | summarize current_speed = avg(avg_speed_kmh) by site_id, site_name | join kind=inner ( c_ndic_clean | where measurement_ts between (ago(7d) .. ago(1d)) | summarize baseline_speed = avg(avg_speed_kmh) by site_id ) on site_id | extend speed_drop_pct = round((baseline_speed - current_speed) / baseline_speed * 100, 1) | where speed_drop_pct > 30 | project site_name, current_speed, baseline_speed, speed_drop_pct | order by speed_drop_pct desc
Per-15min flow trend
c_ndic_clean | where measurement_ts > ago(6h) | summarize total_vehicles = sum(vehicle_flow_rate / 4) // /4 protože 15min interval, flow je per-h by bin(measurement_ts, 15m), highway = extract(@"^(D\d+)", 1, site_name) | order by measurement_ts asc, highway asc
Real-Time Dashboard layout
| Tile | KQL |
|---|---|
| Active sensors (last 5min) | c_ndic_clean | where measurement_ts > ago(5m) | distinct site_id | count |
| Top 10 highway segments by flow | viz "Top 10 nejvytíženějších" |
| Speed heatmap (D1 only) | c_ndic_clean | where site_name startswith "D1" | summarize avg(avg_speed_kmh) by bin(measurement_ts, 15m), site_id |
| Incident alerts | viz "Detekce zpomalení" |
| Map | c_ndic_clean | summarize arg_max(measurement_ts, *) by site_id | project lat, lon, site_name, vehicle_flow_rate, avg_speed_kmh |
Known limitations
- DATEX II XML schema změny — NDIC občas updatne schema (v1 → v2). Po upgrade re-test parser.
- Rate limit — Free tier ~60 req/min. Při polling
recentkaždých 10s = 6 req/min, pohoda. Při větší frekvenci buy paid tier. - Latence NDIC — měření jsou typicky 1-3 min stará před tím než dorazí na API. Pro „real real-time" insufficient, pro „operational awareness" plně dostačí.
- Coverage — ne všechny dálnice mají detektory. D1/D5/D8 jsou hustě pokryté, periferie sparse.
Troubleshooting
| Problem | Fix |
|---|---|
| HTTP 401 z NDIC | API klíč expired nebo špatně formátovaný. Check Authorization: ApiKey <KEY> (ne Bearer) |
| HTTP 429 (rate limit) | Slow poller down, increase POLL_INTERVAL_SEC |
| XML parsing error | Schema změna — log raw XML, srovnej s docs |
c_ndic_raw plný, c_ndic_clean prázdný |
Update policy nepracuje. Check fn_c_ndic_transform() ručně. |
KQL setup skript
Kompletní obsah souboru — rozbalte a zkopírujte tlačítkem Kopírovat.
// Runbook C — NDIC Traffic Counters · KQL setup // Idempotentní. // ────────────────────────────────────────────────────────────────── // 1. Bronze // ────────────────────────────────────────────────────────────────── .create-merge table c_ndic_raw ( EventEnqueuedUtcTime: datetime, site_id: string, site_name: string, lat: real, lon: real, measurement_ts: datetime, vehicle_flow_rate: int, avg_speed_kmh: real, publisher_run_id: string, publisher_ts: datetime ) .alter table c_ndic_raw policy retention @'{"SoftDeletePeriod": "30.00:00:00", "Recoverability": "Disabled"}' // ────────────────────────────────────────────────────────────────── // 2. Silver // ────────────────────────────────────────────────────────────────── .create-merge table c_ndic_clean ( ingest_ts: datetime, measurement_ts: datetime, site_id: string, site_name: string, highway: string, km_marker: real, lat: real, lon: real, vehicle_flow_rate: int, avg_speed_kmh: real, speed_class: string, is_congested: bool ) .alter table c_ndic_clean policy retention @'{"SoftDeletePeriod": "180.00:00:00", "Recoverability": "Enabled"}' // ────────────────────────────────────────────────────────────────── // 3. Update policy function // ────────────────────────────────────────────────────────────────── .create-or-alter function fn_c_ndic_transform() { c_ndic_raw | extend ingest_ts = EventEnqueuedUtcTime | extend highway = extract(@"^MS-(D\d+)", 1, site_id) | extend km_marker = todouble(extract(@"KM-(\d+)", 1, site_id)) | extend speed_class = case( avg_speed_kmh < 30, "stopped", avg_speed_kmh < 60, "slow", avg_speed_kmh < 90, "normal", avg_speed_kmh < 120, "fast", "very_fast" ) | extend is_congested = (avg_speed_kmh < 50 and vehicle_flow_rate > 800) | project ingest_ts, measurement_ts, site_id, site_name, highway, km_marker, lat, lon, vehicle_flow_rate, avg_speed_kmh, speed_class, is_congested } .alter table c_ndic_clean policy update @'[{"IsEnabled": true, "Source": "c_ndic_raw", "Query": "fn_c_ndic_transform()", "IsTransactional": true, "PropagateIngestionProperties": true}]' // ────────────────────────────────────────────────────────────────── // 4. MV — last per site // ────────────────────────────────────────────────────────────────── .create-or-alter materialized-view with (backfill=true, lookback=1d) mv_c_ndic_last_per_site on table c_ndic_clean { c_ndic_clean | summarize arg_max(measurement_ts, *) by site_id } // ────────────────────────────────────────────────────────────────── // 5. MV — congestion summary (5min rolling) // ────────────────────────────────────────────────────────────────── .create-or-alter materialized-view with (backfill=true, lookback=2h) mv_c_ndic_congestion_5min on table c_ndic_clean { c_ndic_clean | where is_congested | summarize n_congested_sites = dcount(site_id), avg_speed = avg(avg_speed_kmh) by bin(measurement_ts, 5m), highway } // ────────────────────────────────────────────────────────────────── // 6. Helper: incident detection // ────────────────────────────────────────────────────────────────── .create-or-alter function fn_c_ndic_incidents(window:timespan = 15m, drop_pct:real = 30.0) { let current = mv_c_ndic_last_per_site | where measurement_ts > ago(window); let baseline = c_ndic_clean | where measurement_ts between (ago(7d) .. ago(1d)) | summarize baseline_speed = avg(avg_speed_kmh) by site_id; current | join kind=inner baseline on site_id | extend speed_drop_pct = round((baseline_speed - avg_speed_kmh) / baseline_speed * 100, 1) | where speed_drop_pct > drop_pct | project site_id, site_name, highway, km_marker, current_speed = avg_speed_kmh, baseline_speed, speed_drop_pct, lat, lon | order by speed_drop_pct desc }
Kompletní kód publisheru
Kompletní obsah souboru — rozbalte a zkopírujte tlačítkem Kopírovat.
""" Notebook C — NDIC DATEX II → Eventstream Custom Endpoint publisher DATEX II XML parsing + auth API + retry pattern. DOCS: https://api.dopravniinfo.cz/docs/openapi/ """ import json import time import uuid import xml.etree.ElementTree as ET from datetime import datetime, timezone import requests NDIC_URL = "https://api.dopravniinfo.cz/v1/datex2/measurement/recent" NDIC_API_KEY = "<your-ndic-api-key>" ES_URL = "<your-eventstream-custom-endpoint-url>" ES_KEY = "<your-eventstream-access-key>" POLL_INTERVAL_SEC = 60 BATCH_SIZE = 200 MAX_RETRIES = 3 RETRY_BACKOFF_SEC = 5 NS = {"d2": "http://datex2.eu/schema/2/2_0"} def fetch_ndic_measurement_xml() -> bytes: """Download recent measurement XML from NDIC.""" headers = { "Authorization": f"ApiKey {NDIC_API_KEY}", "Accept": "application/xml", } resp = requests.get(NDIC_URL, headers=headers, timeout=30) resp.raise_for_status() return resp.content def parse_datex2_measurements(xml_bytes: bytes) -> list[dict]: """ Parse DATEX II measurement XML → list of event dicts. Note: DATEX II je generic standard, exact element structure se může lišit podle endpoint verze. Tahle implementace je pragmatic best-effort + tolerantní k missing fields. """ root = ET.fromstring(xml_bytes) publisher_run_id = str(uuid.uuid4()) publisher_ts = datetime.now(timezone.utc).isoformat() events = [] # Iterate all measurementSiteRecord elements # Use .// to find them anywhere in tree for site in root.findall(".//d2:measurementSiteRecord", NS): try: site_id = site.get("id") or "" name_el = site.find(".//d2:measurementSiteName", NS) site_name = name_el.text if name_el is not None else "" lat_el = site.find(".//d2:latitude", NS) lon_el = site.find(".//d2:longitude", NS) lat = float(lat_el.text) if lat_el is not None else None lon = float(lon_el.text) if lon_el is not None else None mts_el = site.find(".//d2:measurementTimeDefault", NS) measurement_ts = mts_el.text if mts_el is not None else publisher_ts flow_el = site.find(".//d2:vehicleFlowRate", NS) speed_el = site.find(".//d2:averageVehicleSpeed", NS) flow = int(flow_el.text) if flow_el is not None else 0 speed = float(speed_el.text) if speed_el is not None else 0.0 event = { "site_id": site_id, "site_name": site_name, "lat": lat, "lon": lon, "measurement_ts": measurement_ts, "vehicle_flow_rate": flow, "avg_speed_kmh": speed, "publisher_run_id": publisher_run_id, "publisher_ts": publisher_ts, } events.append(event) except Exception as e: print(f"Skipping site {site.get('id')}: {e}") continue return events def publish_batch(events: list[dict]) -> dict: if not events: return {"sent": 0, "failed": 0} headers = { "Content-Type": "application/x-ndjson", "Authorization": f"Bearer {ES_KEY}", } sent = 0 failed = 0 for i in range(0, len(events), BATCH_SIZE): chunk = events[i:i + BATCH_SIZE] body = "\n".join(json.dumps(e) for e in chunk) for attempt in range(1, MAX_RETRIES + 1): try: r = requests.post(ES_URL, headers=headers, data=body, timeout=30) r.raise_for_status() sent += len(chunk) break except requests.HTTPError as e: if r.status_code in (429, 502, 503, 504) and attempt < MAX_RETRIES: wait = RETRY_BACKOFF_SEC * attempt print(f"Retry {attempt}/{MAX_RETRIES} po {wait}s") time.sleep(wait) continue failed += len(chunk) break except requests.RequestException: if attempt < MAX_RETRIES: time.sleep(RETRY_BACKOFF_SEC * attempt) continue failed += len(chunk) break return {"sent": sent, "failed": failed} def run_once() -> dict: t0 = time.time() print(f"[{datetime.now(timezone.utc).isoformat()}] Fetching NDIC...") xml = fetch_ndic_measurement_xml() events = parse_datex2_measurements(xml) t_fetch = time.time() - t0 print(f" Parsed {len(events)} events in {t_fetch:.1f}s") t1 = time.time() stats = publish_batch(events) t_pub = time.time() - t1 print(f" Published: sent={stats['sent']}, failed={stats['failed']} in {t_pub:.1f}s") return {**stats, "fetched": len(events), "fetch_sec": round(t_fetch, 2), "publish_sec": round(t_pub, 2)} def run_continuous(max_iterations: int = 0): iteration = 0 while True: iteration += 1 try: run_once() except Exception as e: print(f" ✗ iteration {iteration} failed: {e}") if max_iterations and iteration >= max_iterations: break time.sleep(POLL_INTERVAL_SEC) if __name__ == "__main__": print(run_once())