From bbdcea57a7816ef9ddf8728c9807735c95fe1152 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 11 Mar 2026 19:45:59 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20weather=20service=20=E2=80=94=20geocodi?= =?UTF-8?q?ng,=20Open-Meteo=20forecast,=20cache,=20change=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/fabledassistant/services/weather.py | 170 ++++++++++++++++++++++++ tests/test_weather_service.py | 56 ++++++++ 2 files changed, 226 insertions(+) create mode 100644 src/fabledassistant/services/weather.py create mode 100644 tests/test_weather_service.py diff --git a/src/fabledassistant/services/weather.py b/src/fabledassistant/services/weather.py new file mode 100644 index 0000000..9389b8a --- /dev/null +++ b/src/fabledassistant/services/weather.py @@ -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 diff --git a/tests/test_weather_service.py b/tests/test_weather_service.py new file mode 100644 index 0000000..b71f189 --- /dev/null +++ b/tests/test_weather_service.py @@ -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()