Commit Graph
8 Commits
Author SHA1 Message Date
kemal 3dd45f7ebf Joystick labelling, a clock guard, and throttle logging
Three things the hardware offers that the code ignored.

The joystick has never had a line of code. Left records a dry label, right a
wet one, middle cycles the LED scene, and a full-panel flash acknowledges the
press because a headless box gives no other sign and a button you cannot tell
worked gets pressed twice. Precipitation is the weakest head in the bank and
strong labels are its binding constraint: this station has 80 of them against
thousands of proxy ones, entirely because the only label control lives in a web
page, and a web page is not where anyone is standing when it starts raining.

The board has no RTC, so a power cut without a network gives a clock somewhere
in 1970 on the next boot. Solar elevation, the diurnal harmonics and a sample's
position on the 5-minute grid then all lie with complete confidence, and unlike
a gap in the record the damage cannot be identified afterwards. train() now
refuses a clock below 2025 or one that has stepped behind the newest stored
row, and logs the refusal rather than training on fiction.

Undervoltage and thermal capping both shift the SoC temperature, which is the
regressor in the self-heating compensation, so a weak power supply presents as
an unexplained temperature bias rather than as anything resembling a power
problem. get_throttled is now sampled hourly and logged when set.

Measured and deliberately not done: colour features. r, g and b are logged and
74% of rows carry usable colour, but adding blue/red, green/red and saturation
made MAE 1.50% worse and helped in only 13 of 72 cases. Three more regressors
on a 33-feature model whose longest horizon trains on 13 independent pairs is
straightforwardly overfitting. That also prompted a sweep of the RLS prior and
forgetting factor in both directions; delta = 100 with lambda = 0.9985 is a
local optimum on both axes, so neither moved.
2026-08-19 19:45:47 +01:00
kemal 50b29f8077 Readout scene, environment regime tracking, and a Kalman cadence bug in recompute
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.
2026-08-15 23:38:27 +01:00
kemal 23cd76c96e Fix: the weather glyphs never appeared, and day/night was inverted
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.
2026-08-15 23:26:46 +01:00
kemal 3c99cd53e3 Weather glyphs: sun, umbrella, snowflake, switched by the data
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%.
2026-08-15 23:15:50 +01:00
kemal e05667d75b Make the matrix frame rate configurable
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.
2026-08-15 23:04:05 +01:00
kemal 30944c2978 Rewrite the LED matrix as an animated instrument
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.
2026-08-15 23:00:35 +01:00
kemal dbbea1a95f chore: enforce ruff config and fix lint findings 2026-08-15 20:45:02 +01:00
kemal 06ce53bc44 Initial release: Ashvale Station 1.0.0 2026-08-15 20:43:51 +01:00