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 E: PID GTFS-RT (Protobuf adapter)

Polohy vozidel PID přes Golemio API: Protobuf decode v Notebooku jako adapter pattern
📡 Runbook · Real-Time Intelligence

Cíle kapitoly

  • Dekódovat GTFS-RT Protobuf v Notebooku pomocí gtfs-realtime-bindings
  • Publikovat polohy vozidel PID do Eventstreamu přes Custom Endpoint
  • Dotazovat aktivní vozidla, linky a bounding box v KQL
  • Vysvětlit adapter pattern pro formáty, které Fabric nativně neumí
20.1

Úvod

Pattern: Custom Endpoint ← Notebook adapter (Protobuf decoder) · Auth: API key· Risk: medium Fabric capacity: F4+ recommended (Protobuf decode není trivial na F2)

20.2

Cíl

Real-time stream poloh všech vozidel Pražské integrované dopravy (autobusy, tramvaje, metro, vlaky) — refresh ~20 sekund, ~3000 vozidel současně.

Pedagogická hodnota:

  • Adapter pattern — když Fabric out-of-the-box nestačí, postavíš tenký adapter mezi Notebookem
  • Protobuf decode — GTFS-RT je binární Protobuf format, ne JSON
  • Sub-minute streaming — z runbooků v knihovně tohle má nejnižší latency (~30s end-to-end)
  • Real Czech infrastructure — Praha + Středočeský kraj integrovaná doprava

Reuse mimo kurz:

  • Konferenční talk / blog post — flagship content. „Co Fabric out-of-the-box neumí (Protobuf), ale 80 řádků Pythonu vyřeší."
  • Klientské scénáře s legacy binary protokoly — Modbus, ASN.1, protobuf-based RPC
  • Showcase adapter pattern napříč klientskými POCs
20.3

Architektura

SCHÉMA
Pražská integrovaná doprava — Golemio API
https://api.golemio.cz/v2/vehiclepositions/gtfsrt
   │ auth: X-Access-Token: <KEY>
   │ HTTP GET, Content-Type: application/x-protobuf
   │ refresh ~20s
   ▼
┌────────────────────────────────────────────────┐
│ Fabric Notebook (Pure Python)                   │
│ nb_e_pid_adapter                                │
│   • requests.get → bytes                        │
│   • gtfs-realtime-bindings → FeedMessage proto  │
│   • iterate entities → flatten to dicts         │
│   • POST batch NDJSON → ES Custom Endpoint      │
└──────────┬─────────────────────────────────────┘
           ▼
┌────────────────────────────────────────────────┐
│ Eventstream  es_e_pid                           │
│   Custom Endpoint → KQL DB e_pid_raw            │
└──────────┬─────────────────────────────────────┘
           ▼
┌────────────────────────────────────────────────┐
│ KQL DB                                          │
│   e_pid_raw (bronze)                            │
│   e_pid_clean (silver, with route classification)│
│   mv_e_pid_active_vehicles                      │
│   mv_e_pid_route_summary                        │
└────────────────────────────────────────────────┘
20.4

Pre-requisites

A. Golemio API klíč (jednou)

  1. Registrace na https://api.golemio.cz/api-keys
  2. Email confirmation
  3. Generate token — free tier 60 req/min (dostačí pro 20s polling = 3 req/min)
  4. Ulož do KeyVault jako golemio-api-key

B. GTFS-RT Python bindings

Fabric Notebook environment musí mít balíček gtfs-realtime-bindings:

PYTHON
%pip install gtfs-realtime-bindings protobuf

(První cell Notebooku — instaluje per-session.)

C. Eventstream es_e_pid

Custom App source → KQL DB destination = e_pid_raw.

D. KQL setup

Spusť KQL setup skript (sekce níže).

20.5

Schema (decoded GTFS-RT entity)

JSON
{
  "vehicle_id": "PID_BUS_2305",
  "trip_id": "990_1234_250507",
  "route_id": "L240",
  "route_short_name": "240",
  "lat": 50.0823,
  "lon": 14.4234,
  "bearing": 87.5,
  "speed_mps": 12.3,
  "timestamp": "2026-05-07T10:30:15Z",
  "vehicle_label": "240",
  "occupancy_status": "FEW_SEATS_AVAILABLE",
  "current_stop_sequence": 12,
  "publisher_run_id": "uuid"
}

occupancy_status enum: EMPTY | MANY_SEATS_AVAILABLE | FEW_SEATS_AVAILABLE | STANDING_ROOM_ONLY | CRUSHED_STANDING_ROOM_ONLY | FULL | NOT_ACCEPTING_PASSENGERS

20.6

Story arc (samostatně, flagship demo)

Krok 1: motivace

„GTFS-RT je open standard pro real-time public transport data, používá ho Praha, Brno, většina světových měst. Ale data nejsou JSON — jsou Protocol Buffers, binary format.

Fabric Eventstream Custom Endpoint očekává JSON. Co dělat?

Nejednoduší řešení: Notebook jako adapter. Bere Protobuf, vrací JSON, posílá do Eventstreamu. To je v podstatě ekvivalent Azure Function, jen v Fabricu."

Krok 2: Notebook walkthrough

  • Cell 1: install gtfs-realtime-bindings
  • Cell 2: fetch + decode (live ukázat raw bytes vs decoded dict)
  • Cell 3: publish batch
  • Run cell 4 (orchestrate) — events začnou téct

Krok 3: KQL queries (live)

  • e_pid_clean | take 10 — co je v Praze právě teď
  • mv_e_pid_active_vehicles | count — kolik vozidel současně
  • Top 5 nejvíce vytížených linek
  • Speed distribution (které linky jsou nejrychlejší)

Krok 4: dashboard

  • rtd_e_pid_live — mapa Prahy s živými polohami
  • Filter na linku 22 (tramvaj okolo centra)
  • Zoom in: vidíš každou tramvaj přesně kde je

Krok 5: Q&A + retrospective

„To samé byste postavili i pro Brno, Ostravu (mají také GTFS-RT), Berlín (BVG), New York (MTA). Pattern je univerzální. Adapter Notebook je 80 řádků Pythonu."

20.7

KQL queries cheat sheet

Aktuálně aktivní vozidla

KQL
mv_e_pid_active_vehicles
| count

Per-route summary (last 5min)

KQL
e_pid_clean
| where timestamp > ago(5m)
| summarize n_vehicles = dcount(vehicle_id),
            avg_speed_kmh = avg(speed_mps * 3.6)
            by route_id, route_short_name, vehicle_type
| top 20 by n_vehicles desc

Tramvaj 22 — live

KQL
e_pid_clean
| where route_short_name == "22"
| where timestamp > ago(1m)
| project vehicle_id, lat, lon, speed_mps, current_stop_sequence

Occupancy distribution

KQL
e_pid_clean
| where timestamp > ago(15m)
| summarize n = count() by occupancy_status, vehicle_type
| order by n desc

Average travel speed per vehicle type

KQL
e_pid_clean
| where timestamp > ago(1h)
| where speed_mps > 0
| summarize avg_kmh = avg(speed_mps * 3.6) by vehicle_type

Geo-spatial: vehicles in Praha center bounding box

KQL
let LAT_MIN = 50.080;
let LAT_MAX = 50.095;
let LON_MIN = 14.410;
let LON_MAX = 14.450;
mv_e_pid_active_vehicles
| where lat between (LAT_MIN .. LAT_MAX) and lon between (LON_MIN .. LON_MAX)
| project vehicle_id, route_short_name, vehicle_type, lat, lon

Vehicle trajectory (last 30 min)

KQL
e_pid_clean
| where vehicle_id == "PID_TRAM_9051"
| where timestamp > ago(30m)
| project timestamp, lat, lon, speed_mps
| order by timestamp asc
20.8

Real-Time Dashboard layout

Tile Content
Active vehicles number mv_e_pid_active_vehicles | count
By type piechart mv_e_pid_active_vehicles | summarize count() by vehicle_type
Top 10 routes table viz "Per-route summary"
Map (all vehicles) mv_e_pid_active_vehicles z lat/lon
Avg speed timechart e_pid_clean | where timestamp > ago(2h) | summarize avg(speed_mps * 3.6) by bin(timestamp, 5m), vehicle_type
Occupancy heatmap viz "Occupancy distribution"
20.9

Metriky

Metrika Hodnota
Throughput ~3000 events / 20s = 150 events/s
Protobuf decode time ~50ms per snapshot
End-to-end latency 25-40s (mostly polling interval + Protobuf decode)
F2 capacity impact ~5-7 CU/s (na hraně)
F4+ recommended yes — adapter loop má 2-3 min cold start na F2
20.10

Why this is interesting (selling point pro klienty)

GTFS-RT je „skutečný" real-time stream — ne hodinový poll, ne syntetic. Praha, Brno, ČD a většina ČR má feed kompatibilní.

Z architektonického pohledu:

  • Demonstruje že Fabric NENÍ uzavřený svět. Vlastní adapter pattern v Notebooku otevírá libovolnou third-party data source.
  • Pattern je opakovatelný: Modbus/OPC UA z továrny → adapter → Eventstream. AMQP z legacy ESB → adapter → Eventstream. ASN.1 z telco → adapter → Eventstream.
  • Klient si může adapter spravovat sám, bez závislosti na Microsoft connectoru roadmap.
20.11

Troubleshooting

Problem Fix
pip install gtfs-realtime-bindings selhává Fabric Notebook env restrictions — try %pip install --quiet --user gtfs-realtime-bindings
DecodeError: Error parsing message Endpoint vrací HTML error page místo Protobuf. Check API key + URL.
Throughput < 100 events/s Adapter běží too slow. Switch from sync requests na httpx.AsyncClient (parallel batches)
Notebook timeout po 60 min Spark cluster default. Use Pure Python notebook → vlastní lifecycle.
Some vehicles missing route_id GTFS-RT TripDescriptor občas null pro neaktivní trips. Filter | where isnotempty(route_id).
API rate 60/min insufficient Polling 1× za 20s = 3/min. Pohoda. Pro více Eventstreamů paralelně buy higher tier.
20.12

KQL setup skript

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

Zobrazit setup.kql▼
KQL
// Runbook E — PID GTFS-RT · KQL setup
// Idempotentní.

// ──────────────────────────────────────────────────────────────────
// 1. Bronze
// ──────────────────────────────────────────────────────────────────

.create-merge table e_pid_raw (
    EventEnqueuedUtcTime: datetime,
    vehicle_id: string,
    trip_id: string,
    route_id: string,
    route_short_name: string,
    lat: real,
    lon: real,
    bearing: real,
    speed_mps: real,
    timestamp: datetime,
    vehicle_label: string,
    occupancy_status: string,
    current_stop_sequence: int,
    publisher_run_id: string
)

.alter table e_pid_raw policy retention
    @'{"SoftDeletePeriod": "30.00:00:00", "Recoverability": "Disabled"}'

// ──────────────────────────────────────────────────────────────────
// 2. Silver
// ──────────────────────────────────────────────────────────────────

.create-merge table e_pid_clean (
    ingest_ts: datetime,
    timestamp: datetime,
    vehicle_id: string,
    trip_id: string,
    route_id: string,
    route_short_name: string,
    vehicle_type: string,        // tram | bus | metro | train | trolley | other
    lat: real,
    lon: real,
    bearing: real,
    speed_mps: real,
    speed_kmh: real,
    occupancy_status: string,
    occupancy_pct_estimate: int,
    current_stop_sequence: int,
    is_moving: bool
)

.alter table e_pid_clean policy retention
    @'{"SoftDeletePeriod": "180.00:00:00", "Recoverability": "Enabled"}'

// ──────────────────────────────────────────────────────────────────
// 3. Update policy
// ──────────────────────────────────────────────────────────────────

.create-or-alter function fn_e_pid_transform() {
    e_pid_raw
    | extend ingest_ts = EventEnqueuedUtcTime
    | extend speed_kmh = round(speed_mps * 3.6, 1)
    | extend is_moving = (speed_mps > 0.5)
    | extend vehicle_type = case(
        vehicle_id contains "TRAM" or route_id startswith "T", "tram",
        vehicle_id contains "BUS" or route_id startswith "L" or route_id startswith "B", "bus",
        vehicle_id contains "METRO" or route_id startswith "M", "metro",
        vehicle_id contains "TRAIN" or route_id startswith "S", "train",
        vehicle_id contains "TROLLEY", "trolley",
        "other"
      )
    | extend occupancy_pct_estimate = case(
        occupancy_status == "EMPTY", 0,
        occupancy_status == "MANY_SEATS_AVAILABLE", 25,
        occupancy_status == "FEW_SEATS_AVAILABLE", 50,
        occupancy_status == "STANDING_ROOM_ONLY", 75,
        occupancy_status == "CRUSHED_STANDING_ROOM_ONLY", 95,
        occupancy_status == "FULL", 100,
        -1
      )
    | project ingest_ts, timestamp, vehicle_id, trip_id, route_id, route_short_name,
              vehicle_type, lat, lon, bearing, speed_mps, speed_kmh,
              occupancy_status, occupancy_pct_estimate,
              current_stop_sequence, is_moving
}

.alter table e_pid_clean policy update
@'[{"IsEnabled": true, "Source": "e_pid_raw", "Query": "fn_e_pid_transform()", "IsTransactional": true, "PropagateIngestionProperties": true}]'

// ──────────────────────────────────────────────────────────────────
// 4. MV — active vehicles (last position per vehicle, < 5min old)
// ──────────────────────────────────────────────────────────────────

.create-or-alter materialized-view with (backfill=true, lookback=10m)
    mv_e_pid_active_vehicles on table e_pid_clean {
        e_pid_clean
        | summarize arg_max(timestamp, *) by vehicle_id
    }

// ──────────────────────────────────────────────────────────────────
// 5. MV — route summary (5min rolling)
// ──────────────────────────────────────────────────────────────────

.create-or-alter materialized-view with (backfill=true, lookback=2h)
    mv_e_pid_route_summary on table e_pid_clean {
        e_pid_clean
        | summarize n_vehicles = dcount(vehicle_id),
                    avg_speed_kmh = avg(speed_kmh),
                    avg_occupancy = avg(occupancy_pct_estimate)
                    by bin(timestamp, 5m), route_id, route_short_name, vehicle_type
        | extend window_start = timestamp
        | project-away timestamp
    }

// ──────────────────────────────────────────────────────────────────
// 6. Helper: vehicles in geographic bbox
// ──────────────────────────────────────────────────────────────────

.create-or-alter function fn_e_pid_in_bbox(
    lat_min:real, lat_max:real, lon_min:real, lon_max:real,
    age:timespan = 2m
) {
    mv_e_pid_active_vehicles
    | where timestamp > ago(age)
    | where lat between (lat_min .. lat_max) and lon between (lon_min .. lon_max)
    | project vehicle_id, route_short_name, vehicle_type, lat, lon, speed_kmh, occupancy_status
}

// Praha centrum:
//   fn_e_pid_in_bbox(50.080, 50.095, 14.410, 14.450, 2m)
20.13

Kompletní kód adapteru

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

Zobrazit nb_e_pid_adapter.py▼
PYTHON
"""
Notebook E — PID GTFS-RT Protobuf Adapter

Decoder pro Pražská integrovaná doprava real-time vehicle positions.
GTFS-RT je binary Protocol Buffers format — Fabric Eventstream out-of-the-box neumí.
Tenhle Notebook je adapter: poll → decode → publish JSON.

DOCS:
  https://api.golemio.cz/v2/pid/docs/openapi/
  https://gtfs.org/realtime/reference/
"""

import json
import time
import uuid
from datetime import datetime, timezone

import requests
from google.transit import gtfs_realtime_pb2

PID_URL = "https://api.golemio.cz/v2/vehiclepositions/gtfsrt"
GOLEMIO_KEY = "<your-golemio-api-key>"

ES_URL = "<your-eventstream-custom-endpoint-url>"
ES_KEY = "<your-eventstream-access-key>"

POLL_INTERVAL_SEC = 20         # GTFS-RT refreshuje ~20s na zdroji
BATCH_SIZE = 500
MAX_RETRIES = 3
RETRY_BACKOFF_SEC = 3

def fetch_gtfs_rt() -> bytes:
    """Download GTFS-RT FeedMessage protobuf bytes."""
    headers = {
        "X-Access-Token": GOLEMIO_KEY,
        "Accept": "application/x-protobuf",
    }
    resp = requests.get(PID_URL, headers=headers, timeout=15)
    resp.raise_for_status()
    return resp.content

def decode_feed(pb_bytes: bytes) -> tuple[gtfs_realtime_pb2.FeedMessage, datetime]:
    """Decode protobuf bytes → FeedMessage + feed timestamp."""
    feed = gtfs_realtime_pb2.FeedMessage()
    feed.ParseFromString(pb_bytes)
    feed_ts = datetime.fromtimestamp(feed.header.timestamp, tz=timezone.utc) \
              if feed.header.timestamp else datetime.now(timezone.utc)
    return feed, feed_ts

_OCCUPANCY_NAMES = {
    0: "EMPTY",
    1: "MANY_SEATS_AVAILABLE",
    2: "FEW_SEATS_AVAILABLE",
    3: "STANDING_ROOM_ONLY",
    4: "CRUSHED_STANDING_ROOM_ONLY",
    5: "FULL",
    6: "NOT_ACCEPTING_PASSENGERS",
}

def feed_to_events(feed: gtfs_realtime_pb2.FeedMessage,
                   feed_ts: datetime,
                   run_id: str) -> list[dict]:
    """Iterate FeedMessage entities → list of vehicle event dicts."""
    events = []

    for entity in feed.entity:
        if not entity.HasField("vehicle"):
            continue

        v = entity.vehicle
        pos = v.position if v.HasField("position") else None
        trip = v.trip if v.HasField("trip") else None
        veh = v.vehicle if v.HasField("vehicle") else None

        if not pos:
            continue

        ts_unix = v.timestamp if v.timestamp else feed_ts.timestamp()
        ts_iso = datetime.fromtimestamp(ts_unix, tz=timezone.utc).isoformat()

        event = {
            "vehicle_id": veh.id if veh else entity.id,
            "trip_id": trip.trip_id if trip else "",
            "route_id": trip.route_id if trip else "",
            "route_short_name": "",  # GTFS-RT pure neobsahuje, doplnit lookupem z routes.txt (volitelné)
            "lat": pos.latitude,
            "lon": pos.longitude,
            "bearing": pos.bearing if pos.HasField("bearing") else 0.0,
            "speed_mps": pos.speed if pos.HasField("speed") else 0.0,
            "timestamp": ts_iso,
            "vehicle_label": veh.label if veh and veh.HasField("label") else "",
            "occupancy_status": _OCCUPANCY_NAMES.get(v.occupancy_status, "UNKNOWN"),
            "current_stop_sequence": v.current_stop_sequence if v.HasField("current_stop_sequence") else -1,
            "publisher_run_id": run_id,
        }
        events.append(event)

    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:
                    time.sleep(RETRY_BACKOFF_SEC * attempt)
                    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:
    run_id = str(uuid.uuid4())[:8]
    t0 = time.time()

    pb_bytes = fetch_gtfs_rt()
    t_fetch = time.time() - t0

    feed, feed_ts = decode_feed(pb_bytes)
    t_decode = time.time() - t0 - t_fetch

    events = feed_to_events(feed, feed_ts, run_id)
    t_transform = time.time() - t0 - t_fetch - t_decode

    stats = publish_batch(events)
    t_publish = time.time() - t0 - t_fetch - t_decode - t_transform

    print(f"[{run_id}] feed_ts={feed_ts.isoformat()} "
          f"events={len(events)} sent={stats['sent']} failed={stats['failed']} "
          f"fetch={t_fetch:.1f}s decode={t_decode:.2f}s "
          f"publish={t_publish:.1f}s")

    return {
        "events": len(events),
        "sent": stats["sent"],
        "failed": stats["failed"],
        "fetch_sec": round(t_fetch, 2),
        "decode_sec": round(t_decode, 3),
        "publish_sec": round(t_publish, 2),
    }

def run_continuous(duration_sec: int = 600):
    t_start = time.time()
    iteration = 0
    while True:
        iteration += 1
        try:
            run_once()
        except Exception as e:
            print(f"  ✗ iteration {iteration} failed: {e}")

        elapsed = time.time() - t_start
        if duration_sec and elapsed >= duration_sec:
            break

        time.sleep(POLL_INTERVAL_SEC)

if __name__ == "__main__":
    run_continuous(duration_sec=300)  # 5 min