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
15 changes: 8 additions & 7 deletions docs/user_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1261,29 +1261,30 @@ the current Moon and phase cycle at the bottom. The display also provides
theme-matched six-day details. It reuses the Sensorius forecast and does not
maintain a separate settings file or readings database.

Today's forecast includes a compact **Historical daily average** row
Today's forecast includes a compact **Historical Min, Max, Avg** section
beneath its expected ranges, with temperature, RH, wind, and rain in the same
column order. These are averages for the same calendar date at the hub's
column order. These are statistics for the same calendar date at the hub's
Astral location, using Open-Meteo/ERA5 historical data independently of the
selected forecast provider. Temperature, RH, and wind are averages of daily
means; rain is the average daily rainfall total, including dry days, **not a
rain probability**. Units follow the selected Metric or Imperial display.
rain probability**. Min and Max use daily lows and highs for temperature, RH,
and wind, and daily totals for rain. Each extreme includes its occurrence year;
ties show the earliest year. Existing averages-only caches are rebuilt automatically. Units follow the selected Metric or Imperial display.

The date follows the hub's timezone. History starts in 1991 and extends through
the latest available data, allowing for ERA5's roughly five-day publication
delay; unpublished trailing days are excluded. Each calendar date uses its available daily samples, including recent
years; February 29 uses only leap years. The label shows the actual year range and hub-local date, for example
**Historical daily average 1991–2026 for 18-Sep-2026**,
**Historical Min, Max, Avg 1991–2026 for 18-Sep**,
and its tooltip gives the exact historical cutoff and sample count. These are
regional climate estimates, not readings from the selected station. The source link and tooltip
explain the baseline without adding a separate note or increasing the card's
height. Dashes mean history is still loading or unavailable, never zero.
explain the baseline. Dashes mean history is still loading or unavailable, never zero.
Retrieval is non-blocking. The cache is checked hourly and refreshed as the
historical end date advances; a compact calendar-day cache survives restarts
at `/home/<user>/Sensorius/weather_cache/weather_climate.json` on Linux or
`/Users/<user>/Sensorius/weather_cache/weather_climate.json` on macOS. Updating
the hub's location invalidates the active history. The displayed date and
averages refresh automatically, including after the hourly forecast refresh.
statistics refresh automatically, including after the hourly forecast refresh.

The Windy map is initially interaction-locked so the mouse wheel continues to
scroll the Caelus page. Select **Click to interact with map** on the map's top
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.261.7"
__version__ = "v0.26.262.1"
55 changes: 46 additions & 9 deletions sensorius/saiWeatherClimate.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Provide same-calendar-date climate averages for the Caelus forecast card.
"""Provide same-calendar-date climate statistics for the Caelus forecast card.

Reuse the rainfall service's background loading and location-specific disk cache,
keeping one compact 366-day climatology instead of the downloaded daily history.
Expand All @@ -16,6 +16,11 @@
"wind_speed_10m_mean": "wind_kmh",
"rain_sum": "rain_mm",
}
EXTREME_VARIABLES = {
kind: {variable.replace("_mean", f"_{kind}"): field for variable, field in VARIABLES.items()}
for kind in ("min", "max")
}
ALL_VARIABLES = {**VARIABLES, **EXTREME_VARIABLES["min"], **EXTREME_VARIABLES["max"]}
DAY_KEYS = {(date(2000, 1, 1) + timedelta(days=i)).strftime("%m-%d") for i in range(366)}


Expand All @@ -27,12 +32,12 @@ def _valid_value(key: str, value) -> bool:


def summarize_weather_history(payload: dict, end: date) -> dict:
"""Average daily temperature, RH, wind and rain by calendar date over the requested range."""
"""Summarize calendar-date means and extremes, keeping the earliest year for ties."""
daily = payload.get("daily") or {}
count = (end - START).days + 1
if any(len(daily.get(key, [])) != count for key in ("time", *VARIABLES)):
if any(len(daily.get(key, [])) != count for key in ("time", *ALL_VARIABLES)):
raise ValueError("Weather history does not cover the complete baseline")
totals, samples = {}, {}
totals, samples, extremes = {}, {}, {}
for index in range(count):
day = START + timedelta(days=index)
if daily["time"][index] != day.isoformat():
Expand All @@ -44,17 +49,29 @@ def summarize_weather_history(payload: dict, end: date) -> dict:
if not _valid_value(field, value):
raise ValueError(f"Weather history contains invalid {variable}")
sums[field] += value
records = extremes.setdefault(key, {"min": {}, "max": {}})
for kind, variables in EXTREME_VARIABLES.items():
for variable, field in variables.items():
value = daily[variable][index]
if not _valid_value(field, value):
raise ValueError(f"Weather history contains invalid {variable}")
previous = records[kind].get(field)
if previous is None or (value < previous["value"] if kind == "min" else value > previous["value"]):
records[kind][field] = {"value": value, "year": day.year}
samples[key] = samples.get(key, 0) + 1
return {key: {**{field: round(value / samples[key], 3) for field, value in sums.items()},
"samples": samples[key]} for key, sums in totals.items()}
"samples": samples[key], "extremes": extremes[key]} for key, sums in totals.items()}


class WeatherClimateService(RainClimateService):
"""Cache weather averages and select today's date in the hub's timezone."""
"""Cache weather statistics and select today's date in the hub's timezone."""

daily_variables = ",".join(VARIABLES)
daily_variables = ",".join(ALL_VARIABLES)
expected_units = {"temperature_2m_mean": "°C", "relative_humidity_2m_mean": "%",
"wind_speed_10m_mean": "km/h", "rain_sum": "mm"}
"wind_speed_10m_mean": "km/h", "rain_sum": "mm",
"temperature_2m_min": "°C", "temperature_2m_max": "°C",
"relative_humidity_2m_min": "%", "relative_humidity_2m_max": "%",
"wind_speed_10m_min": "km/h", "wind_speed_10m_max": "km/h"}

def _location(self) -> dict:
location = super()._location()
Expand All @@ -72,13 +89,33 @@ def _valid_summary(self, payload: dict) -> bool:
samples = dict.fromkeys(DAY_KEYS, 0)
for offset in range((end - START).days + 1):
samples[(START + timedelta(days=offset)).strftime("%m-%d")] += 1

def valid_extremes(key, row):
for kind in ("min", "max"):
records = row.get("extremes", {}).get(kind, {})
for field in VARIABLES.values():
record = records.get(field, {})
year = record.get("year")
if not _valid_value(field, record.get("value")) or type(year) is not int:
return False
try:
occurred = date.fromisoformat(f"{year}-{key}")
except ValueError:
return False
if not START <= occurred <= end:
return False
return all(row["extremes"]["min"][field]["value"] <= row[field] + 0.001
and row[field] <= row["extremes"]["max"][field]["value"] + 0.001
for field in VARIABLES.values())

return all(isinstance(row, dict)
and row.get("samples") == samples[key]
and all(_valid_value(field, row.get(field)) for field in VARIABLES.values())
and valid_extremes(key, row)
for key, row in averages.items())

def snapshot(self, *, now: datetime | None = None) -> dict:
"""Return today's four averages, keeping the full climatology server-side."""
"""Return today's averages and extremes, keeping the full climatology server-side."""
payload = super().snapshot()
calendar = payload.pop("calendar_averages", {})
if payload.get("status") == "ready":
Expand Down
2 changes: 1 addition & 1 deletion sensorius/saiWebRoutes.py
Original file line number Diff line number Diff line change
Expand Up @@ -706,7 +706,7 @@ async def api_rain_climate():

@router.get("/api/weather-climate")
async def api_weather_climate():
"""Return historical daily averages for today's date at the hub location."""
"""Return historical daily means and extremes for today's date at the hub location."""
return weather_climate_service.snapshot()

daily_summary_service = DailySummaryService(settings=settings, data_logger=data_logger)
Expand Down
8 changes: 7 additions & 1 deletion testApparatus/playwright_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,10 +203,16 @@ def rain_gauges(unit_system: str = "Metric") -> HTMLResponse:

@app.get("/api/weather-climate")
def weather_climate() -> dict:
"""Supply deterministic historical averages without external network access."""
"""Supply deterministic historical statistics without external network access."""
return {"status": "ready", "date": "2026-09-18", "baseline": "1991–2026",
"start_date": "1991-01-01", "end_date": "2026-09-13", "averages": {
"temperature_c": 20, "humidity_pct": 55, "wind_kmh": 16.09344, "rain_mm": 2.54, "samples": 35,
"extremes": {
"min": {field: {"value": value, "year": 1993} for field, value in
{"temperature_c": 6.7, "humidity_pct": 10, "wind_kmh": 0.3218688, "rain_mm": 0}.items()},
"max": {field: {"value": value, "year": 2016} for field, value in
{"temperature_c": 30.7, "humidity_pct": 100, "wind_kmh": 28.16352, "rain_mm": 21.082}.items()},
},
}}


Expand Down
41 changes: 36 additions & 5 deletions testApparatus/test_weather_climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,39 @@
from sensorius import saiRainClimate as climate

END = date(2020, 12, 31)
from sensorius.saiWeatherClimate import WeatherClimateService, summarize_weather_history
from sensorius.saiWeatherClimate import WeatherClimateService, summarize_weather_history, EXTREME_VARIABLES, VARIABLES


@pytest.fixture(autouse=True)
def cutoff(monkeypatch):
monkeypatch.setattr(climate, 'latest_history_date', lambda: END)


def add_extremes(data):
for kind, variables in EXTREME_VARIABLES.items():
for variable, field in variables.items():
if variable not in data["daily"]:
mean_variable = next(key for key, value in VARIABLES.items() if value == field)
offset = -1 if kind == "min" else 1
data["daily"][variable] = [value + offset for value in data["daily"][mean_variable]]
return data


def history():
days = [START + timedelta(days=i) for i in range((END - START).days + 1)]
return {"daily_units": WeatherClimateService.expected_units, "daily": {
return add_extremes({"daily_units": WeatherClimateService.expected_units, "daily": {
"time": [d.isoformat() for d in days],
"temperature_2m_mean": [d.year - 1990 for d in days],
"relative_humidity_2m_mean": [50.0] * len(days),
"wind_speed_10m_mean": [10.0] * len(days),
"rain_sum": [4.0 if d.year % 2 == 0 else 0.0 for d in days],
}}
}})


def test_calendar_date_means_include_dry_days_and_leap_day_samples():
averages = summarize_weather_history(history(), END)
assert len(averages) == 366
assert averages['09-18'] == {'temperature_c': 15.5, 'humidity_pct': 50,
assert {key: value for key, value in averages['09-18'].items() if key != 'extremes'} == {'temperature_c': 15.5, 'humidity_pct': 50,
'wind_kmh': 10, 'rain_mm': 2, 'samples': 30}
assert averages['02-29']['samples'] == 8
assert averages['02-29']['temperature_c'] == 16
Expand Down Expand Up @@ -98,7 +108,7 @@ def test_extended_averages_use_available_samples_and_validate_new_cache():
'wind_speed_10m_mean': [10] * len(days),
'rain_sum': [1] * len(days),
}}
averages = summarize_weather_history(data, end)
averages = summarize_weather_history(add_extremes(data), end)
assert averages['09-13']['samples'] == 36
assert averages['09-13']['temperature_c'] == 18.5
assert averages['09-14']['samples'] == 35
Expand Down Expand Up @@ -140,3 +150,24 @@ def respond(request):
data['daily']['rain_sum'][-8:] = [None] * 8
with pytest.raises(ValueError):
service._available_history(data, END)


def test_extremes_track_daily_lows_highs_years_and_earliest_ties():
rows = summarize_weather_history(history(), END)
assert rows["09-18"]["extremes"]["min"]["temperature_c"] == {"value": 0, "year": 1991}
assert rows["09-18"]["extremes"]["max"]["temperature_c"] == {"value": 31, "year": 2020}
assert rows["09-18"]["extremes"]["min"]["rain_mm"] == {"value": 0, "year": 1991}
assert rows["09-18"]["extremes"]["max"]["rain_mm"] == {"value": 4, "year": 1992}
assert rows["02-29"]["extremes"]["min"]["humidity_pct"]["year"] == 1992
service = WeatherClimateService(None, "unused.json")
payload = {"end_date": END.isoformat(), "calendar_averages": rows}
assert service._valid_summary(payload)
del rows["09-18"]["extremes"]
assert not service._valid_summary(payload)


def test_invalid_daily_extreme_rejected():
data = history()
data["daily"]["temperature_2m_min"][0] = None
with pytest.raises(ValueError):
summarize_weather_history(data, END)
12 changes: 6 additions & 6 deletions tests/weather-forecast.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ for (const budget of cardBudgets) {
const initialHeight = (await card.boundingBox()).height;
const history = card.locator('[data-weather-history]');
await expect(history).toHaveAttribute('data-status', 'ready');
await expect(history).toContainText('Historical daily average 1991–2026 for 18-Sep-2026');
await expect(history).toContainText('Historical Min, Max, Avg 1991–2026 for 18-Sep');
await expect(history).toHaveAttribute('title', /through 2026-09-13.*35 samples/);
await expect(history.locator('[data-history-value="temperature_c"]')).toHaveText(units === 'Metric' ? '20.0°C' : '68.0°F');
await expect(history.locator('[data-history-value="humidity_pct"]')).toHaveText('55%');
await expect(history.locator('[data-history-value="wind_kmh"]')).toHaveText(units === 'Metric' ? '16.1 km/h' : '10.0 mph');
await expect(history.locator('[data-history-value="rain_mm"]')).toHaveText(units === 'Metric' ? '2.5 mm' : '0.10 in');
await expect(history.locator('[data-history-value="temperature_c"]')).toHaveText(units === 'Metric' ? 'Min 6.7°C (1993)Max 30.7°C (2016)Avg 20.0°C' : 'Min 44.1°F (1993)Max 87.3°F (2016)Avg 68.0°F');
await expect(history.locator('[data-history-value="humidity_pct"]')).toHaveText('Min 10% (1993)Max 100% (2016)Avg 55%');
await expect(history.locator('[data-history-value="wind_kmh"]')).toHaveText(units === 'Metric' ? 'Min 0.3 km/h (1993)Max 28.2 km/h (2016)Avg 16.1 km/h' : 'Min 0.2 mph (1993)Max 17.5 mph (2016)Avg 10.0 mph');
await expect(history.locator('[data-history-value="rain_mm"]')).toHaveText(units === 'Metric' ? 'Min 0.0 mm (1993)Max 21.1 mm (2016)Avg 2.5 mm' : 'Min 0.00 in (1993)Max 0.83 in (2016)Avg 0.10 in');
const originalHeight = (await card.boundingBox()).height;
expect(originalHeight).toBe(initialHeight);
const days = card.locator('.forecast-day');
Expand Down Expand Up @@ -81,6 +81,6 @@ test('historical averages load asynchronously and show missing values without ch
await expect(row).toHaveAttribute('data-status', 'warming');
const height = (await page.locator('.forecast-panel').boundingBox()).height;
await expect(row).toHaveAttribute('data-status', 'unavailable', { timeout: 10000 });
await expect(row.locator('[data-history-value]')).toHaveText(['—', '—', '—', '—']);
await expect(row.locator('[data-history-value]')).toHaveText(Array(4).fill('Min —Max —Avg —'));
expect((await page.locator('.forecast-panel').boundingBox()).height).toBe(height);
});
3 changes: 2 additions & 1 deletion ui_static/weather_forecast/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -534,9 +534,10 @@ body.caelus-theme-preview-mode .dashboard-return { opacity: 0; pointer-events: n
.forecast-ranges > span { color: var(--muted); font-size: 0.52rem; text-align: center; }
.forecast-ranges strong { display: block; color: var(--ink); font-size: 0.68rem; }
.forecast-history { margin-top: 0.3rem; text-align: center; }
.forecast-history-label { display: block; color: var(--muted); font-size: 0.46rem; line-height: 1.25; text-decoration: none; }
.forecast-history-label { display: block; margin-bottom: 0.15rem; color: var(--muted); font-size: 0.46rem; line-height: 1.25; text-decoration: none; }
.forecast-history-label:hover { text-decoration: underline; }
.forecast-history .forecast-ranges > span { color: var(--ink); font-size: 0.58rem; line-height: 1.4; }
.forecast-history [data-history-value] > span { display: block; }
.forecast-history + .forecast-synopsis { margin-top: 0.3rem; }
.forecast-synopsis { height: 3.4rem; overflow: auto; margin: 0.6rem 0 0; font-size: 0.66rem; line-height: 1.3; }
.forecast-synopsis:focus-visible { outline: 1px solid var(--accent-2); }
Expand Down
Loading
Loading