""" BAN YARO — Wetter via Open-Meteo - get_weather_summary(): Push-Job, prüft 5 deutsche Städte - get_weather_for_location(): API-Endpoint, beliebiger Standort mit TTL-Cache """ import time import logging import httpx from datetime import datetime logger = logging.getLogger(__name__) # WMO-Wettercodes → (Beschreibung, Phosphor-Icon-Name) _WMO = { 0: ('Klar', 'sun'), 1: ('Überwiegend klar', 'cloud-sun'), 2: ('Teilweise bewölkt', 'cloud-sun'), 3: ('Bedeckt', 'cloud'), 45: ('Nebel', 'cloud-fog'), 48: ('Gefrierender Nebel', 'cloud-fog'), 51: ('Leichter Nieselregen', 'cloud-rain'), 53: ('Nieselregen', 'cloud-rain'), 55: ('Starker Nieselregen', 'cloud-rain'), 61: ('Leichter Regen', 'cloud-rain'), 63: ('Regen', 'cloud-rain'), 65: ('Starker Regen', 'cloud-rain'), 71: ('Leichter Schnee', 'snowflake'), 73: ('Schnee', 'snowflake'), 75: ('Starker Schnee', 'snowflake'), 77: ('Schneekörner', 'snowflake'), 80: ('Leichte Schauer', 'cloud-rain'), 81: ('Schauer', 'cloud-rain'), 82: ('Starke Schauer', 'cloud-rain'), 85: ('Schneeschauer', 'snowflake'), 86: ('Starke Schneeschauer', 'snowflake'), 95: ('Gewitter', 'cloud-lightning'), 96: ('Gewitter mit Hagel', 'cloud-lightning'), 99: ('Schweres Gewitter', 'cloud-lightning'), } # TTL-Cache: (round(lat,1), round(lon,1)) → (timestamp, data) _location_cache: dict = {} _CACHE_TTL = 1800 # 30 Minuten async def get_weather_for_location(lat: float, lon: float) -> dict: """Holt aktuelles Wetter für einen Standort. 30-min TTL-Cache.""" key = (round(lat, 1), round(lon, 1)) now = time.time() if key in _location_cache: ts, cached = _location_cache[key] if now - ts < _CACHE_TTL: return cached url = ( "https://api.open-meteo.com/v1/forecast" f"?latitude={lat}&longitude={lon}" "¤t=temperature_2m,apparent_temperature,weathercode,windspeed_10m,is_day" "&daily=precipitation_probability_max,uv_index_max" "&timezone=Europe%2FBerlin&forecast_days=1" ) async with httpx.AsyncClient(timeout=8.0) as client: resp = await client.get(url) resp.raise_for_status() raw = resp.json() cur = raw.get('current', {}) daily = raw.get('daily', {}) temp = cur.get('temperature_2m') feels_like = cur.get('apparent_temperature') wcode = cur.get('weathercode', 0) wind = cur.get('windspeed_10m') is_day = cur.get('is_day', 1) precip = (daily.get('precipitation_probability_max') or [None])[0] uv = (daily.get('uv_index_max') or [None])[0] desc, icon = _WMO.get(wcode, ('Unbekannt', 'cloud')) if wcode == 0 and not is_day: icon = 'moon' month = datetime.now().month zecken = None if temp is not None and temp > 7.0 and 3 <= month <= 10: zecken = 'hoch' if temp > 20 else ('mittel' if temp > 12 else 'niedrig') data = { 'temp_c': temp, 'feels_like_c': feels_like, 'weathercode': wcode, 'desc': desc, 'icon': icon, 'wind_kmh': wind, 'precip_prob': precip, 'uv_index': uv, 'is_day': bool(is_day), 'zecken_warnung': zecken, } _location_cache[key] = (now, data) return data # Wichtige deutsche Städte als Stichprobe CITIES = [ {"name": "Berlin", "lat": 52.52, "lon": 13.41}, {"name": "München", "lat": 48.14, "lon": 11.58}, {"name": "Hamburg", "lat": 53.55, "lon": 10.00}, {"name": "Frankfurt", "lat": 50.11, "lon": 8.68}, {"name": "Köln", "lat": 50.94, "lon": 6.96}, ] # WMO-Wettercodes 95–99 = Gewitter THUNDERSTORM_CODES = {95, 96, 99} async def get_weather_summary() -> dict: """ Holt Tagesprognose für mehrere deutsche Städte von Open-Meteo. Gibt zurück: {"max_temp_c": float, "thunderstorm": bool} """ max_temp = -999.0 thunderstorm = False async with httpx.AsyncClient(timeout=10.0) as client: for city in CITIES: url = ( "https://api.open-meteo.com/v1/forecast" f"?latitude={city['lat']}&longitude={city['lon']}" "&daily=temperature_2m_max,precipitation_probability_max,weathercode" "&timezone=Europe%2FBerlin&forecast_days=1" ) try: resp = await client.get(url) resp.raise_for_status() data = resp.json() daily = data.get("daily", {}) temps = daily.get("temperature_2m_max", [None]) precip_probs = daily.get("precipitation_probability_max", [None]) weathercodes = daily.get("weathercode", [None]) temp = temps[0] if temps else None precip_prob = precip_probs[0] if precip_probs else None weathercode = weathercodes[0] if weathercodes else None if temp is not None and temp > max_temp: max_temp = temp if ( precip_prob is not None and precip_prob > 50 and weathercode is not None and int(weathercode) in THUNDERSTORM_CODES ): thunderstorm = True logger.info(f"Gewitter erkannt: {city['name']} (Code {weathercode}, {precip_prob}% Niederschlag)") except Exception as e: logger.warning(f"Wetter-Abruf für {city['name']} fehlgeschlagen: {e}") if max_temp == -999.0: max_temp = 0.0 logger.info(f"Wetter-Zusammenfassung: max_temp={max_temp}°C, thunderstorm={thunderstorm}") return {"max_temp_c": max_temp, "thunderstorm": thunderstorm} # --------------------------------------------------------------------------- # 7-Tage-Vorhersage # --------------------------------------------------------------------------- import asyncio # noqa: E402 — appended section _forecast_cache: dict = {} _FORECAST_TTL = 3600 # 1 Stunde _WDAY_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] def _wind_dir(deg: float) -> str: dirs = ['N', 'NO', 'O', 'SO', 'S', 'SW', 'W', 'NW'] idx = round(deg / 45) % 8 return dirs[idx] def _asphalt_temp(air_max: float, uv_max: float) -> tuple[float, str]: bonus = min(uv_max * 3.0, 30.0) asphalt = air_max + bonus if asphalt <= 30: warn = 'safe' elif asphalt <= 40: warn = 'warm' elif asphalt <= 55: warn = 'hot' else: warn = 'danger' return round(asphalt, 1), warn def _pollen_lvl(val: float | None) -> dict: if val is None: return {'level': 0, 'label': 'keine'} if val < 5: return {'level': 1, 'label': 'niedrig'} if val < 25: return {'level': 2, 'label': 'mittel'} if val < 100: return {'level': 3, 'label': 'hoch'} return {'level': 4, 'label': 'sehr hoch'} async def get_forecast(lat: float, lon: float) -> dict: """7-Tage-Wettervorhersage inkl. Pollen, Asphalttemperatur, Zecken. 1h TTL-Cache.""" key = (round(lat, 2), round(lon, 2)) now = time.time() if key in _forecast_cache: ts, cached = _forecast_cache[key] if now - ts < _FORECAST_TTL: return cached forecast_url = ( "https://api.open-meteo.com/v1/forecast" f"?latitude={lat}&longitude={lon}" "&daily=temperature_2m_max,temperature_2m_min,apparent_temperature_max," "apparent_temperature_min,precipitation_probability_max,precipitation_sum," "weathercode,windspeed_10m_max,winddirection_10m_dominant,uv_index_max," "sunrise,sunset" "&timezone=auto&forecast_days=7" ) pollen_url = ( "https://air-quality-api.open-meteo.com/v1/air-quality" f"?latitude={lat}&longitude={lon}" "&hourly=alder_pollen,birch_pollen,grass_pollen,mugwort_pollen,ragweed_pollen" "&timezone=auto&forecast_days=7" ) async with httpx.AsyncClient(timeout=10.0) as client: forecast_task = client.get(forecast_url) pollen_task = client.get(pollen_url) forecast_resp, pollen_resp = await asyncio.gather( forecast_task, pollen_task, return_exceptions=True ) # --- Forecast (required) --- if isinstance(forecast_resp, Exception): raise forecast_resp forecast_resp.raise_for_status() raw = forecast_resp.json() daily = raw.get('daily', {}) timezone = raw.get('timezone', 'auto') dates = daily.get('time', []) temp_max = daily.get('temperature_2m_max', []) temp_min = daily.get('temperature_2m_min', []) feels_max = daily.get('apparent_temperature_max', []) feels_min = daily.get('apparent_temperature_min', []) precip_prob = daily.get('precipitation_probability_max', []) precip_sum = daily.get('precipitation_sum', []) wcodes = daily.get('weathercode', []) wind_kmh = daily.get('windspeed_10m_max', []) wind_deg = daily.get('winddirection_10m_dominant', []) uv_index = daily.get('uv_index_max', []) sunrises = daily.get('sunrise', []) sunsets = daily.get('sunset', []) # --- Pollen (optional) --- pollen_daily: dict | None = None if not isinstance(pollen_resp, Exception): try: pollen_resp.raise_for_status() praw = pollen_resp.json() hourly = praw.get('hourly', {}) htimes = hourly.get('time', []) # aggregate hourly → daily max per type pollen_types = { 'erle': hourly.get('alder_pollen', []), 'birke': hourly.get('birch_pollen', []), 'graeser': hourly.get('grass_pollen', []), 'beifuss': hourly.get('mugwort_pollen', []), 'ambrosia': hourly.get('ragweed_pollen', []), } # build date → max mapping per type pollen_daily = {ptype: {} for ptype in pollen_types} for i, ts_str in enumerate(htimes): day_str = ts_str[:10] # 'YYYY-MM-DD' for ptype, vals in pollen_types.items(): v = vals[i] if i < len(vals) else None if v is not None: prev = pollen_daily[ptype].get(day_str) pollen_daily[ptype][day_str] = max(prev, v) if prev is not None else v except Exception as e: logger.warning(f"Pollen-Abruf fehlgeschlagen: {e}") pollen_daily = None # --- Assemble days --- days = [] for i, date_str in enumerate(dates): wcode = int(wcodes[i]) if i < len(wcodes) and wcodes[i] is not None else 0 desc, icon = _WMO.get(wcode, ('Unbekannt', 'cloud')) t_max = temp_max[i] if i < len(temp_max) else None t_min = temp_min[i] if i < len(temp_min) else None f_max = feels_max[i] if i < len(feels_max) else None f_min = feels_min[i] if i < len(feels_min) else None pp = precip_prob[i] if i < len(precip_prob) else None ps = precip_sum[i] if i < len(precip_sum) else None wk = wind_kmh[i] if i < len(wind_kmh) else None wd_deg = wind_deg[i] if i < len(wind_deg) else None uv = uv_index[i] if i < len(uv_index) else None # Sunrise / Sunset → HH:MM only (format: "2025-05-02T06:12") sunrise_raw = sunrises[i] if i < len(sunrises) else None sunset_raw = sunsets[i] if i < len(sunsets) else None sunrise_hm = sunrise_raw[11:16] if sunrise_raw and len(sunrise_raw) >= 16 else sunrise_raw sunset_hm = sunset_raw[11:16] if sunset_raw and len(sunset_raw) >= 16 else sunset_raw # Weekday try: dt_obj = datetime.strptime(date_str, '%Y-%m-%d') wday = _WDAY_DE[dt_obj.weekday()] except Exception: wday = '' # Asphalt asphalt_t, asphalt_w = _asphalt_temp(t_max or 0.0, uv or 0.0) # Zecken month = datetime.strptime(date_str, '%Y-%m-%d').month zecken = None if t_max is not None and t_max > 7.0 and 3 <= month <= 10: zecken = 'hoch' if t_max > 20 else ('mittel' if t_max > 12 else 'niedrig') # Pollen if pollen_daily is not None: pollen_out = { pt: _pollen_lvl(pollen_daily[pt].get(date_str)) for pt in ('erle', 'birke', 'graeser', 'beifuss', 'ambrosia') } else: pollen_out = None days.append({ 'date': date_str, 'wday': wday, 'weathercode': wcode, 'desc': desc, 'icon': icon, 'temp_max': t_max, 'temp_min': t_min, 'feels_max': f_max, 'feels_min': f_min, 'precip_prob': pp, 'precip_sum': ps, 'wind_kmh': wk, 'wind_dir': _wind_dir(wd_deg) if wd_deg is not None else None, 'wind_dir_deg': wd_deg, 'uv_index': uv, 'sunrise': sunrise_hm, 'sunset': sunset_hm, 'asphalt_temp': asphalt_t, 'asphalt_warn': asphalt_w, 'pollen': pollen_out, 'zecken': zecken, 'thunderstorm': wcode in {95, 96, 99}, 'paw_cold': wcode in {71, 73, 75, 77, 85, 86} or (t_min is not None and t_min < 0), }) result = {'timezone': timezone, 'days': days} _forecast_cache[key] = (now, result) _log_forecast(round(lat, 1), round(lon, 1), days) return result def _log_forecast(lat_r: float, lon_r: float, days: list) -> None: """Speichert jeden Forecast-Tag in weather_log (INSERT OR IGNORE — kein Überschreiben).""" if not days: return try: import json from database import db with db() as conn: for d in days: pollen = d.get('pollen') or {} conn.execute(""" INSERT OR IGNORE INTO weather_log (date, lat_r, lon_r, temp_max, temp_min, feels_max, precip_prob, precip_sum, wind_kmh, wind_dir, uv_index, weathercode, weatherdesc, sunrise, sunset, asphalt_temp, asphalt_warn, zecken, pollen_erle, pollen_birke, pollen_graeser, pollen_beifuss, pollen_ambrosia, forecast_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) """, ( d['date'], lat_r, lon_r, d.get('temp_max'), d.get('temp_min'), d.get('feels_max'), d.get('precip_prob'), d.get('precip_sum'), d.get('wind_kmh'), d.get('wind_dir'), d.get('uv_index'), d.get('weathercode'), d.get('desc'), d.get('sunrise'), d.get('sunset'), d.get('asphalt_temp'), d.get('asphalt_warn'), d.get('zecken'), pollen.get('erle', {}).get('level'), pollen.get('birke', {}).get('level'), pollen.get('graeser', {}).get('level'), pollen.get('beifuss', {}).get('level'), pollen.get('ambrosia',{}).get('level'), json.dumps(d, ensure_ascii=False), )) except Exception as e: logger.warning(f"weather_log insert fehlgeschlagen: {e}")