Chore: Sprint32-36 Zwischenstand — alle Änderungen aus dieser Session committen

This commit is contained in:
rene 2026-05-03 11:09:39 +02:00
parent f4052fbb7d
commit 747c353444
20 changed files with 3115 additions and 63 deletions

View file

@ -161,3 +161,251 @@ async def get_weather_summary() -> dict:
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}")