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:
parent
6a06c9be7e
commit
5330681059
17 changed files with 4685 additions and 23 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
1239
|
1240
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 (0–120 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
18
docker-compose.dwd.yml
Normal 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"
|
||||||
15
tools/dwd-radar/Dockerfile
Normal file
15
tools/dwd-radar/Dockerfile
Normal 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"]
|
||||||
188
tools/dwd-radar/make_radar_tiles.py
Normal file
188
tools/dwd-radar/make_radar_tiles.py
Normal 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, 0–120 min)
|
||||||
|
2. je Frame: decodieren → RGBA kolorieren → DE1200-GeoTIFF → Warp 3857
|
||||||
|
→ MBTiles (z0–9) → 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 z0–MAX_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())
|
||||||
2120
tools/dwd-radar/poc/DE1200_RV2606061540_000
Normal file
2120
tools/dwd-radar/poc/DE1200_RV2606061540_000
Normal file
File diff suppressed because one or more lines are too long
2073
tools/dwd-radar/poc/DE1200_RV2606061540_060
Normal file
2073
tools/dwd-radar/poc/DE1200_RV2606061540_060
Normal file
File diff suppressed because one or more lines are too long
113
tools/dwd-radar/poc/decode_poc.py
Normal file
113
tools/dwd-radar/poc/decode_poc.py
Normal 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])
|
||||||
BIN
tools/dwd-radar/poc/poc_3857.tif
Normal file
BIN
tools/dwd-radar/poc/poc_3857.tif
Normal file
Binary file not shown.
BIN
tools/dwd-radar/poc/poc_de1200.tif
Normal file
BIN
tools/dwd-radar/poc/poc_de1200.tif
Normal file
Binary file not shown.
BIN
tools/dwd-radar/poc/rv.tar.bz2
Normal file
BIN
tools/dwd-radar/poc/rv.tar.bz2
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue