feat: add alarm_thresholds to pegel detail endpoint

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>
This commit is contained in:
Matthias JacobandCopilot committed 2026-10-28 01:42:10 +01:00
1 parent 489a026b9c
commit b9cee0a7fb
2 files changed
+91 -6

No files matched your search

+18 -1
View File
@@ -101,6 +101,12 @@ Beispielantwort:
"flood_alert_level": "Kein Hochwasser" "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", "source": "https://www.umwelt.sachsen.de/umwelt/infosysteme/hwims/portal/web/feed/wasserstand-pegel-560021",
"fetched_at": "2026-05-05T00:16:00+00:00" "fetched_at": "2026-05-05T00:16:00+00:00"
} }
@@ -118,6 +124,7 @@ Beispielantwort:
| `water_level_cm` | number oder `null` | Wasserstand in Zentimetern | | `water_level_cm` | number oder `null` | Wasserstand in Zentimetern |
| `flow_m3s` | number oder `null` | Durchfluss in Kubikmetern pro Sekunde | | `flow_m3s` | number oder `null` | Durchfluss in Kubikmetern pro Sekunde |
| `flood_alert_level` | string oder `null` | Meldestufe als Text, siehe unten | | `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 | | `source` | string | URL des verwendeten RSS-Feeds |
| `fetched_at` | string | Zeitpunkt des Abrufs durch die API als UTC-Zeitstempel | | `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 | | `Niedrigwasser` | Niedrigwasser |
| `Kein Hochwasser` | Hochwassermeldepegel, Richtwerte nicht erreicht | | `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) | | `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. 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 ## Fehlerantworten
Fehler der API enthalten ein JSON-Objekt mit dem Feld `detail`. 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: <https://www.umwelt.sachsen.de/umwelt/infosysteme/hwims/portal/web/wasserstand-uebersicht> - Übersicht: <https://www.umwelt.sachsen.de/umwelt/infosysteme/hwims/portal/web/wasserstand-uebersicht>
- Beispiel-Feed: <https://www.umwelt.sachsen.de/umwelt/infosysteme/hwims/portal/web/feed/wasserstand-pegel-560021> - Beispiel-Feed: <https://www.umwelt.sachsen.de/umwelt/infosysteme/hwims/portal/web/feed/wasserstand-pegel-560021>
- Beispiel-Detailseite (Quelle für `alarm_thresholds`): <https://www.umwelt.sachsen.de/umwelt/infosysteme/hwims/portal/web/wasserstand-pegel-560021>
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. 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.
+73 -5
View File
@@ -57,10 +57,11 @@ class PegelEntry(BaseModel):
flood_alert_level: str | None = Field( flood_alert_level: str | None = Field(
description=( description=(
"Meldestufe als Text. Beobachtete Werte: `Niedrigwasser`, `Kein Hochwasser`, " "Meldestufe als Text. Beobachtete Werte: `Niedrigwasser`, `Kein Hochwasser`, "
"`Kein Hochwassermeldepegel` (Pegel ohne Hochwassermeldefunktion). Bei " "`Kein Hochwassermeldepegel` (der Pegel wird nicht für amtliche "
"Hochwasser sind laut HWIMS-Legende `Alarmstufe 1` bis `Alarmstufe 4` zu " "Hochwassermeldungen bzw. -steuerung genutzt; für solche Pegel liefert "
"erwarten; deren genaue Schreibweise im Feed ist nicht verifiziert. " "`alarm_thresholds` `null`). Bei Hochwasser sind laut HWIMS-Legende "
"Clients sollten unbekannte Werte tolerieren." "`Alarmstufe 1` bis `Alarmstufe 4` zu erwarten; deren genaue Schreibweise "
"im Feed ist nicht verifiziert. Clients sollten unbekannte Werte tolerieren."
), ),
examples=[ examples=[
"Niedrigwasser", "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): class PegelResponse(BaseModel):
station_id: str station_id: str
station_title: str | None station_title: str | None
count: int count: int
latest: PegelEntry latest: PegelEntry
entries: list[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 source: str
fetched_at: str fetched_at: str
BASE = "https://www.umwelt.sachsen.de/umwelt/infosysteme/hwims/portal/web/feed/wasserstand-pegel-{}" 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" DISCOVERY_URL = "https://www.umwelt.sachsen.de/umwelt/infosysteme/hwims/portal/web/wasserstand-uebersicht"
INDEX_CACHE_TTL_SECONDS = 900 INDEX_CACHE_TTL_SECONDS = 900
LATEST_CACHE_TTL_SECONDS = 120 LATEST_CACHE_TTL_SECONDS = 120
ALARM_THRESHOLDS_CACHE_TTL_SECONDS = 86400
ALARM_THRESHOLD_PATTERN = re.compile(
r'Alarmstufe\s*(\d)</span>.*?<td headers="rws">\s*([\d.,]+)\s*cm\s*</td>',
re.S,
)
class IndexCache(TypedDict): class IndexCache(TypedDict):
items: list[dict[str, Any]] | None items: list[dict[str, Any]] | None
@@ -98,6 +118,7 @@ _index_cache: IndexCache = {
} }
_latest_cache: dict[str, dict[str, Any]] = {} _latest_cache: dict[str, dict[str, Any]] = {}
_alarm_thresholds_cache: dict[str, dict[str, Any]] = {}
def parse_number(pattern, text): def parse_number(pattern, text):
m = re.search(pattern, text, re.I) m = re.search(pattern, text, re.I)
@@ -253,6 +274,48 @@ def _get_latest_for_station(station_id: str):
} }
return latest 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( @app.get(
"/api/pegel", "/api/pegel",
response_model=PegelIndexResponse, response_model=PegelIndexResponse,
@@ -329,7 +392,11 @@ def list_pegel(limit: int | None = None):
"`latest` ist der neueste verbleibende Eintrag und entspricht `entries[0]`; " "`latest` ist der neueste verbleibende Eintrag und entspricht `entries[0]`; "
"die Reihenfolge von `entries` folgt dem Feed (neueste zuerst). " "die Reihenfolge von `entries` folgt dem Feed (neueste zuerst). "
"Pegel-IDs bestehen aus mindestens sechs Ziffern. Einzelne fehlende " "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.", response_description="Stationsmetadaten, neuester Messwert und Feed-Historie.",
responses={ responses={
@@ -358,6 +425,7 @@ def get_pegel(pegel_id: str):
"count": len(entries), "count": len(entries),
"latest": entries[0], "latest": entries[0],
"entries": entries, "entries": entries,
"alarm_thresholds": _get_alarm_thresholds(pegel_id),
"source": url, "source": url,
"fetched_at": dt.datetime.now(dt.UTC).isoformat(), "fetched_at": dt.datetime.now(dt.UTC).isoformat(),
}) })