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

+73 -5
View File
@@ -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)</span>.*?<td headers="rws">\s*([\d.,]+)\s*cm\s*</td>',
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(),
})