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.
This commit is contained in:
2026-08-15 22:39:37 +01:00
parent 9d2e884b74
commit 98210bff8f
49 changed files with 1437 additions and 12 deletions
+8 -1
View File
@@ -27,7 +27,7 @@ jobs:
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements.txt pip install -r requirements.txt
pip install httpx pip install httpx pytest
- name: Lint - name: Lint
run: | run: |
@@ -43,6 +43,13 @@ jobs:
- name: Seed synthetic history - name: Seed synthetic history
run: python scripts/simulate.py --days 10 --wipe 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, # The backtest is the real test: it exercises features, the RLS heads,
# climatology and conformal calibration end to end, and fails loudly if # climatology and conformal calibration end to end, and fails loudly if
# any of them stop producing finite numbers. # any of them stop producing finite numbers.
+86
View File
@@ -25,11 +25,13 @@ import asyncio
import json import json
import time import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import numpy as np import numpy as np
from fastapi import FastAPI, HTTPException, Query, Request from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, StreamingResponse from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from .config import CONFIG from .config import CONFIG
@@ -78,6 +80,14 @@ app = FastAPI(
lifespan=lifespan, 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: def _st() -> Station:
if station is None: if station is None:
@@ -158,6 +168,7 @@ def telemetry() -> Dict:
"cpu_offset": live.get("cpu_offset"), "cpu_offset": live.get("cpu_offset"),
"compensator_k": live.get("compensator_k"), "compensator_k": live.get("compensator_k"),
"hum_offset": live.get("hum_offset"), "hum_offset": live.get("hum_offset"),
"outdoor_c": live.get("outdoor_c"),
"hum_psychrometric": live.get("hum_psychrometric"), "hum_psychrometric": live.get("hum_psychrometric"),
"rates": { "rates": {
"temperature_c_per_h": live.get("temp_rate"), "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") @app.get("/api/nerd")
def nerd() -> Dict: def nerd() -> Dict:
"""Every internal number the estimator and the learners are carrying. """Every internal number the estimator and the learners are carrying.
@@ -393,10 +452,22 @@ def nerd() -> Dict:
m = head.model m = head.model
P = np.asarray(m.P, dtype=float) P = np.asarray(m.P, dtype=float)
theta = np.asarray(m.theta, 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({ heads.append({
"target": target, "horizon_s": h, "target": target, "horizon_s": h,
"n_updates": int(m.n_updates), "n_updates": int(m.n_updates),
"trace_p": float(np.trace(P)), "trace_p": float(np.trace(P)),
"cond_p": cond,
"theta_norm": float(np.linalg.norm(theta)), "theta_norm": float(np.linalg.norm(theta)),
"rmse_ewma": float(np.sqrt(max(m.ewma_sq_error, 0.0))), "rmse_ewma": float(np.sqrt(max(m.ewma_sq_error, 0.0))),
"lam": float(m.lam), "p_max": float(m.p_max), "lam": float(m.lam), "p_max": float(m.p_max),
@@ -459,6 +530,8 @@ def nerd() -> Dict:
"logloss_ewma": st.precip.ewma_logloss, "logloss_ewma": st.precip.ewma_logloss,
}, },
"monitoring": monitoring, "monitoring": monitoring,
"innovation": _innovation_histogram(st),
"reliability": _reliability_curve(st),
}) })
@@ -515,12 +588,25 @@ def calibrate_humidity(body: HumidityCalibrationIn) -> Dict:
return _clean(result) 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") @app.get("/api/status")
def status() -> Dict: def status() -> Dict:
st = _st() st = _st()
return _clean({ return _clean({
**st.status(), **st.status(),
"display_frame": display.frame_name if display else None, "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), "events": st.store.recent_events(15),
}) })
+5
View File
@@ -66,6 +66,11 @@ class SensorConfig:
# HIGH and this correction would push it higher still. The error is an # 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 # additive element bias, not a thermal gradient. Leave off unless your own
# reference says otherwise. # 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_psychrometric: bool = False
hum_offset: float = 0.0 hum_offset: float = 0.0
hum_offset_min: float = -35.0 hum_offset_min: float = -35.0
+49 -9
View File
@@ -48,14 +48,15 @@ DASHBOARD_HTML = r"""
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ashvale Station</title> <title>Ashvale Station</title>
<script src="https://cdn.tailwindcss.com"></script> <!-- Served from the station, not a CDN, so the dashboard renders on a LAN with
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css"> no internet at all. See ashvale/static/. -->
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.js"></script> <script src="/static/tailwind.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script> <link rel="stylesheet" href="/static/katex.min.css">
<script src="https://cdn.jsdelivr.net/npm/[email protected]/hammer.min.js"></script> <script defer src="/static/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chartjs-plugin-zoom.min.js"></script> <script src="/static/chart.umd.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com"> <script src="/static/hammer.min.js"></script>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap"> <script src="/static/chartjs-plugin-zoom.min.js"></script>
<link rel="stylesheet" href="/static/gfonts.css">
<style> <style>
body { font-family:'Plus Jakarta Sans',sans-serif; } body { font-family:'Plus Jakarta Sans',sans-serif; }
.font-mono { font-family:'JetBrains Mono',monospace; } .font-mono { font-family:'JetBrains Mono',monospace; }
@@ -404,7 +405,7 @@ DASHBOARD_HTML = r"""
<!-- Everything the estimator and the 18 learners are actually carrying, read <!-- Everything the estimator and the 18 learners are actually carrying, read
straight off the live objects. No internal scrollers: the head bank is a straight off the live objects. No internal scrollers: the head bank is a
fixed 18 rows and the attribution list is capped at what fits. --> fixed 18 rows and the attribution list is capped at what fits. -->
<section id="pane-nerd" class="pane h-full min-h-0 gap-3 grid-cols-1 lg:grid-cols-4 lg:grid-rows-[auto_1fr]"> <section id="pane-nerd" class="pane h-full min-h-0 gap-3 grid-cols-1 lg:grid-cols-4 lg:grid-rows-[auto_1fr_auto]">
<div class="glass rounded-2xl p-3 lg:col-span-3"> <div class="glass rounded-2xl p-3 lg:col-span-3">
<div class="flex items-baseline justify-between mb-1.5"> <div class="flex items-baseline justify-between mb-1.5">
@@ -450,6 +451,14 @@ DASHBOARD_HTML = r"""
<div id="n-precip" class="font-mono text-[10px] space-y-0.5"></div> <div id="n-precip" class="font-mono text-[10px] space-y-0.5"></div>
</div> </div>
</div> </div>
<div class="glass rounded-2xl p-3 lg:col-span-4 shrink-0">
<div class="flex items-baseline justify-between mb-1.5">
<h2 class="text-sm font-bold">Reliability</h2>
<span class="text-[9px] font-mono text-slate-600 uppercase">realised coverage against the 90% nominal</span>
</div>
<div id="n-rel" class="grid grid-cols-3 sm:grid-cols-6 gap-x-3 gap-y-0.5 font-mono text-[9px]"></div>
</div>
</section> </section>
<!-- ---------------- METHODS ---------------- --> <!-- ---------------- METHODS ---------------- -->
@@ -965,6 +974,15 @@ el('m-hcalrst').addEventListener('click', async () => {
/* ---------------- STATS FOR NERDS ---------------- */ /* ---------------- STATS FOR NERDS ---------------- */
let nerdDoc = null, nerdHead = null; let nerdDoc = null, nerdHead = null;
const HL = (h) => h<3600 ? (h/60)+'m' : h<86400 ? (h/3600)+'h' : (h/86400)+'d'; const HL = (h) => h<3600 ? (h/60)+'m' : h<86400 ? (h/3600)+'h' : (h/86400)+'d';
// A 21-bin histogram drawn as inline divs. No canvas: Chart.js instances cost
// memory and this is three tiny plots that never need interaction.
function histo(counts, colour, h) {
if (!counts || !counts.length) return '<div class="text-slate-700 text-[9px]">warming up</div>';
const mx = Math.max.apply(null, counts) || 1;
return '<div class="flex items-end gap-px" style="height:'+h+'px">'+
counts.map(c=>'<div style="flex:1;height:'+Math.max(1,(c/mx)*h)+'px;background:'+colour+
';opacity:'+(0.35+0.65*(c/mx))+'"></div>').join('')+'</div>';
}
function bar(frac, colour) { function bar(frac, colour) {
const w = Math.max(0, Math.min(1, frac||0))*100; const w = Math.max(0, Math.min(1, frac||0))*100;
return '<div class="h-1 bg-slate-950 rounded-full overflow-hidden border border-slate-800/70">'+ return '<div class="h-1 bg-slate-950 rounded-full overflow-hidden border border-slate-800/70">'+
@@ -975,6 +993,7 @@ async function loadNerd() {
nerdDoc = d; nerdDoc = d;
const COL = {temperature:'#f59e0b', humidity:'#06b6d4', pressure:'#a78bfa'}; const COL = {temperature:'#f59e0b', humidity:'#06b6d4', pressure:'#a78bfa'};
const iv = d.innovation||{};
el('n-filters').innerHTML = Object.keys(d.filters||{}).map(k=>{ el('n-filters').innerHTML = Object.keys(d.filters||{}).map(k=>{
const f = d.filters[k]; const f = d.filters[k];
// NIS is chi-square(1) distributed when consistent, so 1 is the target and // NIS is chi-square(1) distributed when consistent, so 1 is the target and
@@ -987,6 +1006,12 @@ async function loadNerd() {
'<div class="flex justify-between text-slate-500 mt-1">&sigma; level<span class="text-slate-300">'+fmt(f.sigma_level,4)+'</span></div>'+ '<div class="flex justify-between text-slate-500 mt-1">&sigma; level<span class="text-slate-300">'+fmt(f.sigma_level,4)+'</span></div>'+
'<div class="flex justify-between text-slate-500">P rate<span class="text-slate-300">'+f.p_rate.toExponential(2)+'</span></div>'+ '<div class="flex justify-between text-slate-500">P rate<span class="text-slate-300">'+f.p_rate.toExponential(2)+'</span></div>'+
'<div class="flex justify-between text-slate-600">q / r<span>'+f.q.toExponential(1)+' / '+fmt(f.r,3)+'</span></div>'+ '<div class="flex justify-between text-slate-600">q / r<span>'+f.q.toExponential(1)+' / '+fmt(f.r,3)+'</span></div>'+
// innovation shape: should look standard normal if the filter is honest
'<div class="mt-1 pt-1 border-t border-slate-800/70">'+
histo((iv[k]||{}).counts, COL[k], 16)+
'<div class="flex justify-between text-slate-600 mt-0.5 text-[9px]">'+
'<span>innov &mu;='+fmt((iv[k]||{}).mean,2)+' &sigma;='+fmt((iv[k]||{}).std,2)+'</span>'+
'<span>skew '+fmt((iv[k]||{}).skew,2)+'</span></div></div>'+
'</div>'; '</div>';
}).join(''); }).join('');
@@ -1004,6 +1029,7 @@ async function loadNerd() {
el('n-heads').innerHTML = heads.length ? '<table class="w-full font-mono text-[10px]">'+ el('n-heads').innerHTML = heads.length ? '<table class="w-full font-mono text-[10px]">'+
'<thead class="text-slate-600 uppercase text-[9px]"><tr class="border-b border-slate-800">'+ '<thead class="text-slate-600 uppercase text-[9px]"><tr class="border-b border-slate-800">'+
'<th class="text-left py-0.5">head</th><th class="text-right">n</th><th class="text-right">tr P</th>'+ '<th class="text-left py-0.5">head</th><th class="text-right">n</th><th class="text-right">tr P</th>'+
'<th class="text-right">cond</th>'+
'<th class="text-right">|&theta;|</th><th class="text-right">rmse</th><th class="text-right">&alpha;</th>'+ '<th class="text-right">|&theta;|</th><th class="text-right">rmse</th><th class="text-right">&alpha;</th>'+
'<th class="text-right">cov</th><th class="text-right">&plusmn;</th><th class="text-right pl-2">p/c/l</th></tr></thead><tbody>'+ '<th class="text-right">cov</th><th class="text-right">&plusmn;</th><th class="text-right pl-2">p/c/l</th></tr></thead><tbody>'+
heads.map(h=>{ heads.map(h=>{
@@ -1015,6 +1041,10 @@ async function loadNerd() {
'<td class="py-0.5 text-slate-400">'+h.target.slice(0,4)+' <span class="text-slate-600">'+HL(h.horizon_s)+'</span></td>'+ '<td class="py-0.5 text-slate-400">'+h.target.slice(0,4)+' <span class="text-slate-600">'+HL(h.horizon_s)+'</span></td>'+
'<td class="text-right text-slate-600">'+h.n_updates+'</td>'+ '<td class="text-right text-slate-600">'+h.n_updates+'</td>'+
'<td class="text-right '+(sat>0.95?'text-rose-300':'text-slate-400')+'">'+h.trace_p.toExponential(1)+'</td>'+ '<td class="text-right '+(sat>0.95?'text-rose-300':'text-slate-400')+'">'+h.trace_p.toExponential(1)+'</td>'+
// a huge condition number means some of the 33 directions are barely
// excited, so their weights are close to arbitrary
'<td class="text-right '+(h.cond_p>1e8?'text-amber-300':'text-slate-500')+'">'+
(isFinite(h.cond_p)?h.cond_p.toExponential(0):'inf')+'</td>'+
'<td class="text-right text-slate-300">'+fmt(h.theta_norm,1)+'</td>'+ '<td class="text-right text-slate-300">'+fmt(h.theta_norm,1)+'</td>'+
'<td class="text-right text-slate-300">'+fmt(h.rmse_ewma,3)+'</td>'+ '<td class="text-right text-slate-300">'+fmt(h.rmse_ewma,3)+'</td>'+
'<td class="text-right text-indigo-300">'+fmt(h.alpha,3)+'</td>'+ '<td class="text-right text-indigo-300">'+fmt(h.alpha,3)+'</td>'+
@@ -1025,6 +1055,16 @@ async function loadNerd() {
}).join('')+'</tbody></table>' }).join('')+'</tbody></table>'
: '<p class="text-[10px] text-slate-600 font-mono">No heads trained yet.</p>'; : '<p class="text-[10px] text-slate-600 font-mono">No heads trained yet.</p>';
const rel = ((d.reliability||{}).points)||[];
el('n-rel').innerHTML = rel.length ? rel.map(pt=>{
const gap = pt.realised - pt.nominal;
const cls = Math.abs(gap)<0.03 ? '#34d399' : Math.abs(gap)<0.06 ? '#fbbf24' : '#fb7185';
return '<div class="flex items-center gap-1.5">'+
'<span class="text-slate-600" style="width:42%">'+pt.target.slice(0,4)+' '+HL(pt.horizon_s)+'</span>'+
'<span class="flex-1">'+bar(pt.realised, cls)+'</span>'+
'<span style="width:26%;text-align:right;color:'+cls+'">'+(pt.realised*100).toFixed(0)+'%</span></div>';
}).join('') : '<p class="text-slate-600">Nothing scored yet.</p>';
const sel = el('n-head-sel'); const sel = el('n-head-sel');
if (sel.options.length !== heads.length) { if (sel.options.length !== heads.length) {
sel.innerHTML = heads.map((h,i)=>'<option value="'+i+'">'+h.target.slice(0,4)+' '+HL(h.horizon_s)+'</option>').join(''); sel.innerHTML = heads.map((h,i)=>'<option value="'+i+'">'+h.target.slice(0,4)+' '+HL(h.horizon_s)+'</option>').join('');
+16
View File
@@ -30,6 +30,7 @@ Two jobs here, both familiar from soft-sensor work:
from __future__ import annotations from __future__ import annotations
from collections import deque
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Dict, Optional from typing import Dict, Optional
@@ -54,6 +55,7 @@ class KalmanCV:
P: np.ndarray = field(default_factory=lambda: np.eye(2) * 1e3) P: np.ndarray = field(default_factory=lambda: np.eye(2) * 1e3)
initialised: bool = False initialised: bool = False
nis: float = 0.0 # normalised innovation squared, for health monitoring nis: float = 0.0 # normalised innovation squared, for health monitoring
innovation_z: float = 0.0
def update(self, z: float, dt: float) -> tuple[float, float]: def update(self, z: float, dt: float) -> tuple[float, float]:
if not np.isfinite(z): if not np.isfinite(z):
@@ -83,6 +85,11 @@ class KalmanCV:
self.P = I_KH @ self.P @ I_KH.T + K @ K.T * self.r # Joseph form, stays PSD self.P = I_KH @ self.P @ I_KH.T + K @ K.T * self.r # Joseph form, stays PSD
self.nis = (y * y) / S self.nis = (y * y) / S
# y/sqrt(S) is the innovation in units of its own predicted spread, so it
# is comparable across signals and should look standard normal when the
# filter is consistent. Cheap to keep, and the only honest way to see
# skew or fat tails rather than inferring them from a single NIS value.
self.innovation_z = float(y / np.sqrt(S)) if S > 0 else 0.0
return float(self.x[0]), float(self.x[1]) return float(self.x[0]), float(self.x[1])
@property @property
@@ -265,6 +272,12 @@ class SignalTracker:
"pressure": KalmanCV(cfg.sensor.kalman_q_press, cfg.sensor.kalman_r_press), "pressure": KalmanCV(cfg.sensor.kalman_q_press, cfg.sensor.kalman_r_press),
} }
self.last_ts: Optional[float] = None self.last_ts: Optional[float] = None
# 600 samples per signal is 20 minutes at the live 2 s cadence, about
# 14 kB total. Bounded on purpose: this board has 512 MB and an
# unbounded diagnostic buffer is a slow memory leak with a nice name.
self.innovations: Dict[str, deque] = {
k: deque(maxlen=600) for k in self.filters
}
def step(self, ts: float, temp_raw: float, hum: float, press: float, def step(self, ts: float, temp_raw: float, hum: float, press: float,
cpu_temp: float) -> Dict[str, float]: cpu_temp: float) -> Dict[str, float]:
@@ -278,6 +291,9 @@ class SignalTracker:
t_lvl, t_rate = self.filters["temperature"].update(temp_c, dt) t_lvl, t_rate = self.filters["temperature"].update(temp_c, dt)
h_lvl, h_rate = self.filters["humidity"].update(hum_c, dt) h_lvl, h_rate = self.filters["humidity"].update(hum_c, dt)
p_lvl, p_rate = self.filters["pressure"].update(press, dt) p_lvl, p_rate = self.filters["pressure"].update(press, dt)
for name, kf in self.filters.items():
if kf.initialised:
self.innovations[name].append(kf.innovation_z)
return { return {
"temp_c": temp_c, "temp_c": temp_c,
+93
View File
@@ -24,14 +24,18 @@ same code to the Pi unchanged.
from __future__ import annotations from __future__ import annotations
import logging
import math import math
import time import time
from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import numpy as np import numpy as np
from .physics import dew_point, sea_level_pressure, solar_position from .physics import dew_point, sea_level_pressure, solar_position
log = logging.getLogger(__name__)
TCS3400_ENABLE = 0x80 TCS3400_ENABLE = 0x80
TCS3400_ATIME = 0x81 TCS3400_ATIME = 0x81
TCS3400_CONTROL = 0x8F TCS3400_CONTROL = 0x8F
@@ -105,6 +109,95 @@ class SimulatedBoard:
pass pass
class OutdoorProbe:
"""Optional DS18B20 on the 1-Wire bus, read through the kernel's w1 driver.
Why this matters more than any model change: indoors the station forecasts
a room. Pressure passes through walls, temperature and humidity do not. One
three-pound sensor on a metre of cable outside the window removes the single
largest caveat in the project.
No new dependency. The kernel exposes each probe as a text file under
/sys/bus/w1/devices/28-*/w1_slave, so this is a file read and two string
splits. Enable with `dtoverlay=w1-gpio` in /boot/firmware/config.txt.
How it fails: the DS18B20 takes up to 750 ms to convert, and the driver
blocks for that whole time. Reading it on the 2 s sample loop would eat a
third of the budget on a single-issue core, so it is polled on its own
slower cadence and the last good value is reused in between. A probe that
goes missing (cable pulled, bad CRC) returns None rather than a stale value
forever: `age_s` lets the caller decide when to stop trusting it.
"""
ROOT = "/sys/bus/w1/devices"
def __init__(self, min_period_s: float = 20.0) -> None:
self.min_period_s = float(min_period_s)
self.device: Optional[str] = None
self.available = False
self.last_value: Optional[float] = None
self.last_ts: Optional[float] = None
self.errors = 0
self._discover()
def _discover(self) -> None:
try:
root = Path(self.ROOT)
if not root.is_dir():
return
probes = sorted(p for p in root.glob("28-*") if (p / "w1_slave").exists())
if probes:
self.device = str(probes[0] / "w1_slave")
self.available = True
log.info("outdoor probe found at %s", self.device)
except OSError as exc:
log.warning("1-wire scan failed: %r", exc)
def read(self) -> Optional[float]:
"""Celsius, or None. Cached between polls so the sample loop never blocks."""
if not self.available or self.device is None:
return None
now = time.time()
if self.last_ts is not None and (now - self.last_ts) < self.min_period_s:
return self.last_value
try:
with open(self.device, "r") as fh:
text = fh.read()
except OSError as exc:
self.errors += 1
log.warning("outdoor probe read failed: %r", exc)
return self.last_value
# Two lines: the first ends in YES only when the CRC checked out.
if "YES" not in text.split("\n")[0]:
self.errors += 1
return self.last_value
marker = text.find("t=")
if marker < 0:
self.errors += 1
return self.last_value
try:
milli = int(text[marker + 2:].strip())
except ValueError:
self.errors += 1
return self.last_value
# 85000 is the DS18B20 power-on default and means "never converted".
if milli == 85000:
self.errors += 1
return self.last_value
value = milli / 1000.0
if not (-55.0 <= value <= 125.0):
self.errors += 1
return self.last_value
self.last_value = value
self.last_ts = now
return value
def status(self) -> Dict[str, Any]:
age = None if self.last_ts is None else round(time.time() - self.last_ts, 1)
return {"available": self.available, "device": self.device,
"value_c": self.last_value, "age_s": age, "errors": self.errors}
class SenseBoard: class SenseBoard:
"""Real hardware wrapper. Attribute `available` tells you which world """Real hardware wrapper. Attribute `available` tells you which world
you are in without try/except at every call site.""" you are in without try/except at every call site."""
+20
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+396
View File
@@ -0,0 +1,396 @@
/* cyrillic-ext */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx3cwgknk-6nFg.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxTcwgknk-6nFg.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxPcwgknk-6nFg.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* vietnamese */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx_cwgknk-6nFg.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx7cwgknk-6nFg.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxDcwgknk-4.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx3cwgknk-6nFg.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxTcwgknk-6nFg.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxPcwgknk-6nFg.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* vietnamese */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx_cwgknk-6nFg.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx7cwgknk-6nFg.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxDcwgknk-4.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx3cwgknk-6nFg.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxTcwgknk-6nFg.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxPcwgknk-6nFg.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* vietnamese */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx_cwgknk-6nFg.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx7cwgknk-6nFg.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxDcwgknk-4.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx3cwgknk-6nFg.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxTcwgknk-6nFg.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxPcwgknk-6nFg.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* vietnamese */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx_cwgknk-6nFg.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx7cwgknk-6nFg.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxDcwgknk-4.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko70yygg_vbd-E.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* vietnamese */
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko50yygg_vbd-E.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko40yygg_vbd-E.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko20yygg_vb.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko70yygg_vbd-E.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* vietnamese */
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko50yygg_vbd-E.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko40yygg_vbd-E.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko20yygg_vb.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko70yygg_vbd-E.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* vietnamese */
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko50yygg_vbd-E.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko40yygg_vbd-E.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko20yygg_vb.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko70yygg_vbd-E.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* vietnamese */
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko50yygg_vbd-E.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko40yygg_vbd-E.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko20yygg_vb.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 800;
font-display: swap;
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko70yygg_vbd-E.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* vietnamese */
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 800;
font-display: swap;
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko50yygg_vbd-E.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 800;
font-display: swap;
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko40yygg_vbd-E.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 800;
font-display: swap;
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko20yygg_vb.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+7
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+79 -2
View File
@@ -42,13 +42,13 @@ import numpy as np
from . import physics from . import physics
from .config import Config from .config import Config
from .estimation import SignalTracker from .estimation import KalmanCV, SignalTracker
from .features import build_features from .features import build_features
from .models.anomaly import AnomalyMonitor from .models.anomaly import AnomalyMonitor
from .models.climatology import HarmonicClimatology from .models.climatology import HarmonicClimatology
from .models.nowcast import NowcastEnsemble from .models.nowcast import NowcastEnsemble
from .models.precip import PrecipitationModel, proxy_wet_label, zambretti from .models.precip import PrecipitationModel, proxy_wet_label, zambretti
from .sensors import SenseBoard, enrich from .sensors import OutdoorProbe, SenseBoard, enrich
from .storage import Store, resample from .storage import Store, resample
STATE_VERSION = 1 STATE_VERSION = 1
@@ -65,6 +65,9 @@ class Station:
latitude=cfg.site.latitude, latitude=cfg.site.latitude,
longitude=cfg.site.longitude, longitude=cfg.site.longitude,
) )
# Optional and entirely absent on a board without one wired up.
self.probe = (OutdoorProbe(cfg.sensor.outdoor_probe_period_s)
if cfg.sensor.outdoor_probe else None)
self.tracker = SignalTracker(cfg) self.tracker = SignalTracker(cfg)
self.nowcast = NowcastEnsemble(cfg.model.targets, cfg.model.horizons_s, cfg.model) self.nowcast = NowcastEnsemble(cfg.model.targets, cfg.model.horizons_s, cfg.model)
self.climatology = HarmonicClimatology( self.climatology = HarmonicClimatology(
@@ -191,6 +194,7 @@ class Station:
"cpu_offset": (raw.get("cpu_temp") or float("nan")) - (raw.get("temp_raw") or float("nan")), "cpu_offset": (raw.get("cpu_temp") or float("nan")) - (raw.get("temp_raw") or float("nan")),
"compensator_k": self.tracker.compensator.k, "compensator_k": self.tracker.compensator.k,
"hum_offset": self.tracker.hum_compensator.offset, "hum_offset": self.tracker.hum_compensator.offset,
"outdoor_c": (self.probe.read() if self.probe is not None else None),
"hum_psychrometric": float(est["hum_c"]) - float(raw.get("hum") or float("nan")), "hum_psychrometric": float(est["hum_c"]) - float(raw.get("hum") or float("nan")),
"health": anomaly["health_overall"], "health": anomaly["health_overall"],
"novelty_d2": anomaly["novelty"].get("d2", 0.0), "novelty_d2": anomaly["novelty"].get("d2", 0.0),
@@ -265,6 +269,11 @@ class Station:
result = self.tracker.compensator.calibrate(float(raw), float(cpu), float(reference_c)) result = self.tracker.compensator.calibrate(float(raw), float(cpu), float(reference_c))
self.store.log_event("calibration", "info", self.store.log_event("calibration", "info",
f"k -> {result['k']:.3f} (residual {result['residual']:+.2f} C)") f"k -> {result['k']:.3f} (residual {result['residual']:+.2f} C)")
# Discontinuity marker: everything logged before this instant used a
# different coefficient. Kept as its own event kind so the scorecard and
# the records view can find it without parsing prose.
self.store.log_event("discontinuity", "warn",
f"temperature k {result['k']:.4f}")
return result return result
def calibrate_humidity(self, reference_pct: float) -> Dict: def calibrate_humidity(self, reference_pct: float) -> Dict:
@@ -279,6 +288,8 @@ class Station:
self.store.log_event("calibration", "info", self.store.log_event("calibration", "info",
f"rh offset -> {result['offset']:+.2f}% " f"rh offset -> {result['offset']:+.2f}% "
f"(residual {result['residual']:+.2f}%)") f"(residual {result['residual']:+.2f}%)")
self.store.log_event("discontinuity", "warn",
f"humidity offset {result['offset']:+.4f}")
return result return result
def reset_humidity_calibration(self) -> Dict: def reset_humidity_calibration(self) -> Dict:
@@ -293,6 +304,72 @@ class Station:
f"rh offset reset to prior {self.cfg.sensor.hum_offset}") f"rh offset reset to prior {self.cfg.sensor.hum_offset}")
return {"offset": self.tracker.hum_compensator.offset, "reset": True, "n": 0} return {"offset": self.tracker.hum_compensator.offset, "reset": True, "n": 0}
def recompute_history(self) -> Dict:
"""Re-derive every compensated column from the stored raw values.
Why this exists: calibration only changes readings from that moment on,
so a correction of any size leaves a step in the record. Measured on this
station, one humidity calibration put a 25-point discontinuity through
the middle of the day. That contaminates the all-time records with values
that were never real weather, and makes the learners train across a jump.
It is possible at all because the raw columns are never overwritten:
`temp_raw`, `cpu_temp` and `hum` are exactly what the sensor reported, so
the current coefficients can be applied to the whole history.
The Kalman levels are re-run rather than shifted, because the filter is
not a constant offset. That means the smoothing is *re-derived*, not bit
identical to what was logged live: the replay sees the stored cadence,
which for tiered rows is coarser than the 2 s the filter runs at. The
levels are right, the fine texture of old raw rows is not recoverable.
"""
data = self.store.all_for_recompute()
ts = data["ts"]
if ts.size == 0:
return {"rows": 0, "reason": "no history"}
t0 = time.time()
comp, hcomp = self.tracker.compensator, self.tracker.hum_compensator
n = ts.size
temp_c = np.empty(n)
hum_c = np.empty(n)
for i in range(n):
tr, cp, hu = data["temp_raw"][i], data["cpu_temp"][i], data["hum"][i]
temp_c[i] = comp.compensate(tr, cp) if np.isfinite(tr) and np.isfinite(cp) else tr
hum_c[i] = (hcomp.compensate(hu, tr, temp_c[i])
if np.isfinite(hu) and np.isfinite(tr) else hu)
# Replay the filters over the corrected series. Fresh instances, so an
# old contaminated state cannot leak into the re-derivation.
kt = KalmanCV(self.cfg.sensor.kalman_q_temp, self.cfg.sensor.kalman_r_temp)
kh = KalmanCV(self.cfg.sensor.kalman_q_hum, self.cfg.sensor.kalman_r_hum)
temp_s = np.empty(n)
temp_r = np.empty(n)
hum_s = np.empty(n)
prev = None
for i in range(n):
dt = 1.0 if prev is None else max(ts[i] - prev, 1e-3)
prev = ts[i]
lvl, rate = kt.update(temp_c[i], dt)
temp_s[i], temp_r[i] = lvl, rate * 3600.0
hum_s[i], _ = kh.update(hum_c[i], dt)
dew = np.asarray(physics.dew_point(temp_s, hum_s), dtype=float)
slp = np.asarray(physics.sea_level_pressure(
data["press"], temp_s, self.cfg.site.altitude_m), dtype=float)
written = self.store.apply_recompute(ts, {
"temp_c": temp_c, "temp_smooth": temp_s, "temp_rate": temp_r,
"hum_smooth": hum_s, "dew_c": dew, "press_slp": slp,
})
secs = time.time() - t0
self.store.log_event(
"recompute", "info",
f"re-derived {written} rows from raw with k={comp.k:.4f}, "
f"rh offset={hcomp.offset:+.2f}% in {secs:.1f}s")
return {"rows": written, "seconds": round(secs, 2),
"k": comp.k, "hum_offset": hcomp.offset}
def reset_calibration(self) -> Dict: def reset_calibration(self) -> Dict:
"""Return the self-heating coefficient to its configured prior. """Return the self-heating coefficient to its configured prior.
+46
View File
@@ -154,6 +154,52 @@ class Store:
# ------------------------------------------------------------- reads # ------------------------------------------------------------- reads
def all_for_recompute(self) -> Dict[str, np.ndarray]:
"""Every stored row's *raw* inputs, oldest first.
Only the columns a re-derivation actually needs. The raw sensor values
are never overwritten, which is precisely what makes recomputation
possible after a calibration changes k or the humidity offset.
"""
cols = ["ts", "temp_raw", "cpu_temp", "hum", "press"]
with self._conn() as conn:
rows = conn.execute(
f"SELECT {', '.join(cols)} FROM telemetry ORDER BY ts ASC").fetchall()
if not rows:
return {c: np.empty(0) for c in cols}
arr = np.array(rows, dtype=object)
out = {}
for i, c in enumerate(cols):
out[c] = np.array([np.nan if v is None else float(v) for v in arr[:, i]],
dtype=float)
return out
def apply_recompute(self, ts: np.ndarray, updates: Dict[str, np.ndarray],
chunk: int = 2000) -> int:
"""Write recomputed derived columns back, in chunks.
Chunked because a year of tiered history is a six-figure row count and a
single statement would hold the whole parameter list in memory on a
512 MB board.
"""
names = list(updates.keys())
sql = (f"UPDATE telemetry SET {', '.join(n + ' = ?' for n in names)} "
f"WHERE ts = ?")
n_written = 0
with self._conn() as conn:
for start in range(0, ts.size, chunk):
stop = min(start + chunk, ts.size)
batch = [
tuple(
[None if not np.isfinite(updates[n][i]) else float(updates[n][i])
for n in names] + [float(ts[i])]
)
for i in range(start, stop)
]
conn.executemany(sql, batch)
n_written += len(batch)
return n_written
def window(self, hours: float, columns: Optional[Iterable[str]] = None) -> Dict[str, np.ndarray]: def window(self, hours: float, columns: Optional[Iterable[str]] = None) -> Dict[str, np.ndarray]:
"""Return the last `hours` of telemetry as column arrays, oldest first.""" """Return the last `hours` of telemetry as column arrays, oldest first."""
cols = list(columns) if columns else COLUMNS cols = list(columns) if columns else COLUMNS
+179
View File
@@ -0,0 +1,179 @@
# Copyright 2026 Kemal Yaylali
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Compensators and the Kalman bank.
The inverse-property tests here exist because getting that algebra wrong has
already cost this project twice: once on temperature, where a mismatched
simulator injected 1.2 C of phantom noise floor, and once on humidity, where
the correction ran the wrong way against a reference hygrometer.
"""
from __future__ import annotations
import numpy as np
import pytest
from ashvale.estimation import HumidityCompensator, KalmanCV, ThermalCompensator
from ashvale.physics import dew_point, saturation_vapour_pressure
# ---------------------------------------------------------------- thermal
def test_thermal_forward_model_is_the_exact_inverse_of_the_compensator():
"""T_raw = (T + k*T_cpu)/(1+k) must invert T = T_raw - k(T_cpu - T_raw)."""
for k, t_true, t_cpu in [(0.55, 19.0, 40.0), (0.26, 24.4, 40.2), (1.0, 5.0, 30.0)]:
c = ThermalCompensator(k0=k, k_min=0.0, k_max=2.0)
t_raw = (t_true + k * t_cpu) / (1.0 + k)
assert c.compensate(t_raw, t_cpu) == pytest.approx(t_true, abs=1e-9)
def test_thermal_calibration_moves_k_toward_the_truth():
c = ThermalCompensator(k0=0.30, k_min=0.05, k_max=1.5)
k_true, t_true, t_cpu = 0.62, 19.0, 41.0
t_raw = (t_true + k_true * t_cpu) / (1.0 + k_true)
before = abs(c.k - k_true)
c.calibrate(t_raw, t_cpu, t_true)
assert abs(c.k - k_true) < before
def test_thermal_clamp_survives_a_mistyped_reference():
c = ThermalCompensator(k0=0.55, k_min=0.15, k_max=1.20)
for _ in range(50):
c.calibrate(25.0, 40.0, -300.0) # absurd reference
assert c.k_min <= c.k <= c.k_max
def test_thermal_compensation_is_a_noop_without_a_gradient():
c = ThermalCompensator(k0=0.8)
assert c.compensate(21.0, 21.0) == pytest.approx(21.0)
# and never amplifies when the CPU is cooler than the sensor
assert c.compensate(21.0, 15.0) == pytest.approx(21.0)
# ---------------------------------------------------------------- humidity
def test_humidity_psychrometric_round_trip():
"""The simulator's forward model must invert the compensator exactly."""
rh_true, t_true, t_raw = 62.0, 19.0, 25.6
rh_sensor = rh_true * float(saturation_vapour_pressure(t_true) /
saturation_vapour_pressure(t_raw))
hc = HumidityCompensator(psychrometric=True)
assert hc.compensate(rh_sensor, t_raw, t_true) == pytest.approx(rh_true, abs=1e-6)
def test_humidity_psychrometric_preserves_dew_point():
"""Vapour pressure is the conserved quantity, so dew point must not move."""
rh_sensor, t_raw, t_true = 60.0, 25.6, 19.0
hc = HumidityCompensator(psychrometric=True)
out = hc.compensate(rh_sensor, t_raw, t_true)
assert float(dew_point(t_true, out)) == pytest.approx(float(dew_point(t_raw, rh_sensor)),
abs=1e-6)
def test_humidity_psychrometric_disabled_by_default():
hc = HumidityCompensator()
assert hc.compensate(60.0, 25.6, 19.0) == pytest.approx(60.0)
def test_humidity_offset_converges_on_a_reference():
"""The measured case: board reads 75.35% where the truth is 50.4%."""
hc = HumidityCompensator()
errors = []
for _ in range(6):
hc.calibrate(75.35, 27.94, 24.86, 50.4)
errors.append(abs(hc.compensate(75.35, 27.94, 24.86) - 50.4))
assert errors[-1] < errors[0]
assert errors[-1] < 0.5
def test_humidity_offset_is_clamped():
hc = HumidityCompensator()
for _ in range(50):
hc.calibrate(50.0, 20.0, 20.0, 100.0)
assert hc.off_min <= hc.offset <= hc.off_max
def test_humidity_output_stays_in_range():
hc = HumidityCompensator(offset=30.0)
assert 0.0 <= hc.compensate(95.0, 20.0, 20.0) <= 100.0
hc2 = HumidityCompensator(offset=-30.0)
assert 0.0 <= hc2.compensate(5.0, 20.0, 20.0) <= 100.0
def test_humidity_state_round_trips_through_dict():
hc = HumidityCompensator(offset=-24.2, psychrometric=True)
hc.calibrate(70.0, 25.0, 21.0, 50.0)
back = HumidityCompensator.from_dict(hc.to_dict())
assert back.offset == pytest.approx(hc.offset)
assert back.psychrometric is hc.psychrometric
assert back.n_calibrations == hc.n_calibrations
# ---------------------------------------------------------------- kalman
def test_kalman_covariance_stays_symmetric_and_psd():
"""Joseph form exists precisely so this holds over a long run."""
kf = KalmanCV(q=1e-6, r=0.05)
rng = np.random.default_rng(7)
for _ in range(20000):
kf.update(20.0 + 0.05 * rng.normal(), 2.0)
P = np.asarray(kf.P, dtype=float)
assert np.allclose(P, P.T, atol=1e-12)
assert np.all(np.linalg.eigvalsh(P) > -1e-12)
def test_kalman_tracks_a_constant_and_reports_zero_rate():
kf = KalmanCV(q=1e-8, r=0.01)
for _ in range(2000):
kf.update(15.0, 2.0)
assert kf.level == pytest.approx(15.0, abs=1e-3)
assert kf.rate == pytest.approx(0.0, abs=1e-5)
def test_kalman_recovers_a_known_ramp_rate():
kf = KalmanCV(q=1e-4, r=0.01)
true_rate = 0.5 / 3600.0 # 0.5 units per hour
for i in range(6000):
kf.update(10.0 + true_rate * i * 2.0, 2.0)
assert kf.rate * 3600.0 == pytest.approx(0.5, rel=0.05)
def test_kalman_ignores_non_finite_measurements():
kf = KalmanCV(q=1e-6, r=0.05)
kf.update(20.0, 2.0)
lvl_before = kf.level
kf.update(float("nan"), 2.0)
assert kf.level == pytest.approx(lvl_before)
def test_kalman_nis_is_near_one_when_noise_matches_the_model():
"""NIS is the honest self-check: consistent filter, NIS about 1."""
r = 0.04
kf = KalmanCV(q=1e-7, r=r)
rng = np.random.default_rng(11)
nis = []
for i in range(4000):
kf.update(18.0 + np.sqrt(r) * rng.normal(), 2.0)
if i > 500:
nis.append(kf.nis)
assert 0.5 < float(np.mean(nis)) < 2.0
def test_kalman_state_round_trips_through_dict():
kf = KalmanCV(q=1e-6, r=0.05)
for _ in range(50):
kf.update(12.0, 2.0)
back = KalmanCV.from_dict(kf.to_dict())
assert back.level == pytest.approx(kf.level)
assert back.rate == pytest.approx(kf.rate)
+151
View File
@@ -0,0 +1,151 @@
# Copyright 2026 Kemal Yaylali
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The learners: RLS, adaptive conformal, and the Zambretti prior.
The covariance-cap test is the important one in this file. Unbounded P growth
through an unexcited subspace is the most common way a field RLS deployment
dies, and it dies silently until the first excited sample.
"""
from __future__ import annotations
import numpy as np
import pytest
from ashvale.models.precip import zambretti
from ashvale.models.rls import AdaptiveConformal, RecursiveLeastSquares
# ---------------------------------------------------------------- RLS
def test_rls_recovers_known_coefficients():
rng = np.random.default_rng(3)
truth = np.array([0.5, -1.25, 2.0, 0.0])
m = RecursiveLeastSquares(n_features=4, forgetting=0.999)
for _ in range(4000):
x = rng.normal(size=4)
m.update(x, float(truth @ x))
assert np.allclose(m.theta, truth, atol=0.02)
def test_rls_covariance_trace_never_exceeds_the_cap():
"""A quiet regressor is exactly what inflates P. It must not run away."""
m = RecursiveLeastSquares(n_features=8, forgetting=0.99, p_max=1e4)
quiet = np.zeros(8)
quiet[0] = 1.0 # only one direction ever excited
for _ in range(50000):
m.update(quiet, 1.0)
tr = float(np.trace(np.asarray(m.P, dtype=float)))
assert np.isfinite(tr)
assert tr <= 1e4 * (1.0 + 1e-6)
def test_rls_covariance_stays_symmetric():
rng = np.random.default_rng(5)
m = RecursiveLeastSquares(n_features=6, forgetting=0.995)
for _ in range(5000):
m.update(rng.normal(size=6), float(rng.normal()))
P = np.asarray(m.P, dtype=float)
assert np.allclose(P, P.T, atol=1e-9)
def test_rls_survives_a_non_finite_sample_without_poisoning_theta():
m = RecursiveLeastSquares(n_features=3, forgetting=0.99)
for _ in range(100):
m.update(np.array([1.0, 0.5, -0.2]), 1.0)
good = m.theta.copy()
m.update(np.array([np.nan, 1.0, 1.0]), 1.0)
assert np.all(np.isfinite(m.theta)), "a NaN sample must not poison the weights"
m.update(np.array([1.0, 1.0, 1.0]), float("inf"))
assert np.all(np.isfinite(m.theta))
assert good.shape == m.theta.shape
def test_rls_forgetting_gives_the_documented_effective_memory():
m = RecursiveLeastSquares(n_features=2, forgetting=0.9985)
assert 1.0 / (1.0 - m.lam) == pytest.approx(666.67, rel=1e-3)
# ---------------------------------------------------------------- conformal
def test_conformal_coverage_tracks_the_target_on_stationary_noise():
ac = AdaptiveConformal(alpha=0.1, gamma=0.02)
rng = np.random.default_rng(17)
inside = 0
n = 4000
for i in range(n):
err = float(rng.normal())
q = float(ac.quantile())
covered = bool(np.isfinite(q) and abs(err) <= q)
if i > 400 and covered:
inside += 1
ac.observe(err, covered)
assert 0.84 <= inside / (n - 400) <= 0.96
def test_conformal_alpha_is_clamped():
ac = AdaptiveConformal(alpha=0.1, gamma=0.2)
for _ in range(5000):
ac.observe(1e9, False) # always a miss, alpha should rise then stop
assert 0.005 <= ac.alpha <= 0.75
def test_conformal_widens_after_misses_and_narrows_after_hits():
"""Mind the sign. The update is
alpha <- alpha + gamma * (alpha_target - 1[miss])
so a hit adds +gamma*alpha_target and a miss subtracts gamma*(1-alpha_target).
Since the band is the (1-alpha) quantile, a *rising* alpha is a *narrowing*
band. Hits therefore push alpha up and misses push it down, which reads
backwards until you follow it through.
"""
ac = AdaptiveConformal(alpha=0.1, gamma=0.05)
for _ in range(200):
ac.observe(0.1, True)
a_hits = ac.alpha
assert a_hits > 0.1, "a run of hits should raise alpha, narrowing the band"
for _ in range(200):
ac.observe(1e6, False)
assert ac.alpha < a_hits, "a run of misses should lower alpha, widening the band"
# ---------------------------------------------------------------- zambretti
def test_zambretti_ordering_rising_is_never_worse_than_falling():
"""Z increases toward bad weather, so falling must not score below rising."""
for p in [980.0, 1000.0, 1013.0, 1030.0]:
rising = zambretti(p, +1.2, 6)["z"]
steady = zambretti(p, 0.0, 6)["z"]
falling = zambretti(p, -1.2, 6)["z"]
assert rising <= steady <= falling, f"ordering broken at {p} hPa"
def test_zambretti_z_decreases_with_pressure_within_a_branch():
for tend in (-1.2, 0.0, 1.2):
zs = [zambretti(p, tend, 6)["z"] for p in (985.0, 1000.0, 1015.0, 1030.0)]
assert all(a >= b for a, b in zip(zs, zs[1:])), f"not monotonic for tend={tend}"
def test_zambretti_stays_on_the_26_point_scale():
for p in (940.0, 1050.0):
for tend in (-5.0, 0.0, 5.0):
assert 1 <= zambretti(p, tend, 6)["z"] <= 26
def test_zambretti_rain_prior_rises_with_z():
settled = zambretti(1035.0, 1.5, 6)
stormy = zambretti(960.0, -2.5, 6)
assert stormy["prior_rain_prob"] > settled["prior_rain_prob"]
+95
View File
@@ -0,0 +1,95 @@
# Copyright 2026 Kemal Yaylali
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Physics closed forms.
These are properties, not golden numbers. A golden number test tells you the
output changed; a property test tells you the output became unphysical, which
is the failure that actually matters here.
"""
from __future__ import annotations
import numpy as np
import pytest
from ashvale import physics
@pytest.mark.parametrize("t", [-20.0, -5.0, 0.0, 12.3, 25.0, 40.0])
def test_dew_point_at_saturation_equals_temperature(t):
"""100% RH means the air is already at its dew point."""
assert float(physics.dew_point(t, 100.0)) == pytest.approx(t, abs=1e-6)
@pytest.mark.parametrize("t,rh", [(20.0, 50.0), (5.0, 80.0), (30.0, 20.0), (-3.0, 95.0)])
def test_dew_point_never_exceeds_temperature(t, rh):
assert float(physics.dew_point(t, rh)) <= t + 1e-9
def test_dew_point_round_trip_through_vapour_pressure():
"""e(T, RH) evaluated at the dew point must be the saturation pressure."""
for t, rh in [(20.0, 50.0), (25.6, 72.9), (0.5, 90.0)]:
td = float(physics.dew_point(t, rh))
assert float(physics.vapour_pressure(t, rh)) == pytest.approx(
float(physics.saturation_vapour_pressure(td)), rel=1e-6)
def test_saturation_vapour_pressure_is_monotonic_in_temperature():
t = np.linspace(-30.0, 50.0, 400)
es = np.asarray(physics.saturation_vapour_pressure(t), dtype=float)
assert np.all(np.diff(es) > 0.0)
@pytest.mark.parametrize("t,rh", [(20.0, 50.0), (30.0, 30.0), (10.0, 95.0)])
def test_wet_bulb_between_dew_point_and_temperature(t, rh):
"""The psychrometric ordering Td <= Tw <= T is not optional."""
td = float(physics.dew_point(t, rh))
tw = float(physics.wet_bulb(t, rh))
assert td - 1e-6 <= tw <= t + 1e-6
def test_vpd_is_zero_at_saturation_and_positive_below():
assert float(physics.vapour_pressure_deficit(20.0, 100.0)) == pytest.approx(0.0, abs=1e-9)
assert float(physics.vapour_pressure_deficit(20.0, 40.0)) > 0.0
def test_sea_level_pressure_round_trips_with_station_pressure():
for p, t, alt in [(1000.0, 15.0, 11.0), (1024.5, -2.0, 250.0), (985.0, 28.0, 0.0)]:
slp = float(physics.sea_level_pressure(p, t, alt))
back = float(physics.station_pressure(slp, t, alt))
assert back == pytest.approx(p, rel=1e-9)
def test_sea_level_pressure_is_above_station_pressure_when_elevated():
assert float(physics.sea_level_pressure(1000.0, 15.0, 100.0)) > 1000.0
assert float(physics.sea_level_pressure(1000.0, 15.0, 0.0)) == pytest.approx(1000.0, rel=1e-12)
def test_solar_elevation_is_higher_at_local_noon_than_midnight():
# 21 June 2026, Cambridge. Noon UTC against midnight UTC.
noon, _ = physics.solar_position(np.array([1781784000.0]), 52.2053, 0.1218)
midnight, _ = physics.solar_position(np.array([1781740800.0]), 52.2053, 0.1218)
assert float(np.atleast_1d(noon)[0]) > float(np.atleast_1d(midnight)[0])
def test_clear_sky_irradiance_is_zero_below_the_horizon():
assert float(np.atleast_1d(physics.clear_sky_irradiance(np.array([-10.0])))[0]) == 0.0
assert float(np.atleast_1d(physics.clear_sky_irradiance(np.array([45.0])))[0]) > 0.0
def test_absolute_humidity_rises_with_temperature_at_fixed_rh():
a = float(physics.absolute_humidity(10.0, 60.0))
b = float(physics.absolute_humidity(25.0, 60.0))
assert b > a
+115
View File
@@ -0,0 +1,115 @@
# Copyright 2026 Kemal Yaylali
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""History re-derivation after a calibration.
The property that matters is idempotence. Recompute always starts from the
untouched raw columns, so running it twice must land in exactly the same place.
If it ever compounds, a user who clicks the button twice silently corrupts
their entire record.
"""
from __future__ import annotations
import sqlite3
import pytest
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient # noqa: E402
import ashvale.api as api # noqa: E402
from ashvale.config import CONFIG # noqa: E402
def _avg(col: str) -> float:
with sqlite3.connect(CONFIG.storage.db_path) as c:
return c.execute(f"SELECT round(avg({col}), 6) FROM telemetry").fetchone()[0]
def _snapshot() -> dict:
"""Per-row values keyed by timestamp.
Deliberately not an aggregate. The station's sample loop is live under
TestClient, so rows arrive between calls and any average over the whole
table is a moving target. Comparing the rows present in both snapshots
tests the property that actually matters.
"""
with sqlite3.connect(CONFIG.storage.db_path) as c:
return {r[0]: (r[1], r[2]) for r in
c.execute("SELECT ts, hum_smooth, temp_smooth FROM telemetry")}
def _rows() -> int:
with sqlite3.connect(CONFIG.storage.db_path) as c:
return c.execute("SELECT count(*) FROM telemetry").fetchone()[0]
@pytest.fixture(scope="module")
def client():
with TestClient(api.app) as c:
yield c
def test_recompute_is_idempotent(client):
"""Running it twice must land in exactly the same place, row for row.
It always starts from the untouched raw columns, so it cannot compound. If
that ever breaks, a user clicking the button twice silently corrupts their
whole record, which is why this is tested per row rather than on an average.
"""
if _rows() == 0:
pytest.skip("no history in the database")
client.post("/api/recompute")
first = _snapshot()
client.post("/api/recompute")
second = _snapshot()
common = set(first) & set(second)
assert common, "no overlapping rows to compare"
differing = [ts for ts in common if first[ts] != second[ts]]
assert not differing, f"{len(differing)} of {len(common)} rows changed on re-run"
def test_recompute_preserves_row_count(client):
if _rows() == 0:
pytest.skip("no history in the database")
before = _rows()
client.post("/api/recompute")
assert _rows() == before
def test_recompute_tracks_the_current_offset(client):
"""Changing the calibration must move the whole history, not just new rows."""
if _rows() == 0:
pytest.skip("no history in the database")
client.post("/api/calibrate/humidity", json={"reset": True})
client.post("/api/recompute")
base = _avg("hum_smooth")
client.post("/api/calibrate/humidity", json={"reference_pct": 30.0})
client.post("/api/recompute")
shifted = _avg("hum_smooth")
assert shifted != pytest.approx(base), "history did not follow the new offset"
client.post("/api/calibrate/humidity", json={"reset": True})
client.post("/api/recompute")
assert _avg("hum_smooth") == pytest.approx(base, abs=0.5), "reset did not restore"
def test_calibration_logs_a_discontinuity_marker(client):
client.post("/api/calibrate/humidity", json={"reference_pct": 55.0})
kinds = [e["kind"] for e in client.get("/api/status").json()["events"]]
assert "discontinuity" in kinds
client.post("/api/calibrate/humidity", json={"reset": True})