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: Big Medallion OpenAQ (2 roky)

Dvouletá historie ze S3 archivu OpenAQ, pure Python ingest, sezónní trendy a heatmapy
📓 Runbook · medallion / batch

Cíle kapitoly

  • Enumerovat a paralelně stáhnout dva roky měření z S3 archivu OpenAQ
  • Spojit data v Pandas / Polars, uložit Parquet a materializovat Delta jedinou Spark buňkou
  • Postavit idempotentní Bronze DDL a tři Gold tabulky pro trendy, heatmapu a ranking
  • Vědět, kde se pipeline liší od mini runbooku a co se nespouští live
14.1

Úvod

ℹ️
Účel: Doplněk k runbooku Mini Medallion OpenAQ. Místo hodinového snapshotu (~XXX řádků) tu pracujeme s 2 lety historie přes 100+ stanic (~1-3 M řádků). Pure Python ingest přes OpenAQ S3 archive, Parquet output, jeden PySpark cell na materializaci Delta.
ℹ️
Story: Toto NESPOUŠTĚJ live na kurzu — je to "tak vypadá pipeline až dojede přes plná data". Trendy 2023-2024, sezónní patterny, srovnání měst v čase. Ukaž v hotové verzi v PBI.

Klíčový rozdíl od mini runbooku:

  • Source: OpenAQ S3 archive (openaq-data-archive bucket, public read, no API key, no rate limit)
  • Volume: ~1-3 M měření za 2 roky
  • Tooling: Pure Python (Pandas + Polars), žádné Spark transformace
  • Single PySpark cell: converze Parquet → Delta tabulka
14.2

Setup (jednorázově)

  1. Workspace v Fabricu (samostatný, např. OpenAQ_Full) — neminí se s mini runbook
  2. Lakehouse: LH_OpenAQ_Full
  3. Warehouse: WH_OpenAQ_Full
  4. API key z mini runbooku (na získání seznamu CZ stanic — historická data API key nepotřebují)
14.3

Pure Python notebook: stáhnout 2 roky CZ historie (jednorázově)

  1. Workspace → New → Notebook
  2. Language: PySpark (Python) (nutné kvůli Cell 4 na konci)
  3. Add Lakehouse → LH_OpenAQ_Full → Make default

Cell 1 — Seznam CZ lokací (přes API)

PYTHON
import urllib.request, json, os, gzip, io
import pandas as pd
from concurrent.futures import ThreadPoolExecutor, as_completed
import time

API_KEY = "REPLACE_WITH_YOUR_OPENAQ_KEY"

def fetch_api(path, params=""):
    url = f"https://api.openaq.org/v3{path}?{params}"
    req = urllib.request.Request(url, headers={"X-API-Key": API_KEY})
    return json.loads(urllib.request.urlopen(req).read())

locations = fetch_api("/locations", "countries_id=49&limit=1000")
cz_loc_ids = [L["id"] for L in locations["results"]]
print(f"CZ locations: {len(cz_loc_ids)}")

loc_meta = pd.DataFrame([{
    "location_id": L["id"],
    "name": L.get("name"),
    "city": L.get("locality") or "",
    "lat": (L.get("coordinates") or {}).get("latitude"),
    "lon": (L.get("coordinates") or {}).get("longitude"),
    "owner_name": L.get("owner", {}).get("name", ""),
    "is_mobile": L.get("isMobile", False),
} for L in locations["results"]])

os.makedirs("/lakehouse/default/Files/historical", exist_ok=True)
loc_meta.to_parquet("/lakehouse/default/Files/historical/cz_locations.parquet", index=False)
print(f"Locations metadata: {len(loc_meta)} rows uloženo")

Cell 2 — Stáhnout 2 roky měření z S3 archivu (list-then-download)

⚠️
Pozor:

POZOR — naučená lekce: OpenAQ archiv má soubory per-DAY, ne per-MONTH:

  • location-{id}-{YYYYMMDD}.csv.gz (reálný formát)
  • location-{id}-{YYYYMM}.csv.gz (NEEXISTUJE — pokud guessuješ URL podle měsíce, dostaneš 404 na všechno)

Proto nejdřív list_objects_v2 enumeruje reálné keys, pak download. Boto3 navíc lépe handluje S3 než urllib.

PYTHON
import subprocess
subprocess.run(['pip', 'install', 'boto3', '--quiet'], check=True)

import boto3
from botocore import UNSIGNED
from botocore.client import Config
from concurrent.futures import ThreadPoolExecutor, as_completed
import io, gzip, time
import pandas as pd

s3 = boto3.client('s3', config=Config(signature_version=UNSIGNED))
YEARS = (2023, 2024)

def list_keys_for_loc(loc_id):
    keys = []
    paginator = s3.get_paginator('list_objects_v2')
    for year in YEARS:
        prefix = f'records/csv.gz/locationid={loc_id}/year={year}/'
        for page in paginator.paginate(Bucket='openaq-data-archive', Prefix=prefix):
            for obj in page.get('Contents', []):
                keys.append(obj['Key'])
    return keys

print("=== A: enumerace S3 keys ===")
start = time.time()
all_keys = []
with ThreadPoolExecutor(max_workers=20) as ex:
    futures = {ex.submit(list_keys_for_loc, loc): loc for loc in cz_loc_ids}
    for i, f in enumerate(as_completed(futures)):
        all_keys.extend(f.result())
        if (i+1) % 30 == 0:
            print(f"  enumerated {i+1}/{len(cz_loc_ids)} stanic, keys: {len(all_keys)}")

print(f"\nTotal keys k downloadu: {len(all_keys)} ({time.time()-start:.0f}s)")

def download_key(key):
    try:
        obj = s3.get_object(Bucket='openaq-data-archive', Key=key)
        with gzip.GzipFile(fileobj=io.BytesIO(obj['Body'].read())) as f:
            df = pd.read_csv(f)
            df['_location_id'] = int(key.split('locationid=')[1].split('/')[0])
            return df
    except Exception:
        return None

print("\n=== B: download ===")
start = time.time()
all_dfs = []
with ThreadPoolExecutor(max_workers=30) as ex:
    futures = [ex.submit(download_key, k) for k in all_keys]
    for i, f in enumerate(as_completed(futures)):
        df = f.result()
        if df is not None and not df.empty:
            all_dfs.append(df)
        if (i+1) % 1000 == 0:
            print(f"  progress: {i+1}/{len(all_keys)} ({time.time()-start:.0f}s, {len(all_dfs)} success)")

print(f"\nDownloaded {len(all_dfs)} files v {time.time()-start:.0f}s")
print(f"Total rows: {sum(len(d) for d in all_dfs):,}")

Očekávání:

  • KROK A: ~1-2 min, vyplivne typicky 20-60 tisíc keys (CZ má ~150 stanic × ~365 dní × 2 roky, ne všechny ale měřily celé období)
  • KROK B: 20-60 min download podle bandwidth

Varianty zkrácení pro test / demo:

PYTHON
YEARS = (2024,)

cz_loc_ids = cz_loc_ids[:30]

Doporučuju nejdřív pustit jen KROK A (uvidíš počet keys) a podle toho rozhodnout.

Cell 3 — Concat + uložit jako Parquet

PYTHON
df_all = pd.concat(all_dfs, ignore_index=True)
print(f"Concatenated: {df_all.shape}")
print(df_all.dtypes)
print(df_all.head(3))

df_all.columns = [c.strip() for c in df_all.columns]

if "datetime" in df_all.columns:
    df_all["datetime"] = pd.to_datetime(df_all["datetime"], errors="coerce")

out_path = "/lakehouse/default/Files/historical/openaq_cz_measurements_2y.parquet"
df_all.to_parquet(out_path, index=False, compression="snappy")
print(f"Saved {len(df_all):,} rows to {out_path}")
print(f"File size: {os.path.getsize(out_path)/1024/1024:.1f} MB")

del all_dfs, df_all

Pure Python s Pandas zvládne ~1-3 M řádků v paměti. Pokud out-of-memory, switch na chunked write:

PYTHON
import polars as pl
combined = pl.concat([pl.from_pandas(d) for d in all_dfs])
combined.write_parquet(out_path)

Cell 4 — Single PySpark cell: Parquet → Delta tables

PYTHON
df_meas = spark.read.parquet("Files/historical/openaq_cz_measurements_2y.parquet")
df_meas.write.format("delta").mode("overwrite").saveAsTable("openaq_meas_full_raw")
print(f"openaq_meas_full_raw: {df_meas.count():,} rows")

df_loc = spark.read.parquet("Files/historical/cz_locations.parquet")
df_loc.write.format("delta").mode("overwrite").saveAsTable("openaq_loc_full_raw")
print(f"openaq_loc_full_raw: {df_loc.count()} rows")
14.4

Wait + refresh SQL endpoint

V workspace → SQL analytics endpoint LH_OpenAQ_Full → Refresh schema dbo.

Sanity: SELECT COUNT(*) FROM dbo.openaq_meas_full_raw; — měl by vrátit ~1-3 M.

14.5

Bronze + Silver + Gold (Warehouse SQL)

V Warehouse WH_OpenAQ_Full → New SQL query.

Cell 3.1a — Bronze DDL (schema + tables, idempotent)

ℹ️
Poznámka k pattern „CREATE OR REPLACE": T-SQL / Fabric Warehouse to nemá pro tabulky a schemy. Ekvivalent: DROP TABLE IF EXISTS + CREATE TABLE. Pro views/procs/functions později používej CREATE OR ALTER VIEW. Schema sám nedrop-uju — kaskáda by sebrala vše.
SQL
-- Schema guard (idempotentní)
IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = 'bronze')
    EXEC('CREATE SCHEMA bronze');
GO

-- Tables (drop + create = ekvivalent CREATE OR REPLACE)
DROP TABLE IF EXISTS bronze.openaq_meas_full;
CREATE TABLE bronze.openaq_meas_full (
    location_id INT,
    sensors_id INT,
    parameter_name VARCHAR(50),
    parameter_units VARCHAR(20),
    value DECIMAL(18,6),
    datetime_from DATETIME2(6),
    datetime_to DATETIME2(6),
    lat DECIMAL(10,6),
    lon DECIMAL(10,6),
    _ingested_at DATETIME2(6)
);

DROP TABLE IF EXISTS bronze.openaq_loc_full;
CREATE TABLE bronze.openaq_loc_full (
    location_id INT,
    name VARCHAR(200),
    city VARCHAR(100),
    lat DECIMAL(10,6),
    lon DECIMAL(10,6),
    owner_name VARCHAR(200),
    is_mobile BIT,
    _ingested_at DATETIME2(6)
);
GO

Cell 3.1b — Bronze INSERT (3-part-naming přes Lakehouse SQL endpoint)

ℹ️
OVĚŘ NÁZVY SLOUPCŮ v Lakehouse Explorer (nebo SELECT TOP 1 * FROM LH_OpenAQ_Full.dbo.openaq_meas_full_raw;) před spuštěním. OpenAQ archiv má typicky sloupce: location, sensors_id, value, datetime, units, parameter, lat, lon. Pokud jsou v tvém downloadu jiné, uprav INSERT.
SQL
INSERT INTO bronze.openaq_meas_full (location_id, parameter_name, parameter_units, value, datetime_from, lat, lon, _ingested_at)
SELECT
    _location_id,
    [parameter],
    [units],
    [value],
    TRY_CONVERT(DATETIME2(6), [datetime]),
    [lat], [lon],
    SYSDATETIME()
FROM LH_OpenAQ_Full.dbo.openaq_meas_full_raw;

INSERT INTO bronze.openaq_loc_full (location_id, name, city, lat, lon, owner_name, is_mobile, _ingested_at)
SELECT location_id, name, city, lat, lon, owner_name, is_mobile, SYSDATETIME()
FROM LH_OpenAQ_Full.dbo.openaq_loc_full_raw;

SELECT 'meas' AS tbl, COUNT(*) AS rows FROM bronze.openaq_meas_full
UNION ALL SELECT 'loc', COUNT(*) FROM bronze.openaq_loc_full;

Očekáváno: meas v rozsahu 1–3 M, loc ~150.

Cell 3.2 — Silver (typed dim + cleaned fact + derived columns)

SQL
CREATE SCHEMA silver;
GO

CREATE TABLE silver.dim_location (
    location_id INT NOT NULL,
    name VARCHAR(200),
    city VARCHAR(100),
    lat DECIMAL(10,6),
    lon DECIMAL(10,6)
);

CREATE TABLE silver.fact_measurement (
    location_id INT,
    city VARCHAR(100),
    parameter_name VARCHAR(50),
    parameter_units VARCHAR(20),
    value DECIMAL(18,6),
    mereni_ts DATETIME2(6),
    datum DATE,
    rok SMALLINT,
    mesic SMALLINT,
    den_v_tydnu SMALLINT,
    hodina SMALLINT,
    _silver_loaded_at DATETIME2(6)
);
GO

INSERT INTO silver.dim_location
SELECT DISTINCT location_id, name, city, lat, lon FROM bronze.openaq_loc_full;

INSERT INTO silver.fact_measurement
SELECT
    m.location_id,
    l.city,
    m.parameter_name,
    m.parameter_units,
    m.value,
    m.datetime_from,
    CAST(m.datetime_from AS DATE),
    YEAR(m.datetime_from),
    MONTH(m.datetime_from),
    DATEPART(WEEKDAY, m.datetime_from),
    DATEPART(HOUR, m.datetime_from),
    SYSDATETIME()
FROM bronze.openaq_meas_full m
LEFT JOIN bronze.openaq_loc_full l ON l.location_id = m.location_id
WHERE m.value IS NOT NULL
  AND m.value > 0  -- filter zjevně chybné
  AND m.datetime_from >= '2023-01-01';

Cell 3.3 — Gold (multiple aggregates pro různé story arcs)

SQL
CREATE SCHEMA gold;
GO

-- Gold 1: měsíční trendy per city × parameter
CREATE TABLE gold.fact_monthly_trends (
    rok SMALLINT,
    mesic SMALLINT,
    city VARCHAR(100),
    parameter_name VARCHAR(50),
    pocet_meri INT,
    avg_value DECIMAL(18,6),
    max_value DECIMAL(18,6)
);

-- Gold 2: hodina × den (heatmap pollution patterns)
CREATE TABLE gold.fact_hourly_pattern (
    parameter_name VARCHAR(50),
    den_v_tydnu SMALLINT,
    hodina SMALLINT,
    avg_value DECIMAL(18,6),
    pocet_meri INT
);

-- Gold 3: top stanice (ranking)
CREATE TABLE gold.fact_station_summary (
    location_id INT,
    name VARCHAR(200),
    city VARCHAR(100),
    parameter_name VARCHAR(50),
    pocet_meri INT,
    avg_value DECIMAL(18,6),
    p95_value DECIMAL(18,6)
);
GO

INSERT INTO gold.fact_monthly_trends
SELECT rok, mesic, city, parameter_name,
       COUNT(*), AVG(value), MAX(value)
FROM silver.fact_measurement
WHERE city IS NOT NULL AND city <> ''
GROUP BY rok, mesic, city, parameter_name;

INSERT INTO gold.fact_hourly_pattern
SELECT parameter_name, den_v_tydnu, hodina,
       AVG(value), COUNT(*)
FROM silver.fact_measurement
GROUP BY parameter_name, den_v_tydnu, hodina;

INSERT INTO gold.fact_station_summary
SELECT
    f.location_id,
    l.name,
    f.city,
    f.parameter_name,
    COUNT(*),
    AVG(f.value),
    PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY f.value) OVER (PARTITION BY f.location_id, f.parameter_name)
FROM silver.fact_measurement f
LEFT JOIN silver.dim_location l ON l.location_id = f.location_id
GROUP BY f.location_id, l.name, f.city, f.parameter_name;

SELECT TOP 20 * FROM gold.fact_station_summary
WHERE parameter_name = 'pm10'
ORDER BY avg_value DESC;
14.6

Power BI report

PBI Desktop → Get Data → OneLake catalog → WH_OpenAQ_Full → Direct Lake → vyber všechny 3 gold tabulky + silver.dim_location.

Vizualizace:

  1. Mapa CZ — dim_location lat/lon, color = avg PM10 z fact_station_summary
  2. Line chart trendů — fact_monthly_trends X=date(rok-mesic), Y=avg_value, multi-line per city, slicer parameter
  3. Heatmap — fact_hourly_pattern X=hodina, Y=den_v_tydnu, color=avg PM10 (ukáže "rush hour" pollution)
  4. Bar chart top zóny — fact_station_summary top 10 stanic dle avg
  5. Slicery: rok, parametr, kraj/město
14.7

Story arc pro live demo

"Toto je stejná pipeline jako mini runbook, jen nad 2 lety historie — 1-3 miliony měření přes 100+ stanic. Pipeline jsem spustil offline, demo ukazuje výsledek.

Trendy 2023-2024: vidíte sezónní patterny — vyšší PM10 v zimních měsících (topení, smog inverze), nižší v létě. Heatmap ukazuje pollution patterns — ranní špička v pracovní dny vs víkendový pokles.

Architektura je IDENTICKÁ s mini medallion. Stejné Bronze, Silver, Gold, stejné T-SQL procedury. Když nahradíte 1 měsíc za 24, dostanete tohle. Tohle je síla správně postaveného pipeline."

14.8

Tipy

  • První download trvá dlouho (10-30 min). Pusť, jdi si dát kafe.
  • Zkraťte na 1 rok pro test: YEARS = [2024] v Cell 2
  • Limit lokací: cz_loc_ids[:30] v Cell 2 — 30 stanic stačí pro demo trends
  • Pure Python alternativa Cell 4 (kdyby ses chtěl zbavit i tohohle Sparku): write Parquet a používej Lakehouse "Load to table" UI — ale POZOR, leading whitespace bug. Pro Parquet UI funguje (whitespace problém je u CSV).
14.9

Co opravit v případě chyby

Symptom Fix
urllib.error.HTTPError: 404 na S3 archive Stanice neměřila daný měsíc — normální, skript to ignoruje.
Pandas OOM při concat Switch na Polars (Cell 3 alternativa).
parameter sloupec chybí Skutečné jméno sloupce v archivu se mohlo změnit. Otevři jeden CSV a uprav INSERT.
Spark cell padne na "table exists" mode("overwrite") to řeší — ale pokud je rozdílné schema, použij .option("overwriteSchema", "true").