Everything that was previously a curl command now has a surface: surroundings,
site geometry, the matrix, the psychrometric flag, and the maintenance actions.
Changes persist to data/state/settings.json, not config.yaml. That file is
hand-annotated and hand-edited per station, and rewriting it from an API would
destroy the comments and risk clobbering something the owner set. The overlay is
applied last in load_config, so a dashboard change beats both the file and the
environment, and deleting the overlay reverts everything. Written atomically via
a temp file so a crash cannot truncate it.
Every field applies live. A settings page that needs a restart is one people
stop trusting, so site geometry is re-read per sample, the compensator flag is
set on the live object, and the display picks up its rate the next frame.
Two deliberate frictions. Selecting a surroundings pill only stages it: nothing
is recorded until you press the button, because that writes a discontinuity
marker and queues a retrain. And changing altitude or the psychrometric flag
says outright that the stored history is now inconsistent and offers the
re-derive, rather than leaving a silent mismatch.
Verified in a browser: pills stage and apply, the toggle round-trips, re-derive
ran 6201 rows in 0.29 s from the button, all six tabs report zero scrollbars and
zero clipping, zero console errors.
recompute replayed the Kalman over stored rows at their own spacing while q
stays tuned for the live 2 s cadence. Q scales with dt^3, so at the 30 s
persist interval the process noise was 3375x too large and the filter tracked
noise instead of smoothing: it wrote indoor temperature rates of +/-20 C/h into
the history. This is the exact trap DESIGN.md section 2 documents for
simulate.py, which does scale q, and I walked into it anyway. Now rescaled per
step, because tiering means the stored cadence is not constant. Mean |rate| on
the real board dropped to 2.73 C/h; what remains above 10 is the filter's
warm-up transient in the first four samples, which is honest.
Readout scene puts the actual numbers between the animations: temperature,
humidity, sea-level pressure and the signed three hour forecast, each in its
channel colour, scrolling. Text is drawn whole-pixel on purpose. Everything
else here is sub-pixel and that is what makes it look good, but splitting a
3 px glyph across two columns halves its peak and smears it illegible. Crisp
beats smooth when the thing has to be read.
site.environment and site.enclosure record where the sensor lives and what has
changed around it, with POST /api/environment to change them at runtime. This
is not cosmetic: closing a door changes how strongly the sensor couples to
outside, which is a regime change in the process the heads are fitting, and at
lambda 0.9985 they carry about 55 hours of memory. Left alone they keep
predicting the old room for two days. Page-Hinkley would notice eventually but
needs matured forecasts to do it, which at the long horizons is the same two
days. So the endpoint marks a discontinuity and queues a retrain.
Two bugs, both found by checking the real board rather than the test harness.
The glyphs never showed. _pick_glyph gated each one behind narrow conditions
and returned None otherwise, so on the Pi's actual state (27.3 C, rain
probability 0.024, condition settled, solar elevation -20.3) nothing qualified
and the panel silently fell back to the ambient scenes every time. A forecast
symbol is the default, not an exception, so it now always returns one of the
three: cold wins, then wet, then fair.
Day and night were inverted. night = _smoothstep(2.0, -8.0, elev) passes a
descending range, and _smoothstep treated edge1 <= edge0 as a degenerate step
returning the opposite of the intent, so the panel drew a moon at midday and a
sun at midnight. Caught by rendering it and looking, not by reading it.
_smoothstep now handles descending ranges, and only the degenerate equal-edge
case takes the step branch.
The fair-weather glyph also needed to survive after dark or it vanishes for
half of every day, which is how it went missing in the first place. Same
geometry, cool palette, rays drawn in to a halo.
Three references were requested as 8x8 animations. Copying their frames does
not work and I measured it rather than asserting it: at 8x8 the sun is a
2025:1 area reduction and its rays vanish, the umbrella loses canopy and
handle, and the snowflake averages into the background. Downsampled they move
0.0037, 0.0175 and 0.0027 per frame against 0.0177 for the aurora already on
the panel, so frame-copying would have been a downgrade. The sun source is
only 3 frames and the umbrella 4. These are hand-drawn at 8x8 instead, taking
the palette and subject from the references, which also keeps three artists'
frames out of an Apache-2.0 repo.
Transitions are now the data. _pick_glyph reads rain probability, Kalman
temperature, solar elevation and cloud index and selects sun, umbrella or
snowflake; a change preempts whatever is on screen and crossfades immediately,
so the panel dissolves because the weather moved, not because a timer expired.
Between changes the informational scenes still rotate. Verified switching live:
sunny -> sun, rain forecast -> umbrella, temperature to 0.4 C -> snowflake,
clearing -> sun.
Getting them to read took two failed passes, both recorded in comments. First
version blew the canopy to white and fused the snowflake into a blob, because
seventeen arc samples over ten pixels overlap 1.7 deep. Dropping alpha made
them muddy instead. The fix was sampling density, not brightness.
Profiled again since these share the board: the glyphs first cost 11 to 13% of
a core. Making plot() write scalar components rather than a 3-vector slice, and
expressing the sun's eight-fold rays as one angular field instead of 56 splats,
took the sun from 275 to 43 us and the worst scene overall from 13.2% to 8.0%.
Equality on the row count raced the live sample loop under TestClient, which
legitimately inserts rows mid-test. Now asserts no rows are lost, which is the
property that matters. Run three times to confirm it is stable.
24 fps costs about 11% of one core on a Zero 2 W, measured on the board. That
is a reasonable default for something you look at, but it is a decorative load
sharing a 512 MB machine with the forecaster, so it should be the owner's
choice. server.led_fps is clamped to 4..30.
Particle fall speed now divides by the configured rate rather than the module
constant, so rain falls at the same real-world speed whatever the frame rate,
instead of slowing down when you turn the frame rate down.
The old display drew static glyphs, held them, and cut to the next, which looks
like a microwave clock. This is a continuous 24 fps renderer.
Three things do most of the work. Gamma, because LED duty cycle is linear and
perception is not, so ungamma'd gradients band and dim colours vanish.
Sub-pixel rendering, so a dot at x=3.4 lights two pixels and motion glides
rather than steps. Crossfades, so scenes dissolve over 1.3 s and nothing ever
cuts.
Added temporal dithering after finding the framebuffer is RGB565: 32 levels of
red and blue, which after gamma leaves very few steps exactly where an aurora
and a star field live. A Bayer pattern rotated each frame alternates between
adjacent hardware levels, measured landing on 1.75, 4.31 and 8.06 where the
panel can only display integers. The panel is also dimmed by measured lux on a
log curve, so at night it is a glow rather than a searchlight.
Five scenes, each a reading rather than decoration. Aurora: hue is temperature,
curtain drift direction is pressure tendency, contrast is humidity. Solar sky:
sun at its true azimuth and elevation over a dawn/day/dusk gradient, becoming a
twinkling star field and moon after sunset. Precipitation: drop count from rain
probability, snow below 1.5 C with sideways sway, lightning with exponential
afterglow when stormy. Forecast ribbon: six horizons scrolling, height is the
predicted delta, pale caps are the conformal half-width so uncertainty is
visible. Barometer: a breathing ring whose period is the tendency.
Profiled because it shares a 512 MB board with the station. The first ribbon
cost 330 us a frame, about 16% of a core scaled to a Zero 2 W; vectorising it
into fields rather than 84 sub-pixel splats brought the worst scene to 7.6%.
Verified 23.6 fps sustained with zero malformed frames.
The tab bar was its own grid row costing about 70 px of vertical space on
every tab to hold five buttons, which is a poor trade on a layout that refuses
to scroll. The tablist now sits in the header between the title and the status
block, so the shell drops from three rows to two.
That space goes to the pressure tendency chart, which was 56 px and had no
room for Chart.js to lay out its tick row: measured, the caption sat 2 px below
its own card. The chart is now 80 px with explicit layout padding, and the
column has 11 px of slack instead of overflowing.
Also made the tendency x-axis adaptive. Hour-only labels collapsed to three
identical ticks on a short window, which is what a young station always has.
Below a six hour span the label now carries minutes.
Verified at 1600x900: Live, History, Models and Nerd all zero scrollbars, zero
clipping, zero console errors. Tabs stay reachable and the header stays one row
at 1280 and 1024 wide. Methods improved incidentally, from 68 px of overflow
to 4.
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.
Shutdown hang, the cause of every restart taking systemd's full 90 s timeout
and ending in SIGKILL: /api/stream looped forever with no disconnect or
shutdown check, so an open dashboard was an in-flight request that never
completed and uvicorn's graceful shutdown waited on it. Reproduced cleanly:
with no stream client the process stopped in 2 s, with one open client it was
still running after 15 s. Fixed by bounding timeout_graceful_shutdown, and by
having the generator exit on client disconnect and on a shutdown event. Now
7 s with a client attached.
Equations rendered as blank boxes whenever the KaTeX CDN was unreachable,
which is a real case for a Pi on wifi. The elements carried the TeX only in a
data attribute, so with no KaTeX there was nothing to display. The source is
now written into the element as text first and KaTeX replaces it, so it
degrades to readable TeX. Verified by aborting the katex request. A window
load handler re-runs typesetting for a slow CDN. The code comment claiming
this fallback already existed was wrong and is corrected.
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.
The Live tab now carries the observed-and-forecast chart (a superset of the
old rolling window, which plotted the same observed series without the
prediction) and the precipitation panel. Estimator internals moves to Models,
beside the calibration input that sets the coefficient it reports. The seven
day outlook moves to History. Verified in Chromium at 1600x900: all four tabs
scrollHeight 900 against innerHeight 900, zero clipped elements outside
internal scrollers, zero console errors.
CONTRIBUTING.md now states plainly that this is a solo project: bug reports
welcome, pull requests unlikely to be merged, fork it instead. No other
developer was ever named anywhere in the repository.