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