From b9cee0a7fb705df0b90226126794982f2de9151e Mon Sep 17 00:00:00 2001 From: Matthias Jacob Date: Wed, 28 Oct 2026 01:42:10 +0100 Subject: [PATCH] feat: add alarm_thresholds to pegel detail endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scrape the official alarm level thresholds (Richtwasserstände) from the station detail page and include them in GET /api/pegel/{pegel_id} as alarm_thresholds, cached for 24 hours with a stale-cache fallback on errors. null for stations without a flood alert function. Also clarify in the flood_alert_level docs that 'Kein Hochwassermeldepegel' means the station is not used for official flood alerts/control, not that measurement data is missing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 19 ++++++++++++- api/app.py | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7a4e49d..61c11e9 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,12 @@ Beispielantwort: "flood_alert_level": "Kein Hochwasser" } ], + "alarm_thresholds": [ + { "level": 1, "water_level_cm": 360 }, + { "level": 2, "water_level_cm": 400 }, + { "level": 3, "water_level_cm": 480 }, + { "level": 4, "water_level_cm": 600 } + ], "source": "https://www.umwelt.sachsen.de/umwelt/infosysteme/hwims/portal/web/feed/wasserstand-pegel-560021", "fetched_at": "2026-05-05T00:16:00+00:00" } @@ -118,6 +124,7 @@ Beispielantwort: | `water_level_cm` | number oder `null` | Wasserstand in Zentimetern | | `flow_m3s` | number oder `null` | Durchfluss in Kubikmetern pro Sekunde | | `flood_alert_level` | string oder `null` | Meldestufe als Text, siehe unten | +| `alarm_thresholds` | array oder `null` | Richtwasserstände der Alarmstufen 1–4, siehe unten | | `source` | string | URL des verwendeten RSS-Feeds | | `fetched_at` | string | Zeitpunkt des Abrufs durch die API als UTC-Zeitstempel | @@ -131,11 +138,20 @@ Einträge ohne Wasserstand und ohne Durchfluss (Platzhalter der Quelle für noch | --- | --- | | `Niedrigwasser` | Niedrigwasser | | `Kein Hochwasser` | Hochwassermeldepegel, Richtwerte nicht erreicht | -| `Kein Hochwassermeldepegel` | Pegel ohne Hochwassermeldefunktion | +| `Kein Hochwassermeldepegel` | Der Pegel wird nicht für amtliche Hochwassermeldungen bzw. -steuerung genutzt. Das bedeutet nicht, dass Messwerte fehlen; solche Pegel haben lediglich keine Alarmstufen und `alarm_thresholds` ist `null` | | `Alarmstufe 1` bis `Alarmstufe 4` | Richtwert der jeweiligen Alarmstufe überschritten (1 Meldedienst, 2 Kontrolldienst, 3 Wachdienst, 4 Hochwasserabwehr) | Die ersten drei Werte wurden in den Feeds aller Pegel beobachtet. Die Alarmstufen stammen aus der Legende der HWIMS-Übersichtsseite; ihre genaue Schreibweise im RSS-Feed ist nicht verifiziert, da sie bei der Prüfung nicht aufgetreten sind. Clients sollten unbekannte Werte tolerieren. +### `alarm_thresholds` + +Richtwasserstände (in cm), ab denen die jeweilige Alarmstufe laut HWIMS gilt. Die Werte werden von der Pegel-Detailseite (nicht dem RSS-Feed) ermittelt und 24 Stunden zwischengespeichert, da sie sich praktisch nie ändern. Ist die Detailseite nicht erreichbar, liefert die API einen vorhandenen (ggf. abgelaufenen) Cache-Wert oder `null`, ohne dass die übrige Antwort davon betroffen ist. Pegel ohne Hochwassermeldefunktion (`flood_alert_level` = `Kein Hochwassermeldepegel`) haben keine Richtwasserstände; hier ist das Feld `null`. + +| Feld | Typ | Bedeutung | +| --- | --- | --- | +| `level` | integer | Alarmstufe, 1 bis 4 | +| `water_level_cm` | integer | Richtwasserstand dieser Alarmstufe in Zentimetern | + ## Fehlerantworten Fehler der API enthalten ein JSON-Objekt mit dem Feld `detail`. @@ -207,6 +223,7 @@ Die Messwerte werden von der offiziellen Website des Freistaats Sachsen bereitge - Übersicht: - Beispiel-Feed: +- Beispiel-Detailseite (Quelle für `alarm_thresholds`): Verfügbarkeit und Richtigkeit der Quelldaten werden nicht garantiert. Änderungen am Aufbau der HWIMS-Seiten oder RSS-Feeds können die Verarbeitung beeinträchtigen. Dieses Projekt ist nicht mit dem Freistaat Sachsen verbunden. diff --git a/api/app.py b/api/app.py index ee3540a..61ba743 100644 --- a/api/app.py +++ b/api/app.py @@ -57,10 +57,11 @@ class PegelEntry(BaseModel): flood_alert_level: str | None = Field( description=( "Meldestufe als Text. Beobachtete Werte: `Niedrigwasser`, `Kein Hochwasser`, " - "`Kein Hochwassermeldepegel` (Pegel ohne Hochwassermeldefunktion). Bei " - "Hochwasser sind laut HWIMS-Legende `Alarmstufe 1` bis `Alarmstufe 4` zu " - "erwarten; deren genaue Schreibweise im Feed ist nicht verifiziert. " - "Clients sollten unbekannte Werte tolerieren." + "`Kein Hochwassermeldepegel` (der Pegel wird nicht für amtliche " + "Hochwassermeldungen bzw. -steuerung genutzt; für solche Pegel liefert " + "`alarm_thresholds` `null`). Bei Hochwasser sind laut HWIMS-Legende " + "`Alarmstufe 1` bis `Alarmstufe 4` zu erwarten; deren genaue Schreibweise " + "im Feed ist nicht verifiziert. Clients sollten unbekannte Werte tolerieren." ), examples=[ "Niedrigwasser", @@ -73,19 +74,38 @@ class PegelEntry(BaseModel): ], ) +class AlarmThreshold(BaseModel): + level: int = Field(description="Alarmstufe, 1 bis 4.") + water_level_cm: int = Field(description="Richtwasserstand dieser Alarmstufe in Zentimetern.") + class PegelResponse(BaseModel): station_id: str station_title: str | None count: int latest: PegelEntry entries: list[PegelEntry] + alarm_thresholds: list[AlarmThreshold] | None = Field( + description=( + "Richtwasserstände der Alarmstufen 1 bis 4 in Zentimetern, von der " + "Pegel-Detailseite ermittelt. `null`, wenn der Pegel keine " + "Hochwassermeldefunktion hat (siehe `flood_alert_level`) oder die " + "Werte nicht ermittelt werden konnten." + ) + ) source: str fetched_at: str BASE = "https://www.umwelt.sachsen.de/umwelt/infosysteme/hwims/portal/web/feed/wasserstand-pegel-{}" +DETAIL_BASE = "https://www.umwelt.sachsen.de/umwelt/infosysteme/hwims/portal/web/wasserstand-pegel-{}" DISCOVERY_URL = "https://www.umwelt.sachsen.de/umwelt/infosysteme/hwims/portal/web/wasserstand-uebersicht" INDEX_CACHE_TTL_SECONDS = 900 LATEST_CACHE_TTL_SECONDS = 120 +ALARM_THRESHOLDS_CACHE_TTL_SECONDS = 86400 + +ALARM_THRESHOLD_PATTERN = re.compile( + r'Alarmstufe\s*(\d).*?\s*([\d.,]+)\s*cm\s*', + re.S, +) class IndexCache(TypedDict): items: list[dict[str, Any]] | None @@ -98,6 +118,7 @@ _index_cache: IndexCache = { } _latest_cache: dict[str, dict[str, Any]] = {} +_alarm_thresholds_cache: dict[str, dict[str, Any]] = {} def parse_number(pattern, text): m = re.search(pattern, text, re.I) @@ -253,6 +274,48 @@ def _get_latest_for_station(station_id: str): } return latest +def parse_alarm_thresholds(body: str): + # Pegel ohne Hochwassermeldefunktion ("Kein Hochwassermeldepegel") haben + # keine Richtwasserstände-Tabelle; in diesem Fall liefert das Muster + # keine Treffer und die Funktion gibt None zurück. + matches = ALARM_THRESHOLD_PATTERN.findall(body) + if not matches: + return None + return [ + {"level": int(level), "water_level_cm": round(float(value.replace(",", ".")))} + for level, value in matches + ] + +def _fetch_alarm_thresholds(station_id: str): + req = Request( + DETAIL_BASE.format(station_id), + headers={"User-Agent": "sachsen-pegel-proxy/1.0"}, + ) + with urlopen(req, timeout=12) as resp: + body = resp.read().decode("utf-8", errors="ignore") + return parse_alarm_thresholds(body) + +def _get_alarm_thresholds(station_id: str): + cache_hit = _alarm_thresholds_cache.get(station_id) + if cache_hit and _is_cache_fresh( + cache_hit.get("fetched_at"), ALARM_THRESHOLDS_CACHE_TTL_SECONDS + ): + return cache_hit.get("thresholds") + + try: + thresholds = _fetch_alarm_thresholds(station_id) + except (URLError, HTTPError, TimeoutError, OSError): + # Richtwasserstände sind ein optionales Zusatzfeld; bei Fehlern lieber + # auf einen vorhandenen (ggf. abgelaufenen) Cache zurückfallen oder + # `None` liefern, statt die ganze Anfrage scheitern zu lassen. + return cache_hit.get("thresholds") if cache_hit else None + + _alarm_thresholds_cache[station_id] = { + "thresholds": thresholds, + "fetched_at": _now_utc(), + } + return thresholds + @app.get( "/api/pegel", response_model=PegelIndexResponse, @@ -329,7 +392,11 @@ def list_pegel(limit: int | None = None): "`latest` ist der neueste verbleibende Eintrag und entspricht `entries[0]`; " "die Reihenfolge von `entries` folgt dem Feed (neueste zuerst). " "Pegel-IDs bestehen aus mindestens sechs Ziffern. Einzelne fehlende " - "Messwerte werden als `null` ausgegeben." + "Messwerte werden als `null` ausgegeben. `alarm_thresholds` wird " + "zusätzlich von der Pegel-Detailseite ermittelt und 24 Stunden " + "zwischengespeichert; ist sie nicht erreichbar, wird ein vorhandener " + "Cache verwendet oder `null` geliefert, ohne die übrige Antwort zu " + "beeinträchtigen." ), response_description="Stationsmetadaten, neuester Messwert und Feed-Historie.", responses={ @@ -358,6 +425,7 @@ def get_pegel(pegel_id: str): "count": len(entries), "latest": entries[0], "entries": entries, + "alarm_thresholds": _get_alarm_thresholds(pegel_id), "source": url, "fetched_at": dt.datetime.now(dt.UTC).isoformat(), })