Runbook B: ČHMÚ HTTP polling
Cíle kapitoly
- Postavit E2E stream nad daty ČHMÚ: publisher → Custom Endpoint → KQL DB
- Nasadit update policy Bronze → Silver a materialized view pro poslední hodnoty
- Sestavit Real-Time Dashboard a Power BI composite model (DirectQuery + Direct Lake)
- Diagnostikovat, když eventy netečou nebo update policy nic neprodukuje
Úvod
Pattern: Custom Endpoint ← Notebook poller (Pure Python) · Auth: žádná· Risk: low Fabric capacity: F2 OK (running) / F4+ recommended (sustained)
Cíl
Full E2E real-time pipeline nad reálnými CZ open data — kvalita ovzduší ČHMÚ, refresh každých 5 min. Z reálné public HTTP CSV → Eventstream → KQL DB → Real-Time Dashboard → Power BI.
Pedagogická hodnota:
- Polling pattern (HTTP poll + push do Custom Endpoint) — neexistuje native HTTP source v Fabric
- CSV → JSON event transformation
- Bronze → Silver update policy v KQL
- Materialized view pro fast dashboard queries
- Direct continuity z Days 1-2 (stejná data, jiný engine)
Reuse mimo kurz:
- Klientské POCs s vlastními HTTP API (změna URL + schema = hotovo)
- Konferenční talk o batch vs stream nad stejným datasetem
Architektura
┌────────────────────────────────────────────────────┐
│ ČHMÚ Open Data │
│ https://opendata.chmi.cz/air_quality/recent/data/ │
│ airquality_1h_avg_CZ.csv (refresh každou hodinu) │
└─────────────────────┬──────────────────────────────┘
│
│ HTTP GET, every 5 min
▼
┌────────────────────────────────────────────────────┐
│ Fabric Notebook (Pure Python, scheduled run) │
│ nb_b_chmi_publisher.ipynb │
│ • download CSV │
│ • parse → list of dicts (one per measurement) │
│ • POST batch (NDJSON) na Custom Endpoint │
└─────────────────────┬──────────────────────────────┘
│
│ POST /events
▼
┌────────────────────────────────────────────────────┐
│ Eventstream es_b_chmi │
│ Source: Custom Endpoint │
│ Sink: KQL DB → table b_chmi_raw │
└─────────────────────┬──────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────┐
│ KQL DB kql_rti_demo │
│ ├── b_chmi_raw (bronze, raw events) │
│ ├── update policy raw → clean (joined dim_*) │
│ ├── b_chmi_clean (silver, typed, joined) │
│ └── mv_b_chmi_last_per_station (last reading) │
└─────────────────────┬──────────────────────────────┘
│
┌────────────┴─────────────┐
▼ ▼
┌────────────────────┐ ┌────────────────────┐
│ Real-Time Dashboard │ │ Power BI │
│ rtd_b_chmi_live │ │ rpt_b_chmi_realtime │
│ • PM10 line chart │ │ • DirectQuery KQL │
│ • Top 10 stations │ │ • Composite model │
│ • Map (lat/lon) │ │ + Lakehouse gold │
│ • Over-limit count │ │ │
└────────────────────┘ └────────────────────┘Story arc (jak to říct nahlas)
Krok 1: motivace
„Včera jsme ČHMÚ snapshot zpracovali batch způsobem — Notebook stáhne CSV jednou, transformuje do Lakehouse, pak Warehouse, pak PBI report. Funguje, ale když chci vědět teď hned jaké je PM10 v Hradci, refresh PBI semantického modelu trvá minuty.
Co kdybych chtěl dashboard, který se sám aktualizuje každých pár sekund — bez button refresh, bez 5min cache?
To je use case pro Real-Time Intelligence. Pojďme postavit přesně to, ale nad stejnými daty co včera. Uvidíte rozdíl."
Krok 2: postavíme infrastrukturu
- Eventhouse + KQL DB (5 klikatí)
- KQL setup script → tables + update policy + MV
- Eventstream → Custom Endpoint source
Krok 3: rozjedeme publisher
- Notebook
nb_b_chmi_publisher→ run cell - Watch Eventstream "Data preview" — events začnou téct
- KQL query:
b_chmi_raw | take 10 - KQL query:
b_chmi_clean | take 10(update policy už pracovala)
Krok 4: dashboard
- Open
rtd_b_chmi_live(pre-built) - Watch line chart updatovat se v reálném čase
- Filter: jen Hradec Králové → vidíš PM10 trend
- Map: barevně po krajích (z
dim_krajjoined v Silver)
Krok 5: PBI teaser
- Open
.pbixs DirectQuery na KQL DB - Klik refresh → query běží proti KQL (~ 200ms)
- Composite model: KQL = current state, Lakehouse = historical
- Story závěr: „Real-Time Dashboard pro ops týmy (sub-sekundový refresh, jednoduchá vizualizace), Power BI pro management (rich visuals, historie, RLS)."
Pre-requisites (jednou)
A. Workspace artefakty
- Workspace
DP600_Demo_RTIna min. F2 capacity - Eventhouse
eh_rti_demo - KQL DB
kql_rti_demo - Lakehouse
lh_rti_lookups(prodim_kraj,dim_pollutant,dim_stationz Days 1-2)
B. Lookups upload
Z Days 1-2 máš dim_kraj.csv, dim_pollutant.csv, dim_station.csv (ke stažení v runbooku Mini Medallion ČHMÚ).
Upload do lh_rti_lookups/Files/lookups/. Reusable napříč všemi runbooky.
C. Eventstream es_b_chmi
- New Eventstream
es_b_chmi - Add source → Custom App (nebo "Custom Endpoint" podle UI verze)
- Source name:
chmi_publisher - Po vytvoření: copy Ingestion URL + Access Key ze "Sample data" panel → ulož do KeyVault jako secret
es-key-b+es-url-b - Add destination → Eventhouse → KQL DB
kql_rti_demo→ tableb_chmi_raw - Wire source → destination → Publish
D. KQL setup
dim_pollutant a dim_station (.set-or-replace) a teprve ty joinuje. Po změně lookup CSV je nutné materializaci zopakovat. Přesné podmínky update policies (streaming ingestion, transakčnost) ověřte v dokumentaci.Spusť KQL setup skript (sekce níže) v kql_rti_demo. Vytvoří tabulky, update policy, MV.
E. Notebook publisher
Nahraj publisher (kompletní kód v sekci níže) jako Notebook do workspace. Schedule: every 5 minutes (volitelné — pro live demo stačí ad-hoc run).
Live demo execution
Pre-flight (před demem)
- Start F2 capacity (pokud start/stop mode)
- V Eventstream
es_b_chmizkontroluj že source + destination je healthy (zelená) - Spusť
nb_b_chmi_publisherad-hoc run pro warmup - KQL:
b_chmi_raw | count— měl by vrátit > 0 z předchozích runs (pre-cached)
Live run
Postupuj podle Story arc nahoře. Poller spusť ručně před min 8 (při „rozjedeme publisher" části).
Post-demo cleanup
- Pokud děláš jiné demo dál: nech Eventstream běžet, nech publisher schedule ON
- Pokud končíš: stop publisher schedule, pause Eventstream → ušetří CU
KQL queries cheat sheet (pro live demo)
Raw vs Clean comparison
b_chmi_raw | count b_chmi_clean | count // Clean by mělo být ~stejně, demo update policy fungování
Latest events
b_chmi_clean | top 20 by ingest_ts desc | project ingest_ts, measurement_ts, idRegistration, pollutant_code, value, eu_limit, over_limit
Per-hour aggregates (last 24h)
b_chmi_clean | where measurement_ts > ago(24h) | where pollutant_code == "PM10" | summarize avg_pm10 = avg(value) by bin(measurement_ts, 1h) | order by measurement_ts asc
Top stations exceeding EU limit
b_chmi_clean | where measurement_ts > ago(6h) | where over_limit == true | summarize n_breaches = count() by idRegistration, pollutant_code | top 10 by n_breaches desc
Time-series with anomaly detection
b_chmi_clean | where pollutant_code == "PM10" | make-series val = avg(value) on measurement_ts step 1h by idRegistration | extend (anom, score, _) = series_decompose_anomalies(val, 2.0) | mv-expand val to typeof(real), measurement_ts to typeof(datetime), anom to typeof(int), score to typeof(real) | where anom != 0 | project measurement_ts, idRegistration, val, anom, score
Last per station (z MV — fast)
mv_b_chmi_last_per_station | where pollutant_code == "PM10" | top 10 by value desc
Real-Time Dashboard layout
Dashboard rtd_b_chmi_live má 4 tiles:
| Position | Tile | KQL |
|---|---|---|
| Top-left | Total events (last 1h) number tile | b_chmi_clean | where ingest_ts > ago(1h) | count |
| Top-right | Active stations count | mv_b_chmi_last_per_station | where measurement_ts > ago(2h) | distinct idRegistration | count |
| Mid-left | PM10 line chart (24h) time chart | viz "Per-hour aggregates" |
| Mid-right | Top 10 stations over-limit table | viz "Top stations exceeding" |
| Bottom | Map (lat/lon z dim_station joined) | mv_b_chmi_last_per_station | join dim_station on idRegistration | project lat, lon, station_nazev, value, pollutant_code |
Auto-refresh: 30 sekund. Dashboard se staví ručně poprvé, pak jde exportovat jako JSON template.
Power BI integrace
Connection setup
- PBI Desktop → Get Data → Microsoft Fabric → KQL Database
- Cluster URL:
https://<your-eventhouse>.kusto.fabric.microsoft.com - Database:
kql_rti_demo - DirectQuery mode (NE Import — bys ztratil real-time)
Tabulky
b_chmi_clean(DirectQuery)mv_b_chmi_last_per_station(DirectQuery, fast)dim_station(Direct Lake z Lakehouse — composite model)dim_kraj(Direct Lake)dim_pollutant(Direct Lake)
Composite model trick
PBI semantic model = DirectQuery (KQL DB) + Direct Lake (Lakehouse) v jednom modelu.
- Current state z KQL (rychlé pro real-time visuals)
- Historical from Lakehouse gold (Days 1-2 medallion)
- DAX measures se umí ptát obou storage layers
PM10 Latest = SELECTEDVALUE(mv_b_chmi_last_per_station[value]) PM10 Avg 7d (Lakehouse) = AVERAGEX( FILTER(gold_fact_kvalita_ovzdusi, gold_fact_kvalita_ovzdusi[date] > TODAY() - 7), gold_fact_kvalita_ovzdusi[pm10] ) PM10 Trend = [PM10 Latest] - [PM10 Avg 7d (Lakehouse)]
Refresh
- DirectQuery part = real-time (každý visual interaction = nová KQL query)
- Direct Lake part = automatické „framing“ při detekci nové verze Delta tabulky
Troubleshooting
| Problem | Diagnose | Fix |
|---|---|---|
Notebook poller selhává requests.get |
ČHMÚ endpoint dostupnost | Test mimo Fabric: curl https://opendata.chmi.cz/air_quality/recent/data/airquality_1h_avg_CZ.csv |
| Events nepříchází do KQL | Eventstream "Data preview" prázdný | Zkontroluj Custom App URL/key v Notebook secrets. Test ručně: curl -X POST <URL> -H "Authorization: Bearer <KEY>" -d '{"test":1}' |
| Update policy nic neprodukuje | fn_b_chmi_transform() ručně |
Spusť funkci → pokud vrací data, problém v IsEnabled. Re-alter policy. |
| Dashboard auto-refresh laguje > 30s | KQL DB query latency | Check MV exists: .show materialized-views. MV by měla být fresh. |
| PBI DirectQuery throws timeout | Big query | Limit visual scope. Query musí běžet < 30s. |
| Notebook spark cluster timeout | nb_b_chmi_publisher po 60 min umře |
Nepoužívej PySpark — Pure Python notebook (Fabric supports). |
Metriky
| Metrika | Hodnota |
|---|---|
| ČHMÚ snapshot velikost | ~470 řádků, ~30 KB |
| Events publikováno per 5min run | ~470 |
| F2 capacity impact (sustained) | ~1-2 CU/s během publish, ~0.1 CU/s idle |
| End-to-end latency (Notebook → Dashboard visible) | 5-15 sec |
| KQL DB storage growth | ~5 MB/den |
Known limitations
- ČHMÚ refresh = 1h native. Náš poller každých 5 min = 12 polls/h, většinu času dostane stejná data. Pro „opravdový" sub-minutový stream by se musel use case přefactorovat (sensor → push → Eventstream přímo).
- CSV parsing v Notebooku — pokud ČHMÚ změní header order, parsing selže. Zvaž schema validation step.
- Mappings dim_station — známý gap z Days 1-2: ne všechny
idRegistrationmají match vdim_station. V Silver layer se objeví jakokraj_id = -1. To je fine pro demo.
KQL setup skript
Kompletní obsah souboru — rozbalte a zkopírujte tlačítkem Kopírovat.
// Runbook B — ČHMÚ Polling · KQL setup // Idempotentní, lze spustit opakovaně. // Předpoklad: // - KQL DB `kql_rti_demo` v Eventhouse `eh_rti_demo` // - Lakehouse `lh_rti_lookups` s dim_kraj.csv, dim_pollutant.csv, dim_station.csv (Files/lookups/) // - Eventstream `es_b_chmi` napojený na destination = `b_chmi_raw` // ────────────────────────────────────────────────────────────────── // 1. Bronze table — Eventstream destination // ────────────────────────────────────────────────────────────────── .create-merge table b_chmi_raw ( EventEnqueuedUtcTime: datetime, // auto-added by Eventstream idRegistration: int, startTime: datetime, idValueType: string, value: real ) .alter table b_chmi_raw policy retention @'{"SoftDeletePeriod": "30.00:00:00", "Recoverability": "Disabled"}' // ────────────────────────────────────────────────────────────────── // 2. Silver table — typed, joined, business model // ────────────────────────────────────────────────────────────────── .create-merge table b_chmi_clean ( ingest_ts: datetime, measurement_ts: datetime, idRegistration: int, pollutant_code: string, pollutant_name: string, value: real, unit: string, eu_limit: real, over_limit: bool, station_nazev: string, kraj_kod: string, kraj_nazev: string, lat: real, lon: real ) .alter table b_chmi_clean policy retention @'{"SoftDeletePeriod": "180.00:00:00", "Recoverability": "Enabled"}' // ────────────────────────────────────────────────────────────────── // 3. Lookup external tables (point at Lakehouse Files/lookups/) // ────────────────────────────────────────────────────────────────── // Replace <abfss URL> with actual OneLake path. Get URL via: // Lakehouse → right-click file → Properties → ABFSS path .create-or-alter external table dim_pollutant_ext ( pollutant_code: string, pollutant_name: string, unit: string, eu_limit: real ) kind = storage dataformat = csv ( 'abfss://<workspace-id>@onelake.dfs.fabric.microsoft.com/lh_rti_lookups.Lakehouse/Files/lookups/dim_pollutant.csv' ) with (includeHeaders = 'all') .create-or-alter external table dim_station_ext ( loc_code: string, id_registration: int, station_nazev: string, kraj_kod: string, kraj_nazev: string, lat: real, lon: real ) kind = storage dataformat = csv ( 'abfss://<workspace-id>@onelake.dfs.fabric.microsoft.com/lh_rti_lookups.Lakehouse/Files/lookups/dim_station.csv' ) with (includeHeaders = 'all') // ────────────────────────────────────────────────────────────────── // 3b. Materializace lookupu do beznych tabulek — update policy nesmi cist external tables // ──────────────────────────────────────────────────────────── .set-or-replace dim_pollutant <| external_table('dim_pollutant_ext') .set-or-replace dim_station <| external_table('dim_station_ext') // Pokud .alter policy update nize odmitne join kvuli streaming ingestion na b_chmi_raw: // .alter table b_chmi_raw policy streamingingestion disable // ──────────────────────────────────────────────────────────── // 4. Update policy function (transforms raw → clean) // ────────────────────────────────────────────────────────────────── .create-or-alter function fn_b_chmi_transform() { b_chmi_raw | extend ingest_ts = EventEnqueuedUtcTime | extend measurement_ts = startTime | join kind=leftouter dim_pollutant on $left.idValueType == $right.pollutant_code | join kind=leftouter dim_station on $left.idRegistration == $right.id_registration | extend over_limit = iif(isnotnull(eu_limit), value > eu_limit, false) | extend station_nazev = iif(isempty(station_nazev), strcat("Unmapped (id=", tostring(idRegistration), ")"), station_nazev) | extend kraj_kod = iif(isempty(kraj_kod), "UNK", kraj_kod) | extend kraj_nazev = iif(isempty(kraj_nazev), "Neznámý", kraj_nazev) | project ingest_ts, measurement_ts, idRegistration, pollutant_code = idValueType, pollutant_name = coalesce(pollutant_name, idValueType), value, unit = coalesce(unit, ""), eu_limit = coalesce(eu_limit, real(null)), over_limit, station_nazev, kraj_kod, kraj_nazev, lat, lon } // ────────────────────────────────────────────────────────────────── // 5. Wire update policy // ────────────────────────────────────────────────────────────────── .alter table b_chmi_clean policy update @'[{"IsEnabled": true, "Source": "b_chmi_raw", "Query": "fn_b_chmi_transform()", "IsTransactional": true, "PropagateIngestionProperties": true}]' // ────────────────────────────────────────────────────────────────── // 6. Materialized view — last reading per station + pollutant // ────────────────────────────────────────────────────────────────── .create-or-alter materialized-view with (backfill=true, lookback=1d) mv_b_chmi_last_per_station on table b_chmi_clean { b_chmi_clean | summarize arg_max(measurement_ts, *) by idRegistration, pollutant_code } // ────────────────────────────────────────────────────────────────── // 7. Helper function — kraj summary // ────────────────────────────────────────────────────────────────── .create-or-alter function fn_b_kraj_summary(window:timespan = 1h) { b_chmi_clean | where measurement_ts > ago(window) | summarize avg_value = avg(value), max_value = max(value), stations = dcount(idRegistration) by kraj_kod, kraj_nazev, pollutant_code } // ────────────────────────────────────────────────────────────────── // 8. Sanity / smoke test queries (run after publisher beží) // ────────────────────────────────────────────────────────────────── // Counts // b_chmi_raw | count // b_chmi_clean | count // mv_b_chmi_last_per_station | count // Latest 10 // b_chmi_clean | top 10 by ingest_ts desc // Per-hour PM10 trend // b_chmi_clean // | where measurement_ts > ago(24h) and pollutant_code == "PM10" // | summarize avg_pm10 = avg(value) by bin(measurement_ts, 1h) // | order by measurement_ts asc // Stations over EU limit (last 6h) // b_chmi_clean // | where measurement_ts > ago(6h) and over_limit // | summarize n = count() by station_nazev, pollutant_code // | top 10 by n desc // Per-kraj summary // fn_b_kraj_summary(6h) | order by avg_value desc
Kompletní kód publisheru
Kompletní obsah souboru — rozbalte a zkopírujte tlačítkem Kopírovat.
""" Notebook B — ČHMÚ recent CSV → Eventstream Custom Endpoint publisher Run mode: Fabric Pure Python notebook (NE PySpark — Pure Python má lepší lifecycle pro continuous runs). Schedule: every 5 minutes (Fabric Data Pipeline → Notebook activity). Ad-hoc: run cells manually for live demo. Cell 1: imports + config Cell 2: fetch + parse Cell 3: publish batch Cell 4: orchestration loop (volitelné, pro continuous run) """ import csv import io import json import time import uuid from datetime import datetime, timezone import requests CHMI_URL = "https://opendata.chmi.cz/air_quality/recent/data/airquality_1h_avg_CZ.csv" ES_URL = "https://<region>.eventstream.fabric.microsoft.com/<endpoint-id>/events" ES_KEY = "<your-eventstream-access-key>" POLL_INTERVAL_SEC = 300 # 5 min BATCH_SIZE = 200 # events per HTTP POST MAX_RETRIES = 3 RETRY_BACKOFF_SEC = 5 def fetch_chmi_snapshot() -> list[dict]: """ Stáhne current CHMU snapshot, parse CSV, vrátí list of event dicts. Schema (per row): idRegistration: int startTime: datetime ISO 8601 string idValueType: string (např. PM10, NO2, O3, ...) value: float """ resp = requests.get(CHMI_URL, timeout=30) resp.raise_for_status() # CHMU CSV má leading whitespace v cells → skipinitialspace reader = csv.DictReader( io.StringIO(resp.text), skipinitialspace=True ) events = [] for row in reader: try: event = { "idRegistration": int(row["idRegistration"]), "startTime": row["startTime"].strip(), # už je v ISO 8601 z CHMU "idValueType": row["idValueType"].strip(), "value": float(row["value"]), # client-side enrichment (volitelné — můžeme i v KQL update policy) "publisher_run_id": str(uuid.uuid4()), "publisher_ts": datetime.now(timezone.utc).isoformat(), } events.append(event) except (KeyError, ValueError) as e: # log a skip — corrupted row print(f"Skipping row: {e} | row={row}") continue return events def publish_batch(events: list[dict]) -> dict: """ POST batch as NDJSON (newline-delimited JSON). Eventstream Custom Endpoint accepts both JSON array and NDJSON. NDJSON je preferovaný pro batch (single HTTP roundtrip per batch). Returns: dict with stats {sent, failed}. """ if not events: return {"sent": 0, "failed": 0} headers = { "Content-Type": "application/x-ndjson", "Authorization": f"Bearer {ES_KEY}", } sent = 0 failed = 0 # Chunk do BATCH_SIZE 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 (HTTP {r.status_code})") time.sleep(wait) continue print(f"FAIL chunk po {attempt} retries: {e}") failed += len(chunk) break except requests.RequestException as e: if attempt < MAX_RETRIES: wait = RETRY_BACKOFF_SEC * attempt print(f"Network retry {attempt}/{MAX_RETRIES} po {wait}s: {e}") time.sleep(wait) continue print(f"FAIL chunk po {attempt} retries: {e}") failed += len(chunk) break return {"sent": sent, "failed": failed} def run_once() -> dict: """Single fetch + publish cycle.""" t0 = time.time() print(f"[{datetime.now(timezone.utc).isoformat()}] Fetching CHMU snapshot...") events = fetch_chmi_snapshot() t_fetch = time.time() - t0 print(f" Got {len(events)} events in {t_fetch:.1f}s") t1 = time.time() stats = publish_batch(events) t_publish = time.time() - t1 print(f" Published: sent={stats['sent']}, failed={stats['failed']} in {t_publish:.1f}s") return { "fetched": len(events), "sent": stats["sent"], "failed": stats["failed"], "fetch_sec": round(t_fetch, 2), "publish_sec": round(t_publish, 2), } def run_continuous(max_iterations: int = 0): """ Continuous poll loop. max_iterations = 0 → běží navždy (manuálně stop) max_iterations > 0 → po N cyklech skončí (testing) """ iteration = 0 while True: iteration += 1 try: stats = run_once() print(f" → iteration {iteration} done, sleeping {POLL_INTERVAL_SEC}s") 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__": # Default: single run print(run_once())