feat: weather service — geocoding, Open-Meteo forecast, cache, change detection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 19:45:59 -04:00
parent dd8dc519ab
commit bbdcea57a7
2 changed files with 226 additions and 0 deletions
+170
View File
@@ -0,0 +1,170 @@
"""Weather service: geocoding, Open-Meteo 7-day forecast, caching, change detection."""
import logging
from datetime import datetime, timezone
import httpx
from sqlalchemy import select
from fabledassistant.models import async_session
from fabledassistant.models.weather_cache import WeatherCache
logger = logging.getLogger(__name__)
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
OPEN_METEO_DAILY = (
"temperature_2m_max,temperature_2m_min,precipitation_sum,"
"weathercode,windspeed_10m_max"
)
# WMO weather code → description (subset; covers the most common codes)
_WMO_CODES: dict[int, str] = {
0: "Clear sky",
1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast",
45: "Fog", 48: "Depositing rime fog",
51: "Drizzle: light", 53: "Drizzle: moderate", 55: "Drizzle: dense",
61: "Rain: slight", 63: "Rain: moderate", 65: "Rain: heavy",
71: "Snow: slight", 73: "Snow: moderate", 75: "Snow: heavy",
77: "Snow grains",
80: "Rain showers: slight", 81: "Rain showers: moderate", 82: "Rain showers: violent",
85: "Snow showers: slight", 86: "Snow showers: heavy",
95: "Thunderstorm",
96: "Thunderstorm with slight hail", 99: "Thunderstorm with heavy hail",
}
def describe_weathercode(code: int) -> str:
return _WMO_CODES.get(code, f"Unknown (code {code})")
def parse_forecast(raw: dict) -> list[dict]:
"""Convert Open-Meteo daily response into a clean list of day dicts."""
daily = raw.get("daily", {})
dates = daily.get("time", [])
return [
{
"date": dates[i],
"temp_max": daily.get("temperature_2m_max", [])[i],
"temp_min": daily.get("temperature_2m_min", [])[i],
"precip_mm": daily.get("precipitation_sum", [])[i],
"weathercode": daily.get("weathercode", [])[i],
"description": describe_weathercode(daily.get("weathercode", [])[i]),
"windspeed_max": daily.get("windspeed_10m_max", [])[i],
}
for i in range(len(dates))
]
def detect_changes(old_days: list[dict], new_days: list[dict]) -> list[str]:
"""Return human-readable change strings where the forecast has meaningfully shifted."""
old_by_date = {d["date"]: d for d in old_days}
changes = []
for day in new_days:
date = day["date"]
old = old_by_date.get(date)
if not old:
continue
notes = []
# Weather condition changed
if day["weathercode"] != old["weathercode"]:
notes.append(
f"conditions changed from '{describe_weathercode(old['weathercode'])}' "
f"to '{describe_weathercode(day['weathercode'])}'"
)
# Precipitation appeared or disappeared (threshold: 1mm)
was_dry = old["precip_mm"] < 1.0
now_wet = day["precip_mm"] >= 1.0
was_wet = old["precip_mm"] >= 1.0
now_dry = day["precip_mm"] < 1.0
if was_dry and now_wet:
notes.append(f"rain now expected ({day['precip_mm']:.1f}mm)")
elif was_wet and now_dry:
notes.append("rain no longer expected")
# Temperature shifted by 4°C or more
temp_shift = abs(day["temp_max"] - old["temp_max"])
if temp_shift >= 4:
direction = "warmer" if day["temp_max"] > old["temp_max"] else "colder"
notes.append(f"high temperature {direction} by {temp_shift:.0f}°C")
if notes:
changes.append(f"{date}: " + "; ".join(notes))
return changes
async def geocode(query: str) -> tuple[float, float, str]:
"""Return (lat, lon, display_name) for a place name using Nominatim."""
async with httpx.AsyncClient(timeout=10.0, headers={"User-Agent": "FabledAssistant/1.0"}) as client:
resp = await client.get(NOMINATIM_URL, params={"q": query, "format": "json", "limit": 1})
resp.raise_for_status()
results = resp.json()
if not results:
raise ValueError(f"No geocoding result for: {query!r}")
r = results[0]
return float(r["lat"]), float(r["lon"]), r.get("display_name", query)
async def _fetch_open_meteo(lat: float, lon: float) -> dict:
"""Fetch 7-day forecast from Open-Meteo."""
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(OPEN_METEO_URL, params={
"latitude": lat,
"longitude": lon,
"daily": OPEN_METEO_DAILY,
"timezone": "auto",
"forecast_days": 7,
})
resp.raise_for_status()
return resp.json()
async def refresh_location_cache(
user_id: int, location_key: str, location_label: str, lat: float, lon: float
) -> WeatherCache:
"""Fetch fresh forecast and update the cache row for one location."""
raw = await _fetch_open_meteo(lat, lon)
async with async_session() as session:
result = await session.execute(
select(WeatherCache).where(
WeatherCache.user_id == user_id,
WeatherCache.location_key == location_key,
)
)
cache = result.scalars().first()
if cache is None:
cache = WeatherCache(
user_id=user_id,
location_key=location_key,
location_label=location_label,
)
session.add(cache)
# Rotate: current becomes previous before overwriting
cache.previous_json = cache.forecast_json
cache.forecast_json = raw
cache.location_label = location_label
cache.fetched_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(cache)
return cache
async def get_cached_weather(user_id: int) -> list[dict]:
"""Return all cached weather entries for the user, with parsed days and change notes."""
async with async_session() as session:
result = await session.execute(
select(WeatherCache).where(WeatherCache.user_id == user_id)
)
rows = list(result.scalars().all())
out = []
for row in rows:
current_days = parse_forecast(row.forecast_json) if row.forecast_json else []
previous_days = parse_forecast(row.previous_json) if row.previous_json else []
changes = detect_changes(previous_days, current_days) if previous_days else []
out.append({
"location_key": row.location_key,
"location_label": row.location_label,
"fetched_at": row.fetched_at.isoformat(),
"days": current_days,
"changes_since_last_fetch": changes,
})
return out