feat(weather): add hourly precipitation summaries and peak timing to weather card
Fetch hourly precipitation probabilities from Open-Meteo alongside daily
forecasts. Generate human-readable precip summaries ("Rain likely 2–5 PM",
"Rain likely all day") for today and each forecast day. Display today's
summary as a styled callout and show peak precipitation hour in forecast rows.
Also fix briefing pipeline to parse all weather location rows (not just first).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,8 @@ interface ForecastDay {
|
||||
precip_probability: number | null
|
||||
precip_mm: number | null
|
||||
windspeed_max: number
|
||||
precip_summary?: string
|
||||
precip_peak_hour?: string
|
||||
}
|
||||
|
||||
interface WeatherData {
|
||||
@@ -21,6 +23,7 @@ interface WeatherData {
|
||||
yesterday_high: number | null
|
||||
yesterday_low: number | null
|
||||
wind_unit?: string
|
||||
precip_summary?: string | null
|
||||
forecast: ForecastDay[]
|
||||
}
|
||||
|
||||
@@ -68,6 +71,11 @@ const fetchedAtLabel = computed(() => {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
function hasPrecip(day: ForecastDay): boolean {
|
||||
return (day.precip_probability != null && day.precip_probability > 0) ||
|
||||
(day.precip_mm != null && day.precip_mm > 0)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -85,6 +93,9 @@ const fetchedAtLabel = computed(() => {
|
||||
Today: {{ weather.today_high }}° / {{ weather.today_low }}°
|
||||
<span v-if="tempDelta" class="weather-delta"> · {{ tempDelta }}</span>
|
||||
</div>
|
||||
<div v-if="weather.precip_summary" class="weather-precip-summary">
|
||||
💧 {{ weather.precip_summary }}
|
||||
</div>
|
||||
<table class="weather-forecast" v-if="weather.forecast.length">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -100,8 +111,14 @@ const fetchedAtLabel = computed(() => {
|
||||
<td class="forecast-day-name">{{ day.day }}</td>
|
||||
<td class="forecast-icon">{{ weatherIcon(day.condition) }}</td>
|
||||
<td class="forecast-temps">{{ day.high }}° / {{ day.low }}°</td>
|
||||
<td class="forecast-precip" :class="{ 'forecast-precip--dry': !(day.precip_probability != null && day.precip_probability > 0) && !(day.precip_mm != null && day.precip_mm > 0) }">
|
||||
<template v-if="day.precip_probability != null && day.precip_probability > 0">{{ day.precip_probability }}%</template>
|
||||
<td class="forecast-precip" :class="{ 'forecast-precip--dry': !hasPrecip(day) }">
|
||||
<template v-if="day.precip_summary">
|
||||
<span class="precip-detail" :title="day.precip_summary">
|
||||
{{ day.precip_probability }}%
|
||||
<span v-if="day.precip_peak_hour" class="precip-peak">{{ day.precip_peak_hour }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="day.precip_probability != null && day.precip_probability > 0">{{ day.precip_probability }}%</template>
|
||||
<template v-else-if="day.precip_mm != null && day.precip_mm > 0">{{ day.precip_mm.toFixed(1) }}mm</template>
|
||||
<template v-else>—</template>
|
||||
</td>
|
||||
@@ -168,7 +185,7 @@ const fetchedAtLabel = computed(() => {
|
||||
|
||||
.weather-today {
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@@ -177,6 +194,16 @@ const fetchedAtLabel = computed(() => {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.weather-precip-summary {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.83rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
border-left: 2px solid color-mix(in srgb, var(--color-primary) 40%, transparent);
|
||||
}
|
||||
|
||||
.weather-forecast {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -227,6 +254,18 @@ const fetchedAtLabel = computed(() => {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.precip-detail {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.3em;
|
||||
}
|
||||
|
||||
.precip-peak {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.forecast-wind {
|
||||
text-align: right;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
@@ -379,7 +379,11 @@ async def run_compilation(
|
||||
if item.get("id")
|
||||
]
|
||||
|
||||
weather_card = parse_weather_card_data(weather_rows[0], temp_unit) if weather_rows else None
|
||||
weather_cards = [
|
||||
card for row in weather_rows
|
||||
if (card := parse_weather_card_data(row, temp_unit)) is not None
|
||||
]
|
||||
weather_card = weather_cards[0] if weather_cards else None
|
||||
|
||||
briefing_text, agentic_messages = await run_agentic_briefing(
|
||||
user_id, slot, model, conv_id=None, rss_override=filtered_rss,
|
||||
|
||||
@@ -17,6 +17,7 @@ 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] = {
|
||||
@@ -93,6 +94,55 @@ def detect_changes(old_days: list[dict], new_days: list[dict]) -> list[str]:
|
||||
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",
|
||||
@@ -138,6 +188,30 @@ def parse_weather_card_data(
|
||||
|
||||
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(),
|
||||
@@ -148,18 +222,8 @@ def parse_weather_card_data(
|
||||
"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
|
||||
],
|
||||
"precip_summary": today_precip_summary,
|
||||
"forecast": [_forecast_day(d) for d in future_days],
|
||||
}
|
||||
|
||||
|
||||
@@ -259,12 +323,13 @@ async def fetch_hourly_precip(lat: float, lon: float) -> dict[str, int]:
|
||||
|
||||
|
||||
async def _fetch_open_meteo(lat: float, lon: float) -> dict:
|
||||
"""Fetch 7-day forecast from Open-Meteo with current conditions and yesterday's data."""
|
||||
"""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",
|
||||
|
||||
@@ -59,7 +59,6 @@ def test_detect_forecast_changes_rain_added():
|
||||
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()
|
||||
@@ -71,7 +70,6 @@ def test_parse_weather_card_data_returns_none_when_stale():
|
||||
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()
|
||||
@@ -103,3 +101,66 @@ def test_parse_weather_card_data_returns_card_schema():
|
||||
assert card["yesterday_low"] == 9
|
||||
assert len(card["forecast"]) >= 1
|
||||
assert card["location"] == "Berlin, DE"
|
||||
assert card["precip_summary"] is None
|
||||
|
||||
|
||||
def test_summarize_precip_dry():
|
||||
from fabledassistant.services.weather import _summarize_precip
|
||||
assert _summarize_precip([(h, 10) for h in range(24)]) is None
|
||||
|
||||
|
||||
def test_summarize_precip_all_day():
|
||||
from fabledassistant.services.weather import _summarize_precip
|
||||
result = _summarize_precip([(h, 60) for h in range(24)])
|
||||
assert result is not None
|
||||
assert "all day" in result.lower()
|
||||
|
||||
|
||||
def test_summarize_precip_window():
|
||||
from fabledassistant.services.weather import _summarize_precip
|
||||
hourly = [(h, 5) for h in range(24)]
|
||||
for h in range(14, 18):
|
||||
hourly[h] = (h, 65)
|
||||
result = _summarize_precip(hourly)
|
||||
assert result is not None
|
||||
assert "2 PM" in result
|
||||
assert "65%" in result
|
||||
|
||||
|
||||
def test_parse_weather_card_includes_hourly_precip_summary():
|
||||
from datetime import datetime, timezone, timedelta, date
|
||||
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()
|
||||
|
||||
hourly_times = [f"{today}T{h:02d}:00" for h in range(24)]
|
||||
hourly_probs = [5] * 24
|
||||
for h in range(14, 18):
|
||||
hourly_probs[h] = 70
|
||||
|
||||
cache = MagicMock()
|
||||
cache.fetched_at = datetime.now(timezone.utc)
|
||||
cache.location_label = "Berlin, DE"
|
||||
cache.forecast_json = {
|
||||
"current_weather": {"temperature": 12.0, "weathercode": 61},
|
||||
"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, 5.0, 1.5],
|
||||
"precipitation_probability_max": [0, 70, 30],
|
||||
"weathercode": [1, 61, 61],
|
||||
"windspeed_10m_max": [10.0, 12.0, 8.0],
|
||||
},
|
||||
"hourly": {
|
||||
"time": hourly_times,
|
||||
"precipitation_probability": hourly_probs,
|
||||
},
|
||||
}
|
||||
|
||||
card = parse_weather_card_data(cache)
|
||||
assert card is not None
|
||||
assert card["precip_summary"] is not None
|
||||
assert "2 PM" in card["precip_summary"]
|
||||
|
||||
Reference in New Issue
Block a user