"""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," "precipitation_probability_max,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", []) precip_prob = daily.get("precipitation_probability_max", []) 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], "precip_probability": precip_prob[i] if i < len(precip_prob) else None, "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 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] imperial = temp_unit == "F" def to_temp(c: float) -> int: return round(c * 9 / 5 + 32) if imperial else round(c) def to_wind(kmh: float) -> int: return round(kmh * 0.621371) if imperial else round(kmh) 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 wind_unit = "mph" if imperial else "km/h" 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, "wind_unit": wind_unit, "forecast": [ { "day": day_label(d["date"]), "condition": d["description"], "high": to_temp(d["temp_max"]), "low": to_temp(d["temp_min"]), "precip_probability": d["precip_probability"], "precip_mm": d["precip_mm"], "windspeed_max": to_wind(d["windspeed_max"]), } 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: 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_current_conditions(lat: float, lon: float) -> dict | None: """Fetch current temperature + conditions + next 3 hours precipitation. Lightweight call — no daily forecast, no caching needed. Returns dict with: temperature, windspeed, weathercode, description, and precip_next_3h (list of hourly precip probabilities). """ try: async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.get(OPEN_METEO_URL, params={ "latitude": lat, "longitude": lon, "current_weather": "true", "hourly": "precipitation_probability", "forecast_days": 1, "timezone": "auto", }) resp.raise_for_status() data = resp.json() current = data.get("current_weather", {}) hourly = data.get("hourly", {}) hourly_times = hourly.get("time", []) hourly_precip = hourly.get("precipitation_probability", []) # Find the current hour index and get next 3 hours of precip current_time = current.get("time", "") precip_next_3h = [] try: idx = next(i for i, t in enumerate(hourly_times) if t >= current_time) precip_next_3h = hourly_precip[idx:idx + 3] except (StopIteration, IndexError): pass code = current.get("weathercode", 0) return { "temperature": current.get("temperature"), "windspeed": current.get("windspeed"), "weathercode": code, "description": describe_weathercode(code), "time": current_time, "precip_next_3h": precip_next_3h, } except Exception: logger.debug("Failed to fetch current conditions for %s, %s", lat, lon, exc_info=True) return None async def fetch_hourly_precip(lat: float, lon: float) -> dict[str, int]: """Fetch today's hourly precipitation probabilities. Returns a dict of ISO hour string → probability percentage, e.g.: {"2026-04-08T14:00": 65, "2026-04-08T15:00": 80, ...} """ try: async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.get(OPEN_METEO_URL, params={ "latitude": lat, "longitude": lon, "hourly": "precipitation_probability", "forecast_days": 1, "timezone": "auto", }) resp.raise_for_status() data = resp.json() hourly = data.get("hourly", {}) times = hourly.get("time", []) probs = hourly.get("precipitation_probability", []) return {t: p for t, p in zip(times, probs) if p is not None} except Exception: logger.debug("Failed to fetch hourly precip for %s, %s", lat, lon, exc_info=True) return {} async def _fetch_open_meteo(lat: float, lon: float) -> dict: """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, }) 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