# -*- coding: utf-8 -*- from flask import Flask, render_template, request, jsonify from geopy.geocoders import Nominatim from geopy.exc import GeocoderTimedOut import math import pandas as pd import datetime as _dt import logging from collections import deque from threading import Lock from time import time from wetterdienst.provider.dwd.mosmix import DwdMosmixRequest from cachetools import TTLCache from astral import LocationInfo from astral.sun import sun as astral_sun import requests as _requests app = Flask(__name__) app.logger.setLevel(logging.INFO) _GEOLOCATOR = Nominatim(user_agent="skywatcher-app/1.0") # ── Zeitzone ──────────────────────────────────────────────────────────── try: import zoneinfo BERLIN = zoneinfo.ZoneInfo("Europe/Berlin") except ImportError: import pytz BERLIN = pytz.timezone("Europe/Berlin") # ── Cache: Forecasts 45 Min, DWD-Warnungen 15 Min ──────────────────────────── _forecast_cache = TTLCache(maxsize=64, ttl=2700) _warn_cache = TTLCache(maxsize=32, ttl=900) _suggest_cache = TTLCache(maxsize=256, ttl=900) # API protection: simple in-memory rate limiting by client IP. _suggest_rate_lock = Lock() _suggest_rate_hits = {} _SUGGEST_RATE_WINDOW_SECONDS = 60 _SUGGEST_RATE_MAX_REQUESTS = 30 STATE_ALIASES = { "baden-wurttemberg": {"baden-wurttemberg", "baden wuerttemberg", "bw"}, "bayern": {"bayern", "by"}, "berlin": {"berlin", "be"}, "brandenburg": {"brandenburg", "bb"}, "bremen": {"bremen", "hb"}, "hamburg": {"hamburg", "hh"}, "hessen": {"hessen", "he"}, "mecklenburg-vorpommern": {"mecklenburg-vorpommern", "mecklenburg vorpommern", "mv"}, "niedersachsen": {"niedersachsen", "ni"}, "nordrhein-westfalen": {"nordrhein-westfalen", "nordrhein westfalen", "nrw", "nw"}, "rheinland-pfalz": {"rheinland-pfalz", "rheinland pfalz", "rp"}, "saarland": {"saarland", "sl"}, "sachsen": {"sachsen", "sn"}, "sachsen-anhalt": {"sachsen-anhalt", "sachsen anhalt", "st"}, "schleswig-holstein": {"schleswig-holstein", "schleswig holstein", "sh"}, "thuringen": {"thuringen", "thueringen", "th"}, } # ── MOSMIX Parameter ───────────────────────────────────────────────────────── MOSMIX_PARAMS = [ "hourly/large/temperature_air_mean_2m", "hourly/large/wind_speed", "hourly/large/wind_direction", "hourly/large/wind_gust_max_last_1h", "hourly/large/cloud_cover_total", "hourly/large/pressure_air_site_reduced", "hourly/large/precipitation_height_last_1h", "hourly/large/sunshine_duration", "hourly/large/probability_precipitation_height_gt_0_1mm_last_1h", "hourly/large/uv_index", ] # ── Wetter-Icons (OpenWeatherMap CDN) ──────────────────────────────────────── _OWM = "https://openweathermap.org/img/wn/" # Day icons – keyed by the emoji that weather_icon() / pick_daily_icon() returns ICON_URLS_DAY = { "☀️": _OWM + "01d@2x.png", # klar "⛅": _OWM + "02d@2x.png", # leicht bewölkt "☁️": _OWM + "04d@2x.png", # bedeckt "🌧️": _OWM + "10d@2x.png", # Regen "🌦️": _OWM + "09d@2x.png", # Regenschauer "❄️": _OWM + "13d@2x.png", # Schnee } # Overrides for night hours (20–5 Uhr): only clear/partly change visually ICON_URLS_NIGHT = { "☀️": _OWM + "01n@2x.png", # klare Nacht (Mond) "⛅": _OWM + "02n@2x.png", # leicht bewölkt nachts } def _icon_url(emoji, hour=12): """Return OWM icon URL. Uses night variants for clear/partly-cloudy between 20–5 Uhr.""" e = str(emoji) is_night = hour >= 20 or hour < 6 if is_night and e in ICON_URLS_NIGHT: return ICON_URLS_NIGHT[e] return ICON_URLS_DAY.get(e, _OWM + "03d@2x.png") def _get_berlin(): try: import zoneinfo return zoneinfo.ZoneInfo("Europe/Berlin") except ImportError: import pytz return pytz.timezone("Europe/Berlin") def _normalize_text(value): if not value: return "" text = str(value).strip().lower() replacements = { "ä": "a", "ö": "o", "ü": "u", "ß": "ss", } for src, dst in replacements.items(): text = text.replace(src, dst) return " ".join(text.split()) def _state_tokens(state_name): normalized = _normalize_text(state_name) if not normalized: return set() for _, aliases in STATE_ALIASES.items(): if normalized in aliases: return aliases return {normalized} def _warning_matches_state(state_hint, warning_state, warning_state_short): hint_tokens = _state_tokens(state_hint) warning_tokens = _state_tokens(warning_state) warning_short = _normalize_text(warning_state_short) if warning_short: warning_tokens.add(warning_short) return bool(hint_tokens and warning_tokens and hint_tokens.intersection(warning_tokens)) def _extract_state_from_location(loc): try: return (loc.raw or {}).get("address", {}).get("state") except Exception: return None def _extract_location_names(loc): """Return a list of normalised name strings (city, county, …) from a Nominatim result for matching against DWD warning regionName fields.""" names = [] try: raw = (loc.raw or {}).get("address", {}) for key in ("city", "town", "village", "hamlet", "municipality", "city_district", "county", "district", "suburb", "borough"): val = raw.get(key) if val: n = _normalize_text(val) if n and len(n) >= 3 and n not in names: names.append(n) except Exception: pass return names def _client_ip(req): forwarded = req.headers.get("X-Forwarded-For", "").strip() if forwarded: return forwarded.split(",")[0].strip() return req.remote_addr or "unknown" def _is_suggest_rate_limited(client_ip): now = time() with _suggest_rate_lock: hits = _suggest_rate_hits.get(client_ip) if hits is None: hits = deque() _suggest_rate_hits[client_ip] = hits cutoff = now - _SUGGEST_RATE_WINDOW_SECONDS while hits and hits[0] < cutoff: hits.popleft() if len(hits) >= _SUGGEST_RATE_MAX_REQUESTS: return True hits.append(now) if len(hits) == 1: stale_ips = [ip for ip, values in _suggest_rate_hits.items() if not values or values[-1] < cutoff] for stale_ip in stale_ips: _suggest_rate_hits.pop(stale_ip, None) return False def geocode_location(query): try: loc = _GEOLOCATOR.geocode(query, language="de", addressdetails=True, timeout=10) if loc: return (loc.latitude, loc.longitude, loc.address, _extract_state_from_location(loc), _extract_location_names(loc)) except GeocoderTimedOut: pass except Exception: app.logger.exception("Geocoding failed for query '%s'", query) return None, None, None, None, [] def haversine(lat1, lon1, lat2, lon2): R = 6371 dlat = math.radians(lat2 - lat1) dlon = math.radians(lon2 - lon1) a = (math.sin(dlat/2)**2 + math.cos(math.radians(lat1))*math.cos(math.radians(lat2))*math.sin(dlon/2)**2) return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1-a)) def _isnan(v): try: return math.isnan(float(v)) except (TypeError, ValueError): return True def _round_temp(k): if k is None or _isnan(k): return None return round(float(k), 1) def _clamp(value, min_value, max_value): return max(min_value, min(max_value, value)) def _estimate_uv(dt_local, lat, cloud_pct): """Rough UV index estimate for Central Europe when the MOSMIX station doesn't provide the UVI parameter. Uses time-of-day, season, latitude, and cloud cover.""" hour = dt_local.hour + dt_local.minute / 60.0 if hour < 5.5 or hour > 20.5: return 0.0 noon = 13.0 # approximate solar noon in Germany (CET) hour_factor = max(0.0, math.cos(math.pi * (hour - noon) / 14.0)) # Typical clear-sky peak UV at solar noon for ~51°N, Jan–Dec monthly_peak = [1.0, 1.8, 3.5, 5.0, 6.5, 7.5, 7.2, 6.2, 4.5, 2.5, 1.2, 0.8] seasonal = monthly_peak[dt_local.month - 1] lat_factor = max(0.5, 1.0 - (float(lat) - 51.0) * 0.015) cloud_factor = 1.0 - (cloud_pct or 0) / 100.0 * 0.75 uv = seasonal * hour_factor * lat_factor * cloud_factor return round(max(0.0, uv), 1) def uv_risk_info(uv_index): if uv_index is None: return "–", "na" uv = float(uv_index) if uv < 3: return "niedrig", "low" if uv < 6: return "moderat", "moderate" if uv < 8: return "hoch", "high" if uv < 11: return "sehr hoch", "very-high" return "extrem", "extreme" def hour_confidence_score(temp_c, precip_mm, rain_prob, wind_kmh, gust_kmh, cloud_pct): score = 100 if temp_c is None: score -= 18 if precip_mm is not None: score -= _clamp(precip_mm * 20, 0, 40) if rain_prob is not None: score -= _clamp(rain_prob * 0.35, 0, 30) if wind_kmh is not None: score -= _clamp((wind_kmh - 25) * 0.6, 0, 18) if gust_kmh is not None: score -= _clamp((gust_kmh - 45) * 0.45, 0, 16) if cloud_pct is not None: score -= _clamp((cloud_pct - 85) * 0.4, 0, 8) score = int(round(_clamp(score, 5, 99))) if score >= 80: return score, "hoch" if score >= 60: return score, "mittel" return score, "niedrig" def activity_score(temp_c, precip_mm, rain_prob, wind_kmh, gust_kmh, uv_index): score = 100.0 if temp_c is not None: score -= abs(temp_c - 20) * 3.5 if precip_mm is not None: score -= _clamp(precip_mm * 35, 0, 45) if rain_prob is not None: score -= _clamp(rain_prob * 0.45, 0, 35) if wind_kmh is not None: score -= _clamp((wind_kmh - 18) * 0.7, 0, 16) if gust_kmh is not None: score -= _clamp((gust_kmh - 35) * 0.55, 0, 12) if uv_index is not None and uv_index > 6: score -= _clamp((uv_index - 6) * 6, 0, 16) return int(round(_clamp(score, 0, 100))) def best_activity_window(forecast, horizon_hours=24, window_size=2): hours = forecast[:horizon_hours] if len(hours) < window_size: return None best = None for i in range(0, len(hours) - window_size + 1): segment = hours[i:i + window_size] scores = [h.get("activity_score") for h in segment if h.get("activity_score") is not None] if not scores: continue avg_score = round(sum(scores) / len(scores)) if best is None or avg_score > best["score"]: best = { "start": segment[0]["datetime"], "end": segment[-1]["datetime"], "score": int(avg_score), } return best def pressure_trend_info(forecast, step_hours=6): if len(forecast) <= step_hours: return None, None p0 = forecast[0].get("pressure_hpa") p1 = forecast[step_hours].get("pressure_hpa") if p0 is None or p1 is None: return None, None delta = round(p1 - p0, 1) if delta >= 1.5: return delta, "steigend" if delta <= -1.5: return delta, "fallend" return delta, "stabil" def temp_trend_info(forecast, step_hours=6): if len(forecast) <= step_hours: return None, None t0 = forecast[0].get("temp_c") t1 = forecast[step_hours].get("temp_c") if t0 is None or t1 is None: return None, None delta = round(t1 - t0, 1) if delta >= 1.0: return delta, "wärmer" if delta <= -1.0: return delta, "kälter" return delta, "konstant" def _parse_warning_datetime(value): if value in (None, ""): return None try: if isinstance(value, (int, float)): ts = pd.Timestamp(value, unit="ms", tz="UTC") else: v = str(value).strip() if v.isdigit(): ts = pd.Timestamp(int(v), unit="ms", tz="UTC") else: ts = pd.Timestamp(v) if ts.tzinfo is None: ts = ts.tz_localize("UTC") return ts.tz_convert(_get_berlin()).tz_localize(None).to_pydatetime() except Exception: return None def pick_daily_icon(hours): if not hours: return "☀️" if any((h.get("temp_c") is not None and h["temp_c"] <= 0 and ((h.get("precip_mm") or 0) > 0.1 or (h.get("rain_prob") or 0) >= 50)) for h in hours): return "❄️" if any(((h.get("precip_mm") or 0) >= 0.6 or (h.get("rain_prob") or 0) >= 70) for h in hours): return "🌧️" clouds = [h.get("cloud_pct") for h in hours if h.get("cloud_pct") is not None] avg_cloud = sum(clouds) / len(clouds) if clouds else 0 if avg_cloud > 80: return "☁️" if avg_cloud > 40: return "⛅" return "☀️" def feels_like(temp_c, wind_kmh, cloud_pct): """Apparent / perceived temperature. Uses the JAG/TI wind-chill formula blended smoothly across the full temperature range so there is no abrupt jump at the old 10 °C threshold. Above 27 °C the Rothfusz heat index (assumed RH 60 %) is applied. The previous cloud-based sunshine bonus has been removed: cloud cover fluctuates strongly hour to hour, which caused the felt temperature to jump by several degrees without any real change in conditions. """ if temp_c is None: return None # ── Hot range: heat index (Rothfusz, RH 60 %) ──────────────────── if temp_c >= 27: rh = 60 hi = (-8.78469475556 + 1.61139411 * temp_c + 2.33854883889 * rh - 0.14611605 * temp_c * rh - 0.012308094 * temp_c ** 2 - 0.016424828 * rh ** 2 + 0.002211732 * temp_c ** 2 * rh + 0.00072546 * temp_c * rh ** 2 - 0.000003582 * temp_c ** 2 * rh ** 2) return round(hi, 1) # ── Wind-chill (JAG/TI), blended into mild range ────────────────── # Full wind-chill effect at ≤ 5 °C, linearly faded to zero at ≥ 20 °C. # This avoids the hard jump that the old ≤ 10 °C threshold caused. wind = wind_kmh or 0 adjusted = float(temp_c) if wind > 4.8: wc = (13.12 + 0.6215 * temp_c - 11.37 * (wind ** 0.16) + 0.3965 * temp_c * (wind ** 0.16)) blend = _clamp((20.0 - temp_c) / 15.0, 0.0, 1.0) adjusted = temp_c * (1.0 - blend) + wc * blend result = round(adjusted, 1) return result if abs(result - temp_c) >= 0.5 else temp_c def get_sun_times(lat, lon, date=None): try: loc = LocationInfo(latitude=lat, longitude=lon, timezone="Europe/Berlin") d = date or _dt.date.today() s = astral_sun(loc.observer, date=d, tzinfo=_get_berlin()) return (s["sunrise"].strftime("%H:%M"), s["sunset"].strftime("%H:%M"), s["dawn"].strftime("%H:%M"), s["dusk"].strftime("%H:%M")) except Exception: return None, None, None, None def get_dwd_warnings(lat, lon, state_hint=None, location_names=None): """Fetch DWD warnings and filter by regionName matching the user's municipality/county. The DWD JSON uses warncell-IDs as keys and each warning carries a ``regionName`` field (e.g. "Krefeld", "Kreis Kleve - Niederrhein", "Sauerland") that identifies the affected area far more precisely than the federal-state field. We only include a warning when its regionName contains at least one of the place names extracted from the geocoded location (city, county, …). Warnings for neighbouring NRW regions (Eifel, Sauerland, …) are therefore never shown for Krefeld. Duplicates (same event type + onset + expires) are removed before returning. """ loc_names = location_names or [] key = (round(lat, 1), round(lon, 1)) if key in _warn_cache: return _warn_cache[key] try: url = "https://www.dwd.de/DWD/warnungen/warnapp/json/warnings.json" resp = _requests.get(url, timeout=8) if resp.status_code != 200: _warn_cache[key] = [] return [] text = resp.text if text.startswith("warnWetter.loadWarnings("): text = text[len("warnWetter.loadWarnings("):-2] import json as _json data = _json.loads(text) matched = [] for region_warns in data.get("warnings", {}).values(): for w in (region_warns or []): level = w.get("level", 0) if level < 1: continue region_name = w.get("regionName", "") or "" norm_region = _normalize_text(region_name) # Require the user's city / county to appear in regionName region_match = False for name in loc_names: if name and len(name) >= 3 and name in norm_region: region_match = True break if not region_match: continue matched.append({ "level": level, "type": w.get("event", ""), "headline": w.get("headline", ""), "description": w.get("description", ""), "onset": w.get("onset", ""), "expires": w.get("expires", ""), }) # Deduplicate: same event type + onset time + expires time seen = set() deduped = [] for w in matched: dk = (w["type"], w["onset"], w["expires"]) if dk not in seen: seen.add(dk) deduped.append(w) deduped.sort(key=lambda x: x["level"], reverse=True) result = deduped[:5] _warn_cache[key] = result return result except Exception: app.logger.exception("Could not load DWD warnings") _warn_cache[key] = [] return [] def wind_direction_name(degrees): if degrees is None or _isnan(degrees): return "–" dirs = ["N","NNO","NO","ONO","O","OSO","SO","SSO","S","SSW","SW","WSW","W","WNW","NW","NNW"] idx = round(float(degrees)/22.5) % 16 return dirs[idx] def weather_icon(cloud_pct, precip_mm, rain_prob, temp_c): if temp_c is not None and temp_c <= 0 and (precip_mm and precip_mm > 0 or rain_prob and rain_prob >= 40): return "❄️" if precip_mm and precip_mm > 0.2: return "🌧️" if rain_prob is not None and rain_prob >= 60: return "🌦️" if rain_prob is not None and rain_prob >= 30 and cloud_pct is not None and cloud_pct > 50: return "🌦️" if cloud_pct is not None: if cloud_pct > 80: return "☁️" if cloud_pct > 35: return "⛅" return "☀️" def get_mosmix_forecast(lat, lon, hours=72): cache_key = (round(lat,2), round(lon,2), hours) if cache_key in _forecast_cache: return _forecast_cache[cache_key] try: berlin = _get_berlin() req = DwdMosmixRequest(parameters=MOSMIX_PARAMS) nearest = req.filter_by_rank(latlon=(lat, lon), rank=1) result = nearest.values.all() df = result.df if df is None or (hasattr(df,"__len__") and len(df)==0): return [], {} if hasattr(df,"to_pandas"): df = df.to_pandas() station_info = {} sdf = nearest.df if sdf is not None and len(sdf) > 0: if hasattr(sdf,"to_pandas"): sdf = sdf.to_pandas() station_info = sdf.iloc[0].to_dict() df = df.sort_values("date").copy() min_date = df["date"].min() cutoff = min_date + pd.Timedelta(hours=hours) df = df[df["date"] <= cutoff] forecast = [] for date_val, group in df.groupby("date"): p = {row["parameter"]: row["value"] for _, row in group.iterrows()} temp_c = _round_temp(p.get("temperature_air_mean_2m")) ff = p.get("wind_speed") wind_kmh = round(float(ff)*3.6,1) if not _isnan(ff) else None fx1 = p.get("wind_gust_max_last_1h") gust_kmh = round(float(fx1)*3.6,1) if not _isnan(fx1) else None pppp = p.get("pressure_air_site_reduced") pressure = round(float(pppp),1) if not _isnan(pppp) else None rr1c = p.get("precipitation_height_significant_weather_last_1h") rr1 = p.get("precipitation_height_last_1h") prec_raw = rr1c if not _isnan(rr1c) else (rr1 if not _isnan(rr1) else None) precip = round(float(prec_raw),1) if prec_raw is not None else 0.0 rprob = p.get("probability_precipitation_height_gt_0_1mm_last_1h") rain_prob = round(float(rprob)*100) if not _isnan(rprob) else None n = p.get("cloud_cover_total") clouds = round(float(n)) if not _isnan(n) else None sun = p.get("sunshine_duration") sun_min = round(float(sun)/60) if not _isnan(sun) else 0 wd = p.get("wind_direction") wind_dir = float(wd) if not _isnan(wd) else None uv_raw = p.get("uv_index") dt_local = pd.Timestamp(date_val).tz_convert(berlin).tz_localize(None) if not _isnan(uv_raw): uv = round(float(uv_raw), 1) else: uv = _estimate_uv(dt_local, lat, clouds) uv_label, uv_level = uv_risk_info(uv) feels = feels_like(temp_c, wind_kmh, clouds) confidence_score, confidence_label = hour_confidence_score(temp_c, precip, rain_prob, wind_kmh, gust_kmh, clouds) a_score = activity_score(temp_c, precip, rain_prob, wind_kmh, gust_kmh, uv) forecast.append({ "datetime": dt_local, "temp_c": temp_c, "feels_like": feels, "wind_kmh": wind_kmh, "gust_kmh": gust_kmh, "pressure_hpa": pressure, "precip_mm": precip, "rain_prob": rain_prob, "cloud_pct": clouds, "sun_min": sun_min, "wind_dir": wind_dir, "uv_index": uv, "uv_label": uv_label, "uv_level": uv_level, "confidence": confidence_score, "confidence_label": confidence_label, "activity_score": a_score, "icon": weather_icon(clouds, precip, rain_prob, temp_c), }) result_data = (forecast, station_info) _forecast_cache[cache_key] = result_data return result_data except Exception: app.logger.exception("MOSMIX forecast loading failed") return [], {} def filter_unrealistic_warnings(warnings, forecast, now_local=None): """Remove warnings that contradict the MOSMIX forecast. Frost warnings are suppressed when the minimum forecast temperature within the warning window (or next 48 h if the window is unknown) stays above 3 °C. Rain / heavy-rain warnings are suppressed when neither significant precipitation nor a meaningful rain probability is forecast. Both onset/expires timestamps from DWD and forecast datetimes are compared as naive Berlin-local datetimes so no tz-mismatch can cause an empty overlap window. """ if not warnings or not forecast: return warnings frost_keywords = {"frost", "glatte", "glatt", "eis", "schnee"} rain_keywords = {"regen", "starkregen", "dauerregen"} result = [] for w in warnings: warn_type = _normalize_text(w.get("type", "")) headline = _normalize_text(w.get("headline", "")) onset_dt = _parse_warning_datetime(w.get("onset")) expires_dt = _parse_warning_datetime(w.get("expires")) # Discard already-expired warnings if now_local is not None and expires_dt is not None and expires_dt < now_local: app.logger.info("Discarded expired warning: %s (expires %s)", w.get("headline"), expires_dt) continue # Select forecast hours that fall within the warning window. # Convert everything to pandas Timestamps (naive) so comparisons are type-safe. if onset_dt is not None and expires_dt is not None: ts_onset = pd.Timestamp(onset_dt) ts_expires = pd.Timestamp(expires_dt) relevant = [ h for h in forecast[:48] if h.get("datetime") is not None and ts_onset <= pd.Timestamp(h["datetime"]) <= ts_expires ] # If the window is entirely in the future and forecast doesn't reach it, # fall back to the full 48-h slice to be conservative. if not relevant: relevant = forecast[:48] else: relevant = forecast[:48] temps = [h["temp_c"] for h in relevant if h.get("temp_c") is not None] precips = [h["precip_mm"] for h in relevant if h.get("precip_mm") is not None] rainprobs = [h["rain_prob"] for h in relevant if h.get("rain_prob") is not None] min_temp = min(temps) if temps else None max_precip = max(precips) if precips else 0.0 max_rain_prob = max(rainprobs) if rainprobs else 0 is_frost = any(kw in warn_type or kw in headline for kw in frost_keywords) is_rain = any(kw in warn_type or kw in headline for kw in rain_keywords) skip = False if is_frost and min_temp is not None and min_temp > 3: app.logger.info("Suppressed frost warning (min %.1f °C): %s", min_temp, w.get("headline")) skip = True elif is_rain and max_precip < 0.2 and max_rain_prob < 35: app.logger.info("Suppressed rain warning (max_precip %.1f mm): %s", max_precip, w.get("headline")) skip = True if not skip: w["onset_dt"] = onset_dt w["expires_dt"] = expires_dt result.append(w) return result @app.route("/") def index(): return render_template("index.html") @app.route("/wetter", methods=["GET"]) def wetter(): ort = request.args.get("ort","").strip() lat_param = request.args.get("lat") lon_param = request.args.get("lon") # Geolocation via Browser-Koordinaten lat, lon, display_name, state_hint, location_names = None, None, None, None, [] if lat_param and lon_param: try: lat = float(lat_param) lon = float(lon_param) if not (-90 <= lat <= 90 and -180 <= lon <= 180): raise ValueError("Invalid coordinate range") loc = _GEOLOCATOR.reverse((lat, lon), language="de", addressdetails=True, timeout=10) display_name = loc.address if loc else f"{lat:.2f}, {lon:.2f}" state_hint = _extract_state_from_location(loc) if loc else None location_names = _extract_location_names(loc) if loc else [] if not ort or ort == "Mein Standort": ort = display_name.split(",")[0] except Exception: app.logger.info("Invalid or unusable browser coordinates for /wetter") lat, lon, display_name, state_hint, location_names = None, None, None, None, [] if lat is None: if not ort: return render_template("index.html", error="Bitte einen Ort eingeben.") lat, lon, display_name, state_hint, location_names = geocode_location(ort) if lat is None: return render_template("index.html", error=f'Ort "{ort}" konnte nicht gefunden werden.') forecast, mosmix_station = get_mosmix_forecast(lat, lon, hours=240) if not forecast: return render_template("index.html", error="Keine Wetterdaten verfügbar. Bitte später erneut versuchen.") station_name = mosmix_station.get("name", ort) station_id = mosmix_station.get("station_id", "–") station_lat = float(mosmix_station.get("latitude", lat)) station_lon = float(mosmix_station.get("longitude", lon)) station_dist = round(haversine(lat, lon, station_lat, station_lon), 1) berlin = _get_berlin() now_local_dt = _dt.datetime.now(berlin) now_local = now_local_dt.strftime("%H:%M") now_berlin_naive = now_local_dt.replace(minute=0, second=0, microsecond=0, tzinfo=None) current_idx = 0 for i, h in enumerate(forecast): dt = h["datetime"] dt_naive = dt.replace(tzinfo=None) if hasattr(dt,"tzinfo") and dt.tzinfo is not None else dt if dt_naive >= now_berlin_naive: current_idx = i break current = forecast[current_idx] forecast = forecast[current_idx:] sunrise, sunset, dawn, dusk = get_sun_times(lat, lon) warnings = get_dwd_warnings(lat, lon, state_hint=state_hint, location_names=location_names) warnings = filter_unrealistic_warnings(warnings, forecast, now_local=now_local_dt.replace(tzinfo=None)) pressure_delta, pressure_trend = pressure_trend_info(forecast) temp_delta_6h, temp_trend_6h = temp_trend_info(forecast) best_window = best_activity_window(forecast, horizon_hours=24, window_size=2) daily = {} for h in forecast: dt = h["datetime"] day = dt.date() if hasattr(dt,"date") else str(dt)[:10] if day not in daily: daily[day] = {"temps":[], "precip":0.0, "cloud":[], "wind":[], "icons":[], "rain_prob":[], "uv":[], "hours":[]} daily[day]["hours"].append(h) if h["temp_c"] is not None: daily[day]["temps"].append(h["temp_c"]) daily[day]["precip"] += h.get("precip_mm") or 0 if h["cloud_pct"] is not None: daily[day]["cloud"].append(h["cloud_pct"]) if h["wind_kmh"] is not None: daily[day]["wind"].append(h["wind_kmh"]) if h.get("rain_prob") is not None: daily[day]["rain_prob"].append(h["rain_prob"]) if h.get("uv_index") is not None: daily[day]["uv"].append(h["uv_index"]) daily[day]["icons"].append(h["icon"]) daily_summary = [] for day, d in daily.items(): daily_summary.append({ "date": day, "temp_min": min(d["temps"]) if d["temps"] else None, "temp_max": max(d["temps"]) if d["temps"] else None, "precip": round(d["precip"],1), "rain_prob": max(d["rain_prob"]) if d["rain_prob"] else None, "cloud": round(sum(d["cloud"])/len(d["cloud"])) if d["cloud"] else None, "wind_max": max(d["wind"]) if d["wind"] else None, "uv_max": max(d["uv"]) if d["uv"] else None, "icon": pick_daily_icon(d["hours"]), }) chart_labels, chart_temps, chart_feels, chart_precip, chart_rain_prob = [], [], [], [], [] for h in forecast[:72]: dt = h["datetime"] label = dt.strftime("%d.%m %H:%M") if hasattr(dt,"strftime") else str(dt)[5:16] chart_labels.append(label) chart_temps.append(h["temp_c"]) chart_feels.append(h.get("feels_like")) chart_precip.append(h.get("precip_mm") or 0) chart_rain_prob.append(h.get("rain_prob") or 0) return render_template( "weather.html", ort=ort, display_name=display_name, lat=lat, lon=lon, station_name=station_name, station_id=station_id, station_dist=station_dist, current=current, now_local=now_local, pressure_delta=pressure_delta, pressure_trend=pressure_trend, temp_delta_6h=temp_delta_6h, temp_trend_6h=temp_trend_6h, best_window=best_window, sunrise=sunrise, sunset=sunset, warnings=warnings, forecast=forecast[:48], daily=daily_summary, chart_labels=chart_labels, chart_temps=chart_temps, chart_feels=chart_feels, chart_precip=chart_precip, chart_rain_prob=chart_rain_prob, wind_dir_name=wind_direction_name, uv_risk_info=uv_risk_info, icon_url=_icon_url, ) @app.route("/api/suggest") def suggest(): q = request.args.get("q","").strip() if len(q) < 2: return jsonify([]) client_ip = _client_ip(request) if _is_suggest_rate_limited(client_ip): return jsonify({"error": "Zu viele Anfragen. Bitte kurz warten."}), 429 cache_key = _normalize_text(q) if cache_key in _suggest_cache: return jsonify(_suggest_cache[cache_key]) try: results = _GEOLOCATOR.geocode(q, exactly_one=False, limit=5, language="de", addressdetails=True, timeout=5) payload = [{"name": r.address, "lat": r.latitude, "lon": r.longitude} for r in results] if results else [] _suggest_cache[cache_key] = payload return jsonify(payload) except Exception: app.logger.exception("Suggest lookup failed for query '%s'", q) return jsonify([]) if __name__ == "__main__": app.run(debug=True, host="0.0.0.0", port=5000)