From 98210bff8f2f279064d39fddfdaeca73ee56af19 Mon Sep 17 00:00:00 2001 From: Kemal Yaylali Date: Sat, 15 Aug 2026 22:39:37 +0100 Subject: [PATCH] Six enhancements: recompute, markers, vendoring, tests, nerd stats, DS18B20 1. POST /api/recompute re-derives every compensated column from the untouched raw values, removing the step a calibration otherwise leaves through the history. Possible because temp_raw, cpu_temp and hum are never overwritten. Idempotent by construction and tested per row: 0 of 6051 rows change on a second run. 6069 rows in 0.25 s here, so a few seconds on the Pi. 2. Calibration now emits a 'discontinuity' event alongside the calibration log, so downstream views can find the boundary without parsing prose. 3. Vendored Tailwind, Chart.js, hammer, the zoom plugin, KaTeX with its 20 woff2 faces, and both Google fonts into ashvale/static, served by the station. 1.4 MB. Verified with every non-localhost request aborted in the browser: zero external requests, equations still render, fonts still load. The dashboard no longer needs internet. 4. 54 pytest cases over the pure numerics: physics closed forms and round trips, both compensator inverse properties, the Kalman covariance invariants and NIS consistency, the RLS trace cap under a deliberately unexcited regressor, conformal coverage, and the Zambretti ordering. Wired into CI after the seed step so the recompute cases have history. Writing them caught my own sign error on the conformal update: a hit raises alpha and narrows the band, which reads backwards until you follow it through. 5. Stats for Nerds gains the condition number of each head's covariance, a standardised innovation histogram per Kalman filter from a bounded 600 sample ring buffer, and a reliability strip of realised against nominal coverage. All arithmetic on data already in memory. 6. OutdoorProbe reads a DS18B20 over the kernel 1-Wire driver, no new dependency. Polled on its own slower cadence because the sensor blocks for up to 750 ms during conversion, which would eat a third of the 2 s sample budget. Rejects the 85000 power-on sentinel and out-of-range values, and reports age so a dead probe cannot masquerade as fresh. --- .github/workflows/ci.yml | 9 +- ashvale/api.py | 86 ++++ ashvale/config.py | 5 + ashvale/dashboard.py | 58 ++- ashvale/estimation.py | 16 + ashvale/sensors.py | 93 ++++ ashvale/static/chart.umd.min.js | 20 + ashvale/static/chartjs-plugin-zoom.min.js | 7 + ashvale/static/fonts/KaTeX_AMS-Regular.woff2 | Bin 0 -> 28076 bytes .../static/fonts/KaTeX_Caligraphic-Bold.woff2 | Bin 0 -> 6912 bytes .../fonts/KaTeX_Caligraphic-Regular.woff2 | Bin 0 -> 6908 bytes ashvale/static/fonts/KaTeX_Fraktur-Bold.woff2 | Bin 0 -> 11348 bytes .../static/fonts/KaTeX_Fraktur-Regular.woff2 | Bin 0 -> 11316 bytes ashvale/static/fonts/KaTeX_Main-Bold.woff2 | Bin 0 -> 25324 bytes .../static/fonts/KaTeX_Main-BoldItalic.woff2 | Bin 0 -> 16780 bytes ashvale/static/fonts/KaTeX_Main-Italic.woff2 | Bin 0 -> 16988 bytes ashvale/static/fonts/KaTeX_Main-Regular.woff2 | Bin 0 -> 26272 bytes .../static/fonts/KaTeX_Math-BoldItalic.woff2 | Bin 0 -> 16400 bytes ashvale/static/fonts/KaTeX_Math-Italic.woff2 | Bin 0 -> 16440 bytes .../static/fonts/KaTeX_SansSerif-Bold.woff2 | Bin 0 -> 12216 bytes .../static/fonts/KaTeX_SansSerif-Italic.woff2 | Bin 0 -> 12028 bytes .../fonts/KaTeX_SansSerif-Regular.woff2 | Bin 0 -> 10344 bytes .../static/fonts/KaTeX_Script-Regular.woff2 | Bin 0 -> 9644 bytes .../static/fonts/KaTeX_Size1-Regular.woff2 | Bin 0 -> 5468 bytes .../static/fonts/KaTeX_Size2-Regular.woff2 | Bin 0 -> 5208 bytes .../static/fonts/KaTeX_Size3-Regular.woff2 | Bin 0 -> 3624 bytes .../static/fonts/KaTeX_Size4-Regular.woff2 | Bin 0 -> 4928 bytes .../fonts/KaTeX_Typewriter-Regular.woff2 | Bin 0 -> 13568 bytes ...aomQNQcsA88c7O9yZ4KMCoOg4Ko20yygg_vb.woff2 | Bin 0 -> 27272 bytes ...QNQcsA88c7O9yZ4KMCoOg4Ko40yygg_vbd-E.woff2 | Bin 0 -> 21688 bytes ...QNQcsA88c7O9yZ4KMCoOg4Ko50yygg_vbd-E.woff2 | Bin 0 -> 8292 bytes ...QNQcsA88c7O9yZ4KMCoOg4Ko70yygg_vbd-E.woff2 | Bin 0 -> 1716 bytes ...D8yKwBNntkaToggR7BYRbKPx3cwgknk-6nFg.woff2 | Bin 0 -> 1664 bytes ...D8yKwBNntkaToggR7BYRbKPx7cwgknk-6nFg.woff2 | Bin 0 -> 11596 bytes ...T8RD8yKwBNntkaToggR7BYRbKPxDcwgknk-4.woff2 | Bin 0 -> 31340 bytes ...D8yKwBNntkaToggR7BYRbKPxPcwgknk-6nFg.woff2 | Bin 0 -> 6800 bytes ...D8yKwBNntkaToggR7BYRbKPxTcwgknk-6nFg.woff2 | Bin 0 -> 8892 bytes ...D8yKwBNntkaToggR7BYRbKPx_cwgknk-6nFg.woff2 | Bin 0 -> 5872 bytes ashvale/static/gfonts.css | 396 ++++++++++++++++++ ashvale/static/hammer.min.js | 7 + ashvale/static/katex.min.css | 1 + ashvale/static/katex.min.js | 1 + ashvale/static/tailwind.js | 83 ++++ ashvale/station.py | 81 +++- ashvale/storage.py | 46 ++ tests/test_estimation.py | 179 ++++++++ tests/test_models.py | 151 +++++++ tests/test_physics.py | 95 +++++ tests/test_recompute.py | 115 +++++ 49 files changed, 1437 insertions(+), 12 deletions(-) create mode 100644 ashvale/static/chart.umd.min.js create mode 100644 ashvale/static/chartjs-plugin-zoom.min.js create mode 100644 ashvale/static/fonts/KaTeX_AMS-Regular.woff2 create mode 100644 ashvale/static/fonts/KaTeX_Caligraphic-Bold.woff2 create mode 100644 ashvale/static/fonts/KaTeX_Caligraphic-Regular.woff2 create mode 100644 ashvale/static/fonts/KaTeX_Fraktur-Bold.woff2 create mode 100644 ashvale/static/fonts/KaTeX_Fraktur-Regular.woff2 create mode 100644 ashvale/static/fonts/KaTeX_Main-Bold.woff2 create mode 100644 ashvale/static/fonts/KaTeX_Main-BoldItalic.woff2 create mode 100644 ashvale/static/fonts/KaTeX_Main-Italic.woff2 create mode 100644 ashvale/static/fonts/KaTeX_Main-Regular.woff2 create mode 100644 ashvale/static/fonts/KaTeX_Math-BoldItalic.woff2 create mode 100644 ashvale/static/fonts/KaTeX_Math-Italic.woff2 create mode 100644 ashvale/static/fonts/KaTeX_SansSerif-Bold.woff2 create mode 100644 ashvale/static/fonts/KaTeX_SansSerif-Italic.woff2 create mode 100644 ashvale/static/fonts/KaTeX_SansSerif-Regular.woff2 create mode 100644 ashvale/static/fonts/KaTeX_Script-Regular.woff2 create mode 100644 ashvale/static/fonts/KaTeX_Size1-Regular.woff2 create mode 100644 ashvale/static/fonts/KaTeX_Size2-Regular.woff2 create mode 100644 ashvale/static/fonts/KaTeX_Size3-Regular.woff2 create mode 100644 ashvale/static/fonts/KaTeX_Size4-Regular.woff2 create mode 100644 ashvale/static/fonts/KaTeX_Typewriter-Regular.woff2 create mode 100644 ashvale/static/fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko20yygg_vb.woff2 create mode 100644 ashvale/static/fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko40yygg_vbd-E.woff2 create mode 100644 ashvale/static/fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko50yygg_vbd-E.woff2 create mode 100644 ashvale/static/fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko70yygg_vbd-E.woff2 create mode 100644 ashvale/static/fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx3cwgknk-6nFg.woff2 create mode 100644 ashvale/static/fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx7cwgknk-6nFg.woff2 create mode 100644 ashvale/static/fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxDcwgknk-4.woff2 create mode 100644 ashvale/static/fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxPcwgknk-6nFg.woff2 create mode 100644 ashvale/static/fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxTcwgknk-6nFg.woff2 create mode 100644 ashvale/static/fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx_cwgknk-6nFg.woff2 create mode 100644 ashvale/static/gfonts.css create mode 100644 ashvale/static/hammer.min.js create mode 100644 ashvale/static/katex.min.css create mode 100644 ashvale/static/katex.min.js create mode 100644 ashvale/static/tailwind.js create mode 100644 tests/test_estimation.py create mode 100644 tests/test_models.py create mode 100644 tests/test_physics.py create mode 100644 tests/test_recompute.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6f7b20..3d1eda9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt - pip install httpx + pip install httpx pytest - name: Lint run: | @@ -43,6 +43,13 @@ jobs: - name: Seed synthetic history run: python scripts/simulate.py --days 10 --wipe + # Unit tests over the pure numerics: physics closed forms, the compensator + # inverse properties, the Kalman covariance invariants and the RLS trace + # cap. Run after seeding so the recompute tests have history rather than skipping. + - name: Unit tests + run: python -m pytest tests/ -q + + # The backtest is the real test: it exercises features, the RLS heads, # climatology and conformal calibration end to end, and fails loudly if # any of them stop producing finite numbers. diff --git a/ashvale/api.py b/ashvale/api.py index 85127ab..32f3a82 100644 --- a/ashvale/api.py +++ b/ashvale/api.py @@ -25,11 +25,13 @@ import asyncio import json import time from contextlib import asynccontextmanager +from pathlib import Path from typing import Any, Dict, List, Optional import numpy as np from fastapi import FastAPI, HTTPException, Query, Request from fastapi.responses import HTMLResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field from .config import CONFIG @@ -78,6 +80,14 @@ app = FastAPI( lifespan=lifespan, ) +# Vendored browser libraries. The dashboard used to pull Tailwind, Chart.js, +# hammer, the zoom plugin, KaTeX and two Google fonts from CDNs at runtime, +# which meant the Pi needed internet to render its own UI. Serving them from +# disk costs about 1.4 MB and removes that dependency entirely. +_STATIC = Path(__file__).resolve().parent / "static" +if _STATIC.is_dir(): + app.mount("/static", StaticFiles(directory=str(_STATIC)), name="static") + def _st() -> Station: if station is None: @@ -158,6 +168,7 @@ def telemetry() -> Dict: "cpu_offset": live.get("cpu_offset"), "compensator_k": live.get("compensator_k"), "hum_offset": live.get("hum_offset"), + "outdoor_c": live.get("outdoor_c"), "hum_psychrometric": live.get("hum_psychrometric"), "rates": { "temperature_c_per_h": live.get("temp_rate"), @@ -361,6 +372,54 @@ def models() -> Dict: }) +def _innovation_histogram(st, bins: int = 21) -> Dict: + """Distribution of recent standardised Kalman innovations, per signal. + + y/sqrt(S) should be standard normal when a filter is consistent. The single + NIS number says whether the spread is right on average; this says whether + the *shape* is right. Skew means systematic bias, excess kurtosis means the + filter is surprised more often than it admits. + """ + out = {} + for name, buf in st.tracker.innovations.items(): + z = np.array(buf, dtype=float) + z = z[np.isfinite(z)] + if z.size < 20: + out[name] = {"counts": [], "n": int(z.size)} + continue + clipped = np.clip(z, -4.0, 4.0) + counts, edges = np.histogram(clipped, bins=bins, range=(-4.0, 4.0)) + out[name] = { + "counts": [int(c) for c in counts], + "edges": [round(float(e), 2) for e in edges], + "n": int(z.size), + "mean": round(float(np.mean(z)), 4), + "std": round(float(np.std(z)), 4), + "skew": round(float(np.mean(((z - z.mean()) / (z.std() or 1.0)) ** 3)), 3), + "kurtosis": round(float(np.mean(((z - z.mean()) / (z.std() or 1.0)) ** 4)), 3), + } + return out + + +def _reliability_curve(st) -> Dict: + """Realised coverage against nominal, per horizon. + + The scorecard reports one coverage number per head. This asks the sharper + question: is the *shape* right. Points below the diagonal mean the intervals + are lying, and by how much. + """ + out = [] + for (target, h), head in sorted(st.nowcast.heads.items()): + cov = head.conformal.empirical_coverage + if not np.isfinite(cov): + continue + out.append({"target": target, "horizon_s": h, + "nominal": round(1.0 - head.conformal.alpha_target, 4), + "realised": round(float(cov), 4), + "n": int(head.n_scored)}) + return {"points": out} + + @app.get("/api/nerd") def nerd() -> Dict: """Every internal number the estimator and the learners are carrying. @@ -393,10 +452,22 @@ def nerd() -> Dict: m = head.model P = np.asarray(m.P, dtype=float) theta = np.asarray(m.theta, dtype=float) + # Condition number of P says whether the 33 directions are being excited + # evenly. A huge value means some directions carry almost no information + # and the fit there is effectively arbitrary, which is the quiet failure + # the trace cap only partly protects against. eigvalsh because P is + # symmetric by construction. + try: + ev = np.linalg.eigvalsh(P) + lo, hi = float(np.min(ev)), float(np.max(ev)) + cond = float(hi / lo) if lo > 1e-12 else float("inf") + except np.linalg.LinAlgError: + cond = float("nan") heads.append({ "target": target, "horizon_s": h, "n_updates": int(m.n_updates), "trace_p": float(np.trace(P)), + "cond_p": cond, "theta_norm": float(np.linalg.norm(theta)), "rmse_ewma": float(np.sqrt(max(m.ewma_sq_error, 0.0))), "lam": float(m.lam), "p_max": float(m.p_max), @@ -459,6 +530,8 @@ def nerd() -> Dict: "logloss_ewma": st.precip.ewma_logloss, }, "monitoring": monitoring, + "innovation": _innovation_histogram(st), + "reliability": _reliability_curve(st), }) @@ -515,12 +588,25 @@ def calibrate_humidity(body: HumidityCalibrationIn) -> Dict: return _clean(result) +@app.post("/api/recompute") +def recompute() -> Dict: + """Re-derive every compensated column in the history from the raw values. + + Run after a calibration to remove the step it leaves behind. Safe to repeat: + it always starts from the untouched raw columns, never from a previous + result, so it cannot compound. + """ + result = _st().recompute_history() + return _clean(result) + + @app.get("/api/status") def status() -> Dict: st = _st() return _clean({ **st.status(), "display_frame": display.frame_name if display else None, + "outdoor_probe": (st.probe.status() if st.probe is not None else None), "events": st.store.recent_events(15), }) diff --git a/ashvale/config.py b/ashvale/config.py index e65b254..0e9a41f 100644 --- a/ashvale/config.py +++ b/ashvale/config.py @@ -66,6 +66,11 @@ class SensorConfig: # HIGH and this correction would push it higher still. The error is an # additive element bias, not a thermal gradient. Leave off unless your own # reference says otherwise. + # Optional DS18B20 on the 1-Wire bus, outside the window. When present its + # reading is logged as outdoor_c and surfaced in the API. It does not feed + # the forecasting features yet: that needs history to train against. + outdoor_probe: bool = True + outdoor_probe_period_s: float = 20.0 hum_psychrometric: bool = False hum_offset: float = 0.0 hum_offset_min: float = -35.0 diff --git a/ashvale/dashboard.py b/ashvale/dashboard.py index 309bb03..5c90eb7 100644 --- a/ashvale/dashboard.py +++ b/ashvale/dashboard.py @@ -48,14 +48,15 @@ DASHBOARD_HTML = r""" Ashvale Station - - - - - - - - + + + + + + + +