Chore: Sprint32-36 Zwischenstand — alle Änderungen aus dieser Session committen
This commit is contained in:
parent
f4052fbb7d
commit
747c353444
20 changed files with 3115 additions and 63 deletions
|
|
@ -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}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue