- Backend: Open-Meteo Forecast-Request um hourly precipitation_probability, precipitation und weathercode erweitert; stündliche Daten werden pro Tag gruppiert und im API-Response unter "hourly" je Tag ausgeliefert - Frontend: Neue _renderRainTimeline()-Funktion rendert horizontale Balken-Zeitskala für alle 24 Stunden des gewählten Tages; bei "Heute" wird automatisch zur aktuellen Stunde gescrollt und "jetzt" hervorgehoben; Farb-Gradient von hellgrau (<10%) bis dunkelblau (≥75%) - SW/APP_VER/CSS auf 690 gebumpt
432 lines
17 KiB
Python
432 lines
17 KiB
Python
"""
|
||
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"
|
||
"&hourly=precipitation_probability,precipitation,weathercode"
|
||
"&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', {})
|
||
hourly_fc = raw.get('hourly', {})
|
||
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', [])
|
||
|
||
# --- Hourly precipitation data grouped by day ---
|
||
hourly_times = hourly_fc.get('time', [])
|
||
hourly_pp = hourly_fc.get('precipitation_probability', [])
|
||
hourly_precip = hourly_fc.get('precipitation', [])
|
||
hourly_wcode = hourly_fc.get('weathercode', [])
|
||
# Build: date_str → list of {hour, precip_prob, precip, weathercode}
|
||
_hourly_by_day: dict = {}
|
||
for idx, ts_str in enumerate(hourly_times):
|
||
day_str = ts_str[:10] # 'YYYY-MM-DD'
|
||
hour_str = ts_str[11:16] # 'HH:MM'
|
||
entry = {
|
||
'hour': hour_str,
|
||
'precip_prob': hourly_pp[idx] if idx < len(hourly_pp) else None,
|
||
'precip': hourly_precip[idx] if idx < len(hourly_precip) else None,
|
||
'weathercode': int(hourly_wcode[idx]) if idx < len(hourly_wcode) and hourly_wcode[idx] is not None else None,
|
||
}
|
||
_hourly_by_day.setdefault(day_str, []).append(entry)
|
||
|
||
# --- 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),
|
||
'hourly': _hourly_by_day.get(date_str, []),
|
||
})
|
||
|
||
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}")
|