# -*- 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", ] 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 _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) 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 feels_like(temp_c, wind_kmh, cloud_pct): if temp_c is None: return None if temp_c <= 10 and wind_kmh is not None and wind_kmh > 4.8: v = wind_kmh wc = 13.12 + 0.6215*temp_c - 11.37*(v**0.16) + 0.3965*temp_c*(v**0.16) return round(wc, 1) 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) return 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): state_key = _normalize_text(state_hint) key = (round(lat, 1), round(lon, 1), state_key) 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 data = json.loads(text) warnings = [] for region_warns in data.get("warnings", {}).values(): for w in region_warns: level = w.get("level", 0) if level >= 1: warnings.append({ "level": level, "type": w.get("event", ""), "headline": w.get("headline", ""), "description": w.get("description", ""), "state": w.get("state", ""), "state_short": w.get("stateShort", ""), }) filtered = [] if state_hint: filtered = [w for w in warnings if _warning_matches_state(state_hint, w.get("state"), w.get("state_short"))] result_source = filtered if filtered else [] result_source.sort(key=lambda x: x["level"], reverse=True) result = [ { "level": w["level"], "type": w["type"], "headline": w["headline"], "description": w["description"], } for w in result_source[:3] ] _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") uv = round(float(uv_raw),1) if not _isnan(uv_raw) else None feels = feels_like(temp_c, wind_kmh, clouds) dt_local = pd.Timestamp(date_val).tz_convert(berlin).tz_localize(None) 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, "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 [], {} @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 = 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 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 = 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 = 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=72) 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) 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":[]} 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": max(set(d["icons"]), key=d["icons"].count), }) chart_labels, chart_temps, chart_precip, chart_rain_prob = [], [], [], [] for h in forecast[:48]: 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_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, sunrise=sunrise, sunset=sunset, warnings=warnings, forecast=forecast[:48], daily=daily_summary, chart_labels=chart_labels, chart_temps=chart_temps, chart_precip=chart_precip, chart_rain_prob=chart_rain_prob, wind_dir_name=wind_direction_name, ) @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)