Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/user_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,18 @@ Google Account when the hub is retired or no longer uses that account.

#### Weather Forecast

Both forecast tiles include a forecast synopsis. Open-Meteo and MET Norway
summaries are assembled from hourly conditions, temperature range, maximum wind,
and peak precipitation chance, using the selected display units. NWS supplies
its own period narrative when available; the period name is shown and its text
keeps the provider’s original units. If that optional text is unavailable,
Sensorius builds the same hourly summary used for the other providers.

The Caelus forecast card also shows temperature, humidity, and wind ranges and
hourly humidity and wind alongside precipitation chances. Both cards retain
their existing footprint; focus or scroll the synopsis to read longer text.
Missing provider values appear as dashes rather than a zero forecast.

The full-screen forecast is titled **Living Weather**, with the Caelus icon and
“Created by Peace Hill Studios” beneath the title. The Biodynamic Calendar
includes this credit in the central calendar icon. The settings menu displays
Expand Down
2 changes: 1 addition & 1 deletion sensorius/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
release notes, and supporting tooling can report a consistent build identity.
"""

__version__ = "v0.26.253.1"
__version__ = "v0.26.253.5"
6 changes: 3 additions & 3 deletions sensorius/saiHtml.py
Original file line number Diff line number Diff line change
Expand Up @@ -1751,7 +1751,7 @@ def _status_color_hex(status: str) -> str:
yield "#weatherForecastBox .astro-title{width:100%;text-align:center;color:var(--forecast-ink);text-shadow:0 1px 2px rgba(0,0,0,.24);}"
yield ".forecast-status{font-size:.55rem;line-height:1.1;text-align:center;color:var(--forecast-muted);min-height:1.1em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}"
yield ".forecast-current{border:1px solid var(--forecast-border);border-radius:8px;background:var(--forecast-panel);padding:.42rem .48rem;display:flex;flex-direction:column;gap:.3rem;min-width:0;backdrop-filter:blur(3px);}"
yield ".forecast-current-summary{font-size:.68rem;line-height:1.16;color:var(--forecast-ink);text-align:center;min-height:2.35em;overflow-wrap:anywhere;}"
yield ".forecast-current-summary{font-size:.68rem;line-height:1.16;color:var(--forecast-ink);text-align:center;height:2.35em;overflow:auto;overflow-wrap:anywhere;}"
yield ".forecast-current-grid{display:grid;grid-template-columns:auto minmax(0,1fr);gap:.15rem .4rem;align-items:start;font-size:.64rem;line-height:1.12;}"
yield ".forecast-current-grid dt{font-weight:700;color:var(--forecast-muted);margin:0;}"
yield ".forecast-current-grid dd{margin:0;color:var(--forecast-ink);overflow-wrap:anywhere;}"
Expand Down Expand Up @@ -1946,7 +1946,7 @@ def _status_color_hex(status: str) -> str:
yield " <span class='spinner dashboard-card-spinner' aria-hidden='true'></span>"
yield " <div class='forecast-status' id='forecastStatus'></div>"
yield " <div class='forecast-current'>"
yield " <div class='forecast-current-summary' id='forecastOverall'>--</div>"
yield " <div class='forecast-current-summary' id='forecastOverall' tabindex='0' aria-label='Forecast synopsis'>--</div>"
yield " <dl class='forecast-current-grid'>"
yield " <dt>Temp</dt><dd id='forecastTempRange'>--</dd>"
yield " <dt>RH</dt><dd id='forecastRhRange'>--</dd>"
Expand Down Expand Up @@ -3557,7 +3557,7 @@ def _format_stat_value(stat_value):
yield " const provider = forecastProviderLabel(data);"
yield " const cacheText = data.stale ? 'Cached' : 'Live';"
yield " if (statusEl) statusEl.textContent = provider ? `${cacheText} - ${provider}` : cacheText;"
yield " setForecastText('forecastOverall', cur.overall || cur.forecast || '--');"
yield " setForecastText('forecastOverall', cur.synopsis || cur.overall || cur.forecast || '--');"
yield " setForecastText('forecastTempRange', forecastReadingAndRange(forecastCurrentTemperature(data), cur.temp_range));"
yield " setForecastWind(cur.wind || '--');"
yield " setForecastText('forecastRhRange', forecastReadingAndRange(forecastCurrentHumidity(data), cur.rh_range));"
Expand Down
51 changes: 45 additions & 6 deletions sensorius/saiWeatherForecast.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,17 @@ def normalize_met_forecast(payload: dict[str, Any], *, tz_name: str) -> list[dic
return sorted(rows, key=lambda row: row["time"])


def _open_meteo_symbol(value: object) -> str:
code = _safe_float(value)
if code in (95, 96, 99):
return "Thunderstorms"
if code in (71, 73, 75, 77, 85, 86):
return "Snow"
if code in (51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82):
return "Rain"
return {0: "Clear", 1: "Mostly clear", 2: "Partly cloudy", 3: "Overcast", 45: "Fog", 48: "Fog"}.get(code, "")


def normalize_open_meteo_forecast(payload: dict[str, Any], *, tz_name: str) -> list[dict[str, Any]]:
"""Normalize Open-Meteo forecast payload into hourly rows."""
hourly = payload.get("hourly") if isinstance(payload, dict) else None
Expand All @@ -389,6 +400,7 @@ def normalize_open_meteo_forecast(payload: dict[str, Any], *, tz_name: str) -> l
precip_probability = hourly.get("precipitation_probability")
clouds = hourly.get("cloud_cover")
winds = hourly.get("wind_speed_10m")
codes = hourly.get("weather_code")
if not isinstance(times, list):
return []
rows: list[dict[str, Any]] = []
Expand All @@ -402,6 +414,7 @@ def _at(values: object) -> object:
"temp_c": _at(temps),
"rh": _at(rhs),
"wind_mps": _at(winds),
"symbol": _open_meteo_symbol(_at(codes)),
"cloud": _at(clouds),
"precip_mm": _at(precip) or 0.0,
"precip_probability": _at(precip_probability),
Expand Down Expand Up @@ -750,7 +763,7 @@ async def _fetch_probability_supplement() -> list[dict[str, Any]]:
async def _fetch_open_meteo_forecast(latitude: float, longitude: float, *, tz_name: str, timeout_sec: float) -> list[dict[str, Any]]:
hourly = (
"temperature_2m,relative_humidity_2m,precipitation,"
"precipitation_probability,cloud_cover,wind_speed_10m"
"precipitation_probability,cloud_cover,wind_speed_10m,weather_code"
)
async with httpx.AsyncClient(timeout=timeout_sec, headers={"User-Agent": USER_AGENT}, follow_redirects=True) as client:
resp = await client.get(
Expand Down Expand Up @@ -780,17 +793,20 @@ async def _fetch_nws_forecast(latitude: float, longitude: float, *, tz_name: str
if not hourly_url:
raise RuntimeError("NWS hourly forecast URL unavailable")

async def _fetch_grid() -> httpx.Response | None:
if not grid_url:
async def _fetch_optional(url: str) -> httpx.Response | None:
if not url:
return None
try:
return await client.get(grid_url)
return await client.get(url)
except Exception as exc:
if DEBUG:
printDM(f"NWS grid probability request failed: {exc}", location=MODULE)
printDM(f"NWS optional forecast request failed: {exc}", location=MODULE)
return None

hourly_resp, grid_resp = await asyncio.gather(client.get(hourly_url), _fetch_grid())
narrative_url = str((props or {}).get("forecast") or "").strip()
hourly_resp, grid_resp, narrative_resp = await asyncio.gather(
client.get(hourly_url), _fetch_optional(grid_url), _fetch_optional(narrative_url)
)
hourly_resp.raise_for_status()
rows = normalize_nws_forecast(hourly_resp.json(), tz_name=tz_name)
if grid_resp is not None:
Expand All @@ -802,9 +818,32 @@ async def _fetch_grid() -> httpx.Response | None:
printDM(f"NWS grid probability supplement failed: {exc}", location=MODULE)
else:
supplement_precipitation_probabilities(rows, supplemental)
if narrative_resp is not None:
try:
narrative_resp.raise_for_status()
attach_nws_narratives(rows, narrative_resp.json())
except Exception as exc:
if DEBUG:
printDM(f"NWS narrative supplement failed: {exc}", location=MODULE)
return rows


def attach_nws_narratives(rows: list[dict[str, Any]], payload: dict[str, Any]) -> None:
"""Attach native NWS text only to hours within its forecast period."""
properties = payload.get("properties") or {}
for period in properties.get("periods") or []:
start = _parse_datetime(period.get("startTime"))
end = _parse_datetime(period.get("endTime"))
text = str(period.get("detailedForecast") or "").strip()
if not start or not end or not text:
continue
for row in rows:
instant = _parse_datetime(row.get("time"))
if instant and start <= instant < end:
row["narrative"] = text
row["narrative_period"] = str(period.get("name") or "Forecast period")


def _resolve_forecast_location(settings: Any, *, timeout_sec: float) -> dict[str, Any]:
try:
resolved = settings.resolve_astral_location(persist_if_auto=False, timeout_sec=timeout_sec) or {}
Expand Down
43 changes: 43 additions & 0 deletions sensorius/saiWeatherForecastApp.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,44 @@ def _hour_window_condition(hours: list[dict[str, Any]]) -> str:
return "Clear"


def _forecast_synopsis(hours: list[dict[str, Any]], unit_system: str, provider: str) -> str:
"""Describe the upcoming hourly window, preferring its native NWS period text."""
if not hours:
return ""
if provider == "us" and hours[0].get("narrative"):
return f"{hours[0].get('narrative_period') or 'Forecast period'} (NWS · original units): {hours[0]['narrative']}"
names = {"sunny": "Clear", "partly-cloudy": "Partly cloudy", "cloudy": "Overcast",
"rain": "Rain", "snow": "Snow", "thunder": "Thunderstorms", "fog": "Fog"}
def condition(row):
value = _hour_window_condition([row])
return "Conditions unavailable" if value == "Forecast unavailable" else names[_condition_icon_key(value)]
def when(row):
value = str(row.get("local_time") or row.get("time") or "")
first = str(hours[0].get("local_time") or hours[0].get("time") or "")
return _format_hour_label(value) + (" next day" if value[:10] != first[:10] else "")
first_condition = condition(hours[0])
text = first_condition
for row in hours[1:]:
next_condition = condition(row)
if next_condition != first_condition:
text += f", then {next_condition.lower()} around {when(row)}"
break
parts = [text + "."]
temperatures = [value for row in hours if (value := _forecast_display_value(row.get("temp_c"), "Temperature", "°C", unit_system)[0]) is not None]
temp_unit = _forecast_display_value(None, "Temperature", "°C", unit_system)[1]
if temperatures:
parts.append(f"Temperatures {min(temperatures):.0f}–{max(temperatures):.0f}{temp_unit}.")
winds = [value for row in hours if (value := _forecast_display_value(row.get("wind_mps"), "Wind Speed", "m/s", unit_system)[0]) is not None]
wind_unit = _forecast_display_value(None, "Wind Speed", "m/s", unit_system)[1]
if winds:
parts.append(f"Winds up to {max(winds):.0f} {wind_unit}.")
probabilities = [row for row in hours if _safe_float(row.get("precip_probability")) is not None]
if probabilities:
peak = max(probabilities, key=lambda row: float(row["precip_probability"]))
parts.append(f"Precipitation chance peaks at {float(peak['precip_probability']):.0f}% around {when(peak)}.")
return " ".join(parts)


def build_weather_display_forecast(
payload: dict[str, Any],
unit_system: object = "Imperial",
Expand All @@ -332,6 +370,9 @@ def build_weather_display_forecast(
hours.append(
{
"label": label,
"humidity": round(rh) if (rh := _safe_float(raw.get("rh"))) is not None else None,
"wind_speed": round(wind) if (wind := _forecast_display_value(raw.get("wind_mps"), "Wind Speed", "m/s", display_unit_system)[0]) is not None else None,
"wind_unit": _forecast_display_value(None, "Wind Speed", "m/s", display_unit_system)[1],
"icon": _condition_icon(condition),
"icon_key": _condition_icon_key(condition),
"precip_label": _precipitation_chance_label(condition),
Expand Down Expand Up @@ -404,6 +445,8 @@ def build_weather_display_forecast(
"stale": bool(payload.get("stale", False)),
"reason": str(payload.get("reason") or ""),
"condition": condition,
"synopsis": _forecast_synopsis(normalized_hours, display_unit_system, str(payload.get("provider") or "")) or condition,
"wind_range": _forecast_wind(current.get("wind"), display_unit_system, include_description=False),
"icon": _condition_icon(_hour_window_condition(normalized_hours[:3]) if normalized_hours else condition),
"icon_key": _condition_icon_key(
_hour_window_condition(normalized_hours[:3]) if normalized_hours else condition
Expand Down
1 change: 1 addition & 0 deletions sensorius/saiWebRoutes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6163,6 +6163,7 @@ async def api_weather_forecast(
payload = copy.deepcopy(payload)
current_24h = payload.get("current_24h")
if isinstance(current_24h, dict):
current_24h["synopsis"] = display_forecast.get("synopsis")
current_24h["temp_range"] = display_forecast.get("temp_range") or current_24h.get("temp_range")
payload["unit_system"] = display_unit_system
if forecast_app_service is not None:
Expand Down
10 changes: 6 additions & 4 deletions testApparatus/playwright_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,19 +127,21 @@ def switch_settings() -> HTMLResponse:


@app.get("/weather-forecast", response_class=HTMLResponse)
def weather_forecast(request: Request, units: str = "Metric"):
def weather_forecast(request: Request, units: str = "Metric", native: bool = False):
"""Render the real Caelus page with a complete, deterministic six-day outlook."""
from sensorius.saiWeatherForecastApp import build_weather_display_forecast

start = datetime(2026, 9, 9, 15, tzinfo=timezone.utc)
forecast = build_weather_display_forecast({
"ok": True, "provider": "nws",
"current_24h": {"overall": "Cloudy early, clearing late", "precip_probability": 55},
"ok": True, "provider": "us" if native else "open_meteo",
"current_24h": {"overall": "Cloudy early, clearing late", "precip_probability": 55, "rh_range": "35-85%", "wind": "Mostly light\n1-8 m/s / 2-18 mph"},
"hourly": [{
"time": (start + timedelta(hours=hour)).isoformat(),
"local_time": (start + timedelta(hours=hour)).isoformat(),
"temp_c": 30 - hour / 2, "precip_probability": 55,
"symbol": "partlycloudy_day",
"narrative": "Sunny, with a high near 80. West wind 5 to 10 mph. " * 12 if native else "",
"narrative_period": "This Afternoon",
"symbol": "partlycloudy_day" if hour < 5 else "rain", "rh": 55, "wind_mps": 4,
} for hour in range(24)],
"days": [{
"date": (start + timedelta(days=day)).date().isoformat(),
Expand Down
36 changes: 35 additions & 1 deletion testApparatus/test_integrated_weather_forecast.py
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,7 @@ async def _fake_forecast(*_args, **_kwargs):
assert "data-hourly-previous" in page.text
assert "data-hourly-next" in page.text
assert ">40%</strong> Rain chance" in page.text
assert "40% rain chance" in page.text
assert ">Rain 40%</small>" in page.text
assert "Rain 42%" in page.text
assert "RH 30-60%" in page.text
assert "Wind --" in page.text
Expand Down Expand Up @@ -859,3 +859,37 @@ def test_six_day_dialog_uses_selected_theme_palette():
assert ".forecast-dialog > header button::before" in css
assert "transform: translate(-50%, -50%) rotate(45deg)" in css
assert "transform: translate(-50%, -50%) rotate(-45deg)" in css


def test_synopsis_uses_local_transition_time_units_and_missing_values():
payload = {"ok": True, "provider": "met_no", "hourly": [
{"local_time": "2026-09-10T23:00:00-06:00", "temp_c": 10, "symbol": "clearsky_night", "wind_mps": 2},
{"local_time": "2026-09-11T00:00:00-06:00", "temp_c": 20, "symbol": "snow", "wind_mps": 4, "rh": 60, "precip_probability": 75},
]}
metric = weather_app.build_weather_display_forecast(payload, "Metric")
assert "then snow around 12 AM next day" in metric["synopsis"]
assert "10–20°C" in metric["synopsis"]
assert "14 km/h" in metric["synopsis"]
assert "75% around 12 AM next day" in metric["synopsis"]
assert metric["hours"][0]["humidity"] is None
assert metric["hours"][1]["humidity"] == 60
imperial = weather_app.build_weather_display_forecast(payload, "Imperial")
assert "50–68°F" in imperial["synopsis"]
assert "9 mph" in imperial["synopsis"]
for hour in payload["hourly"]:
hour.pop("precip_probability", None)
hour.pop("wind_mps", None)
missing = weather_app.build_weather_display_forecast(payload)["synopsis"]
assert "chance" not in missing
assert "Winds" not in missing


def test_synopsis_prefers_nws_native_text_with_generated_fallback():
payload = {"ok": True, "provider": "us", "hourly": [{
"local_time": "2026-09-10T12:00:00Z", "temp_c": 20, "symbol": "Sunny",
"narrative_period": "This Afternoon", "narrative": "Sunny, with a high near 80.",
}]}
result = weather_app.build_weather_display_forecast(payload, "Metric")
assert result["synopsis"] == "This Afternoon (NWS · original units): Sunny, with a high near 80."
del payload["hourly"][0]["narrative"]
assert "Temperatures 20–20°C" in weather_app.build_weather_display_forecast(payload, "Metric")["synopsis"]
Loading