DWD-Regenvorhersage: Pipeline + /radar-Route + Timeline-Integration + Settings-Toggle

PoC BESTANDEN (tools/dwd-radar/poc): Anker (9E,51N) = Pixel-Mitte (470/600),
Ecken decken sich mit der DWD-DE1200-Spec — Georeferenzierung bewiesen.
- tools/dwd-radar: RV-Komposit (25 Frames, 0-120min) -> kolorierte RGBA-
  PMTiles z4-7 je Frame (MapLibre overzoomt darueber) + manifest.json,
  atomarer Swap, KEEP_RUNS-Aufraeumen; 25 Frames in ~14s lokal
- docker-compose.dwd.yml (DSM-Cron alle 5 min, NIE --remove-orphans)
- main.py: /radar/manifest.json (no-store) + /radar/{run}/{file} (Range/206,
  immutable — Run-Id im Pfad); sw.js: /radar/ pass-through
- map.js: Radar-Frames heterogen ({url,time,dwd}) — DWD ersetzt RainViewer-
  Nowcast (0-120min, 5-min-Schritte) wenn Toggle an + GL + Karte in DE +
  Manifest frisch (<30min); sonst RainViewer-Fallback; Label '+X Min - DWD'
- settings.js: Toggle 'DWD-Regenvorhersage' (by_dwd_radar, Default AN)
- pytest 39 passed
Bump v1240
This commit is contained in:
rene 2026-06-06 18:08:57 +02:00
parent 6a06c9be7e
commit 5330681059
17 changed files with 4685 additions and 23 deletions

View file

@ -387,6 +387,60 @@ _FONTS_DIR = os.getenv("FONTS_DIR", os.path.join(_TILES_DIR, "fonts"))
if os.path.isdir(_FONTS_DIR):
app.mount("/fonts", StaticFiles(directory=_FONTS_DIR), name="fonts")
# DWD-Regenvorhersage (tools/dwd-radar, Cron alle 5 Min): run-<id>/rv_*.pmtiles + manifest.json.
_RADAR_DIR = os.getenv("RADAR_DIR", "/data/radar")
@app.get("/radar/manifest.json")
async def radar_manifest():
path = os.path.join(_RADAR_DIR, "manifest.json")
if not os.path.isfile(path):
return Response(status_code=404)
# Immer frisch: der Inhalt wechselt alle 5 Min bei GLEICHER URL
return FileResponse(path, media_type="application/json",
headers={"Cache-Control": "no-store"})
@app.api_route("/radar/{run}/{filename}", methods=["GET", "HEAD"])
async def serve_radar(run: str, filename: str, request: Request):
# Kein Path-Traversal; Run-Verzeichnis ist content-stabil (Run-Id im Pfad) → lange cachebar.
for part in (run, filename):
if "/" in part or "\\" in part or ".." in part:
return Response(status_code=404)
path = os.path.join(_RADAR_DIR, run, filename)
if not os.path.isfile(path):
return Response(status_code=404)
file_size = os.path.getsize(path)
_etag = f'"{file_size:x}-{int(os.path.getmtime(path)):x}"'
base_headers = {"Accept-Ranges": "bytes", "ETag": _etag,
"Cache-Control": "public, max-age=3600, immutable"}
if request.method == "HEAD":
return Response(status_code=200, media_type="application/octet-stream",
headers={**base_headers, "Content-Length": str(file_size)})
range_header = request.headers.get("range")
if range_header and range_header.startswith("bytes="):
rng = range_header[6:].split(",")[0]
start_s, _, end_s = rng.partition("-")
try:
if start_s == "":
length = int(end_s)
start = max(0, file_size - length)
end = file_size - 1
else:
start = int(start_s)
end = int(end_s) if end_s else file_size - 1
except ValueError:
return Response(status_code=416, headers={**base_headers, "Content-Range": f"bytes */{file_size}"})
end = min(end, file_size - 1)
if start > end or start >= file_size:
return Response(status_code=416, headers={**base_headers, "Content-Range": f"bytes */{file_size}"})
with open(path, "rb") as f:
f.seek(start)
data = f.read(end - start + 1)
return Response(data, status_code=206, media_type="application/octet-stream",
headers={**base_headers, "Content-Range": f"bytes {start}-{end}/{file_size}"})
return FileResponse(path, media_type="application/octet-stream", headers=base_headers)
@app.api_route("/tiles/{filename}", methods=["GET", "HEAD"])
async def serve_tile(filename: str, request: Request):
# Kein Path-Traversal