feeds: first complete draft basel air quality

This commit is contained in:
2026-07-29 17:53:49 +02:00
parent 7f674b7ad3
commit 897d4d0ec8
3 changed files with 377 additions and 191 deletions
+291 -120
View File
@@ -1,8 +1,10 @@
from datetime import date from datetime import date, timedelta, datetime
import pandas as pd import pandas as pd
import geopandas as gpd import geopandas as gpd
from shapely import wkb from shapely import wkb
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
from pathlib import Path
import copy
import logging import logging
logger = logging.getLogger("resspublica") logger = logging.getLogger("resspublica")
@@ -10,24 +12,66 @@ logger = logging.getLogger("resspublica")
from .translations import * from .translations import *
from .utils import * from .utils import *
station_coordinates = { start_date = pd.Timestamp("2026-01-01")
"12500": (47.558, 7.588), # https://luftqualitaet.ch/messnetz/station/blSIB start_date_datetime = date.fromisoformat("2026-01-01") # a bit dumb we need those two formats
"12450": (47.500, 7.620), # https://luftqualitaet.ch/messnetz/station/blSIB end_date = pd.Timestamp((date.today() - timedelta(days=1)).isoformat())
"12510": (47.450, 7.780), # https://luftqualitaet.ch/messnetz/station/blMUT
station_information = {
"100048": {
"name": "Basel Chrischona",
"coordinates": (47.571709338, 7.687073826)
},
"100050": {
"name": "Basel Feldbergstrasse",
"coordinates": (47.567022213, 7.594722533)
},
"100049": {
"name": "Basel St. Johannplatz",
"coordinates": (47.565950312, 7.582002453)
},
"12450": {
"name": "Sissach-Bützenen",
"coordinates": (47.465037653, 7.815429278)
},
"12510": {
"name": "A2 Hard",
"coordinates": (47.538075849, 7.648985359)
},
} }
def generateBaselLuftqualitat(ASSETS): pollutants = [
logger.info("Generating Luftqualitat map in Basel feed...") "pm10",
logger.info("Preparing data...") "pm2_5",
"no2",
"o3"
]
pollutant_units = {
"pm10": "µg/m³",
"pm2_5": "µg/m³",
"no2": "µg/m³",
"o3": "µg/m³",
}
# Fixed scale per pollutant so colors remain comparable day-to-day
pollutant_scales = {
"pm10": (0, 50),
"pm2_5": (0, 30),
"no2": (0, 100),
"o3": (0, 200),
}
def generateBaselLuftqualitat(ASSETS, CACHE):
urls = [ urls = [
"https://data.bs.ch/api/v2/catalog/datasets/100048/exports/parquet", "https://data.bs.ch/api/explore/v2.1/catalog/datasets/100048/exports/parquet?where=datum_zeit>2026-01-01",
"https://data.bs.ch/api/v2/catalog/datasets/100050/exports/parquet", "https://data.bs.ch/api/explore/v2.1/catalog/datasets/100050/exports/parquet?where=datum_zeit>2026-01-01",
"https://data.bs.ch/api/v2/catalog/datasets/100093/exports/parquet", "https://data.bs.ch/api/explore/v2.1/catalog/datasets/100093/exports/parquet", # if we use where with this one it 400: Bad Request
"https://data.bs.ch/api/v2/catalog/datasets/100049/exports/parquet", "https://data.bs.ch/api/explore/v2.1/catalog/datasets/100049/exports/parquet?where=datum_zeit>2026-01-01",
"https://data.bs.ch/api/v2/catalog/datasets/100178/exports/parquet", "https://data.bs.ch/api/explore/v2.1/catalog/datasets/100178/exports/parquet",
"https://data.bs.ch/api/v2/catalog/datasets/100158/exports/parquet",
"https://data.bl.ch/api/v2/catalog/datasets/12500/exports/parquet",
"https://data.bl.ch/api/v2/catalog/datasets/12450/exports/parquet", "https://data.bl.ch/api/v2/catalog/datasets/12450/exports/parquet",
"https://data.bl.ch/api/v2/catalog/datasets/12510/exports/parquet" "https://data.bl.ch/api/v2/catalog/datasets/12510/exports/parquet"
] ]
@@ -42,8 +86,16 @@ def generateBaselLuftqualitat(ASSETS):
df = pd.read_parquet(url) df = pd.read_parquet(url)
station_id = url.split("/")[-3] station_id = url.split("/")[-3]
df["station_id"] = station_id df["station_id"] = station_id
# Always define station name
# Use dataset id as fallback until manually mapped
df["station_name"] = station_id
if station_id in station_information:
df["station_name"] = station_information[station_id]["name"]
# ----------------------------------------------------- # -----------------------------------------------------
# Normalize datetime to Swiss time # Normalize datetime to Swiss time
# ----------------------------------------------------- # -----------------------------------------------------
@@ -85,13 +137,16 @@ def generateBaselLuftqualitat(ASSETS):
# ----------------------------------------------------- # -----------------------------------------------------
# Add coordinates for BL datasets # Add coordinates for BL datasets
# ----------------------------------------------------- # -----------------------------------------------------
if station_id in station_coordinates: if station_id in station_information:
lat, lon = station_coordinates[station_id] lat, lon = station_information[station_id]["coordinates"]
df["latitude"] = lat df["latitude"] = lat
df["longitude"] = lon df["longitude"] = lon
df["station_name"] = (
station_information[station_id]["name"]
)
# ----------------------------------------------------- # -----------------------------------------------------
# Convert long format datasets # Convert long format datasets
@@ -176,6 +231,7 @@ def generateBaselLuftqualitat(ASSETS):
keep = [ keep = [
"date_time", "date_time",
"station_id", "station_id",
"station_name",
column column
] ]
@@ -231,7 +287,10 @@ def generateBaselLuftqualitat(ASSETS):
# --------------------------------------------------------- # ---------------------------------------------------------
def safe_load(x): def safe_load(x):
try: try:
return wkb.loads(x) geom = wkb.loads(x)
if geom.is_empty:
return None
return geom
except Exception: except Exception:
return None return None
@@ -266,49 +325,6 @@ def generateBaselLuftqualitat(ASSETS):
crs="EPSG:4326" crs="EPSG:4326"
) )
# ---------------------------------------------------------
# Time filter
# ---------------------------------------------------------
start = pd.Timestamp(
"2026-07-29 00:00:00"
)
end = pd.Timestamp(
"2026-07-29 23:59:59"
)
geo = geo[
(geo["date_time"] >= start)
&
(geo["date_time"] <= end)
]
# ---------------------------------------------------------
# Average per station
# ---------------------------------------------------------
averaged = (
geo
.groupby(
[
"station_id",
"pollutant",
"geometry"
],
as_index=False
)
["value"]
.mean()
)
averaged = gpd.GeoDataFrame(
averaged,
geometry="geometry",
crs="EPSG:4326"
)
# --------------------------------------------------------- # ---------------------------------------------------------
# Boundaries # Boundaries
# --------------------------------------------------------- # ---------------------------------------------------------
@@ -324,87 +340,242 @@ def generateBaselLuftqualitat(ASSETS):
basel_stadt = cantons[ basel_stadt = cantons[
cantons["KANTONSNUMMER"] == 12 cantons["KANTONSNUMMER"] == 12
].to_crs(averaged.crs) ].to_crs("EPSG:4326")
basel_land = cantons[ basel_land = cantons[
cantons["KANTONSNUMMER"] == 13 cantons["KANTONSNUMMER"] == 13
].to_crs(averaged.crs) ].to_crs("EPSG:4326")
# --------------------------------------------------------- # ---------------------------------------------------------
# Plot # Time filter
# --------------------------------------------------------- # ---------------------------------------------------------
pollutants = [ logger.info("Generating daily image...")
"pm10", for day in pd.date_range(start_date, end_date, freq="D"):
"pm2_5",
"no2",
"o3"
]
if Path( CACHE / f"baselAirQuality-{day.strftime("%Y-%m-%d")}.png").exists():
logger.debug(f"Day {day.strftime("%Y-%m-%d")} is already cached. Skipping...")
continue
logger.debug(f"Handling day {day.strftime("%Y-%m-%d")}...")
fig, axes = plt.subplots( next_day = day + pd.Timedelta(days=1)
1,
len(pollutants),
figsize=(20, 5)
)
geo_day = geo[
for ax, pollutant in zip( (geo["date_time"] >= day)
axes, &
pollutants (geo["date_time"] < next_day)
):
subset = averaged[
averaged["pollutant"] == pollutant
] ]
# ---------------------------------------------------------
if subset.empty: # Average per station
ax.set_visible(False) # ---------------------------------------------------------
continue averaged = (
geo_day.groupby(
[
subset.plot( "station_id",
ax=ax, "station_name",
column="value", "pollutant",
cmap="hot", "geometry"
legend=True, ],
markersize=80 as_index=False
)
["value"]
.mean()
) )
basel_land.plot( averaged = gpd.GeoDataFrame(
ax=ax, averaged,
facecolor="none", geometry="geometry",
edgecolor="black", crs="EPSG:4326"
linewidth=2
) )
basel_stadt.plot( # ---------------------------------------------------------
ax=ax, # Plot
facecolor="none", # ---------------------------------------------------------
edgecolor="black",
linewidth=1
fig, axes = plt.subplots(
2,
2,
figsize=(14, 14)
) )
axes = axes.flatten()
for _, row in subset.iterrows():
ax.annotate( for ax, pollutant in zip(
row["station_id"], axes,
( pollutants
row.geometry.x, ):
row.geometry.y
), subset = averaged[
fontsize=8 averaged["pollutant"] == pollutant
]
if subset.empty:
ax.set_visible(False)
continue
vmin, vmax = pollutant_scales[pollutant]
subset.plot(
ax=ax,
column="value",
cmap="hot_r",
legend=True,
markersize=150,
vmin=vmin,
vmax=vmax
) )
ax.set_title( basel_land.plot(
pollutant ax=ax,
) facecolor="none",
edgecolor="black",
ax.axis("off") linewidth=2
)
plt.tight_layout() basel_stadt.plot(
plt.show() ax=ax,
facecolor="none",
edgecolor="black",
linewidth=1
)
ax.set_xlim(
7.45,
7.90
)
ax.set_ylim(
47.35,
47.70
)
# these offsets exist to avoid collision between names
label_offsets = {
"Basel Feldbergstrasse": (10, 8),
"Basel St. Johannplatz": (-10, -12),
"Basel Chrischona": (0, 8),
"Sissach-Bützenen": (0, 8),
"A2 Hard": (0, 8),
}
for _, row in subset.iterrows():
dx, dy = label_offsets.get(
row["station_name"],
(0, 8)
)
ax.annotate(
row["station_name"],
xy=(
row.geometry.x,
row.geometry.y
),
xytext=(dx, dy),
textcoords="offset points",
ha="center",
fontsize=9,
bbox=dict(
facecolor="white",
alpha=0.7,
edgecolor="none",
pad=1
)
)
ax.set_title(
f"{pollutant} ({pollutant_units[pollutant]})"
)
ax.axis("off")
for ax in axes[len(pollutants):]:
ax.set_visible(False)
fig.suptitle(day.strftime("%Y-%m-%d"))
plt.tight_layout()
plt.savefig(CACHE / f"baselAirQuality-{day.strftime("%Y-%m-%d")}.png", dpi=120, bbox_inches="tight")
plt.close()
feeds = {
"fr": [],
"de": [],
"it": [],
"rm": [],
"en": []
}
yesterday = date.today() - timedelta(days=1)
current = start_date_datetime
while current <= yesterday:
dailyEntry = {}
dailyEntry["id"] = f"air-quality-basel-{current.isoformat()}"
dailyEntry["creationDate"] = current.isoformat()
dailyEntry["date"] = current.isoformat()
dailyEntry["source"] = "https://luftqualitaet.ch/"
dailyEntry["url"] = "https://luftqualitaet.ch/"
dailyEntry["text"] = f"<img src\"https://resspublica.tomasrivera.ch/images/baselAirQuality-{current.isoformat()}.png\"alt=\"basel air quality {current.isoformat()}\">"
for lang in ["fr", "de", "it", "rm", "en"]:
dailyEntry["title"] = f"{translatedAirQualityInBasel[lang]} {current.isoformat()}"
feeds[lang].append(copy.deepcopy(dailyEntry))
current += timedelta(days=1)
generateFeed(
translatedAirQualityInBasel["fr"],
f"Flux RSS des {translatedAirQualityInBasel["fr"]}",
translatedAirQualityInBaselCamelCase["fr"],
"fr",
["rss", "atom"],
datetime.fromisoformat(f"{yesterday.isoformat()} 23:59:59").replace(tzinfo=ZoneInfo("Europe/Zurich")),
feeds["fr"]
)
generateFeed(
translatedAirQualityInBasel["de"],
f"RSS-Feed für {translatedAirQualityInBasel['de']}",
translatedAirQualityInBaselCamelCase["de"],
"de",
["rss", "atom"],
datetime.fromisoformat(f"{yesterday.isoformat()} 23:59:59").replace(tzinfo=ZoneInfo("Europe/Zurich")),
feeds["de"]
)
generateFeed(
translatedAirQualityInBasel["en"],
f"RSS feed for {translatedAirQualityInBasel['en']}",
translatedAirQualityInBaselCamelCase["en"],
"en",
["rss", "atom"],
datetime.fromisoformat(f"{yesterday.isoformat()} 23:59:59").replace(tzinfo=ZoneInfo("Europe/Zurich")),
feeds["en"]
)
generateFeed(
translatedAirQualityInBasel["it"],
f"Feed RSS per {translatedAirQualityInBasel['it']}",
translatedAirQualityInBaselCamelCase["it"],
"it",
["rss", "atom"],
datetime.fromisoformat(f"{yesterday.isoformat()} 23:59:59").replace(tzinfo=ZoneInfo("Europe/Zurich")),
feeds["it"]
)
generateFeed(
translatedAirQualityInBasel["rm"],
f"Feed RSS per {translatedAirQualityInBasel['rm']}",
translatedAirQualityInBaselCamelCase["rm"],
"rm",
["rss", "atom"],
datetime.fromisoformat(f"{yesterday.isoformat()} 23:59:59").replace(tzinfo=ZoneInfo("Europe/Zurich")),
feeds["rm"]
)
+1 -1
View File
@@ -43,5 +43,5 @@ def main():
if ( args.gen_bernReligionMap and date.today().day == 1 and date.today().month in [1, 5, 9]) or args.force_gen_bernReligionMap: if ( args.gen_bernReligionMap and date.today().day == 1 and date.today().month in [1, 5, 9]) or args.force_gen_bernReligionMap:
generateBernReligionMap(ASSETS, CACHE) generateBernReligionMap(ASSETS, CACHE)
if args.gen_baselLuftqualitat: if args.gen_baselLuftqualitat:
generateBaselLuftqualitat(ASSETS) generateBaselLuftqualitat(ASSETS, CACHE)
logging.info("Done") logging.info("Done")
+15
View File
@@ -96,3 +96,18 @@ translatedCityOfBern = {
"de": "Stadt Bern", "de": "Stadt Bern",
"fr": "Ville de Bern" "fr": "Ville de Bern"
} }
translatedAirQualityInBasel = {
"fr": "Qualité de l'air à Bâle",
"en": "Air quality in Basel",
"de": "Luftqualität in Basel",
"it": "Qualità dell'aria a Basilea",
"rm": "Qualitad da l'aria a Basilea"
}
translatedAirQualityInBaselCamelCase = {
"fr": "qualiteDeLAirABale",
"en": "airQualityInBasel",
"de": "luftqualitatInBasel",
"it": "qualitaDell'AriaABasilea",
"rm": "qualitadDaLAriaABasilea"
}