HexaWetter v1.3.0: Add precipitation map feature, update README, and enhance UI elements
This commit is contained in:
@@ -2,7 +2,7 @@ import os
|
||||
from pathlib import Path
|
||||
|
||||
APP_NAME = "HexaWetter"
|
||||
APP_VERSION = "1.2.0"
|
||||
APP_VERSION = "1.3.0"
|
||||
|
||||
DATA_DIR = Path(os.getenv("DATA_DIR", "/data"))
|
||||
RAW_RADAR_DIR = DATA_DIR / "raw" / "radar"
|
||||
@@ -39,6 +39,11 @@ CACHE_TTL_GEOCODE = int(os.getenv("CACHE_TTL_GEOCODE", "86400"))
|
||||
CACHE_TTL_WMS_TIMES = int(os.getenv("CACHE_TTL_WMS_TIMES", "120"))
|
||||
CACHE_TTL_HEALTH_SOURCES = int(os.getenv("CACHE_TTL_HEALTH_SOURCES", "90"))
|
||||
CACHE_TTL_DASHBOARD = int(os.getenv("CACHE_TTL_DASHBOARD", "60"))
|
||||
CACHE_TTL_PRECIP_MAP = int(os.getenv("CACHE_TTL_PRECIP_MAP", "900"))
|
||||
|
||||
PRECIP_MAP_SPAN_DEG = float(os.getenv("PRECIP_MAP_SPAN_DEG", "3.0"))
|
||||
PRECIP_MAP_STEP_DEG = float(os.getenv("PRECIP_MAP_STEP_DEG", "0.25"))
|
||||
PRECIP_MAP_HOURS_DEFAULT = int(os.getenv("PRECIP_MAP_HOURS_DEFAULT", "24"))
|
||||
|
||||
RATE_LIMIT_PER_MINUTE = int(os.getenv("RATE_LIMIT_PER_MINUTE", "120"))
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ from app.config import (
|
||||
DEFAULT_LON,
|
||||
DEFAULT_PLACE,
|
||||
DWD_WMS_URL,
|
||||
PRECIP_MAP_HOURS_DEFAULT,
|
||||
PRECIP_MAP_SPAN_DEG,
|
||||
PRECIP_MAP_STEP_DEG,
|
||||
RADAR_PRODUCT,
|
||||
RATE_LIMIT_PER_MINUTE,
|
||||
RAW_RADAR_DIR,
|
||||
@@ -40,9 +43,10 @@ from app.security import (
|
||||
validate_security_config,
|
||||
verify_request_api_key,
|
||||
)
|
||||
from app.services.precipitation_map import get_precipitation_map
|
||||
from app.services.warnings import fetch_warnings_for_location
|
||||
from app.services.weather import get_forecast, get_observations
|
||||
from app.services.wms import fetch_wms_time_steps
|
||||
from app.services.wms import fetch_wms_time_steps, fetch_wms_time_steps_quick
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -200,6 +204,22 @@ async def forecast(
|
||||
raise HTTPException(status_code=502, detail=f"Forecast source unavailable: {exc}") from exc
|
||||
|
||||
|
||||
@app.get("/api/forecast/precipitation-map")
|
||||
async def forecast_precipitation_map(
|
||||
lat: float = Query(DEFAULT_LAT, ge=-90, le=90),
|
||||
lon: float = Query(DEFAULT_LON, ge=-180, le=180),
|
||||
hours: int = Query(PRECIP_MAP_HOURS_DEFAULT, ge=6, le=24),
|
||||
span_deg: float = Query(PRECIP_MAP_SPAN_DEG, ge=1.5, le=6.0),
|
||||
step_deg: float = Query(PRECIP_MAP_STEP_DEG, ge=0.15, le=0.5),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return await get_precipitation_map(lat, lon, hours=hours, span_deg=span_deg, step_deg=step_deg)
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Precipitation map unavailable: {exc}") from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Precipitation map error: {exc}") from exc
|
||||
|
||||
|
||||
@app.get("/api/observations")
|
||||
async def observations(
|
||||
lat: float = Query(DEFAULT_LAT, ge=-90, le=90),
|
||||
@@ -291,8 +311,11 @@ async def radar_wms() -> dict[str, Any]:
|
||||
async def radar_wms_times(
|
||||
layer: str = Query("dwd:Niederschlagsradar"),
|
||||
minutes: int = Query(120, ge=15, le=180),
|
||||
quick: bool = Query(False),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
if quick:
|
||||
return await fetch_wms_time_steps_quick(layer, minutes=minutes)
|
||||
return await fetch_wms_time_steps(layer, minutes=minutes)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"WMS time dimension unavailable: {exc}") from exc
|
||||
|
||||
@@ -53,7 +53,15 @@ async def check_sources() -> list[dict[str, Any]]:
|
||||
_probe("open_meteo", f"{OPEN_METEO_BASE_URL}/forecast?latitude=52.5&longitude=13.4¤t=temperature_2m"),
|
||||
_probe("brightsky", f"{BRIGHTSKY_BASE_URL}/current_weather?lat=52.5&lon=13.4"),
|
||||
_probe("dwd_radar", f"{DWD_RADAR_BASE_URL}/rv/", timeout=4.0),
|
||||
_probe("dwd_wms", f"{DWD_WMS_URL}?service=WMS&request=GetCapabilities", timeout=6.0),
|
||||
_probe(
|
||||
"dwd_wms",
|
||||
(
|
||||
f"{DWD_WMS_URL}?service=WMS&version=1.1.1&request=GetMap"
|
||||
"&layers=dwd:Niederschlagsradar&format=image/png&transparent=true"
|
||||
"&width=64&height=64&srs=EPSG:4326&bbox=8,50,9,51"
|
||||
),
|
||||
timeout=5.0,
|
||||
),
|
||||
_probe("dwd_warnings", DWD_WARNINGS_URL, timeout=4.0),
|
||||
)
|
||||
results = list(probes)
|
||||
|
||||
102
api/app/services/precipitation_map.py
Normal file
102
api/app/services/precipitation_map.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.cache import cache_get, cache_set
|
||||
from app.config import CACHE_TTL_PRECIP_MAP, OPEN_METEO_BASE_URL
|
||||
from app.http_client import fetch_json
|
||||
|
||||
BATCH_SIZE = 80
|
||||
DEFAULT_SPAN_DEG = 3.0
|
||||
DEFAULT_STEP_DEG = 0.25
|
||||
|
||||
|
||||
def _coord_key(lat: float, lon: float) -> tuple[float, float]:
|
||||
return round(lat, 2), round(lon, 2)
|
||||
|
||||
|
||||
def _build_grid(center_lat: float, center_lon: float, span_deg: float, step_deg: float) -> list[tuple[float, float]]:
|
||||
half = span_deg / 2
|
||||
points: list[tuple[float, float]] = []
|
||||
lat = center_lat - half
|
||||
while lat <= center_lat + half + 1e-9:
|
||||
lon = center_lon - half
|
||||
while lon <= center_lon + half + 1e-9:
|
||||
points.append((round(lat, 2), round(lon, 2)))
|
||||
lon += step_deg
|
||||
lat += step_deg
|
||||
return points
|
||||
|
||||
|
||||
async def _fetch_batch(lats: list[float], lons: list[float], hours: int) -> list[dict[str, Any]]:
|
||||
data = await fetch_json(
|
||||
f"{OPEN_METEO_BASE_URL}/forecast",
|
||||
params={
|
||||
"latitude": ",".join(str(v) for v in lats),
|
||||
"longitude": ",".join(str(v) for v in lons),
|
||||
"hourly": "precipitation,precipitation_probability",
|
||||
"forecast_hours": hours,
|
||||
"timezone": "Europe/Berlin",
|
||||
},
|
||||
timeout=30.0,
|
||||
)
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
return [data]
|
||||
|
||||
|
||||
async def get_precipitation_map(
|
||||
lat: float,
|
||||
lon: float,
|
||||
hours: int = 24,
|
||||
span_deg: float = DEFAULT_SPAN_DEG,
|
||||
step_deg: float = DEFAULT_STEP_DEG,
|
||||
) -> dict[str, Any]:
|
||||
hours = max(6, min(hours, 24))
|
||||
span_deg = max(1.5, min(span_deg, 6.0))
|
||||
step_deg = max(0.15, min(step_deg, 0.5))
|
||||
|
||||
lat_k, lon_k = _coord_key(lat, lon)
|
||||
cache_key = f"precip_map:{lat_k}:{lon_k}:{hours}:{span_deg}:{step_deg}"
|
||||
cached = await cache_get(cache_key)
|
||||
if cached:
|
||||
return {**cached, "cached": True}
|
||||
|
||||
grid = _build_grid(lat, lon, span_deg, step_deg)
|
||||
cells: list[dict[str, Any]] = []
|
||||
times: list[str] | None = None
|
||||
|
||||
for offset in range(0, len(grid), BATCH_SIZE):
|
||||
batch = grid[offset : offset + BATCH_SIZE]
|
||||
lats = [point[0] for point in batch]
|
||||
lons = [point[1] for point in batch]
|
||||
results = await _fetch_batch(lats, lons, hours)
|
||||
for (point_lat, point_lon), item in zip(batch, results, strict=False):
|
||||
hourly = item.get("hourly") or {}
|
||||
cell_times = hourly.get("time") or []
|
||||
if times is None:
|
||||
times = cell_times
|
||||
cells.append(
|
||||
{
|
||||
"lat": point_lat,
|
||||
"lon": point_lon,
|
||||
"precipitation": hourly.get("precipitation") or [0.0] * len(cell_times),
|
||||
"probability": hourly.get("precipitation_probability") or [0] * len(cell_times),
|
||||
}
|
||||
)
|
||||
|
||||
payload = {
|
||||
"source": OPEN_METEO_BASE_URL,
|
||||
"model": "Open-Meteo ICON/DWD",
|
||||
"center": {"lat": lat, "lon": lon},
|
||||
"span_deg": span_deg,
|
||||
"step_deg": step_deg,
|
||||
"hours": hours,
|
||||
"times": times or [],
|
||||
"cells": cells,
|
||||
"cell_count": len(cells),
|
||||
"note": "Modellvorhersage, keine Radarmessung. Auflösung grob (~25 km).",
|
||||
"cached": False,
|
||||
}
|
||||
await cache_set(cache_key, payload, CACHE_TTL_PRECIP_MAP)
|
||||
return payload
|
||||
@@ -98,6 +98,50 @@ async def fetch_all_warnings() -> list[dict[str, Any]]:
|
||||
return warnings
|
||||
|
||||
|
||||
def _warning_identity_key(warning: dict[str, Any]) -> tuple[Any, ...]:
|
||||
return (
|
||||
warning.get("event"),
|
||||
warning.get("headline"),
|
||||
warning.get("level"),
|
||||
warning.get("start"),
|
||||
warning.get("end"),
|
||||
warning.get("description"),
|
||||
warning.get("instruction"),
|
||||
)
|
||||
|
||||
|
||||
def _dedupe_warnings(warnings: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
merged: dict[tuple[Any, ...], dict[str, Any]] = {}
|
||||
for warning in warnings:
|
||||
key = _warning_identity_key(warning)
|
||||
region = warning.get("region_name")
|
||||
if key not in merged:
|
||||
merged[key] = {
|
||||
**warning,
|
||||
"_cell_ids": [warning["cell_id"]],
|
||||
"_regions": [region] if region else [],
|
||||
}
|
||||
continue
|
||||
entry = merged[key]
|
||||
if warning["cell_id"] not in entry["_cell_ids"]:
|
||||
entry["_cell_ids"].append(warning["cell_id"])
|
||||
if region and region not in entry["_regions"]:
|
||||
entry["_regions"].append(region)
|
||||
|
||||
result: list[dict[str, Any]] = []
|
||||
for entry in merged.values():
|
||||
regions = entry.pop("_regions", [])
|
||||
cell_ids = entry.pop("_cell_ids", [entry.get("cell_id")])
|
||||
if len(regions) > 1:
|
||||
entry["region_name"] = " · ".join(regions)
|
||||
elif regions:
|
||||
entry["region_name"] = regions[0]
|
||||
entry["cell_id"] = cell_ids[0] if len(cell_ids) == 1 else ",".join(cell_ids)
|
||||
result.append(entry)
|
||||
result.sort(key=lambda w: (-w["level"], w.get("start") or ""))
|
||||
return result
|
||||
|
||||
|
||||
async def fetch_warnings_for_location(lat: float, lon: float, local_only: bool = True) -> dict[str, Any]:
|
||||
warnings = await fetch_all_warnings()
|
||||
warncells = await resolve_warncells(lat, lon)
|
||||
@@ -111,15 +155,16 @@ async def fetch_warnings_for_location(lat: float, lon: float, local_only: bool =
|
||||
]
|
||||
|
||||
if local_only:
|
||||
result_warnings = local
|
||||
result_warnings = _dedupe_warnings(local)
|
||||
else:
|
||||
result_warnings = warnings[:100]
|
||||
result_warnings = _dedupe_warnings(warnings[:100])
|
||||
|
||||
return {
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"count_total": len(warnings),
|
||||
"count_local": len(local),
|
||||
"count_local": len(result_warnings),
|
||||
"count_matched_raw": len(local),
|
||||
"warnings": result_warnings,
|
||||
"warncells": warncells.get("areas", []),
|
||||
"matched_cell_ids": sorted(cell_ids),
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from app.cache import cache_get, cache_set
|
||||
from app.http_client import get_client
|
||||
from app.config import CACHE_TTL_WMS_TIMES, DWD_WMS_URL
|
||||
from app.http_client import get_client
|
||||
|
||||
NS = {"wms": "http://www.opengis.net/wms"}
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WMS_NS = "http://www.opengis.net/wms"
|
||||
WMS_TAG = f"{{{WMS_NS}}}"
|
||||
|
||||
|
||||
def _parse_iso_duration_minutes(duration: str) -> int:
|
||||
@@ -18,10 +22,18 @@ def _parse_iso_duration_minutes(duration: str) -> int:
|
||||
return int(match.group(1))
|
||||
|
||||
|
||||
def _parse_wms_datetime(value: str) -> datetime:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
def _format_wms_datetime(value: datetime) -> str:
|
||||
return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||
|
||||
|
||||
def _parse_time_dimension(value: str) -> tuple[datetime, datetime, int]:
|
||||
start_raw, end_raw, step_raw = value.split("/")
|
||||
start = datetime.fromisoformat(start_raw.replace("Z", "+00:00"))
|
||||
end = datetime.fromisoformat(end_raw.replace("Z", "+00:00"))
|
||||
start = _parse_wms_datetime(start_raw)
|
||||
end = _parse_wms_datetime(end_raw)
|
||||
step_minutes = _parse_iso_duration_minutes(step_raw)
|
||||
return start, end, step_minutes
|
||||
|
||||
@@ -31,63 +43,155 @@ def _generate_time_steps(start: datetime, end: datetime, step_minutes: int) -> l
|
||||
current = start
|
||||
delta = timedelta(minutes=step_minutes)
|
||||
while current <= end:
|
||||
steps.append(current.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"))
|
||||
steps.append(_format_wms_datetime(current))
|
||||
current += delta
|
||||
return steps
|
||||
|
||||
|
||||
def _find_layer_dimension(xml_text: str, layer_name: str) -> str | None:
|
||||
def _align_down(value: datetime, step_minutes: int) -> datetime:
|
||||
aligned = value.replace(second=0, microsecond=0)
|
||||
return aligned - timedelta(minutes=aligned.minute % step_minutes)
|
||||
|
||||
|
||||
def _layer_name_matches(requested: str, actual: str | None) -> bool:
|
||||
if not actual:
|
||||
return False
|
||||
if requested == actual:
|
||||
return True
|
||||
return requested.split(":")[-1] == actual
|
||||
|
||||
|
||||
def _find_layer_times(xml_text: str, layer_name: str) -> tuple[str | None, int, datetime | None]:
|
||||
root = ET.fromstring(xml_text)
|
||||
target = layer_name.split(":")[-1]
|
||||
time_dimension: str | None = None
|
||||
step_minutes = 5
|
||||
latest_available: datetime | None = None
|
||||
|
||||
for layer in root.iter():
|
||||
if not layer.tag.endswith("Layer"):
|
||||
continue
|
||||
name_el = layer.find("wms:Name", NS) or layer.find("Name")
|
||||
if name_el is None or name_el.text != target:
|
||||
name_el = layer.find(f"{WMS_TAG}Name")
|
||||
if not _layer_name_matches(layer_name, name_el.text if name_el is not None else None):
|
||||
continue
|
||||
for dim in layer:
|
||||
if dim.tag.endswith("Dimension") and (dim.attrib.get("name") or "").lower() == "time":
|
||||
return (dim.text or "").strip()
|
||||
return None
|
||||
if not dim.tag.endswith("Dimension"):
|
||||
continue
|
||||
dim_name = (dim.attrib.get("name") or "").lower()
|
||||
if dim_name == "time":
|
||||
time_dimension = (dim.text or "").strip() or None
|
||||
if time_dimension and "/" in time_dimension:
|
||||
_, _, step_raw = time_dimension.split("/")
|
||||
step_minutes = _parse_iso_duration_minutes(step_raw)
|
||||
elif dim_name == "reference_time":
|
||||
default = (dim.attrib.get("default") or "").strip()
|
||||
if default:
|
||||
try:
|
||||
latest_available = _parse_wms_datetime(default)
|
||||
except ValueError:
|
||||
pass
|
||||
if time_dimension:
|
||||
return time_dimension, step_minutes, latest_available
|
||||
return None, step_minutes, latest_available
|
||||
|
||||
|
||||
def _filter_time_steps(
|
||||
all_steps: list[str],
|
||||
minutes: int,
|
||||
latest_available: datetime | None = None,
|
||||
step_minutes: int = 5,
|
||||
) -> list[str]:
|
||||
now = _align_down(datetime.now(timezone.utc), step_minutes)
|
||||
upper = latest_available or now
|
||||
if upper > now:
|
||||
upper = now
|
||||
cutoff = now - timedelta(minutes=minutes)
|
||||
|
||||
filtered: list[str] = []
|
||||
for step in all_steps:
|
||||
try:
|
||||
ts = _parse_wms_datetime(step)
|
||||
except ValueError:
|
||||
continue
|
||||
if cutoff <= ts <= upper:
|
||||
filtered.append(step)
|
||||
return filtered
|
||||
|
||||
|
||||
def _fallback_time_steps(minutes: int, step_minutes: int = 5) -> list[str]:
|
||||
now = _align_down(datetime.now(timezone.utc), step_minutes)
|
||||
start = now - timedelta(minutes=minutes)
|
||||
return _generate_time_steps(start, now, step_minutes)
|
||||
|
||||
|
||||
def _steps_from_dimension(dimension: str) -> list[str]:
|
||||
if "/" in dimension:
|
||||
start, end, step_minutes = _parse_time_dimension(dimension)
|
||||
return _generate_time_steps(start, end, step_minutes)
|
||||
return [item.strip() for item in dimension.split(",") if item.strip()]
|
||||
|
||||
|
||||
async def fetch_wms_time_steps_quick(layer: str, minutes: int = 120) -> dict:
|
||||
step_minutes = 5
|
||||
filtered = _fallback_time_steps(minutes, step_minutes)
|
||||
return {
|
||||
"layer": layer,
|
||||
"minutes": minutes,
|
||||
"step_minutes": step_minutes,
|
||||
"count": len(filtered),
|
||||
"times": filtered,
|
||||
"latest": filtered[-1] if filtered else None,
|
||||
"latest_available": filtered[-1] if filtered else None,
|
||||
"source": "fallback",
|
||||
"partial": True,
|
||||
}
|
||||
|
||||
|
||||
async def fetch_wms_time_steps(layer: str, minutes: int = 120) -> dict:
|
||||
cache_key = f"wms:times:{layer}:{minutes}"
|
||||
cache_key = f"wms:times:v2:{layer}:{minutes}"
|
||||
cached = await cache_get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
params = {"service": "WMS", "version": "1.3.0", "request": "GetCapabilities"}
|
||||
client = get_client()
|
||||
response = await client.get(DWD_WMS_URL, params=params, timeout=15.0)
|
||||
response.raise_for_status()
|
||||
dimension = _find_layer_dimension(response.text, layer)
|
||||
if not dimension:
|
||||
raise ValueError(f"No TIME dimension found for layer {layer}")
|
||||
step_minutes = 5
|
||||
latest_available: datetime | None = None
|
||||
all_steps: list[str] = []
|
||||
source = "capabilities"
|
||||
|
||||
if "/" in dimension:
|
||||
start, end, step_minutes = _parse_time_dimension(dimension)
|
||||
all_steps = _generate_time_steps(start, end, step_minutes)
|
||||
else:
|
||||
all_steps = [item.strip() for item in dimension.split(",") if item.strip()]
|
||||
try:
|
||||
params = {"service": "WMS", "version": "1.3.0", "request": "GetCapabilities"}
|
||||
client = get_client()
|
||||
response = await client.get(DWD_WMS_URL, params=params, timeout=20.0)
|
||||
response.raise_for_status()
|
||||
dimension, step_minutes, latest_available = _find_layer_times(response.text, layer)
|
||||
if dimension:
|
||||
all_steps = _steps_from_dimension(dimension)
|
||||
else:
|
||||
source = "fallback"
|
||||
logger.warning("No TIME dimension in WMS capabilities for layer %s", layer)
|
||||
except Exception as exc:
|
||||
source = "fallback"
|
||||
logger.warning("WMS GetCapabilities failed for layer %s: %s", layer, exc)
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=minutes)
|
||||
filtered = []
|
||||
for step in all_steps:
|
||||
try:
|
||||
ts = datetime.fromisoformat(step.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
continue
|
||||
if ts >= cutoff:
|
||||
filtered.append(step)
|
||||
if not all_steps:
|
||||
all_steps = _fallback_time_steps(minutes, step_minutes)
|
||||
source = "fallback"
|
||||
|
||||
filtered = _filter_time_steps(all_steps, minutes, latest_available, step_minutes)
|
||||
if not filtered:
|
||||
filtered = _fallback_time_steps(minutes, step_minutes)
|
||||
source = "fallback"
|
||||
|
||||
payload = {
|
||||
"layer": layer,
|
||||
"minutes": minutes,
|
||||
"step_minutes": _parse_iso_duration_minutes("PT5M") if "/" in dimension else 5,
|
||||
"step_minutes": step_minutes,
|
||||
"count": len(filtered),
|
||||
"times": filtered,
|
||||
"latest": filtered[-1] if filtered else None,
|
||||
"latest_available": _format_wms_datetime(latest_available) if latest_available else None,
|
||||
"source": source,
|
||||
"partial": False,
|
||||
"note": "Niederschlagsradar zeigt vergangene Messungen, keine Vorhersage in die Zukunft.",
|
||||
}
|
||||
await cache_set(cache_key, payload, CACHE_TTL_WMS_TIMES)
|
||||
return payload
|
||||
|
||||
Reference in New Issue
Block a user