Files
FabledScribe/src/fabledassistant/services/weather.py
T
bvandeusen 7602bf2293 feat(briefing): hard-cut tear-down
Backend:
- Delete briefing services (pipeline, scheduler, conversations, profile, tools)
- Delete routes/briefing.py + remove blueprint registration
- Move _get_temp_unit into services/weather.get_temp_unit (reads top-level temp_unit setting)
- Rename briefing_preferences.py → rss_filtering.py (functions are RSS-specific)
- Strip briefing scheduler hooks from app.py
- Strip briefing scheduler call from routes/settings.py
- Update test imports (test_rss_service, test_tz_helpers)

Frontend:
- Delete BriefingView, BriefingSetupWizard, BriefingToolStatusRow
- Strip /briefing route + nav links (AppHeader, KnowledgeView)
- Strip Settings → Briefing tab + state + functions + imports
- Strip briefing-intermediate handling from ChatMessage
- Hide /news route + nav links (NewsView depended on briefing endpoints; orphaned in tree)
- Drop unused useSettingsStore from AppHeader

The Android BriefingScreen lives in a separate repo and is not touched here.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 22:33:37 -04:00

400 lines
15 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 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"
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) -> 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, 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