Kapitoly kurzu
1. Obsahová mapa kurzu 2. Notebooky: PySpark vs Pure Python 3. Demo: Tři cesty a Mini Medallion 4. CU Costs a Capacity Metrics 5. SQL trojúhelník 6. Real-Time Intelligence: demo arc 7. Semantic modely, DAX a optimalizace 8. Security a administrace Fabricu 9. Zkouška DP-600: tipy
Runbooky — medallion
10. Mini Medallion ČHMÚ 11. Mini Medallion NYC Taxi 12. NYC Taxi třemi cestami 13. Mini Medallion OpenAQ 14. Big Medallion OpenAQ (2 roky)
Runbooky — RTI
15. RTI Library: architektura 16. A: Bicycles 60s 17. B: ČHMÚ HTTP polling 18. C: NDIC dopravní detektory 19. D: Camera ALPR simulátor 20. E: PID GTFS-RT 21. F: Activator alerty 22. KQL cheat sheet
🏠 Rozcestník kurzu
DP-600 · Runbook

Runbook C: NDIC dopravní detektory

Auth API a DATEX II XML: poller s API klíčem a retry, detekce zpomalení v KQL
📡 Runbook · Real-Time Intelligence

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
18.1

Úvod

Pattern: Custom Endpoint ← Notebook poller s API auth · Auth: API key· Risk: medium Fabric capacity: F2 OK

18.2

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"
18.3

Architektura

SCHÉMA
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  │
└──────────────────────────────────┘
18.4

Pre-requisites

A. NDIC API klíč (jednou)

  1. Registrace na https://www.dopravniinfo.cz/registrace
  2. Email confirmation
  3. V profilu: API přístup → Generate key (free tier dostačí)
  4. Read API docs — endpoint URLs se mění mezi v1/v2 verzemi:
  5. Ulož API key do KeyVault jako ndic-api-key

B. Endpoint výběr

⚠️
Pozor: URL endpointů a tvar DATEX II v tomto runbooku jsou ilustrativní — NDIC svá data publikuje přes portál JSDI a přístup, verze API i schéma se mění. Před demem ověřte aktuální dokumentaci na portal.dopravniinfo.cz a upravte 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.

18.5

Schema (DATEX II measurement)

DATEX II XML je verbose, příklad zjednodušeně:

XML
<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:

JSON
{
  "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"
}
18.6

Story arc (samostatně)

  1. Auth pattern — proč rate limit, proč retry. NDIC docs ukázat.
  2. Notebook walkthrough — DATEX II XML parsing, batch publish.
  3. KQL queries — top 10 nejvytíženějších úseků, průměrná rychlost.
  4. "Co dál" — Activator alert na slow speed (incident detection).
18.7

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 desc
  • c_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
18.8

KQL queries cheat sheet

Top 10 nejvytíženějších úseků (last hour)

KQL
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)

KQL
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

KQL
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
18.9

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
18.10

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 recent kaž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.
18.11

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ě.
18.12

KQL setup skript

Kompletní obsah souboru — rozbalte a zkopírujte tlačítkem Kopírovat.

Zobrazit setup.kql▼
KQL
// 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
}
18.13

Kompletní kód publisheru

Kompletní obsah souboru — rozbalte a zkopírujte tlačítkem Kopírovat.

Zobrazit nb_c_ndic_publisher.py▼
PYTHON
"""
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())