Compare commits

...

3 commits

Author SHA1 Message Date
6565d6a999 DWD-Pipeline: Dauer-Container mit 5-Min-Schleife statt DSM-Cron
DSM-Aufgabenplaner kann minimal stuendlich (Rene) — Container laeuft jetzt
dauerhaft (restart: unless-stopped) mit interner Schleife (loop.sh, idle =
sh+sleep, Python/GDAL nur waehrend des Laufs). Einmal 'up -d --build',
ueberlebt Reboots. make_radar_tiles.py ist idempotent, Fehler brechen die
Schleife nicht.
2026-06-06 18:21:52 +02:00
e6558b64d3 DWD-Plan: Umsetzungsstand dokumentiert (Staging v1240, Cron + Geraetetest offen) 2026-06-06 18:13:30 +02:00
5330681059 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
2026-06-06 18:08:57 +02:00
19 changed files with 4727 additions and 24 deletions

View file

@ -1 +1 @@
1239
1240

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

View file

@ -86,14 +86,14 @@
<title>Ban Yaro</title>
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
<script src="/js/boot-early.js?v=1239"></script>
<script src="/js/boot-early.js?v=1240"></script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=1239">
<link rel="stylesheet" href="/css/layout.css?v=1239">
<link rel="stylesheet" href="/css/components.css?v=1239">
<link rel="stylesheet" href="/css/utilities.css?v=1239">
<link rel="stylesheet" href="/css/lists.css?v=1239">
<link rel="stylesheet" href="/css/design-system.css?v=1240">
<link rel="stylesheet" href="/css/layout.css?v=1240">
<link rel="stylesheet" href="/css/components.css?v=1240">
<link rel="stylesheet" href="/css/utilities.css?v=1240">
<link rel="stylesheet" href="/css/lists.css?v=1240">
</head>
<body>
@ -612,11 +612,11 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=1239"></script>
<script src="/js/ui.js?v=1239"></script>
<script src="/js/app.js?v=1239"></script>
<script src="/js/worlds.js?v=1239"></script>
<script src="/js/offline-indicator.js?v=1239"></script>
<script src="/js/api.js?v=1240"></script>
<script src="/js/ui.js?v=1240"></script>
<script src="/js/app.js?v=1240"></script>
<script src="/js/worlds.js?v=1240"></script>
<script src="/js/offline-indicator.js?v=1240"></script>
<!-- Feature-Seiten werden lazy geladen -->
@ -626,7 +626,7 @@
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
<script src="/js/boot.js?v=1239"></script>
<script src="/js/boot.js?v=1240"></script>
</body>

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '1239'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '1240'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
window.APP_VERSION = APP_VERSION;

View file

@ -584,17 +584,57 @@ window.Page_map = (() => {
document.getElementById('central-map')?.appendChild(el);
}
// DWD-Regenvorhersage (Settings-Toggle, Default AN) — nur im GL-Modus sinnvoll
// (PMTiles-Protokoll) und innerhalb der DE1200-Abdeckung.
function _dwdEnabled() {
try { return localStorage.getItem('by_dwd_radar') !== '0'; } catch (e) { return true; }
}
function _mapInDwdCoverage() {
try {
const c = _map.getCenter(); // beide Engines: {lat, lng}
return c.lng >= 1.5 && c.lng <= 18.7 && c.lat >= 45.7 && c.lat <= 56.2;
} catch (e) { return false; }
}
async function _loadRadar() {
if (!_radarActive || !_map) return;
try {
const resp = await fetch('https://api.rainviewer.com/public/weather-maps.json', { cache: 'no-store' });
const data = await resp.json();
const past = data.radar?.past || [], nowcast = data.radar?.nowcast || [];
const frames = [...past, ...nowcast];
if (!frames.length) return;
_radarHost = data.host || _radarHost;
_radarFrames = frames.map(f => ({ path: f.path, time: f.time }));
_radarNowIdx = Math.max(0, past.length - 1); // "jetzt" = letzter Vergangenheits-Frame
if (!past.length && !nowcast.length) return;
_radarHost = data.host || _radarHost;
const rvUrl = f => `${_radarHost}${f.path}/256/{z}/{x}/{y}/4/1_1.png`;
// Default: RainViewer komplett (~2h Vergangenheit + ~30 min Nowcast)
let frames = [...past, ...nowcast].map(f => ({ url: rvUrl(f), time: f.time }));
let nowIdx = Math.max(0, past.length - 1); // "jetzt" = letzter Vergangenheits-Frame
// DWD-Vorhersage (0120 min, 5-Min-Schritte): ersetzt den RainViewer-Nowcast,
// Vergangenheit bleibt RainViewer (docs/DWD_RAIN_FORECAST_PLAN.md).
if (_dwdEnabled() && _engineGL && _mapInDwdCoverage()) {
try {
const r = await fetch('/radar/manifest.json', { cache: 'no-store' });
if (r.ok) {
const man = await r.json();
const runT = Math.floor(Date.parse(man.run_time_utc) / 1000);
// Nur wenn der Lauf frisch ist (< 30 min) — sonst RainViewer-Fallback
if (man.frames?.length && (Date.now() / 1000 - runT) < 1800) {
const pastRv = past.filter(f => f.time <= runT).map(f => ({ url: rvUrl(f), time: f.time }));
const dwd = man.frames.map(fr => ({
url: `pmtiles://${location.origin}/radar/${man.path}/${fr.file}/{z}/{x}/{y}`,
time: runT + fr.lead_min * 60,
dwd: true,
}));
frames = [...pastRv, ...dwd];
nowIdx = pastRv.length; // DWD lead 0 = "jetzt"
}
}
} catch (e) { /* offline/kein Manifest → RainViewer-Fallback */ }
}
_radarFrames = frames;
_radarNowIdx = nowIdx;
if (_radarIdx == null || _radarIdx >= _radarFrames.length) _radarIdx = _radarNowIdx;
_showRadarFrame(_radarIdx);
_buildRadarTimeline();
@ -602,7 +642,7 @@ window.Page_map = (() => {
}
function _radarUrl(idx) {
return `${_radarHost}${_radarFrames[idx].path}/256/{z}/{x}/{y}/4/1_1.png`;
return _radarFrames[idx].url;
}
// Frame anzeigen — wenn möglich smooth via setTiles (kein Flackern), sonst Layer neu.
@ -661,7 +701,7 @@ window.Page_map = (() => {
const hhmm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
const diffMin = Math.round((f.time - _radarFrames[_radarNowIdx].time) / 60);
const rel = diffMin === 0 ? 'jetzt' : (diffMin > 0 ? `+${diffMin} Min` : `${diffMin} Min`);
timeEl.textContent = `${hhmm} · ${rel}`;
timeEl.textContent = `${hhmm} · ${rel}${f.dwd && diffMin > 0 ? ' · DWD' : ''}`;
timeEl.classList.toggle('is-forecast', diffMin > 0);
}
}

View file

@ -825,6 +825,30 @@ window.Page_settings = (() => {
</div>`;
})()}
<!-- DWD-Regenvorhersage (Deutschland) speist die Karten-Radar-Timeline -->
<div class="settings-toggle-row">
<svg class="ph-icon" aria-hidden="true" style="width:1.25rem;height:1.25rem"><use href="/icons/phosphor.svg#cloud-rain"></use></svg>
<div class="settings-toggle-label">
<div style="font-weight:500">DWD-Regenvorhersage</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
2-Stunden-Vorhersage vom Deutschen Wetterdienst im Regenradar (nur Deutschland)
</div>
</div>
<label style="position:relative;display:inline-block;width:44px;height:24px;flex-shrink:0">
<input type="checkbox" id="toggle-dwd-radar"
style="opacity:0;width:0;height:0;position:absolute"
${localStorage.getItem('by_dwd_radar') !== '0' ? 'checked' : ''}>
<span style="position:absolute;cursor:pointer;inset:0;border-radius:12px;
background:${localStorage.getItem('by_dwd_radar') !== '0' ? 'var(--c-primary)' : 'var(--c-border)'};transition:.2s"
id="toggle-dwd-radar-track"></span>
<span id="toggle-dwd-radar-thumb"
style="position:absolute;top:2px;left:${localStorage.getItem('by_dwd_radar') !== '0' ? '22px' : '2px'};
width:20px;height:20px;border-radius:50%;
background:#fff;transition:.2s;
box-shadow:0 1px 3px rgba(0,0,0,.3)"></span>
</label>
</div>
<!-- Goldene Gassi-Stunde -->
<div class="settings-toggle-row" style="border-bottom:none">
<svg class="ph-icon" aria-hidden="true" style="width:1.25rem;height:1.25rem"><use href="/icons/phosphor.svg#paw-print"></use></svg>
@ -1583,6 +1607,19 @@ window.Page_settings = (() => {
: 'Pocket-Modus deaktiviert.');
});
// DWD-Regenvorhersage (Default AN) — ausgewertet in pages/map.js _dwdEnabled()
document.getElementById('toggle-dwd-radar')?.addEventListener('change', e => {
const on = e.target.checked;
localStorage.setItem('by_dwd_radar', on ? '1' : '0');
const track = document.getElementById('toggle-dwd-radar-track');
const thumb = document.getElementById('toggle-dwd-radar-thumb');
if (track) track.style.background = on ? 'var(--c-primary)' : 'var(--c-border)';
if (thumb) thumb.style.left = on ? '22px' : '2px';
UI.toast.info(on
? 'DWD-Regenvorhersage aktiviert — 2h-Vorhersage im Regenradar (Deutschland).'
: 'DWD-Regenvorhersage deaktiviert — Regenradar nutzt RainViewer.');
});
document.getElementById('toggle-notes-ki')?.addEventListener('change', async e => {
const enabled = e.target.checked;
const track = document.getElementById('toggle-notes-ki-track');

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<script src="/js/landing-init.js?v=1239"></script>
<script src="/js/landing-init.js?v=1240"></script>
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, ohne App Store.">
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">

View file

@ -4,7 +4,7 @@
============================================================ */
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
const VER = '1239';
const VER = '1240';
const CACHE_VERSION = `by-v${VER}`;
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
@ -296,6 +296,10 @@ self.addEventListener('fetch', event => {
return;
}
// DWD-Regenradar: NICHT abfangen — manifest.json wechselt alle 5 Min bei gleicher URL
// (no-store vom Server) und die PMTiles-Range-Requests (206) sind eh nicht cachebar.
if (url.pathname.startsWith('/radar/')) return;
// API-Calls mit Timeout, Caching und Write-Queue
if (url.pathname.startsWith('/api/')) {
const method = event.request.method;

21
docker-compose.dwd.yml Normal file
View file

@ -0,0 +1,21 @@
# DWD-Regenvorhersage-Pipeline (RV-Komposit → PMTiles-Frames) — NICHT Teil des Default-Stacks.
# DAUER-CONTAINER mit interner 5-Min-Schleife (loop.sh): der DSM-Aufgabenplaner kann nur
# stündlich, daher KEIN Cron. Einmal starten (überlebt Reboots via restart-Policy):
# docker compose -f docker-compose.dwd.yml up -d --build
# Stoppen: docker compose -f docker-compose.dwd.yml down
# ⚠️ NIE mit --remove-orphans aufrufen (löscht den App-Container)!
# Schreibt ins data-Volume (./data/radar) — ausgeliefert von main.py /radar/* (Range-Route).
# Georeferenzierung PoC-bewiesen 2026-06-08, s. tools/dwd-radar/ + docs/DWD_RAIN_FORECAST_PLAN.md.
services:
dwd-radar:
build: ./tools/dwd-radar
image: banyaro-dwd-radar
container_name: banyaro-dwd-radar
mem_limit: 1g
volumes:
- ./data/radar:/out
environment:
- INTERVAL_S=300 # Schleifen-Intervall (RV-Läufe kommen alle 5 Min)
- FRAME_STEP=1 # alle 25 Frames (5-Min-Schritte); 2 = 10-Min-Schritte falls DS-Last zu hoch
- KEEP_RUNS=2
restart: unless-stopped

View file

@ -1,6 +1,28 @@
# DWD Regen-Vorhersage (Radar-Nowcast) — Scoping-Plan
**Status:** gescoppt + Datenformat verifiziert (2026-06-05). Umsetzung offen.
**Status:** UMGESETZT auf Staging (v1240, 2026-06-08) — PoC bestanden, Pipeline + Frontend live.
**Offen:** DSM-Aufgabenplaner-Cron (alle 5 Min, Staging + Prod) anlegen; Gerätetest; Prod-Deploy.
## Umsetzung (2026-06-08)
- **PoC Georeferenzierung BESTANDEN** (`tools/dwd-radar/poc/`): DE1200 = polar-stereografisch auf
WGS84-Ellipsoid (wradlib-Parameter, False Easting/Northing 543196.835/3622588.862 → LL-Ecke (0,0);
in GDAL-Konvention y südwärts negativ, Gitter y ∈ [-1200000, 0]). Anker (9°E, 51°N) = Mitte von
Pixel (470/600) ✓, Ecken decken sich mit der DE1200-Spec ✓.
- **Pipeline** `tools/dwd-radar/make_radar_tiles.py` (Container: GDAL + go-pmtiles): RV-Komposit →
je Frame dekodieren → RGBA-Farbskala (transparent < 0,05 mm/5min) Warp 3857 MBTiles z7-Basis
+ Overviews bis z4 → PMTiles. 25 Frames ≈ 14 s (Mac) / ~30 s (DS). Manifest + atomarer Swap
(`run-<id>/`), KEEP_RUNS=2. `docker-compose.dwd.yml`**DSM-Aufgabenplaner ALLE 5 MIN**:
`cd <pfad> && docker compose -f docker-compose.dwd.yml run --rm dwd-radar` (⚠️ NIE --remove-orphans).
- **Serving** main.py: `/radar/manifest.json` (no-store) + `/radar/{run}/{file}` (Range/206,
immutable — Run-Id im Pfad). sw.js: `/radar/` Pass-through.
- **Frontend** map.js: Radar-Frames heterogen (`{url, time, dwd}`) — DWD ersetzt den RainViewer-
Nowcast (0120 min) wenn: Toggle an + GL-Modus + Kartenmitte in DE1200-Bbox + Manifest frisch
(< 30 min). Sonst RainViewer-Fallback (auch außerhalb DE / offline / DWD-Ausfall). DWD-Frames
als `pmtiles://`-Raster-Template über das vorhandene Protokoll; Label „+X Min · DWD".
- **Settings-Toggle** „DWD-Regenvorhersage" (`by_dwd_radar`, Default AN), settings.js.
(Ursprüngliches Scoping:)
~~**Status:** gescoppt + Datenformat verifiziert (2026-06-05). Umsetzung offen.~~
**Ziel:** Verlässliche, längere Regen-**Vorhersage** als animiertes Karten-Overlay (bis +2 h) statt RainViewers
unzuverlässigem 30-Min-Nowcast (der oft leer ist). Self-hosted wie die Basemap — passt zur Tile-Server-Philosophie.

View file

@ -0,0 +1,19 @@
# DWD-Regenradar-Pipeline: GDAL (decode/warp/tile) + go-pmtiles (MBTiles → PMTiles)
FROM ghcr.io/osgeo/gdal:alpine-normal-latest
# go-pmtiles-CLI (statisches Binary)
ARG PMTILES_VERSION=1.22.1
ARG TARGETARCH
RUN ARCH=$([ "$TARGETARCH" = "arm64" ] && echo arm64 || echo x86_64) && \
wget -qO /tmp/pmtiles.tar.gz \
"https://github.com/protomaps/go-pmtiles/releases/download/v${PMTILES_VERSION}/go-pmtiles_${PMTILES_VERSION}_Linux_${ARCH}.tar.gz" && \
tar xzf /tmp/pmtiles.tar.gz -C /usr/local/bin pmtiles && \
rm /tmp/pmtiles.tar.gz && pmtiles version || true
COPY make_radar_tiles.py /app/make_radar_tiles.py
COPY loop.sh /app/loop.sh
RUN chmod +x /app/loop.sh
WORKDIR /app
# Dauerbetrieb mit interner 5-Min-Schleife (DSM-Aufgabenplaner kann nur stündlich).
# Einmal-Lauf weiterhin möglich: docker compose run --rm dwd-radar python3 /app/make_radar_tiles.py
CMD ["/app/loop.sh"]

12
tools/dwd-radar/loop.sh Normal file
View file

@ -0,0 +1,12 @@
#!/bin/sh
# Endlos-Schleife statt Cron: Der DSM-Aufgabenplaner kann minimal stündlich (René 2026-06-08),
# die RV-Läufe kommen aber alle 5 Minuten. Der Container läuft daher dauerhaft (restart:
# unless-stopped) und schläft zwischen den Läufen — idle ist nur die sh+sleep (winzig),
# Python/GDAL leben nur während des Laufs. make_radar_tiles.py ist idempotent
# (gleicher Lauf vorhanden → sofort fertig), Fehler brechen die Schleife nicht.
INTERVAL="${INTERVAL_S:-300}"
echo "DWD-Radar-Loop: alle ${INTERVAL}s (INTERVAL_S zum Ändern)"
while true; do
python3 /app/make_radar_tiles.py || echo "Lauf fehlgeschlagen — nächster Versuch in ${INTERVAL}s"
sleep "$INTERVAL"
done

View file

@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""DWD-RV-Regenvorhersage → PMTiles-Frames + Manifest.
Läuft im Container (python3 + GDAL + numpy + pmtiles-CLI, s. Dockerfile).
Alle 5 Min (Cron auf der DS):
1. neuestes DE1200_RV-Komposit von opendata.dwd.de laden (25 Frames, 0120 min)
2. je Frame: decodieren RGBA kolorieren DE1200-GeoTIFF Warp 3857
MBTiles (z09) PMTiles
3. manifest.json + atomarer Swap nach OUT_DIR (rename), alte Läufe aufräumen
Georeferenzierung BEWIESEN (PoC tools/dwd-radar/poc/, 2026-06-08): Anker (9E,51N)
= Pixel-Mitte (470/600), Ecken decken sich mit der DWD-DE1200-Spec.
Format: 194-Byte-ASCII-Header bis ETX, 1200×1100 uint16 LE,
Wert = (raw & 0x0FFF) × 10^-PR mm/5min, raw & 0x2000 = kein Daten.
ENV: OUT_DIR (Default /out), FRAME_STEP (1 = alle 25 Frames, 2 = 10-Min-Schritte),
KEEP_RUNS (Default 2).
Zoom: Basis z7 ( native 1-km-Auflösung, ZOOM_LEVEL_STRATEGY=UPPER) + Overviews bis z4.
Darüber overzoomt MapLibre die Raster-Source nativ (Radar ist ohnehin 1-km-blockig);
unter z4 wird der Layer im Frontend ausgeblendet (minzoom).
"""
import json
import os
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
import urllib.request
from pathlib import Path
import numpy as np
from osgeo import gdal, osr
gdal.UseExceptions()
BASE_URL = "https://opendata.dwd.de/weather/radar/composite/rv/"
OUT_DIR = Path(os.environ.get("OUT_DIR", "/out"))
FRAME_STEP = int(os.environ.get("FRAME_STEP", "1"))
KEEP_RUNS = int(os.environ.get("KEEP_RUNS", "2"))
MIN_ZOOM, MAX_ZOOM = 4, 7 # s. Docstring (Overzoom > z7 macht MapLibre)
NCOLS, NROWS = 1100, 1200
# DE1200, WGS84-Variante (wradlib-Parameter, PoC-verifiziert). In GDALs Achsen-
# Konvention belegt das Gitter x ∈ [0, 1100000], y ∈ [-1200000, 0] (Süden negativ).
DE1200_WKT = (
'PROJCS["Radolan Projection",'
'GEOGCS["Radolan Coordinate System",'
'DATUM["Radolan_Kugel",SPHEROID["WGS 84", 6378137, 298.25722356301]],'
'PRIMEM["Greenwich", 0],'
'UNIT["degree", 0.017453292519943295]],'
'PROJECTION["Polar_Stereographic"],'
'PARAMETER["latitude_of_origin", 60],'
'PARAMETER["central_meridian", 10],'
'PARAMETER["false_easting", 543196.83521776402],'
'PARAMETER["false_northing", 3622588.8619310018],'
'UNIT["m", 1]]'
)
# Farbskala mm/5min → RGBA (an gängige Radar-Paletten angelehnt, RainViewer-ähnlich).
# Unter 0,05 mm/5min transparent (Rauschen), darüber blau→grün→gelb→orange→rot→violett.
SCALE = [
(0.05, (60, 130, 220, 110)),
(0.15, (40, 160, 230, 150)),
(0.40, (50, 200, 130, 170)),
(0.80, (230, 210, 70, 190)),
(1.50, (240, 150, 50, 210)),
(3.00, (235, 70, 50, 230)),
(6.00, (180, 40, 150, 240)),
(99.0, (130, 20, 110, 250)),
]
def latest_archive():
html = urllib.request.urlopen(BASE_URL, timeout=30).read().decode()
names = sorted(set(re.findall(r'DE1200_RV\d{10}\.tar\.bz2', html)))
if not names:
raise RuntimeError("Kein RV-Komposit im DWD-Verzeichnis gefunden")
return names[-1]
def parse_frame(raw):
etx = raw.index(b'\x03')
header = raw[:etx].decode('ascii', 'replace')
prec = 0.01
m = re.search(r'PR E-(\d{2})', header)
if m:
prec = 10 ** -int(m.group(1))
data = np.frombuffer(raw[etx + 1:], dtype='<u2')
if data.size != NCOLS * NROWS:
raise ValueError(f"Datenlänge {data.size} != {NCOLS * NROWS}")
grid = data.reshape(NROWS, NCOLS) # Zeile 0 = Süden
nodata = (grid & 0x2000) > 0
vals = (grid & 0x0FFF).astype(np.float32) * prec
vals[nodata] = 0.0 # kein Daten = transparent wie kein Regen
return vals
def colorize(vals):
"""mm/5min → RGBA uint8 (4, NROWS, NCOLS), Zeile 0 = Norden (für GDAL geflippt)."""
rgba = np.zeros((4, NROWS, NCOLS), dtype=np.uint8)
lower = 0.0
for thresh, (r, g, b, a) in SCALE:
m = (vals > lower) & (vals <= thresh) if lower > 0 else (vals >= 0.05) & (vals <= thresh)
rgba[0][m], rgba[1][m], rgba[2][m], rgba[3][m] = r, g, b, a
lower = thresh
return rgba[:, ::-1, :] # Süd-zuerst → Nord-zuerst
def frame_to_pmtiles(vals, out_pmtiles, tmp):
rgba = colorize(vals)
drv = gdal.GetDriverByName('GTiff')
src = str(tmp / 'frame_de1200.tif')
ds = drv.Create(src, NCOLS, NROWS, 4, gdal.GDT_Byte, options=['COMPRESS=DEFLATE'])
ds.SetProjection(DE1200_WKT)
ds.SetGeoTransform((0, 1000, 0, 0, 0, -1000)) # linke OBERE Ecke (0,0), y südwärts
for i in range(4):
ds.GetRasterBand(i + 1).WriteArray(rgba[i])
ds = None
# Warp 3857 + MBTiles z0MAX_ZOOM + Overviews
warped = str(tmp / 'frame_3857.tif')
gdal.Warp(warped, src, dstSRS='EPSG:3857', resampleAlg='near',
creationOptions=['COMPRESS=DEFLATE'])
mb = str(tmp / 'frame.mbtiles')
gdal.Translate(mb, warped, format='MBTILES',
creationOptions=['TILE_FORMAT=PNG', 'ZOOM_LEVEL_STRATEGY=UPPER'])
mbds = gdal.Open(mb, gdal.GA_Update)
mbds.BuildOverviews('AVERAGE', [2 ** i for i in range(1, MAX_ZOOM - MIN_ZOOM + 1)])
mbds = None
subprocess.run(['pmtiles', 'convert', mb, str(out_pmtiles)],
check=True, capture_output=True)
for f in (src, warped, mb):
Path(f).unlink(missing_ok=True)
def main():
name = latest_archive()
run_id = re.search(r'RV(\d{10})', name).group(1) # YYMMDDHHMM (UTC)
run_dir = OUT_DIR / f'run-{run_id}'
if run_dir.exists():
print(f"Lauf {run_id} existiert schon — nichts zu tun.")
return
print(f"Lade {name}")
with tempfile.TemporaryDirectory() as td:
tmp = Path(td)
arch = tmp / name
urllib.request.urlretrieve(BASE_URL + name, arch)
work = OUT_DIR / f'.tmp-{run_id}'
if work.exists():
shutil.rmtree(work)
work.mkdir(parents=True)
frames = []
with tarfile.open(arch, 'r:bz2') as tf:
members = sorted(tf.getnames())
for i, m in enumerate(members):
lead = int(m.rsplit('_', 1)[1])
if (i % FRAME_STEP) != 0:
continue
vals = parse_frame(tf.extractfile(m).read())
out = work / f'rv_{lead:03d}.pmtiles'
frame_to_pmtiles(vals, out, tmp)
frames.append({'lead_min': lead, 'file': out.name})
print(f" Frame +{lead:03d} min → {out.name}")
# Manifest + atomarer Swap: erst Verzeichnis, dann manifest.json auf den neuen Lauf
ts = run_id # YYMMDDHHMM UTC
iso = f"20{ts[0:2]}-{ts[2:4]}-{ts[4:6]}T{ts[6:8]}:{ts[8:10]}:00Z"
manifest = {'run': run_id, 'run_time_utc': iso, 'interval_min': 5 * FRAME_STEP,
'min_zoom': MIN_ZOOM, 'max_zoom': MAX_ZOOM,
'path': run_dir.name, 'frames': frames}
(work / 'manifest.json').write_text(json.dumps(manifest))
work.rename(run_dir)
(OUT_DIR / 'manifest.json.tmp').write_text(json.dumps(manifest))
(OUT_DIR / 'manifest.json.tmp').rename(OUT_DIR / 'manifest.json')
# Alte Läufe aufräumen (die letzten KEEP_RUNS behalten)
runs = sorted([d for d in OUT_DIR.iterdir() if d.is_dir() and d.name.startswith('run-')])
for old in runs[:-KEEP_RUNS]:
shutil.rmtree(old, ignore_errors=True)
print(f"Fertig: Lauf {run_id}, {len(frames)} Frames → {run_dir}")
if __name__ == '__main__':
sys.exit(main())

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""DWD-RV-PoC: Frame dekodieren + Georeferenzierung beweisen.
Läuft im osgeo/gdal-Container (python3 + GDAL + numpy).
Schritte:
1. RV-Frame parsen (Header bis ETX, 1200×1100 uint16 LE,
Wert = (raw & 0x0FFF) * 10^PR mm/5min, raw & 0x2000 = kein Echo/kein Daten)
2. DE1200-CRS (polar-stereografisch, WGS84-Ellipsoid, wradlib-Parameter):
ANKER-BEWEIS: (9°E, 51°N) muss auf (470000, 600000) m projizieren.
3. GeoTIFF (Float) im DE1200-CRS gdalwarp nach EPSG:3857
4. Ecken in WGS84 ausgeben (Plausibilität: Deutschland-Umgriff)
Doku: docs/DWD_RAIN_FORECAST_PLAN.md · Parameter: wradlib georef/projection.py
"""
import sys
import numpy as np
from osgeo import gdal, osr
gdal.UseExceptions()
NCOLS, NROWS = 1100, 1200
# DE1200, WGS84-Variante (wradlib _radolan_ref['wgs84']['de1200']):
# False Easting/Northing so, dass die LINKE UNTERE Gitterecke bei (0,0) liegt.
DE1200_WKT = (
'PROJCS["Radolan Projection",'
'GEOGCS["Radolan Coordinate System",'
'DATUM["Radolan_Kugel",SPHEROID["WGS 84", 6378137, 298.25722356301]],'
'PRIMEM["Greenwich", 0],'
'UNIT["degree", 0.017453292519943295]],'
'PROJECTION["Polar_Stereographic"],'
'PARAMETER["latitude_of_origin", 60],'
'PARAMETER["central_meridian", 10],'
'PARAMETER["false_easting", 543196.83521776402],'
'PARAMETER["false_northing", 3622588.8619310018],'
'UNIT["m", 1]]'
)
def parse_frame(path):
raw = open(path, 'rb').read()
etx = raw.index(b'\x03')
header = raw[:etx].decode('ascii', 'replace')
# PR-Feld: Genauigkeit, z.B. "PR E-02" → Faktor 0.01
prec = 0.01
if 'E-' in header:
try:
prec = 10 ** -int(header.split('E-')[1][:2])
except Exception:
pass
data = np.frombuffer(raw[etx + 1:], dtype='<u2')
assert data.size == NCOLS * NROWS, f"Datenlänge {data.size} != {NCOLS*NROWS}"
grid = data.reshape(NROWS, NCOLS) # Zeile 0 = SÜDLICHSTE Zeile (RADOLAN: Start links unten)
nodata = (grid & 0x2000) > 0
vals = (grid & 0x0FFF).astype(np.float32) * prec # mm / 5 min
vals[nodata] = np.nan
return header, vals
def main(frame_path, out_prefix):
header, vals = parse_frame(frame_path)
print("Header:", header[:120])
print(f"Werte: min={np.nanmin(vals):.2f} max={np.nanmax(vals):.2f} mm/5min, "
f"Regen-Pixel (>0): {(np.nan_to_num(vals) > 0).sum()}")
srs = osr.SpatialReference(); srs.ImportFromWkt(DE1200_WKT)
wgs = osr.SpatialReference(); wgs.ImportFromEPSG(4326)
wgs.SetAxisMappingStrategy(osr.OAMS_TRADITIONAL_GIS_ORDER)
# --- ANKER-BEWEIS: (9E, 51N) liegt auf der MITTE von Pixel (Spalte 470, Zeile 600
# von unten). In GDALs Achsen-Konvention (polar-stereografisch, Süden negativ) belegt
# das Gitter x ∈ [0, 1100000], y ∈ [-1200000, 0] → Anker ≈ (469500, -599500).
to_de = osr.CoordinateTransformation(wgs, srs)
ax, ay, _ = to_de.TransformPoint(9.0, 51.0)
print(f"Anker (9E,51N) → ({ax:.1f}, {ay:.1f}) [erwartet ≈ (469500, -599500)]")
if abs(ax - 469500) > 600 or abs(ay + 599500) > 600:
print("FEHLER: Anker-Abweichung > 600 m — Projektionsparameter falsch!")
sys.exit(1)
# --- Gitter-Ecken in WGS84 (Plausibilität: Deutschland-Umgriff) ---
to_wgs = osr.CoordinateTransformation(srs, wgs)
for name, (x, y) in [("LL", (0, -NROWS * 1000)), ("LR", (NCOLS * 1000, -NROWS * 1000)),
("UL", (0, 0)), ("UR", (NCOLS * 1000, 0))]:
lon, lat, _ = to_wgs.TransformPoint(float(x), float(y))
print(f"Ecke {name}: {lon:.4f}E {lat:.4f}N")
# --- GeoTIFF im DE1200-CRS (Zeile 0 der Datei = Süden → für GDAL flippen) ---
drv = gdal.GetDriverByName('GTiff')
ds = drv.Create(f"{out_prefix}_de1200.tif", NCOLS, NROWS, 1, gdal.GDT_Float32,
options=['COMPRESS=DEFLATE'])
ds.SetProjection(DE1200_WKT)
# GeoTransform: linke OBERE Ecke (0, 0) — Gitter-y läuft in diesem CRS südwärts negativ
ds.SetGeoTransform((0, 1000, 0, 0, 0, -1000))
band = ds.GetRasterBand(1)
band.SetNoDataValue(-1)
flipped = np.flipud(np.nan_to_num(vals, nan=-1))
band.WriteArray(flipped)
ds = None
print(f"OK: {out_prefix}_de1200.tif geschrieben")
# --- Warp nach EPSG:3857 ---
gdal.Warp(f"{out_prefix}_3857.tif", f"{out_prefix}_de1200.tif",
dstSRS='EPSG:3857', xRes=1000, yRes=1000,
srcNodata=-1, dstNodata=-1, resampleAlg='near',
creationOptions=['COMPRESS=DEFLATE'])
info = gdal.Info(f"{out_prefix}_3857.tif", format='json')
cc = info['cornerCoordinates']
print(f"3857-Bounds: UL={cc['upperLeft']} LR={cc['lowerRight']}")
print("OK: Warp nach EPSG:3857 fertig")
if __name__ == '__main__':
main(sys.argv[1], sys.argv[2])

Binary file not shown.

Binary file not shown.

Binary file not shown.