From 9e1615bd3270ade115d5378b9f3c345d9ece117c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 25 Mar 2026 10:32:59 -0400 Subject: [PATCH] feat(briefing): add past_days/current_weather to Open-Meteo; add parse_weather_card_data() Also adds get_cached_weather_rows() for parallel gather in briefing pipeline. Co-Authored-By: Claude Sonnet 4.6 --- src/fabledassistant/services/weather.py | 74 ++++++++++++++++++++++++- tests/test_weather_service.py | 49 ++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/src/fabledassistant/services/weather.py b/src/fabledassistant/services/weather.py index 9389b8a..6004b8b 100644 --- a/src/fabledassistant/services/weather.py +++ b/src/fabledassistant/services/weather.py @@ -91,6 +91,76 @@ def detect_changes(old_days: list[dict], new_days: list[dict]) -> list[str]: return changes +def parse_weather_card_data( + cache_row, + temp_unit: str = "C", +) -> dict | None: + """ + Parse a WeatherCache row into the metadata.weather card schema. + Returns None if the cache is stale (older than 24 hours) or unavailable. + """ + from datetime import date, timedelta + + if cache_row is None or cache_row.fetched_at is None: + return None + + age_seconds = (datetime.now(timezone.utc) - cache_row.fetched_at).total_seconds() + if age_seconds > 86400: + return None + + raw = cache_row.forecast_json or {} + current_weather = raw.get("current_weather", {}) + days = parse_forecast(raw) + + today_str = date.today().isoformat() + yesterday_str = (date.today() - timedelta(days=1)).isoformat() + + today_day = next((d for d in days if d["date"] == today_str), None) + yesterday_day = next((d for d in days if d["date"] == yesterday_str), None) + future_days = [d for d in days if d["date"] > today_str][:5] + + def to_temp(c: float) -> int: + if temp_unit == "F": + return round(c * 9 / 5 + 32) + return round(c) + + def day_label(date_str: str) -> str: + from datetime import date as _date + try: + return _date.fromisoformat(date_str).strftime("%a") + except ValueError: + return date_str + + return { + "location": getattr(cache_row, "location_label", ""), + "fetched_at": cache_row.fetched_at.isoformat(), + "current_temp": to_temp(current_weather.get("temperature", 0)), + "condition": describe_weathercode(current_weather.get("weathercode", 0)), + "today_high": to_temp(today_day["temp_max"]) if today_day else None, + "today_low": to_temp(today_day["temp_min"]) if today_day else None, + "yesterday_high": to_temp(yesterday_day["temp_max"]) if yesterday_day else None, + "yesterday_low": to_temp(yesterday_day["temp_min"]) if yesterday_day else None, + "forecast": [ + { + "day": day_label(d["date"]), + "condition": d["description"], + "high": to_temp(d["temp_max"]), + "low": to_temp(d["temp_min"]), + } + for d in future_days + ], + } + + +async def get_cached_weather_rows(user_id: int) -> list: + """Return raw WeatherCache ORM rows for a user (for card parsing).""" + async with async_session() as session: + result = await session.execute( + select(WeatherCache).where(WeatherCache.user_id == user_id) + ) + return list(result.scalars().all()) + + 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: @@ -104,12 +174,14 @@ async def geocode(query: str) -> tuple[float, float, str]: async def _fetch_open_meteo(lat: float, lon: float) -> dict: - """Fetch 7-day forecast from Open-Meteo.""" + """Fetch 7-day forecast from Open-Meteo with current conditions and yesterday's data.""" 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, + "current_weather": "true", + "past_days": 1, "timezone": "auto", "forecast_days": 7, }) diff --git a/tests/test_weather_service.py b/tests/test_weather_service.py index b71f189..c27c471 100644 --- a/tests/test_weather_service.py +++ b/tests/test_weather_service.py @@ -54,3 +54,52 @@ def test_detect_forecast_changes_rain_added(): assert len(changes) == 1 assert "2026-03-12" in changes[0] assert "rain" in changes[0].lower() or "precip" in changes[0].lower() + + +def test_parse_weather_card_data_returns_none_when_stale(): + """Should return None when cache is older than 24 hours.""" + from datetime import datetime, timezone, timedelta + from unittest.mock import MagicMock + from fabledassistant.services.weather import parse_weather_card_data + + cache = MagicMock() + cache.fetched_at = datetime.now(timezone.utc) - timedelta(hours=25) + cache.forecast_json = {} + assert parse_weather_card_data(cache) is None + + +def test_parse_weather_card_data_returns_card_schema(): + """Should return the correct metadata.weather schema for fresh cache.""" + from datetime import datetime, timezone, timedelta, date + from unittest.mock import MagicMock + from fabledassistant.services.weather import parse_weather_card_data + + today = date.today().isoformat() + yesterday = (date.today() - timedelta(days=1)).isoformat() + tomorrow = (date.today() + timedelta(days=1)).isoformat() + + cache = MagicMock() + cache.fetched_at = datetime.now(timezone.utc) + cache.location_label = "Berlin, DE" + cache.forecast_json = { + "current_weather": {"temperature": 12.0, "weathercode": 2}, + "daily": { + "time": [yesterday, today, tomorrow], + "temperature_2m_max": [14.0, 16.0, 18.0], + "temperature_2m_min": [9.0, 8.0, 10.0], + "precipitation_sum": [0.0, 0.0, 1.5], + "weathercode": [1, 2, 61], + "windspeed_10m_max": [10.0, 12.0, 8.0], + } + } + + card = parse_weather_card_data(cache) + assert card is not None + assert card["current_temp"] == 12 + assert card["condition"] == "Partly cloudy" + assert card["today_high"] == 16 + assert card["today_low"] == 8 + assert card["yesterday_high"] == 14 + assert card["yesterday_low"] == 9 + assert len(card["forecast"]) >= 1 + assert card["location"] == "Berlin, DE"