Two routes onto a Pi. The image for a blank card, the script for a board that
already works, which is most of them.
deploy/install.sh installs apt dependencies, clones to /opt/ashvale, builds a
venv with --system-site-packages, enables I2C and installs a systemd unit.
Tested end to end on the real Zero 2 W by installing a second instance on port
8099 alongside the live station: both served, and the live station was
untouched throughout.
That test earned its keep twice. The installer first declared success while the
service was crash-looping on a port clash, because systemd reports active for
the instant between exec and the first failed bind; it now polls the HTTP
endpoint instead, which is the only check that means anything. And my first
attempt to verify that fix was itself worthless, because curl on 127.0.0.1:8000
was answered by the live station rather than the instance under test.
deploy/pi-image is a pi-gen stage on Raspberry Pi OS Lite, Trixie, arm64, which
is exactly what the board runs. Built by .github/workflows/image.yml against a
pinned pi-gen commit, so the artifact does not move when an upstream branch
does, and published to Releases where the 2 GB asset limit comfortably fits a
Lite image.
The image ships no password, no WiFi and no SSH host keys. Baked host keys
would give every person who flashed it the same identity and make them
trivially impersonable on their own network. Coordinates default to Greenwich
at 0 m, wrong for everybody on purpose, because a plausible wrong altitude
quietly biases the sea-level reduction on every row.
The source is copied through a .gitignore filter rather than a hand-written
exclude list, and that is a security property rather than tidiness: the
hand-written list I wrote first missed HANDOVER.md, which is gitignored
precisely because it contains LAN addresses and SSH details. Verified: 69 files,
no state, no local notes, all essentials present.
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.
New Stats for Nerds tab over a new read-only /api/nerd endpoint: Kalman NIS
and covariance per signal, both compensators, all 18 RLS heads with trace(P)
against the cap, |theta|, EWMA RMSE, conformal alpha against target, realised
coverage and ensemble weights, plus per-head feature attribution over the 33
standardised weights, the Mahalanobis and Page-Hinkley detector state,
climatology harmonics and precipitation coefficients.
Methods overhaul: KaTeX now renders the equations. They were previously passed
through .replace(/[{}\\]/g,' '), which stripped every brace and backslash and
turned real mathematics into mush. Stages 2, 3, 5, 6 and 7 gained full
derivations (RLS normal equations and the trace cap, Joseph-form Kalman with
NIS, adaptive conformal with its coverage limit, ridge harmonic regression with
anomaly decay) and a per-symbol legend rendered inline.
Conditions ahead gains weather icons chosen from measured cloud index, solar
elevation and temperature rather than the barometric class alone, so a fine
barometer under overcast draws a cloud and after sunset draws a moon. Snow is
selected on temperature.
Seven day outlook moves to the top of Live, directly under the nav. Tab renamed
Models and Calibration.
Verified in Chromium at 1600x900: Live, History, Models and Nerd all report
zero scrollbars, zero clipping, no page scroll, zero console errors. Methods
keeps its documented prose scroller. Backtest numerically unchanged.
Seven day outlook moves from History to Live, which now runs four rows.
Conditions ahead tightened so the Live column no longer needs a scroller.
Adds HumidityCompensator: an additive RH offset estimated by one-step RLS from
a trusted hygrometer, clamped to +/-35%, persisted, exposed at
POST /api/calibrate/humidity and on the renamed Models and calibration tab.
It also implements the psychrometric term (RH moved from element temperature
onto air temperature via conserved vapour pressure) but leaves it OFF by
default. The thermal argument predicts a hot element reads low; measured
against a reference hygrometer this board read 75.4% where the truth was
50.4%, so it reads HIGH and that correction would push it the wrong way. When
the flag is enabled, simulate.py applies the exact inverse, per the
simulator/compensator trap in DESIGN.md section 2.
Models pane rebuilt: the scorecard is one column per target so all 18 heads
are visible, and no panel on the tab uses an internal scroller. Verified in
Chromium at 1600x900: Live, History and Models all report zero scrollbars,
zero clipping, no page scroll, zero console errors. Backtest is numerically
identical to the previous commit, confirming the humidity work is a no-op
while the flag is off.