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

@ -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): if os.path.isdir(_FONTS_DIR):
app.mount("/fonts", StaticFiles(directory=_FONTS_DIR), name="fonts") 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"]) @app.api_route("/tiles/{filename}", methods=["GET", "HEAD"])
async def serve_tile(filename: str, request: Request): async def serve_tile(filename: str, request: Request):
# Kein Path-Traversal # Kein Path-Traversal

View file

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

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. 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 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_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
window.APP_VERSION = APP_VERSION; window.APP_VERSION = APP_VERSION;

View file

@ -584,17 +584,57 @@ window.Page_map = (() => {
document.getElementById('central-map')?.appendChild(el); 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() { async function _loadRadar() {
if (!_radarActive || !_map) return; if (!_radarActive || !_map) return;
try { try {
const resp = await fetch('https://api.rainviewer.com/public/weather-maps.json', { cache: 'no-store' }); const resp = await fetch('https://api.rainviewer.com/public/weather-maps.json', { cache: 'no-store' });
const data = await resp.json(); const data = await resp.json();
const past = data.radar?.past || [], nowcast = data.radar?.nowcast || []; const past = data.radar?.past || [], nowcast = data.radar?.nowcast || [];
const frames = [...past, ...nowcast]; if (!past.length && !nowcast.length) return;
if (!frames.length) return; _radarHost = data.host || _radarHost;
_radarHost = data.host || _radarHost; const rvUrl = f => `${_radarHost}${f.path}/256/{z}/{x}/{y}/4/1_1.png`;
_radarFrames = frames.map(f => ({ path: f.path, time: f.time }));
_radarNowIdx = Math.max(0, past.length - 1); // "jetzt" = letzter Vergangenheits-Frame // 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; if (_radarIdx == null || _radarIdx >= _radarFrames.length) _radarIdx = _radarNowIdx;
_showRadarFrame(_radarIdx); _showRadarFrame(_radarIdx);
_buildRadarTimeline(); _buildRadarTimeline();
@ -602,7 +642,7 @@ window.Page_map = (() => {
} }
function _radarUrl(idx) { 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. // 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 hhmm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
const diffMin = Math.round((f.time - _radarFrames[_radarNowIdx].time) / 60); const diffMin = Math.round((f.time - _radarFrames[_radarNowIdx].time) / 60);
const rel = diffMin === 0 ? 'jetzt' : (diffMin > 0 ? `+${diffMin} Min` : `${diffMin} Min`); 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); timeEl.classList.toggle('is-forecast', diffMin > 0);
} }
} }

View file

@ -825,6 +825,30 @@ window.Page_settings = (() => {
</div>`; </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 --> <!-- Goldene Gassi-Stunde -->
<div class="settings-toggle-row" style="border-bottom:none"> <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> <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.'); : '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 => { document.getElementById('toggle-notes-ki')?.addEventListener('change', async e => {
const enabled = e.target.checked; const enabled = e.target.checked;
const track = document.getElementById('toggle-notes-ki-track'); const track = document.getElementById('toggle-notes-ki-track');

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark"> <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> <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="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"> <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 // ← 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_VERSION = `by-v${VER}`;
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
@ -296,6 +296,10 @@ self.addEventListener('fetch', event => {
return; 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 // API-Calls mit Timeout, Caching und Write-Queue
if (url.pathname.startsWith('/api/')) { if (url.pathname.startsWith('/api/')) {
const method = event.request.method; const method = event.request.method;

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

@ -0,0 +1,18 @@
# DWD-Regenvorhersage-Pipeline (RV-Komposit → PMTiles-Frames) — NICHT Teil des Default-Stacks.
# Trigger: DSM-Aufgabenplaner ALLE 5 MINUTEN:
# docker compose -f docker-compose.dwd.yml run --rm dwd-radar
# ⚠️ 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:
- FRAME_STEP=1 # alle 25 Frames (5-Min-Schritte); 2 = 10-Min-Schritte falls DS-Last zu hoch
- KEEP_RUNS=2
restart: "no"

View file

@ -0,0 +1,15 @@
# 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
WORKDIR /app
CMD ["python3", "/app/make_radar_tiles.py"]

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.