198 lines
6.6 KiB
Python
198 lines
6.6 KiB
Python
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.config import CACHE_TTL_WMS_TIMES, DWD_WMS_URL
|
|
from app.http_client import get_client
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
WMS_NS = "http://www.opengis.net/wms"
|
|
WMS_TAG = f"{{{WMS_NS}}}"
|
|
|
|
|
|
def _parse_iso_duration_minutes(duration: str) -> int:
|
|
match = re.fullmatch(r"PT(\d+)M", duration.strip().upper())
|
|
if not match:
|
|
return 5
|
|
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 = _parse_wms_datetime(start_raw)
|
|
end = _parse_wms_datetime(end_raw)
|
|
step_minutes = _parse_iso_duration_minutes(step_raw)
|
|
return start, end, step_minutes
|
|
|
|
|
|
def _generate_time_steps(start: datetime, end: datetime, step_minutes: int) -> list[str]:
|
|
steps: list[str] = []
|
|
current = start
|
|
delta = timedelta(minutes=step_minutes)
|
|
while current <= end:
|
|
steps.append(_format_wms_datetime(current))
|
|
current += delta
|
|
return steps
|
|
|
|
|
|
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)
|
|
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(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 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:v2:{layer}:{minutes}"
|
|
cached = await cache_get(cache_key)
|
|
if cached:
|
|
return cached
|
|
|
|
step_minutes = 5
|
|
latest_available: datetime | None = None
|
|
all_steps: list[str] = []
|
|
source = "capabilities"
|
|
|
|
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)
|
|
|
|
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": 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
|