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
+49 -9
View File
@@ -48,14 +48,15 @@ DASHBOARD_HTML = r"""
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ashvale Station</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css">
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/hammer.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chartjs-plugin-zoom.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<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">
<!-- Served from the station, not a CDN, so the dashboard renders on a LAN with
no internet at all. See ashvale/static/. -->
<script src="/static/tailwind.js"></script>
<link rel="stylesheet" href="/static/katex.min.css">
<script defer src="/static/katex.min.js"></script>
<script src="/static/chart.umd.min.js"></script>
<script src="/static/hammer.min.js"></script>
<script src="/static/chartjs-plugin-zoom.min.js"></script>
<link rel="stylesheet" href="/static/gfonts.css">
<style>
body { font-family:'Plus Jakarta Sans',sans-serif; }
.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
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. -->
<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="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>
</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>
<!-- ---------------- METHODS ---------------- -->
@@ -965,6 +974,15 @@ el('m-hcalrst').addEventListener('click', async () => {
/* ---------------- STATS FOR NERDS ---------------- */
let nerdDoc = null, nerdHead = null;
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) {
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">'+
@@ -975,6 +993,7 @@ async function loadNerd() {
nerdDoc = d;
const COL = {temperature:'#f59e0b', humidity:'#06b6d4', pressure:'#a78bfa'};
const iv = d.innovation||{};
el('n-filters').innerHTML = Object.keys(d.filters||{}).map(k=>{
const f = d.filters[k];
// 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">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>'+
// 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>';
}).join('');
@@ -1004,6 +1029,7 @@ async function loadNerd() {
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">'+
'<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">cov</th><th class="text-right">&plusmn;</th><th class="text-right pl-2">p/c/l</th></tr></thead><tbody>'+
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="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>'+
// 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.rmse_ewma,3)+'</td>'+
'<td class="text-right text-indigo-300">'+fmt(h.alpha,3)+'</td>'+
@@ -1025,6 +1055,16 @@ async function loadNerd() {
}).join('')+'</tbody></table>'
: '<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');
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('');