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:
@@ -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
|
||||
@@ -0,0 +1,56 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
|
||||
def test_parse_open_meteo_response():
|
||||
"""parse_forecast() should extract daily data into a clean list."""
|
||||
from fabledassistant.services.weather import parse_forecast
|
||||
raw = {
|
||||
"daily": {
|
||||
"time": ["2026-03-11", "2026-03-12"],
|
||||
"temperature_2m_max": [15.0, 12.0],
|
||||
"temperature_2m_min": [8.0, 6.0],
|
||||
"precipitation_sum": [0.0, 3.2],
|
||||
"weathercode": [0, 61],
|
||||
"windspeed_10m_max": [10.0, 25.0],
|
||||
}
|
||||
}
|
||||
days = parse_forecast(raw)
|
||||
assert len(days) == 2
|
||||
assert days[0]["date"] == "2026-03-11"
|
||||
assert days[0]["temp_max"] == 15.0
|
||||
assert days[0]["temp_min"] == 8.0
|
||||
assert days[0]["precip_mm"] == 0.0
|
||||
assert days[0]["weathercode"] == 0
|
||||
assert days[1]["date"] == "2026-03-12"
|
||||
assert days[1]["precip_mm"] == 3.2
|
||||
|
||||
|
||||
def test_describe_weathercode():
|
||||
"""describe_weathercode() should return human-readable strings."""
|
||||
from fabledassistant.services.weather import describe_weathercode
|
||||
assert describe_weathercode(0) == "Clear sky"
|
||||
assert describe_weathercode(61) == "Rain: slight"
|
||||
assert describe_weathercode(95) == "Thunderstorm"
|
||||
assert "Unknown" in describe_weathercode(999)
|
||||
|
||||
|
||||
def test_detect_forecast_changes_no_change():
|
||||
"""detect_changes() should return empty list when forecasts are identical."""
|
||||
from fabledassistant.services.weather import detect_changes
|
||||
day = {"date": "2026-03-12", "temp_max": 12.0, "temp_min": 6.0,
|
||||
"precip_mm": 3.2, "weathercode": 61}
|
||||
assert detect_changes([day], [day]) == []
|
||||
|
||||
|
||||
def test_detect_forecast_changes_rain_added():
|
||||
"""detect_changes() should flag when precipitation appears."""
|
||||
from fabledassistant.services.weather import detect_changes
|
||||
old = [{"date": "2026-03-12", "temp_max": 14.0, "temp_min": 8.0,
|
||||
"precip_mm": 0.0, "weathercode": 2}]
|
||||
new = [{"date": "2026-03-12", "temp_max": 12.0, "temp_min": 7.0,
|
||||
"precip_mm": 4.5, "weathercode": 61}]
|
||||
changes = detect_changes(old, new)
|
||||
assert len(changes) == 1
|
||||
assert "2026-03-12" in changes[0]
|
||||
assert "rain" in changes[0].lower() or "precip" in changes[0].lower()
|
||||
Reference in New Issue
Block a user