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 B: ČHMÚ HTTP polling

E2E stream nad daty ČHMÚ: Notebook poller → Custom Endpoint → KQL DB → dashboard → Power BI
📡 Runbook · Real-Time Intelligence

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
17.1

Úvod

Pattern: Custom Endpoint ← Notebook poller (Pure Python) · Auth: žádná· Risk: low Fabric capacity: F2 OK (running) / F4+ recommended (sustained)

17.2

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
17.3

Architektura

SCHÉMA
┌────────────────────────────────────────────────────┐
│ Č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 │    │                     │
└────────────────────┘    └────────────────────┘
17.4

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

  1. Eventhouse + KQL DB (5 klikatí)
  2. KQL setup script → tables + update policy + MV
  3. Eventstream → Custom Endpoint source

Krok 3: rozjedeme publisher

  1. Notebook nb_b_chmi_publisher → run cell
  2. Watch Eventstream "Data preview" — events začnou téct
  3. KQL query: b_chmi_raw | take 10
  4. KQL query: b_chmi_clean | take 10 (update policy už pracovala)

Krok 4: dashboard

  1. Open rtd_b_chmi_live (pre-built)
  2. Watch line chart updatovat se v reálném čase
  3. Filter: jen Hradec Králové → vidíš PM10 trend
  4. Map: barevně po krajích (z dim_kraj joined v Silver)

Krok 5: PBI teaser

  1. Open .pbix s DirectQuery na KQL DB
  2. Klik refresh → query běží proti KQL (~ 200ms)
  3. Composite model: KQL = current state, Lakehouse = historical
  4. Story závěr: „Real-Time Dashboard pro ops týmy (sub-sekundový refresh, jednoduchá vizualizace), Power BI pro management (rich visuals, historie, RLS)."
17.5

Pre-requisites (jednou)

A. Workspace artefakty

  • Workspace DP600_Demo_RTI na min. F2 capacity
  • Eventhouse eh_rti_demo
  • KQL DB kql_rti_demo
  • Lakehouse lh_rti_lookups (pro dim_kraj, dim_pollutant, dim_station z 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

  1. New Eventstream es_b_chmi
  2. Add source → Custom App (nebo "Custom Endpoint" podle UI verze)
  3. Source name: chmi_publisher
  4. Po vytvoření: copy Ingestion URL + Access Key ze "Sample data" panel → ulož do KeyVault jako secret es-key-b + es-url-b
  5. Add destination → Eventhouse → KQL DB kql_rti_demo → table b_chmi_raw
  6. Wire source → destination → Publish

D. KQL setup

⚠️
Pozor: Update policy nemůže číst external tables — skript proto lookupy nejdřív materializuje do běžných tabulek 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).

17.6

Live demo execution

Pre-flight (před demem)

  1. Start F2 capacity (pokud start/stop mode)
  2. V Eventstream es_b_chmi zkontroluj že source + destination je healthy (zelená)
  3. Spusť nb_b_chmi_publisher ad-hoc run pro warmup
  4. 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
17.7

KQL queries cheat sheet (pro live demo)

Raw vs Clean comparison

KQL
b_chmi_raw | count
b_chmi_clean | count
// Clean by mělo být ~stejně, demo update policy fungování

Latest events

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

KQL
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

KQL
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

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

KQL
mv_b_chmi_last_per_station
| where pollutant_code == "PM10"
| top 10 by value desc
17.8

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.

17.9

Power BI integrace

Connection setup

  1. PBI Desktop → Get Data → Microsoft Fabric → KQL Database
  2. Cluster URL: https://<your-eventhouse>.kusto.fabric.microsoft.com
  3. Database: kql_rti_demo
  4. 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
DAX
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
17.10

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).
17.11

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
17.12

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 idRegistration mají match v dim_station. V Silver layer se objeví jako kraj_id = -1. To je fine pro demo.
17.13

KQL setup skript

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

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

Kompletní kód publisheru

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

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