DP-600 · Runbook
Runbook: Mini Medallion OpenAQ
OpenAQ API v3 jako zdroj: stanice s GPS, JSON → Delta → medallion ve Warehouse
Cíle kapitoly
- Zaregistrovat OpenAQ API key a stáhnout CZ lokace a poslední měření
- Rozbalit JSON do Delta tabulek a načíst je do Warehouse
- Postavit Bronze, Silver a Gold s dim_location a dim_parameter
- Připravit report s mapou stanic a trendem parametrů
13.1
Úvod
Stejný pattern jako původní CHMI runbook, jen zdroj je OpenAQ který má:
- Stanice + GPS + city v jednom záznamu (žádný bridging problém)
- Czech subset přes country filter
- Multi-pollutant, multi-station
Pre-requisita: Registrace na api.openaq.org/register (free, ~30 s) → dostaneš API key. Bez něj v3 API odmítá requesty.
13.2
Setup (jednorázově)
- Workspace v Fabricu (např.
OpenAQ_Demo) na F2 / trial - Lakehouse:
LH_OpenAQ - Warehouse:
WH_OpenAQ(jiné jméno než LH!) - Registruj se na explore.openaq.org/register, ulož si API key (32-znakový string)
13.3
PySpark notebook: stáhnout OpenAQ data + uložit jako Delta
- Workspace → New → Notebook
- Language dropdown: PySpark (Python) — NE "Python"!
- Vlevo Add Lakehouse → Existing →
LH_OpenAQ→ Make default
Cell 1 — Lokalizace (stanice) v ČR
PYTHON
import urllib.request, json, os from pyspark.sql import functions as F API_KEY = "REPLACE_WITH_YOUR_OPENAQ_KEY" # z api.openaq.org/register def fetch_openaq(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_openaq("/locations", "countries_id=49&limit=1000") print(f"Loaded {locations['meta']['found']} CZ locations") os.makedirs("/lakehouse/default/Files/raw", exist_ok=True) with open("/lakehouse/default/Files/raw/openaq_locations.json", "w") as f: json.dump(locations, f, ensure_ascii=False) print("Locations saved to Files/raw/openaq_locations.json")
Run → očekáváno: Loaded ~XXX CZ locations (typicky 100-200).
Cell 2 — Měření (poslední snapshot per sensor)
PYTHON
all_measurements = [] for loc in locations["results"][:50]: # limit pro demo loc_id = loc["id"] try: m = fetch_openaq(f"/locations/{loc_id}/latest") for r in m.get("results", []): r["location_id"] = loc_id r["location_name"] = loc.get("name", "") all_measurements.append(r) except Exception as e: print(f" skip loc {loc_id}: {e}") print(f"Loaded {len(all_measurements)} measurements") with open("/lakehouse/default/Files/raw/openaq_measurements.json", "w") as f: json.dump(all_measurements, f, ensure_ascii=False)
Cell 3 — Transform JSON → Delta tabulky
PYTHON
loc_rows = [] for L in locations["results"]: coords = L.get("coordinates") or {} loc_rows.append({ "location_id": L["id"], "name": L.get("name"), "city": (L.get("locality") or ""), "country": L.get("country", {}).get("code", ""), "lat": coords.get("latitude"), "lon": coords.get("longitude"), "owner_name": L.get("owner", {}).get("name", ""), "is_mobile": L.get("isMobile", False), "sensors_count": len(L.get("sensors", []) or []), }) import pandas as pd df_loc = pd.DataFrame(loc_rows) sdf_loc = spark.createDataFrame(df_loc) sdf_loc.write.format("delta").mode("overwrite").saveAsTable("openaq_locations_raw") print(f"openaq_locations_raw: {sdf_loc.count()} rows") meas_rows = [] for m in all_measurements: period = m.get("period") or {} coords = m.get("coordinates") or {} meas_rows.append({ "location_id": m.get("location_id"), "location_name": m.get("location_name"), "parameter_name": (m.get("parameter") or {}).get("name", ""), "parameter_units": (m.get("parameter") or {}).get("units", ""), "value": m.get("value"), "datetime_utc": (period.get("datetimeFrom") or {}).get("utc", ""), "lat": coords.get("latitude"), "lon": coords.get("longitude"), }) df_m = pd.DataFrame(meas_rows) sdf_m = spark.createDataFrame(df_m) sdf_m.write.format("delta").mode("overwrite").saveAsTable("openaq_measurements_raw") print(f"openaq_measurements_raw: {sdf_m.count()} rows")
Verify: Lakehouse Explorer → Tables → Refresh → uvidíš
openaq_locations_raw a openaq_measurements_raw.13.4
Wait + refresh SQL endpoint
- Workspace → otevři SQL analytics endpoint
LH_OpenAQ - V Explorer panelu → pravý klik na schema
dbo→ Refresh - Sanity:
SELECT TOP 5 * FROM dbo.openaq_locations_raw;
13.5
Bronze + Silver + Gold (Warehouse SQL)
V Warehouse WH_OpenAQ → New SQL query.
Cell 3.1 — Bronze (mirror raw)
SQL
CREATE SCHEMA bronze; GO CREATE TABLE bronze.openaq_locations ( location_id INT, name VARCHAR(200), city VARCHAR(100), country VARCHAR(5), lat DECIMAL(10,6), lon DECIMAL(10,6), owner_name VARCHAR(200), is_mobile BIT, sensors_count INT, _ingested_at DATETIME2(6) ); CREATE TABLE bronze.openaq_measurements ( location_id INT, location_name VARCHAR(200), parameter_name VARCHAR(50), parameter_units VARCHAR(20), value DECIMAL(18,6), datetime_utc VARCHAR(40), lat DECIMAL(10,6), lon DECIMAL(10,6), _ingested_at DATETIME2(6) ); GO INSERT INTO bronze.openaq_locations (location_id, name, city, country, lat, lon, owner_name, is_mobile, sensors_count, _ingested_at) SELECT location_id, name, city, country, lat, lon, owner_name, is_mobile, sensors_count, SYSDATETIME() FROM LH_OpenAQ.dbo.openaq_locations_raw; INSERT INTO bronze.openaq_measurements (location_id, location_name, parameter_name, parameter_units, value, datetime_utc, lat, lon, _ingested_at) SELECT location_id, location_name, parameter_name, parameter_units, value, datetime_utc, lat, lon, SYSDATETIME() FROM LH_OpenAQ.dbo.openaq_measurements_raw; SELECT 'locations' AS tbl, COUNT(*) AS rows FROM bronze.openaq_locations UNION ALL SELECT 'measurements', COUNT(*) FROM bronze.openaq_measurements;
Cell 3.2 — Silver (typed + enriched)
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), is_mobile BIT, owner_name VARCHAR(200) ); CREATE TABLE silver.dim_parameter ( parameter_name VARCHAR(50) NOT NULL, parameter_units VARCHAR(20), description VARCHAR(200) ); CREATE TABLE silver.fact_measurement ( location_id INT, location_name VARCHAR(200), city VARCHAR(100), parameter_name VARCHAR(50), parameter_units VARCHAR(20), value DECIMAL(18,6), mereni_ts DATETIME2(6), datum DATE, hodina SMALLINT, lat DECIMAL(10,6), lon DECIMAL(10,6), _silver_loaded_at DATETIME2(6) ); GO -- dim_location INSERT INTO silver.dim_location SELECT DISTINCT location_id, name, city, lat, lon, is_mobile, owner_name FROM bronze.openaq_locations; -- dim_parameter (auto-extract z měření) INSERT INTO silver.dim_parameter SELECT DISTINCT parameter_name, parameter_units, parameter_name FROM bronze.openaq_measurements WHERE parameter_name IS NOT NULL; -- fact_measurement INSERT INTO silver.fact_measurement SELECT m.location_id, m.location_name, l.city, m.parameter_name, m.parameter_units, m.value, TRY_CONVERT(DATETIME2(6), REPLACE(m.datetime_utc, 'Z', '')), TRY_CONVERT(DATE, REPLACE(m.datetime_utc, 'Z', '')), DATEPART(HOUR, TRY_CONVERT(DATETIME2(6), REPLACE(m.datetime_utc, 'Z', ''))), m.lat, m.lon, SYSDATETIME() FROM bronze.openaq_measurements m LEFT JOIN bronze.openaq_locations l ON l.location_id = m.location_id WHERE m.value IS NOT NULL; SELECT 'dim_location' AS tbl, COUNT(*) AS rows FROM silver.dim_location UNION ALL SELECT 'dim_parameter', COUNT(*) FROM silver.dim_parameter UNION ALL SELECT 'fact_measurement', COUNT(*) FROM silver.fact_measurement;
Cell 3.3 — Gold (BI-friendly aggregates)
SQL
CREATE SCHEMA gold; GO CREATE TABLE gold.fact_air_quality ( datum DATE, hodina SMALLINT, city VARCHAR(100), parameter_name VARCHAR(50), parameter_units VARCHAR(20), pocet_stanic INT, avg_value DECIMAL(18,6), max_value DECIMAL(18,6), min_value DECIMAL(18,6) ); GO INSERT INTO gold.fact_air_quality SELECT datum, hodina, city, parameter_name, parameter_units, COUNT(DISTINCT location_id), AVG(value), MAX(value), MIN(value) FROM silver.fact_measurement WHERE city IS NOT NULL AND city <> '' GROUP BY datum, hodina, city, parameter_name, parameter_units; SELECT TOP 50 * FROM gold.fact_air_quality ORDER BY avg_value DESC;
13.6
Power BI report
PBI Desktop → Get Data → OneLake catalog → WH_OpenAQ → Direct Lake → vyber gold.fact_air_quality + silver.dim_location.
Vizualizace:
- Mapa —
dim_locationlat/lon, color = avg PM2.5 z gold - Bar chart — top 10 měst dle avg PM10
- Line — trend per parameter v čase
- Slicery: city, parameter_name
13.7
Story arc pro demo
"Pracujeme s OpenAQ — agregátorem světových air quality dat. Češi tam přispívají skrz CHMI feed, ale OpenAQ to normalizuje do vlastního schema, takže místo 467 unikátních ID máme cca 100-150 stanic s plnou metadata: jméno, město, GPS, vlastník. Žádné bridge problémy.
Bronze layer mirror surového JSON. Silver rozdělené na dim_location, dim_parameter a fact. Gold per město × polutant × hodina pro PBI. Architektura čistá, datová story funguje."
13.8
Pre-kurz checklist
- ☐ Free OpenAQ API key zaregistrovaný
- ☐ Workspace + Lakehouse + Warehouse vytvořeny
- ☐ Cell 1-3 v notebooku otestován dopředu (ať Spark cold start nezdrží demo)
- ☐ PBI report pre-built s Direct Lake
13.9
Troubleshooting
| Symptom | Fix |
|---|---|
401 Unauthorized na OpenAQ API |
API key chybí v X-API-Key header. Ověř že je v Cell 1 vyplněný. |
Cell 2 selže na latest endpoint |
Některé location nemají měření — try/except to ignoruje. |
country_id=49 vrací 0 lokací |
Ověř ID na /v3/countries — Czechia může mít jiné číslo. |
| Pomalý fetch (>5 min) | Snížit [:50] na [:20] v Cell 2 — pro demo stačí. |