Files
FabledScribe/src/fabledassistant/services/weather.py
T
bvandeusen c33cab7020 fix(journal): wire weather refresh on config save; drop orphaned cache rows
Two related gaps in the journal weather panel:

1. Saving locations via PUT /journal/config didn't trigger a weather
   fetch, so newly-entered sites had no cache row (or a stale one) until
   the user manually clicked the panel's refresh button. The panel
   rendered "two sites with empty values" against pre-existing cache
   rows that no longer matched what the user had configured.

2. get_cached_weather_rows returned every WeatherCache row for the user
   regardless of whether the location was still in journal_config.
   Briefing-era rows survived migration 0040 (which only deleted the
   briefing_config setting, not the cache table) and showed up as
   ghost tabs in the UI.

Changes:
- get_cached_weather_rows accepts an optional valid_keys filter; rows
  whose location_key is not in the set are excluded.
- routes/journal.py:
  - put_config kicks off a background refresh_location_cache for any
    saved location with valid lat/lon.
  - GET /weather and POST /weather/refresh both pass valid_keys derived
    from the current config so orphaned rows don't surface.
- services/journal_prep.py filters the weather section to currently-
  configured locations as well; uses a lazy import of get_journal_config
  to avoid a cycle (journal_scheduler imports journal_prep).

153 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:37:23 -04:00

409 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"
)
OPEN_METEO_HOURLY = "precipitation_probability"
# 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})")
async def get_temp_unit(user_id: int) -> str:
"""Read the user's preferred temperature unit ('C' or 'F'), default 'C'."""
from fabledassistant.services.settings import get_setting
raw = await get_setting(user_id, "temp_unit", "C")
return raw if raw in ("C", "F") else "C"
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 _summarize_precip(hourly_probs: list[tuple[int, int]], threshold: int = 30) -> str | None:
"""Build a human-readable precipitation summary from (hour, probability) pairs.
Returns None when no significant precipitation is expected.
"""
wet_hours = [(h, p) for h, p in hourly_probs if p >= threshold]
if not wet_hours:
return None
peak_hour, peak_prob = max(wet_hours, key=lambda x: x[1])
daytime_hours = [h for h, _ in hourly_probs if 6 <= h <= 22]
if not daytime_hours:
return None
wet_daytime = [h for h, p in hourly_probs if 6 <= h <= 22 and p >= threshold]
if len(wet_daytime) >= 10:
return f"Rain likely all day (up to {peak_prob}%)"
if not wet_daytime:
return None
def _fmt_hour(h: int) -> str:
if h == 0 or h == 24:
return "12 AM"
if h == 12:
return "12 PM"
return f"{h} AM" if h < 12 else f"{h - 12} PM"
start = wet_daytime[0]
end = wet_daytime[-1]
if start == end:
return f"{peak_prob}% chance around {_fmt_hour(start)}"
return f"Rain likely {_fmt_hour(start)}{_fmt_hour(end + 1)} (up to {peak_prob}%)"
def _extract_hourly_precip_for_date(raw: dict, date_str: str) -> list[tuple[int, int]]:
"""Extract (hour, probability) pairs for a specific date from cached forecast JSON."""
hourly = raw.get("hourly", {})
times = hourly.get("precipitation_probability", [])
time_labels = hourly.get("time", [])
pairs: list[tuple[int, int]] = []
prefix = date_str + "T"
for i, t in enumerate(time_labels):
if t.startswith(prefix) and i < len(times) and times[i] is not None:
hour = int(t[11:13])
pairs.append((hour, times[i]))
return pairs
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 row is missing or unparseable. Stale data is
returned as-is — the frontend uses `fetched_at` to convey freshness, and
WeatherCard handles missing today/forecast fields gracefully.
"""
from datetime import date, timedelta
if cache_row is None or cache_row.fetched_at is None or not cache_row.forecast_json:
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"
today_hourly = _extract_hourly_precip_for_date(raw, today_str)
today_precip_summary = _summarize_precip(today_hourly)
def _forecast_day(d: dict) -> dict:
entry: dict = {
"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"]),
}
hourly = _extract_hourly_precip_for_date(raw, d["date"])
summary = _summarize_precip(hourly)
if summary:
entry["precip_summary"] = summary
if hourly:
peak = max(hourly, key=lambda x: x[1])
if peak[1] >= 30:
h = peak[0]
entry["precip_peak_hour"] = f"{h} AM" if h < 12 else ("12 PM" if h == 12 else f"{h - 12} PM")
return entry
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,
"precip_summary": today_precip_summary,
"forecast": [_forecast_day(d) for d in future_days],
}
async def get_cached_weather_rows(
user_id: int,
valid_keys: set[str] | None = None,
) -> list:
"""Return raw WeatherCache ORM rows for a user (for card parsing).
If ``valid_keys`` is provided, only rows whose ``location_key`` is in the
set are returned. This is how callers drop orphaned cache rows whose
location is no longer in the user's ``journal_config.locations`` (e.g.
leftovers from the briefing era, or a location that's been removed).
Passing an empty set returns no rows.
"""
async with async_session() as session:
stmt = select(WeatherCache).where(WeatherCache.user_id == user_id)
if valid_keys is not None:
stmt = stmt.where(WeatherCache.location_key.in_(list(valid_keys)))
result = await session.execute(stmt)
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, hourly precip, 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,
"hourly": OPEN_METEO_HOURLY,
"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