33 Commits
Author SHA1 Message Date
kemal 18b9a29ffa Earn the blend weights and the intervals from forecasts, not from refits
fit() called learn(), and learn() updated three things: the RLS, the conformal
calibrator and the Hedge weights. Only the first belongs to a refit. The comment
above that loop already said so, and was wrong about what the code did.

Measured on 8.2 days of the live station, the 15 minute head had taken 977,078
Hedge updates from 758 distinct supervised pairs, a factor of 1,289, and the
1 day head 296,715 from 12 pairs, a factor of 24,726. A refit is not an outcome.
It is the same week of weather being read again, once every seven minutes.

Hedge is multiplicative, so an edge far too small to be real compounds to
certainty: twelve of twelve temperature and humidity heads had collapsed onto
climatology at a weight of 0.991 or above, while their own member_mae said the
members were within a few percent of each other. The ACI integrator moves by
gamma per observation, so it had likewise pinned against its clips, leaving the
6 hour temperature band (1.571 C) narrower than the 3 hour one (2.258 C), and
pressure at 1 day covering 3 of 7 with alpha jammed at the 0.005 floor.

Three changes, because fixing only the first would freeze the weights forever:

  - fit() calls refit_step(), which touches the regression and nothing else.
    The climatology and setpoint members were evaluated in that loop purely to
    feed the Hedge update, so fit() no longer needs a climatology or a
    setpoint_fn at all.
  - verify() feeds observe_outcome() with the member predictions the forecast
    was actually blended from. These are now written to the forecasts table at
    issue time, because the learned member cannot be recovered afterwards: the
    RLS has moved on.
  - a matured forecast teaches exactly once. It stays readable for an hour so
    the scorecard can aggregate a rolling window, which meant verify() was
    feeding the calibrator the same outcome about twelve times.

The Hedge weights additionally decline an outcome that overlaps the last one
they took, which is the stride rule from fit() applied on the scoring side.
Forecasts are issued every retrain tick, so at the 1 day horizon roughly two
hundred a day resolve against very nearly the same outcome. The conformal window
absorbs that, a quantile over duplicated scores being merely overconfident about
its sample size, but exponentiated gradient cannot.

Walk-forward over the full 8.2 day record, against the current code:

  mean MAE                0.856   (0.938 over the second half alone)
  heads improved          17/18
  beats persistence       7/18 -> 12/18
  coverage |dev from .90| 0.188 -> 0.060,  second half 0.112 -> 0.041

Decimating the conformal feed as well was measured and rejected. It reads better
(coverage |dev| 0.023) and is not: three heads fall below MIN_SCORES, drop to
1.645*sigma, and "cover" with a median band of +/- 107% relative humidity. At
h/4 and h/8 it never starves and lands within noise of not decimating at all, so
the simpler rule wins. Honest regressions: humidity at 12 hours is 19% worse,
and pressure past 6 hours is still under-covered, because at 1 day the point
forecast is genuinely poor and ACI can only widen so far.

A state file from before this change has its weights, member_mae, n_scored and
alpha reset on load. They are products of the replay, they are not evidence, and
they do not decay on their own: Hedge needs about twenty independent outcomes to
climb off its 1e-4 floor and the 1 d head sees one a day. The conformal scores
are kept, being residuals of roughly the right size, and the window refreshes
within about two days.

Schema migration verified against a pristine copy of the live database: 1,437
forecast rows preserved, five columns added, idempotent across restarts.
2026-08-24 00:38:18 +01:00
kemal 9a1033973d Treat the board being moved as a regime change, from the fused IMU attitude
The accelerometer and gyroscope were logged and never used. They measure
nothing about weather, but they do measure the one thing about this station
that nothing else can see: whether the sensor is still where it was.

Measured over four and a half days on the real station, four genuine
movements each stepped the temperature by a median of 1.02 C, against an
ordinary fifteen minute change of 0.107 C with a 95th percentile of 0.841.
A move therefore lands past the 95th percentile of normal variation. The
heads carry about 55 hours of memory, so an undeclared move contaminates two
days of training with a discontinuity they will try to fit rather than ignore.
This now gets the same treatment set_environment gives a window being opened,
because it is the same event: the coupling between the sensor and what it is
measuring changed, and nothing in the data says so.

Three choices in here were made by measurement, and the obvious one was wrong.

Raw accelerometer looks like the natural input and is not. Over the same
record a gravity-vector detector fires 112 times against this one's 4, because
RTIMULib's gyro fusion removes exactly the desk vibration a bare accelerometer
picks up. The fused pitch and roll have a p99 sample-to-sample noise of 0.0001
degrees, so a one degree trigger carries four decades of headroom.

Yaw and compass are excluded. They are the only attitude outputs that depend
on the magnetometer, and indoors the magnetometer is measuring the building.

RTIMULib restarts its fusion from a default attitude when SenseHat is
reconstructed, which put an 18 degree step in the record on every one of this
station's seven service restarts. Without a settle window every deploy would
queue a retrain. 300 seconds rather than 180: one artifact appeared three
minutes after a restart, still converging. Replayed against the full record
the detector finds 4 genuine movements and leaks 0 artifacts.
2026-08-20 07:23:08 +01:00
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 f1647788c8 Learner hygiene: Hedge loss scale, look-ahead residual, conformal minimum
Three changes to ForecastHead and the conformal calibrator, each measured
walk-forward on real data over seven train splits.

Hedge normalised its losses by the current sample's worst loss, so on a quiet
step where every member agreed to within 0.01 C whichever happened to be worst
still took the full exp(-eta) penalty, exactly as if it had been wrong by 5 C.
The regret bound assumes a fixed loss range, not a per-sample one, and the
symptom was weights that jumped around with no relation to horizon. Normalising
by the running member MAE instead is worth 1.81% of MAE, better on 106 of 126
heads, coverage unchanged.

The residual handed to the conformal calibrator was computed after this
sample's loss had already moved the weights, so it was better than anything the
forecaster could produce and the intervals were calibrated about 2% too narrow.
Coverage survived only because ACI notices the extra misses and reopens the
band, a correction that should never have been needed. Scoring the blend with
the pre-update weights leaves MAE untouched, as it must, and widens the
intervals 2% to the honest width.

The conformal quantile refused to produce a band below 20 scores. That number
is arbitrary: the (1-alpha) empirical quantile is the ceil((k+1)(1-alpha))-th
of k order statistics, so alpha = 0.10 needs 9. The 20 became actively harmful
in the previous commit but one, because striding pairs by the horizon leaves a
long-horizon head about 13 scores per refit. Twelve of eighteen heads therefore
fell through to 1.645*sigma with sigma from an unconstrained x'Px, giving bands
of +/- 45 C and +/- 115% relative humidity on a young station. Those cover, by
being absurd, which is why the backtest never flagged them: a long walk-forward
passes 20 scores early and never looks back. After the change all eighteen
heads have a band from the first fit, +/- 3.1 C and +/- 7.7% in the same place.

Also measured and deliberately not done: adding the Kalman level variance to
the predictive spread. It moves sigma by 0.06% at the shortest horizon and
0.00% everywhere else, so the plumbing to carry it through three files buys
nothing.
2026-08-19 19:38:34 +01:00
kemal 41a46ae14a Take model tuning from config too, not from the saved state
The companion to the Kalman fix. RecursiveLeastSquares.from_dict and
AdaptiveConformal.from_dict restore lambda, delta, alpha, gamma and the
conformal window alongside their data, and load_dict replaces the
config-built heads with those, so every one of those knobs was immutable on
any station that already had state. Editing config.yaml and restarting looks
exactly like a change with no effect, which is the failure mode that cost real
time on the Kalman side of this before it was found.

Only the estimate is state now. Weights, covariances and conformal scores are
restored; tuning is re-applied from config on every load. The conformal deques
are rebuilt when the configured window changes, preserving their contents.

load_dict also skips heads for a target or horizon this build no longer has,
rather than resurrecting them from a stale file.

Found while implementing a damped-trend ensemble member, which was then
abandoned: see the following note.
2026-08-19 19:31:03 +01:00
kemal 3f41881c64 Take Kalman tuning from config, not from the saved state
q and r were written into station_state.json and restored over the configured
values, so tuning was effectively immutable in the field. This was found the
expensive way: the retune in the previous commit was deployed, the service
restarted cleanly, and the filters carried on with q = 2e-6 because that is
what the state file said. Measured median rate afterwards was 14.4 C/h against
12.4 before, which is to say nothing happened.

Only the estimate is state. x, P and initialised are restored; q and r now
come from config every time. P may be momentarily inconsistent with a changed
q, which costs a few hundred samples of reconvergence and is far cheaper than
a configuration change that appears to work and does not.

load_dict also now skips filters this build no longer has, rather than
resurrecting them from an older state file.
2026-08-19 19:22:49 +01:00
kemal b9a468542d Match the simulator's barometer noise to a real LPS25HB
The generator added 0.05 hPa of measurement noise where a real board shows
0.0224 hPa as the sd of the change between 30 s samples. Twice the noise
flatters any smoother tested against it and understates the skill available
at short lead, which is where the pressure heads were already weakest.

Correcting my own earlier claim: I reported the simulator's pressure as 60x
too noisy. That figure came from comparing the old local database, whose rows
are hourly-tier aggregates spanning 251 days, against 30 s rows from the
station. Generated like for like at matched cadence the gap is 1.9x, not 60x,
and the simulator was never the blocker on pressure work that I described.

A gap remains after this change: press_slp still steps 0.0415 hPa per 30 s
against the station's 0.0224. That residue is the synoptic OU process moving
faster than Cambridge did over these four days, which is a weather-realism
question rather than a sensor one. Four days of one room is not enough to
retune a synoptic model against, so it is left alone and recorded here.
2026-08-19 19:16:57 +01:00
kemal 40f934901d Retune the Kalman process noise, and fuse the two thermometers
Two changes to the same signal path, one large and one small.

The large one: all three filters were tuned to track one to three decades
faster than their signals move. In a still room the temperature filter
reported a median rate of 12.4 C/h while the air moved 0.4 C/h, and it
overshot a real -36 C/h event by 77%. Sweeping q against the RMSE of the
reported rate versus the true rate, using noise measured on the board
(temperature 0.088 C, pressure 0.022 hPa, humidity 0.40 %):

   temperature   6.45 -> 0.37 C/h RMSE     2e-6 -> 1e-9
   pressure      2.15 -> 0.24 hPa/h RMSE   1e-5 -> 1e-8
   humidity     27.94 -> 3.55 %/h RMSE     5e-5 -> 2e-8

Tracking does not suffer. Lag against a genuine 2 C/h ramp is 0.003 C at both
the old and new values, and the peak response to a five-minute event moves
closer to the truth rather than further from it, because the overshoot goes
away. What is given up is response to sub-minute transients, which for a
station forecasting fifteen minutes to a day ahead is noise to reject.

This matters most for pressure, whose tendency drives the precipitation
forecast, and which was the worst tuned of the three.

config.yaml shadowed kalman_q_temp, so editing the dataclass alone changed
nothing. All six values are now listed there with that hazard spelled out,
because a silent shadow cost real time here.

The small one: temp_raw was the plain average of two thermometers whose
white-noise sds differ by 7x (LPS25HB 0.007 C, HTS221 0.049 C), which throws
the quiet one away. Inverse-variance weighting cuts the raw noise 3.5x.

The trap is that the chips do not agree. They sit at different distances from
the SoC and stand about 1.3 C apart, so weighting by variance alone drags
temp_raw 0.48 C onto the LPS25HB, which after the 1.55x gain of the inverse
compensator is 0.75 C of silent bias on every reading, since k was fitted
against the mean of the two. The gradient is therefore tracked and removed
before weighting and only the deviations are fused: measured mean shift
0.0001 C, noise still 3.5x lower. The tracked gradient is retained because it
is a second observation of self-heating.

Also corrected: the earlier claim that the HTS221 was the quieter channel was
wrong, taken from twelve samples at a cadence slow enough that real drift
dominated. At 0.5 s over 120 samples the LPS25HB is quieter by 7x and takes
98% of the weight.
2026-08-19 19:14:46 +01:00
kemal e3176e29c9 Stride training pairs by the horizon instead of by the grid row
fit() trained every head on every consecutive grid row. At the 1 d horizon on
a 5-minute grid adjacent pairs share 287 of their 288 samples, so the filter
was handed the same outcome 288 times and RLS with forgetting read each one as
fresh evidence:

   horizon  steps  overlap  independent events in a 400-score window
       15m      3   66.7%    133.3
        1h     12   91.7%     33.3
        3h     36   97.2%     11.1
        6h     72   98.6%      5.6
       12h    144   99.3%      2.8
        1d    288   99.7%      1.4

The day-ahead head was therefore fitted on roughly two independent outcomes by
a filter carrying 667 updates of memory, and its interval was a 90th percentile
of a sample of size one.

This is not a compute shortcut that trades accuracy for speed. Measured
walk-forward on four days of real station data and averaged over five train
splits, striding improves every horizon past fifteen minutes:

   15m  +0.6%   1h -12.2%   3h -31.7%   6h -33.3%   12h -39.5%   1d -14.4%

with coverage unchanged at 87 to 92%, and the fit 11.6x faster. The redundancy
was not merely wasted work, it was collapsing P onto the one direction the
repeated sample excited.

The stride phase rotates each refit and is persisted, so a long-lived station
eventually trains on every offset rather than seeing one sample in 288 forever,
and a restart does not pin it to phase 0. A floor relaxes the stride when a
long horizon on a short record would otherwise yield one or two pairs; 12 was
chosen by sweeping it across five splits rather than picked.

Single-split runs showed 10 to 17% regressions at the 1 d horizon that moved
with the parameter. Averaging over five splits removed them, which is the
expected result for a head fitted and scored on under two independent
outcomes. That horizon cannot be evaluated on a four-day record and was not
tuned against.

Incidentally, this also retires the parallel-retrain idea: the Pi's 42 s
retrain becomes a few seconds, and multiprocessing inside a 280 MB cap buys
nothing for a job that short.
2026-08-19 18:50:07 +01:00
kemal bda42a0468 Log both Sense HAT thermometers, and migrate schemas that predate them
The board carries two independent thermometers and the code averaged them
into temp_raw without ever recording either. Measured over 12 samples on a
real station: HTS221 30.973 C at sd 0.060, LPS25HB 29.810 C at sd 0.443, a
standing gradient of 1.163 C with the SoC at 44.55 C.

Two things follow from that and neither is possible without the raw channels.
A plain average of a quiet sensor and one seven times noisier lands at sd
0.223 where inverse-variance weighting reaches 0.060, and the gradient between
two chips at different distances from the SoC is a second observation of
self-heating that could identify the compensator's k with no reference
thermometer. Both need history, and history cannot be backfilled, so the
columns land on their own ahead of the work that consumes them.

CREATE TABLE IF NOT EXISTS is a no-op against a table that already exists, so
adding to COLUMNS would have reached a fresh install and silently missed every
station already running, then surfaced as an OperationalError inside
insert_telemetry. That sits on the sample loop, so it takes a station down
rather than leaving a gap. Store now reconciles the table against COLUMNS on
open, which makes every future column addition safe rather than just this one.

The simulator gains the same two channels, with couplings solved so their
forward models average to exactly the k = 0.55 the compensator is tuned
against. Aggregate behaviour is unchanged; only the per-channel detail is new.
Simulated temp_raw noise does rise from 0.05 to 0.223, which is not a
regression but the end of an over-optimistic figure: it was modelling the
quiet sensor and calling it the average.
2026-08-19 18:41:46 +01:00
kemal 485affe956 Fix the runaway forecasts: refits accumulated, and annual terms fitted too early
Reported from a real station after 1.5 days: a six hour temperature forecast of
53 C in a 24 C room, and 9 C at one day, both carrying a plus or minus of 0.43.
Confidently wrong is the one failure this project is supposed to refuse.

Root cause. fit() replayed history into the live RLS on every retrain tick and
never reset, so 453 grid rows had produced 64,676 updates in a day and a half.
RLS with forgetting reads every update as fresh evidence, so the model believed
it had a hundred times the data it had: P collapsed, in-sample error looked
excellent, and the weights drifted without bound in directions the data never
excited. Measured: cond(P) 3.1e9 and ||theta|| 1680 against a median |theta| of
1.67. A refit now starts from the prior, which makes retraining idempotent.
Across 25 refits on the real data ||theta|| holds at 11.35, drifting 0.03, where
before it grew without limit.

The two largest weights were sin_doy and cos_doy at +1174 and +1191. Annual
harmonics were in the design matrix from the first sample, where they are
near-constant, near-collinear with each other and with the bias, and a
rank-deficient regressor is what RLS answers with enormous cancelling weights.
They are now held at zero until the record spans the same 120 days the
climatology fit already requires, because a day and a half of data says nothing
whatsoever about the season.

Also raised the standardiser's variance floor from 1e-8, which only caught a
bit-exactly constant column, to 1e-3. A feature that merely barely moves was
being divided by its own noise.

The conformal calibrators and Hedge weights are deliberately not reset by a
refit: those are earned from scored forecasts, not from this regression.

Backtest unchanged within noise, coverage still 89 to 91 across all 18 heads.
Four regression tests added, including that refitting the same history twice
must give the same model.
2026-08-17 08:15:45 +01:00
kemal bd233d7b27 Fix image build: register static qemu with the F flag
Second failure was debootstrap dying with 'E: Unable to execute target
architecture'. qemu-user-binfmt registers a dynamically linked emulator, which
cannot run once debootstrap chroots into a rootfs that has no loader for it.
The fix is the static binary registered with the F flag, which preloads the
interpreter so it survives the chroot.

Registration runs after apt, because installing binfmt-support re-registers the
dynamic handlers over the top, and the workflow now asserts the F flag is
present rather than discovering its absence an hour into a build.
2026-08-16 18:37:51 +01:00
kemal 4eefd8816b Fix image build: pi-gen wants qemu-user-binfmt, not qemu-user-static
The first CI build failed in under a second with 'Required dependencies not
installed: qemu-user-binfmt'. pi-gen checks for the binfmt handler rather than
the emulator binary, and I had guessed the package name. The list now comes
from pi-gen's own depends file at the pinned commit, and the workflow asserts
the aarch64 handler is registered before spending an hour discovering it is
not.
2026-08-16 18:34:38 +01:00
kemal 34f62222e1 Prebuilt Pi image and a one-line installer
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.
2026-08-16 18:30:59 +01:00
kemal 372ea4f067 Self-correcting layout density, and no-cache on the dashboard
I claimed 20/20 clean and was over-stating it: that was a fixed list of
viewports, and a fixed list cannot cover a user's zoom level, a larger default
font, or simply more accumulated history than a panel was designed around. All
three change how much space a card needs. I could not reproduce the reported
clipping at any width or height I tried, so rather than keep guessing at
viewports I made the failure impossible by construction.

The page now measures itself after every layout change and on resize: if any
card's content escapes it, density escalates to compact, and if that still is
not enough the height lock is released and the page scrolls. Content is never
silently clipped, whatever the viewport. Scrolling beats hiding.

The escalation is exercised, not dead code: across 34 viewports it used compact
on 3 and scroll on 2, and at 125% zoom it used both.

Also added Cache-Control: no-cache to the dashboard route. The page is generated
from live config and changes every deploy, but carried no cache headers, so a
browser could hold an old copy indefinitely and show layout bugs that were
already fixed. That is a plausible reason a fix can look like it did not land.

Verified: 34 viewports from 1920x1080 to 320x568, six tabs each, 204 tab
renders, zero card overflow and zero horizontal overflow; plus five zoom levels
from 100% to 200%.
2026-08-16 18:09:11 +01:00
kemal ca1d6ba528 Fix cards whose content overflowed them, and the audit that missed it
Reported on a 13 inch screen: the Detectors column and the Learner bank spilled
past their cards. My earlier sweep called those tabs clean because it only
checked content escaping the viewport, never content escaping its own card, and
the cards are overflow-hidden so it was invisible rather than obviously broken.

The worst was not either of those. The Station log was discarding up to 299 px
of entries behind overflow-hidden with no indication, at every viewport. It now
binary-searches the number of rows that fit and says how many are not shown.
The count is searched rather than divided out of an assumed row height, because
a long detail line wraps to two lines and no constant knows that. It runs from
a ResizeObserver rather than once: measuring at call time reads a stale
clientHeight, since the pane has only just become visible and Chart.js resizes
its siblings 40 ms later, which fitted 13 rows into a box that holds 10.

Also: denser learner and attribution rows on short screens, three precipitation
coefficients instead of four, and a lower floor on the tendency chart.

Two mistakes of mine on the way, both worth recording. I declared a second
const rows inside loadModels, which is a parse error that killed the entire
dashboard script; the audit reported it as a flood of sparkline layout faults
because it was not listening for page errors, and it now fails loudly on them.
And the min-height override did nothing at first because the Tailwind CDN
injects its sheet after this style block, so min-h-[42px] won at equal
specificity.

Verified: 20 theme x viewport combinations, 120 tab renders, from 1920x1080 to
375x667 in both themes. Zero card overflow, zero horizontal overflow, zero
console errors.
2026-08-16 17:53:59 +01:00
kemal aea740e85b Light and dark themes with a three-way selector
Auto (follow the system), light, or dark. Auto is a real preference rather than
the absence of one, so it tracks prefers-color-scheme live rather than only at
load. Stored in localStorage, not the station's settings overlay: a theme
belongs to the screen you are reading on, not to the weather station. Control in
the header for one click and in Settings for the explicit three-way choice.

The markup is dark-first Tailwind utilities. Adding a dark: variant to several
hundred class attributes would have been a large diff with a large blast radius,
so light is an overlay that remaps the slate scale and the accent hues under
[data-theme=light]. The dark path is byte-identical: nothing is re-specified
unless the attribute is set.

Chart.js keeps its own copy of every colour and cannot follow a CSS class
change, so the charts read the same tokens from the computed style and are
re-rendered on switch.

Three things the screenshots and measurements caught that reading would not:

- Accent text is the 200-400 shades, picked to glow on black. On white they
  wash out. Remapped to the 600-700 shade of the same hue so the colour coding
  survives.
- My first remap listed exact class names and silently missed every opacity
  variant, because text-amber-200/60 is a different class. Measured at 1.02:1.
  Now matched on the hue prefix.
- The value-changed flash is a pale indigo that dropped a headline reading to
  2.06:1 on white for half a second, which is precisely when you are looking at
  it. Now theme-aware.

Contrast measured with alpha properly composited to the page ground: light 212
nodes below WCAG AA against dark's 208, so light is no worse than the dark
theme it mirrors. The remainder is the design's deliberately quiet tertiary
text, present in both. Layout re-verified: six tabs across three viewports in
both themes, twelve combinations, zero clipping and zero console errors.
2026-08-16 17:23:01 +01:00
kemal 5fe309a8a6 Responsive pass: fix the squeezed chart, mobile navigation, and overflow
Three real faults, all measured rather than guessed.

The forecast chart was unreadable on a 13 inch laptop. A 1280x800 screen has
desktop width but 280 fewer vertical pixels, and the fixed rows (stat cards,
outlook, diagnostics) took that entirely out of the one row meant to flex.
Measured: 404 px tall at 1920x1080, 124 px at 1280x800, 92 px at 1024x768.
Height-aware media queries now compact the furniture instead, and the chart is
201 px at 1280x800.

Mobile navigation did not exist. At 375 px all six tabs were clipped with no
affordance they were there, so five of six sections were unreachable. Below
1024 px the tab row is now a native select on its own full-width line, showing
the current section rather than a bare chevron.

Horizontal overflow on phones: the shell measured 452 px inside a 375 px
viewport. Flex and grid children default to min-width:auto, so a long subtitle
refused to shrink and dragged the layout wider than the screen.

Also restructured the conditions column. It was a fixed stack that simply
overflowed its own card on short screens, and shrinking each piece by media
query chased the symptom; it is now a flex column with exactly one flexible
child, so the tendency chart absorbs the slack and the column fits at any
height. And the no-scroll contract gained a height floor: below 700 px tall the
page scrolls, because at 1024x600 the chart had collapsed to 1 px and a
scrollbar is the better answer.

Verified across ten viewports from 1920x1080 to 375x667, six tabs each: 60
combinations, zero clipping, zero overflow, zero console errors.
2026-08-16 17:09:16 +01:00
kemal 4cca40388f Heated environment: a thermostat member in the forecast ensemble
A room held at a setpoint is a different process from one left to drift. It is
a closed loop, and persistence, the baseline everything here is scored against,
is the wrong statement about it: the truth is not that it stays where it is, it
is that it returns to the setpoint.

So site.heating adds a fourth ensemble member, first order because that is what
a controlled system is:

    dT_set(h) = (T_set - T_now) * (1 - exp(-h / tau))

Humidity follows and is the part that is easy to get wrong. Heating adds no
moisture, so vapour pressure is conserved and not relative humidity:

    RH(h) = RH_now * es(T_now) / es(T_now + dT_set(h))

Warm the air and RH falls although nothing was dried, which is why a heated
house in winter is dry. The test asserts the dew point is unchanged to 1e-6.
Pressure gets zero: a thermostat cannot move the synoptic field.

Offered, not imposed. Hedge scores this member on realised error like any
other, so a wrong tau or a stale setpoint costs accuracy and gets down-weighted
rather than quietly biasing every forecast. Verified: on history with no
heating the ensemble assigned it weight 0.000. With heating off it returns zero
and is identical to persistence.

Going from three members to four means old saved heads must migrate.
from_dict reinitialises weights and member_mae. I missed member_mae first time
and it did not fail on load, it failed later inside learn() on a broadcast
error, which is a much worse place to find out; the migration test now covers
both and calls learn() to prove it.

Settings tab gains the toggle, setpoint and time constant. Turning heating on
or off is treated as a regime change like a door: discontinuity marker plus a
queued retrain.
2026-08-16 16:39:54 +01:00
kemal 498b3f6e38 Settings tab
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.
2026-08-15 23:46:57 +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 db0f877052 Fix flaky row-count assertion in the recompute tests
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.
2026-08-15 23:05:05 +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 e767ca2105 Move tabs into the header, fix Conditions ahead overflow
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.
2026-08-15 22:50:20 +01:00
kemal 98210bff8f 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.
2026-08-15 22:39:37 +01:00
kemal 9d2e884b74 Fix shutdown hang and blank equations when KaTeX is unavailable
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.
2026-08-15 22:19:42 +01:00
kemal bb9f0a588f Stats for Nerds tab, KaTeX methods, weather icons, outlook to top
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.
2026-08-15 22:00:27 +01:00
kemal e27a4b41c8 Outlook to Live, humidity calibration, Models tab rebuilt without scrollers
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.
2026-08-15 21:36:10 +01:00
kemal 49c0aee2e1 Merge Forecast into Live, four tabs; state solo-project policy
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.
2026-08-15 21:17:50 +01:00
kemal dbbea1a95f chore: enforce ruff config and fix lint findings 2026-08-15 20:45:02 +01:00
82 changed files with 6916 additions and 570 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ labels: forecasting
``` ```
``` ```
**Scorecard from the Models tab** (or `GET /api/scorecard`) **Scorecard from the Models and Calibration tab** (or `GET /api/scorecard`)
```json ```json
``` ```
+13 -1
View File
@@ -27,7 +27,12 @@ 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
run: |
pip install ruff
ruff check .
- name: Byte-compile every module - name: Byte-compile every module
run: python -m compileall -q ashvale scripts run.py run: python -m compileall -q ashvale scripts run.py
@@ -38,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.
+138
View File
@@ -0,0 +1,138 @@
name: Pi image
# Builds a ready-to-flash Raspberry Pi OS Lite image with Ashvale Station
# preinstalled, and attaches it to a GitHub Release.
#
# Built in CI rather than on a laptop on purpose. The artifact is something
# other people flash onto their own hardware, so it should be reproducible from
# a public log by anyone who wants to check what went into it, rather than
# appearing from a machine only I can see.
on:
workflow_dispatch:
inputs:
publish:
description: "Attach the image to a release"
type: boolean
default: false
push:
tags:
- "v*"
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 180
steps:
- name: Check out Ashvale
uses: actions/checkout@v4
with:
path: ashvale
# pi-gen needs about 10 GB and a stock runner does not have it spare.
- name: Reclaim disk space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
/usr/local/share/boost "$AGENT_TOOLSDIRECTORY"
df -h / | tail -1
- name: Install build dependencies
run: |
sudo apt-get update
# Taken from pi-gen's own `depends` file at the pinned commit rather
# than guessed. My first attempt installed qemu-user-static, and
# pi-gen wants qemu-user-binfmt: it checks for the binfmt handler, not
# the emulator binary, and failed in under a second.
sudo apt-get install -y --no-install-recommends \
quilt parted coreutils qemu-user-binfmt debootstrap zerofree zip \
dosfstools e2fsprogs libcap2-bin libarchive-tools grep rsync \
xz-utils curl xxd file git kmod bc gpg pigz arch-test
# qemu-user-static too: qemu-user-binfmt registers a dynamically linked
# emulator, which cannot run once debootstrap chroots into a rootfs
# that has no loader for it. That fails as "E: Unable to execute
# target architecture" several steps later.
sudo apt-get install -y --no-install-recommends qemu-user-static binfmt-support
# Registers the static handlers with the F flag, which preloads the
# emulator so it survives the chroot. Run after apt, because installing
# binfmt-support re-registers the dynamic handlers over the top.
- name: Register qemu binfmt handlers
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- name: Verify the aarch64 handler is usable in a chroot
run: |
F=/proc/sys/fs/binfmt_misc/qemu-aarch64
test -f "$F" || { echo "no aarch64 binfmt handler registered"; exit 1; }
cat "$F"
# The F flag is the whole point: without it the interpreter is resolved
# inside the chroot, where it does not exist.
grep -q 'flags:.*F' "$F" || { echo "handler lacks the F flag; chroot will fail"; exit 1; }
echo "aarch64 handler OK"
# Pinned to a commit, not a branch. An image other people flash should not
# change because an upstream branch moved between builds.
- name: Check out pi-gen
uses: actions/checkout@v4
with:
repository: RPi-Distro/pi-gen
ref: ca8aeed0ae300c2a89f55ce9617d5f96a27e99e5 # arm64 branch, pinned
path: pi-gen
fetch-depth: 1
- name: Assemble the custom stage
run: |
cp ashvale/deploy/pi-image/config pi-gen/config
cp -r ashvale/deploy/pi-image/stage-ashvale pi-gen/stage-ashvale
# Lite only: everything from stage3 up is the desktop.
touch pi-gen/stage3/SKIP pi-gen/stage4/SKIP pi-gen/stage5/SKIP
touch pi-gen/stage4/SKIP_IMAGES pi-gen/stage5/SKIP_IMAGES
# stage2 stops exporting so ours is the only image produced.
rm -f pi-gen/stage2/EXPORT_IMAGE
echo "ASHVALE_SRC=${GITHUB_WORKSPACE}/ashvale" >> pi-gen/config
echo "--- config ---" && cat pi-gen/config
- name: Build
working-directory: pi-gen
run: sudo -E ./build.sh
- name: Collect artifact
id: artifact
run: |
IMG=$(find pi-gen/deploy -name '*.img.xz' | head -1)
test -n "$IMG" || { echo "no image produced"; ls -R pi-gen/deploy; exit 1; }
mkdir -p out && mv "$IMG" out/
cd out
NAME=$(basename *.img.xz)
sha256sum "$NAME" > "$NAME.sha256"
echo "name=$NAME" >> "$GITHUB_OUTPUT"
ls -lh
# A release asset is capped at 2 GB; Lite compresses to well under that,
# but fail loudly here rather than at upload time.
SIZE=$(stat -c%s "$NAME")
echo "compressed size: $((SIZE/1024/1024)) MiB"
test "$SIZE" -lt 2000000000 || { echo "image exceeds the 2 GB release limit"; exit 1; }
- name: Upload as a workflow artifact
uses: actions/upload-artifact@v4
with:
name: ashvale-pi-image
path: out/*
retention-days: 14
- name: Attach to release
if: startsWith(github.ref, 'refs/tags/') || inputs.publish
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${GITHUB_REF_NAME}"
gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 \
|| gh release create "$TAG" --repo "$GITHUB_REPOSITORY" \
--title "$TAG" --notes "Ashvale Station image for Raspberry Pi."
gh release upload "$TAG" out/* --repo "$GITHUB_REPOSITORY" --clobber
+29 -10
View File
@@ -1,7 +1,21 @@
# Contributing to Ashvale Station # Contributing to Ashvale Station
Thanks for taking an interest. This is a small project maintained by one person, Read this first, so nobody wastes an afternoon.
so the bar here is "make it easy to say yes", not "follow a 40-page process".
**This is a solo project.** It is written and maintained by one person, for one
weather station, and it is published because the methods may be useful to
someone else, not because it is looking for a team.
**Bug reports are genuinely welcome.** If something crashes, forecasts badly, or
the documentation is wrong, open an issue. That is useful and I will read it.
**Pull requests are unlikely to be merged.** Not from lack of gratitude: this
codebase carries a lot of hard-won reasoning in its comments and docstrings, and
reviewing changes to it properly costs more time than I have. If you want it to
do something different, fork it. Apache 2.0 exists precisely so you can.
The rest of this file documents how the project holds itself to a standard. It
is written for anyone reading the code, including future me.
## Ground rules that actually matter ## Ground rules that actually matter
@@ -54,7 +68,10 @@ python run.py --no-led # dashboard on :8000
No Sense HAT needed. The simulator kicks in automatically and exercises every No Sense HAT needed. The simulator kicks in automatically and exercises every
code path. code path.
## Before you open a pull request ## The bar any change has to clear
Whether it is my own commit or a fork of yours, a change to the forecasting
path is not finished until it can show:
- [ ] `python scripts/evaluate.py` runs clean, and you have posted before/after numbers - [ ] `python scripts/evaluate.py` runs clean, and you have posted before/after numbers
- [ ] Those numbers came from a backfill with `--seed` and `--end` both pinned - [ ] Those numbers came from a backfill with `--seed` and `--end` both pinned
@@ -63,16 +80,18 @@ code path.
- [ ] New model code explains its failure mode in the docstring - [ ] New model code explains its failure mode in the docstring
- [ ] The dashboard still fits one viewport at 1280x800 if you changed the UI - [ ] The dashboard still fits one viewport at 1280x800 if you changed the UI
## Good first contributions ## Roadmap
Where this is going, in rough order of value. Listed so a forker knows what is
already planned rather than as an invitation.
- **DS18B20 or BME280 support.** An outdoor sensor removes the single biggest - **DS18B20 or BME280 support.** An outdoor sensor removes the single biggest
limitation in the project. High impact, self-contained. limitation in the project. High impact, self-contained.
- **Tipping-bucket rain gauge on GPIO.** Real precipitation labels would - **Tipping-bucket rain gauge on GPIO.** Real precipitation labels would
transform the precipitation model. transform the precipitation model.
- **METAR ingestion** from a nearby airfield as a calibration reference. - **METAR ingestion** from a nearby airfield as a calibration reference.
- **Translations** for the dashboard.
- **Tests.** There is a walk-forward backtest but no unit test suite. A pytest - **Tests.** There is a walk-forward backtest but no unit test suite. A pytest
suite over `physics.py`, `estimation.py` and `models/rls.py` would be very welcome. suite over `physics.py`, `estimation.py` and `models/rls.py` is the main gap.
## Reporting bugs ## Reporting bugs
@@ -81,8 +100,8 @@ output of `GET /api/status`, and what you expected instead. If it is a
forecasting problem rather than a crash, the output of `scripts/evaluate.py` forecasting problem rather than a crash, the output of `scripts/evaluate.py`
helps enormously. helps enormously.
## Licensing of contributions ## Licensing
By contributing you agree that your work is licensed under the Apache License The project is Apache 2.0. Fork it, modify it, ship it, subject to the licence
2.0, the same terms as the project. You keep the copyright in your own terms. In the unlikely event a patch is accepted, it is taken under the same
contributions. terms and you keep the copyright in your own work.
+34 -6
View File
@@ -131,6 +131,22 @@ after a fortnight of real data and believe those instead.
--- ---
## Getting it onto a Pi
Already have a working Raspberry Pi:
```bash
curl -fsSL https://raw.githubusercontent.com/lynchaos/ashvale-station/main/deploy/install.sh | sudo bash
```
Blank SD card: download the prebuilt Raspberry Pi OS Lite (Trixie, arm64) image
from [Releases](https://github.com/lynchaos/ashvale-station/releases), verify the
checksum, and flash it with Raspberry Pi Imager, setting your own username,
password and WiFi in the customisation dialog. The image carries no credentials
and no SSH host keys: those are generated on first boot.
Either way, see [deploy/README.md](deploy/README.md).
## Install on the Pi ## Install on the Pi
```bash ```bash
@@ -160,18 +176,30 @@ permanently.
## The dashboard ## The dashboard
Five tabs, one viewport, no scrolling on desktop. Below 1024 px the constraint is Six tabs, one viewport, no scrolling on desktop. Below 1024 px wide the tabs
released, because pinning five panels into a phone viewport produces unreadable become a dropdown and the page scrolls, because pinning six panels into a phone
eight-pixel type. viewport produces unreadable eight-pixel type. The same release applies below
700 px tall: a short screen gets a scrollbar rather than a one-pixel chart.
Light and dark themes, defaulting to your system setting, switchable from the
header or the Settings tab and remembered per browser.
Verified across ten viewports from 1920x1080 to 375x667, six tabs each, in
both themes.
| Tab | Answers | | Tab | Answers |
| --- | --- | | --- | --- |
| **Live** | What is it doing right now | | **Live** | The week ahead, current readings, the forecast with its band, and conditions |
| **Forecast** | What is it about to do, and how sure are we |
| **History** | What did it do, over any timeframe you ask for | | **History** | What did it do, over any timeframe you ask for |
| **Models** | Has the model earned its confidence | | **Models and Calibration** | Has the model earned its confidence, and the calibration inputs |
| **Stats for Nerds** | Every internal the estimator and the 18 learners are carrying |
| **Settings** | Surroundings, site geometry, the matrix, and maintenance actions |
| **Methods** | How the whole thing is wired, and how each stage fails | | **Methods** | How the whole thing is wired, and how each stage fails |
Live carries the current readings, the observed-and-forecast chart with its 90%
conformal band, and the precipitation panel together, so the question "what is
it doing and what happens next" is answered without changing tab.
### History ### History
Presets from 6 hours to a year, plus an explicit from/to range picker. Aggregation Presets from 6 hours to a year, plus an explicit from/to range picker. Aggregation
+399 -10
View File
@@ -25,15 +25,18 @@ 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 from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, StreamingResponse from fastapi.responses import HTMLResponse, Response, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from .config import CONFIG from .config import CONFIG, load_overrides, save_overrides
from .dashboard import DASHBOARD_HTML from .dashboard import DASHBOARD_HTML
from .features import FEATURE_NAMES
from .led import LedDisplay from .led import LedDisplay
from .methods import describe from .methods import describe
from .station import Station from .station import Station
@@ -42,6 +45,14 @@ station: Optional[Station] = None
display: Optional[LedDisplay] = None display: Optional[LedDisplay] = None
# Set when the app is shutting down. The SSE generator watches it: without
# that, an open dashboard is an in-flight request that never completes, so
# uvicorn's graceful shutdown blocks until systemd's timeout SIGKILLs the
# process. Reproduced: with no stream client the service stops in 2 s, with
# one open client it was still running after 15 s.
_shutdown = asyncio.Event()
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
global station, display global station, display
@@ -49,11 +60,13 @@ async def lifespan(app: FastAPI):
station.sample_once() station.sample_once()
station.start() station.start()
if CONFIG.server.led_enabled: if CONFIG.server.led_enabled:
display = LedDisplay(station, CONFIG.server.led_cycle_s) display = LedDisplay(station, CONFIG.server.led_cycle_s, CONFIG.server.led_fps)
station.display = display # lets the joystick drive the panel
display.start() display.start()
try: try:
yield yield
finally: finally:
_shutdown.set()
if display is not None: if display is not None:
await display.stop() await display.stop()
if station is not None: if station is not None:
@@ -68,6 +81,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:
@@ -108,6 +129,35 @@ class CalibrationIn(BaseModel):
"covariance, returning to the configured prior") "covariance, returning to the configured prior")
class SettingsIn(BaseModel):
"""Every field optional: the UI sends only what changed."""
environment: Optional[str] = None
enclosure: Optional[str] = None
note: str = ""
altitude_m: Optional[float] = Field(None, ge=-430, le=9000)
latitude: Optional[float] = Field(None, ge=-90, le=90)
longitude: Optional[float] = Field(None, ge=-180, le=180)
heating: Optional[bool] = None
heating_setpoint_c: Optional[float] = Field(None, ge=5, le=35)
thermal_time_constant_h: Optional[float] = Field(None, ge=0.1, le=24)
hum_psychrometric: Optional[bool] = None
led_enabled: Optional[bool] = None
led_fps: Optional[float] = Field(None, ge=4, le=30)
class EnvironmentIn(BaseModel):
environment: Optional[str] = Field(None, description="indoor | sheltered | outdoor")
enclosure: Optional[str] = Field(None, description="closed | ventilated | open")
note: str = Field("", description="what changed, for the log")
class HumidityCalibrationIn(BaseModel):
reference_pct: Optional[float] = Field(None, ge=0, le=100,
description="Trusted relative humidity in %")
reset: bool = Field(False, description="Discard the learned offset, returning to "
"the configured prior")
# ------------------------------------------------------------ endpoints # ------------------------------------------------------------ endpoints
@app.get("/api/telemetry") @app.get("/api/telemetry")
@@ -140,6 +190,9 @@ def telemetry() -> Dict:
"cpu_temp": live.get("cpu_temp"), "cpu_temp": live.get("cpu_temp"),
"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"),
"outdoor_c": live.get("outdoor_c"),
"hum_psychrometric": live.get("hum_psychrometric"),
"rates": { "rates": {
"temperature_c_per_h": live.get("temp_rate"), "temperature_c_per_h": live.get("temp_rate"),
"humidity_pct_per_h": live.get("hum_rate"), "humidity_pct_per_h": live.get("hum_rate"),
@@ -342,6 +395,169 @@ 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")
def nerd() -> Dict:
"""Every internal number the estimator and the learners are carrying.
Deliberately read-only and computed from live objects rather than stored, so
it cannot drift from what the station is actually using. Everything here is
cheap: no matrix inversions, no queries beyond what the caller already pays
for. `theta` is returned per head so the UI can show which of the 33 features
each horizon actually leans on, which is the single most revealing view of
what the model has learned.
"""
st = _st()
tr = st.tracker
filters = {}
for name, kf in tr.filters.items():
P = np.asarray(kf.P, dtype=float)
filters[name] = {
"level": float(kf.x[0]), "rate_per_h": float(kf.x[1]) * 3600.0,
"nis": float(kf.nis),
"p_level": float(P[0, 0]), "p_rate": float(P[1, 1]),
"p_cross": float(P[0, 1]),
"sigma_level": float(np.sqrt(max(P[0, 0], 0.0))),
"q": float(kf.q), "r": float(kf.r),
"initialised": bool(kf.initialised),
}
heads = []
for (target, h), head in sorted(st.nowcast.heads.items()):
m = head.model
P = np.asarray(m.P, 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({
"target": target, "horizon_s": h,
"n_updates": int(m.n_updates),
"trace_p": float(np.trace(P)),
"cond_p": cond,
"theta_norm": float(np.linalg.norm(theta)),
"rmse_ewma": float(np.sqrt(max(m.ewma_sq_error, 0.0))),
"lam": float(m.lam), "p_max": float(m.p_max),
"eff_memory": float(1.0 / max(1.0 - m.lam, 1e-9)),
"alpha": float(head.conformal.alpha),
"alpha_target": float(head.conformal.alpha_target),
"coverage": (float(head.conformal.empirical_coverage)
if np.isfinite(head.conformal.empirical_coverage) else None),
"halfwidth": (float(head.conformal.quantile())
if np.isfinite(head.conformal.quantile()) else None),
"weights": {k: float(v) for k, v in
zip(("persistence", "climatology", "learned"), head.weights)},
"theta": [round(float(v), 6) for v in theta],
})
mono = st.monitor
nov = getattr(mono, "novelty", None)
ph = getattr(mono, "drift", None)
monitoring = {
"novelty": {
"d2": float(getattr(nov, "last_d2", 0.0)) if nov is not None else None,
"threshold": float(getattr(nov, "threshold", 0.0)) if nov is not None else None,
"n": int(getattr(nov, "n", 0)) if nov is not None else None,
"dims": int(getattr(nov, "d", 0)) if nov is not None else None,
"z": [round(float(v), 4) for v in np.asarray(getattr(nov, "z", []), dtype=float)]
if nov is not None else [],
},
"drift": {
"m_pos": float(getattr(ph, "m_pos", 0.0)) if ph is not None else None,
"m_neg": float(getattr(ph, "m_neg", 0.0)) if ph is not None else None,
"mean": float(getattr(ph, "mean", 0.0)) if ph is not None else None,
"n": int(getattr(ph, "n", 0)) if ph is not None else None,
"alarms": int(getattr(ph, "n_alarms", 0)) if ph is not None else None,
"delta": float(getattr(ph, "delta", 0.0)) if ph is not None else None,
},
}
return _clean({
"feature_names": list(FEATURE_NAMES),
"filters": filters,
"compensators": {
"thermal": tr.compensator.to_dict(),
"humidity": tr.hum_compensator.to_dict(),
},
"heads": heads,
"climatology": {
"ready": st.climatology.ready,
"annual_terms": st.climatology.use_annual,
"history_days": round(st.climatology.n_days, 3),
"diurnal_harmonics": st.climatology.kd,
"annual_harmonics": st.climatology.ka,
"ridge": st.climatology.ridge,
"residual_std": st.climatology.resid_std,
"n_coefficients": {k: len(v) for k, v in st.climatology.coef.items()},
},
"precipitation": {
"coefficients": st.precip.coefficients(),
"strong_labels": st.precip.n_strong,
"weak_labels": st.precip.n_weak,
"logloss_ewma": st.precip.ewma_logloss,
},
"monitoring": monitoring,
"innovation": _innovation_histogram(st),
"reliability": _reliability_curve(st),
})
@app.get("/api/scorecard") @app.get("/api/scorecard")
def scorecard() -> Dict: def scorecard() -> Dict:
st = _st() st = _st()
@@ -382,12 +598,167 @@ def calibrate(body: CalibrationIn) -> Dict:
return _clean(result) return _clean(result)
@app.post("/api/calibrate/humidity")
def calibrate_humidity(body: HumidityCalibrationIn) -> Dict:
st = _st()
if body.reset:
return _clean(st.reset_humidity_calibration())
if body.reference_pct is None:
raise HTTPException(422, "provide reference_pct, or reset=true")
result = st.calibrate_humidity(body.reference_pct)
if "error" in result:
raise HTTPException(409, result["error"])
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.post("/api/environment")
def environment(body: EnvironmentIn) -> Dict:
"""Tell the station its surroundings changed, and have it react.
Marks a discontinuity and queues a retrain, because the learners' 55 hour
memory would otherwise keep predicting the old regime for two days.
"""
valid_env = {"indoor", "sheltered", "outdoor"}
valid_enc = {"closed", "ventilated", "open"}
if body.environment and body.environment not in valid_env:
raise HTTPException(422, f"environment must be one of {sorted(valid_env)}")
if body.enclosure and body.enclosure not in valid_enc:
raise HTTPException(422, f"enclosure must be one of {sorted(valid_enc)}")
if not body.environment and not body.enclosure:
raise HTTPException(422, "provide environment, enclosure, or both")
return _clean(_st().set_environment(body.environment, body.enclosure, body.note))
@app.get("/api/settings")
def get_settings() -> Dict:
st = _st()
return _clean({
"site": {"environment": CONFIG.site.environment,
"enclosure": CONFIG.site.enclosure,
"altitude_m": CONFIG.site.altitude_m,
"latitude": CONFIG.site.latitude,
"longitude": CONFIG.site.longitude,
"timezone": CONFIG.site.timezone,
"heating": CONFIG.site.heating,
"heating_setpoint_c": CONFIG.site.heating_setpoint_c,
"thermal_time_constant_h": CONFIG.site.thermal_time_constant_h,
"name": CONFIG.site.name},
"sensor": {"hum_psychrometric": CONFIG.sensor.hum_psychrometric,
"cpu_heat_k": round(st.tracker.compensator.k, 4),
"hum_offset": round(st.tracker.hum_compensator.offset, 3)},
"server": {"led_enabled": CONFIG.server.led_enabled,
"led_fps": CONFIG.server.led_fps},
"options": {
"environment": ["indoor", "sheltered", "outdoor"],
"enclosure": ["closed", "ventilated", "open"],
},
"overrides": load_overrides(CONFIG),
})
@app.post("/api/settings")
def post_settings(body: SettingsIn) -> Dict:
"""Apply settings live and persist them to the overlay.
Everything here takes effect without a restart, because a settings page that
needs one is a settings page people stop trusting. Site geometry is read per
sample, the compensator flag is a field on a live object, and the display
reads its own rate each frame.
"""
st = _st()
patch: Dict[str, Dict] = {}
applied, needs_recompute = [], False
if body.environment or body.enclosure:
r = st.set_environment(body.environment, body.enclosure, body.note)
if r.get("changed"):
applied.append(r["detail"])
patch.setdefault("site", {}).update(
{"environment": CONFIG.site.environment,
"enclosure": CONFIG.site.enclosure})
for name, value in (("altitude_m", body.altitude_m),
("latitude", body.latitude),
("longitude", body.longitude)):
if value is not None and value != getattr(CONFIG.site, name):
applied.append(f"{name} {getattr(CONFIG.site, name)} -> {value}")
setattr(CONFIG.site, name, float(value))
patch.setdefault("site", {})[name] = float(value)
# Altitude feeds the sea-level reduction on every stored row, so the
# history is now inconsistent with the new value until re-derived.
needs_recompute = needs_recompute or name == "altitude_m"
# Turning the thermostat model on or off changes which process the heads are
# fitting, so it is a regime change and gets the same treatment as a door.
if body.heating is not None and body.heating != CONFIG.site.heating:
CONFIG.site.heating = bool(body.heating)
patch.setdefault("site", {})["heating"] = bool(body.heating)
applied.append(f"heating {'on' if body.heating else 'off'}")
st.store.log_event("discontinuity", "warn",
f"heating {'on' if body.heating else 'off'}")
st.monitor.retrain_requested = True
for name, value, label in (
("heating_setpoint_c", body.heating_setpoint_c, "setpoint"),
("thermal_time_constant_h", body.thermal_time_constant_h, "time constant")):
if value is not None and value != getattr(CONFIG.site, name):
applied.append(f"{label} {getattr(CONFIG.site, name)} -> {value}")
setattr(CONFIG.site, name, float(value))
patch.setdefault("site", {})[name] = float(value)
if body.hum_psychrometric is not None and \
body.hum_psychrometric != CONFIG.sensor.hum_psychrometric:
CONFIG.sensor.hum_psychrometric = bool(body.hum_psychrometric)
st.tracker.hum_compensator.psychrometric = bool(body.hum_psychrometric)
patch.setdefault("sensor", {})["hum_psychrometric"] = bool(body.hum_psychrometric)
applied.append(f"psychrometric correction {'on' if body.hum_psychrometric else 'off'}")
needs_recompute = True
if body.led_enabled is not None and body.led_enabled != CONFIG.server.led_enabled:
CONFIG.server.led_enabled = bool(body.led_enabled)
patch.setdefault("server", {})["led_enabled"] = bool(body.led_enabled)
if display is not None:
display.enabled = bool(body.led_enabled)
if not body.led_enabled:
st.board.clear()
applied.append(f"matrix {'on' if body.led_enabled else 'off'}")
if body.led_fps is not None and body.led_fps != CONFIG.server.led_fps:
CONFIG.server.led_fps = float(body.led_fps)
patch.setdefault("server", {})["led_fps"] = float(body.led_fps)
if display is not None:
display.fps = float(body.led_fps)
applied.append(f"matrix {body.led_fps:g} fps")
if patch:
save_overrides(CONFIG, patch)
st.store.log_event("settings", "info", "; ".join(applied))
return _clean({"applied": applied, "changed": bool(applied),
"needs_recompute": needs_recompute})
@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),
"environment": CONFIG.site.environment,
"enclosure": CONFIG.site.enclosure,
"events": st.store.recent_events(15), "events": st.store.recent_events(15),
}) })
@@ -398,11 +769,18 @@ def events(limit: int = Query(50, ge=1, le=500)) -> List[Dict]:
@app.get("/api/stream") @app.get("/api/stream")
async def stream(): async def stream(request: Request):
"""Server-sent events. One connection instead of a poll every 2 seconds, """Server-sent events. One connection instead of a poll every 2 seconds,
which on a Zero 2 W is the difference between 4% and 0.4% CPU.""" which on a Zero 2 W is the difference between 4% and 0.4% CPU.
The loop exits on shutdown or client disconnect. Both matter: an endless
generator keeps the response in flight, and uvicorn will not finish a
graceful shutdown while one is open.
"""
async def gen(): async def gen():
while True: while not _shutdown.is_set():
if await request.is_disconnected():
break
st = _st() st = _st()
payload = { payload = {
"telemetry": telemetry(), "telemetry": telemetry(),
@@ -411,7 +789,12 @@ async def stream():
"drift_stress": round(st.monitor.drift.stress, 3), "drift_stress": round(st.monitor.drift.stress, 3),
} }
yield f"data: {json.dumps(payload)}\n\n" yield f"data: {json.dumps(payload)}\n\n"
await asyncio.sleep(2.0) # Wait on the shutdown event rather than sleeping blindly, so a stop
# is honoured immediately instead of up to 2 s later.
try:
await asyncio.wait_for(_shutdown.wait(), timeout=2.0)
except asyncio.TimeoutError:
pass
return StreamingResponse(gen(), media_type="text/event-stream", return StreamingResponse(gen(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", headers={"Cache-Control": "no-cache",
@@ -419,5 +802,11 @@ async def stream():
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
def dashboard() -> str: def dashboard() -> Response:
return DASHBOARD_HTML # The page is generated from live config and changes with every deploy, and
# it carried no cache headers, so a browser could hold an old copy
# indefinitely and show layout bugs that were already fixed. The vendored
# assets under /static are fingerprint-free too, but they only change when
# the station is updated, so revalidation is enough for them.
return HTMLResponse(DASHBOARD_HTML,
headers={"Cache-Control": "no-cache, must-revalidate"})
+126 -4
View File
@@ -21,6 +21,7 @@ variables prefixed `ASHVALE_` (e.g. `ASHVALE_SITE__ALTITUDE_M=42`).
from __future__ import annotations from __future__ import annotations
import json
import os import os
from dataclasses import dataclass, field, fields, is_dataclass from dataclasses import dataclass, field, fields, is_dataclass
from pathlib import Path from pathlib import Path
@@ -41,7 +42,40 @@ class SiteConfig:
longitude: float = 0.1218 longitude: float = 0.1218
altitude_m: float = 15.0 # for sea-level pressure reduction altitude_m: float = 15.0 # for sea-level pressure reduction
timezone: str = "Europe/London" timezone: str = "Europe/London"
indoors: bool = True # honest flag, changes how forecasts are worded indoors: bool = True
# Where the sensor actually lives, and what has changed around it.
#
# This matters more than it looks. Indoors, temperature and humidity are
# governed by the building, not the sky: the diurnal swing is damped and
# lagged, and the solar features the model is given correlate weakly with
# what the thermometer does. Pressure is the exception, which is why the
# precipitation model runs on tendency rather than indoor humidity.
#
# "enclosure" is the part worth changing at runtime. Closing a door or
# opening a window is a step change in how strongly the sensor is coupled to
# outside, and the learners carry roughly 55 hours of memory, so they will
# keep predicting the old regime for two days unless told. POST
# /api/environment marks the moment and asks for a retrain.
environment: str = "indoor" # indoor | sheltered | outdoor
enclosure: str = "closed" # closed | ventilated | open
# Central heating or air conditioning holding the room at a setpoint.
#
# This is a genuine change of process, not a label. A free-running room
# follows outdoor forcing and drifts; a thermostatted one is a closed loop
# that pulls back toward heating_setpoint_c whenever it strays. Persistence
# ("tomorrow equals today") is the wrong baseline for a controlled system,
# because the truth is "it returns to the setpoint".
#
# thermal_time_constant_h is how fast that pull acts: the time to close
# about 63% of a gap. A small well-insulated flat with responsive heating is
# under an hour; a large draughty house with slow radiators is several. If
# you do not know it, leave it: the ensemble weights this member against the
# others from measured error, so a wrong constant costs accuracy, not
# correctness.
heating: bool = False
heating_setpoint_c: float = 21.0
thermal_time_constant_h: float = 1.5 # honest flag, changes how forecasts are worded
@dataclass @dataclass
@@ -55,12 +89,50 @@ class SensorConfig:
cpu_heat_k: float = 0.55 cpu_heat_k: float = 0.55
cpu_heat_k_min: float = 0.15 cpu_heat_k_min: float = 0.15
cpu_heat_k_max: float = 1.20 cpu_heat_k_max: float = 1.20
# Additive RH bias of the element. The datasheet claims about +/-3.5%, but
# measured against a reference hygrometer this board read 75.4% where the
# truth was 50.4%, so the clamp has to allow far more than spec. Kept finite
# so one mistyped reference still cannot run away.
# Move RH from the element's temperature onto the compensated air temperature
# via conserved vapour pressure. Physically correct IF the humidity element
# really sits at temp_raw. Measured on this board it does not: against a
# reference hygrometer reading 50.4%, the HTS221 reported 75.4%, so it reads
# 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
# 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_offset: float = 0.0
hum_offset_min: float = -35.0
hum_offset_max: float = 35.0
# Kalman process/measurement noise (per-signal) # Kalman process/measurement noise (per-signal)
kalman_q_temp: float = 2.0e-6 kalman_q_temp: float = 1.0e-9
kalman_r_temp: float = 0.02 kalman_r_temp: float = 0.02
kalman_q_press: float = 1.0e-5 # Process noise, retuned against measured sensor noise rather than guessed.
#
# The originals tracked far faster than any of these signals move. In a
# still room the temperature filter reported a median rate of 12.4 C/h
# while the air moved 0.37 C/h, and it overshot a real -36 C/h event by
# 77%. Sweeping q against the RMSE of the reported rate versus the true
# rate, using noise measured on the board (temp 0.088 C, press 0.022 hPa,
# hum 0.40 %), puts the minimum about two to three decades lower:
#
# temperature 6.45 -> 0.37 C/h RMSE at 2e-6 -> 1e-9
# pressure 2.15 -> 0.24 hPa/h RMSE at 1e-5 -> 1e-8
# humidity 27.94 -> 3.55 %/h RMSE at 5e-5 -> 2e-8
#
# Tracking does not suffer: lag against a genuine 2 C/h ramp is 0.003 C at
# both the old and new values, and peak response to a 5-minute event is
# closer to the truth, not further from it. What is lost is response to
# sub-minute transients, which for a station forecasting 15 minutes to a
# day ahead is noise to reject rather than signal to chase.
kalman_q_press: float = 1.0e-8
kalman_r_press: float = 0.05 kalman_r_press: float = 0.05
kalman_q_hum: float = 5.0e-5 kalman_q_hum: float = 2.0e-8
kalman_r_hum: float = 0.60 kalman_r_hum: float = 0.60
@@ -72,6 +144,7 @@ class ModelConfig:
rls_forgetting: float = 0.9985 # lambda, ~ 11h memory at 5 min rls_forgetting: float = 0.9985 # lambda, ~ 11h memory at 5 min
rls_delta: float = 100.0 # P0 = delta * I rls_delta: float = 100.0 # P0 = delta * I
conformal_window: int = 400 # residuals kept per head conformal_window: int = 400 # residuals kept per head
min_pairs_per_head: int = 12 # floor before the stride relaxes
conformal_alpha: float = 0.10 # 90% intervals conformal_alpha: float = 0.10 # 90% intervals
conformal_gamma: float = 0.01 # adaptive conformal step conformal_gamma: float = 0.01 # adaptive conformal step
train_period_s: float = 600.0 # retrain cadence train_period_s: float = 600.0 # retrain cadence
@@ -98,6 +171,11 @@ class ServerConfig:
port: int = 8000 port: int = 8000
led_enabled: bool = True led_enabled: bool = True
led_cycle_s: float = 0.4 led_cycle_s: float = 0.4
# Matrix frame rate. 24 is smooth and costs about 11% of one core on a
# Zero 2 W. 16 is still fluid and roughly a third cheaper; below about 12
# the crossfades and sub-pixel motion start to judder, which defeats the
# point. Set 0 to keep the panel enabled but static-cheap.
led_fps: float = 24.0
@dataclass @dataclass
@@ -135,6 +213,45 @@ def _apply_env(obj: Any, prefix: str = "ASHVALE_") -> None:
setattr(obj, f.name, raw) setattr(obj, f.name, raw)
# Settings changed from the dashboard land here, not in 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. A
# separate overlay keeps both: the file stays yours, the UI stays useful, and
# either can be reverted independently by deleting the other.
OVERRIDES_NAME = "settings.json"
def overrides_path(cfg: "Config") -> Path:
return Path(cfg.storage.state_dir) / OVERRIDES_NAME
def load_overrides(cfg: "Config") -> Dict[str, Any]:
path = overrides_path(cfg)
if not path.exists():
return {}
try:
with open(path, "r", encoding="utf-8") as fh:
return json.load(fh) or {}
except (OSError, ValueError):
return {}
def save_overrides(cfg: "Config", patch: Dict[str, Any]) -> Dict[str, Any]:
"""Merge a patch into the overlay and write it back."""
current = load_overrides(cfg)
for section, values in patch.items():
if not isinstance(values, dict):
continue
current.setdefault(section, {}).update(values)
path = overrides_path(cfg)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.tmp")
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(current, fh, indent=2, sort_keys=True)
tmp.replace(path) # atomic, so a crash cannot truncate it
return current
def load_config(path: str | os.PathLike | None = None) -> Config: def load_config(path: str | os.PathLike | None = None) -> Config:
cfg = Config() cfg = Config()
candidate = Path(path) if path else REPO_ROOT / "config.yaml" candidate = Path(path) if path else REPO_ROOT / "config.yaml"
@@ -142,6 +259,11 @@ def load_config(path: str | os.PathLike | None = None) -> Config:
with open(candidate, "r", encoding="utf-8") as fh: with open(candidate, "r", encoding="utf-8") as fh:
_apply(cfg, yaml.safe_load(fh) or {}) _apply(cfg, yaml.safe_load(fh) or {})
_apply_env(cfg) _apply_env(cfg)
# Applied last: a change made from the dashboard is the most recent explicit
# instruction from a human, so it wins over both the file and the
# environment. Delete data/state/settings.json to fall back.
Path(cfg.storage.state_dir).mkdir(parents=True, exist_ok=True)
_apply(cfg, load_overrides(cfg))
Path(cfg.storage.db_path).parent.mkdir(parents=True, exist_ok=True) Path(cfg.storage.db_path).parent.mkdir(parents=True, exist_ok=True)
Path(cfg.storage.state_dir).mkdir(parents=True, exist_ok=True) Path(cfg.storage.state_dir).mkdir(parents=True, exist_ok=True)
return cfg return cfg
+1180 -225
View File
File diff suppressed because it is too large Load Diff
+145 -7
View File
@@ -30,12 +30,14 @@ Two jobs here, both familiar from soft-sensor work:
from __future__ import annotations from __future__ import annotations
import math from collections import deque
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Dict, Optional from typing import Dict, Optional
import numpy as np import numpy as np
from .physics import saturation_vapour_pressure
@dataclass @dataclass
class KalmanCV: class KalmanCV:
@@ -53,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):
@@ -82,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
@@ -97,12 +105,28 @@ class KalmanCV:
return {"q": self.q, "r": self.r, "x": self.x.tolist(), return {"q": self.q, "r": self.r, "x": self.x.tolist(),
"P": self.P.tolist(), "initialised": self.initialised} "P": self.P.tolist(), "initialised": self.initialised}
def load_state(self, d: Dict) -> None:
"""Restore the estimate only, leaving q and r as configured.
q and r are tuning, not something the filter learned. Taking them from
the state file pins whatever values were in force when it was written,
so editing them in config.yaml does nothing until someone thinks to
delete the state, and nobody thinks to delete the state. That cost a
retune here: the new q was deployed, the service restarted, and the
filters quietly carried on with the old one.
P may be inconsistent with a newly changed q. That is harmless: the
filter re-converges within a few hundred samples, which is far cheaper
than a tuning change that appears to work and does not.
"""
self.x = np.array(d["x"], dtype=float)
self.P = np.array(d["P"], dtype=float)
self.initialised = bool(d["initialised"])
@classmethod @classmethod
def from_dict(cls, d: Dict) -> "KalmanCV": def from_dict(cls, d: Dict) -> "KalmanCV":
kf = cls(q=d["q"], r=d["r"]) kf = cls(q=d["q"], r=d["r"])
kf.x = np.array(d["x"], dtype=float) kf.load_state(d)
kf.P = np.array(d["P"], dtype=float)
kf.initialised = bool(d["initialised"])
return kf return kf
@@ -159,20 +183,117 @@ class ThermalCompensator:
return tc return tc
class HumidityCompensator:
"""Corrects relative humidity for a sensor sitting hotter than the air.
The HTS221 reports RH at its own temperature, but the air you care about is
at the compensated temperature. Vapour pressure is what is conserved between
the two, so
RH_true = RH_sensor * es(T_sensor) / es(T_true)
Because the element runs hot, es(T_sensor) > es(T_true) and an uncorrected
reading is biased low, by several points on a warm board. That term needs no
calibration constant at all: it falls out of the thermal compensation that is
already running.
Measured on real hardware the psychrometric term is the wrong model: against
a reference hygrometer reading 50.4%, this board's HTS221 reported 75.4%, so
it reads HIGH where the thermal argument predicts LOW. The dominant error is
an additive element bias, which is what `offset` corrects, estimated from a
trusted reference by the same one-step RLS used for `k` with the regressor
fixed at 1, so repeated calibrations converge to a weighted mean. The
psychrometric term is therefore off by default and gated on config.
How it fails: calibrate against a reference while the board is cool, then let
CPU load rise, and an offset-only correction drifts because the psychrometric
error grows with the gradient. Applying the vapour-pressure term first is
exactly what keeps `offset` a constant rather than a function of CPU load.
Clamped for the same reason `k` is: one mistyped reference otherwise biases
every reading until you notice.
"""
def __init__(self, offset: float = 0.0, off_min: float = -35.0,
off_max: float = 35.0, forgetting: float = 0.98,
psychrometric: bool = False):
self.psychrometric = bool(psychrometric)
self.offset = float(offset)
self.off_min, self.off_max = float(off_min), float(off_max)
self.P = 10.0
self.lam = float(forgetting)
self.n_calibrations = 0
self.last_residual = 0.0
def _psychrometric(self, rh_sensor: float, t_sensor: float, t_true: float) -> float:
if not self.psychrometric:
return float(rh_sensor)
es_s = float(saturation_vapour_pressure(t_sensor))
es_t = float(saturation_vapour_pressure(t_true))
if not np.isfinite(es_s) or not np.isfinite(es_t) or es_t <= 1e-9:
return float(rh_sensor)
return float(rh_sensor * es_s / es_t)
def compensate(self, rh_sensor: float, t_sensor: float, t_true: float) -> float:
if not (np.isfinite(rh_sensor) and np.isfinite(t_sensor) and np.isfinite(t_true)):
return float(rh_sensor)
base = self._psychrometric(rh_sensor, t_sensor, t_true)
return float(np.clip(base + self.offset, 0.0, 100.0))
def calibrate(self, rh_sensor: float, t_sensor: float, t_true: float,
rh_reference: float) -> Dict:
"""One RLS step on the residual offset. Regressor is 1."""
base = self._psychrometric(rh_sensor, t_sensor, t_true)
target = float(rh_reference) - base
denom = self.lam + self.P
gain = self.P / denom if denom > 1e-12 else 0.0
residual = target - self.offset
self.offset = float(np.clip(self.offset + gain * residual,
self.off_min, self.off_max))
self.P = float(np.clip((self.P - gain * self.P) / self.lam, 1e-6, 1e4))
self.n_calibrations += 1
self.last_residual = float(residual)
return {"offset": self.offset, "residual": self.last_residual,
"n": self.n_calibrations, "psychrometric": base - float(rh_sensor)}
def to_dict(self) -> Dict:
return {"offset": self.offset, "P": self.P, "lam": self.lam,
"off_min": self.off_min, "off_max": self.off_max,
"n": self.n_calibrations, "psychrometric": self.psychrometric}
@classmethod
def from_dict(cls, d: Dict) -> "HumidityCompensator":
hc = cls(d["offset"], d["off_min"], d["off_max"], d["lam"],
d.get("psychrometric", False))
hc.P = d["P"]
hc.n_calibrations = d.get("n", 0)
return hc
class SignalTracker: class SignalTracker:
"""Bank of Kalman filters plus the compensator, driven at sample rate.""" """Bank of Kalman filters plus the compensators, driven at sample rate."""
def __init__(self, cfg): def __init__(self, cfg):
self.compensator = ThermalCompensator( self.compensator = ThermalCompensator(
cfg.sensor.cpu_heat_k, cfg.sensor.cpu_heat_k_min, cfg.sensor.cpu_heat_k, cfg.sensor.cpu_heat_k_min,
cfg.sensor.cpu_heat_k_max, cfg.sensor.cpu_heat_k_max,
) )
self.hum_compensator = HumidityCompensator(
cfg.sensor.hum_offset, cfg.sensor.hum_offset_min,
cfg.sensor.hum_offset_max,
psychrometric=cfg.sensor.hum_psychrometric,
)
self.filters = { self.filters = {
"temperature": KalmanCV(cfg.sensor.kalman_q_temp, cfg.sensor.kalman_r_temp), "temperature": KalmanCV(cfg.sensor.kalman_q_temp, cfg.sensor.kalman_r_temp),
"humidity": KalmanCV(cfg.sensor.kalman_q_hum, cfg.sensor.kalman_r_hum), "humidity": KalmanCV(cfg.sensor.kalman_q_hum, cfg.sensor.kalman_r_hum),
"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]:
@@ -180,12 +301,19 @@ class SignalTracker:
self.last_ts = ts self.last_ts = ts
temp_c = self.compensator.compensate(temp_raw, cpu_temp) temp_c = self.compensator.compensate(temp_raw, cpu_temp)
# RH is reported at the element's temperature, not the air's, so it must
# be moved onto the compensated temperature before it is filtered.
hum_c = self.hum_compensator.compensate(hum, temp_raw, temp_c)
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, 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,
"hum_c": hum_c,
"temp_smooth": t_lvl, "temp_smooth": t_lvl,
"temp_rate": t_rate * 3600.0, # C per hour "temp_rate": t_rate * 3600.0, # C per hour
"hum_smooth": h_lvl, "hum_smooth": h_lvl,
@@ -199,13 +327,23 @@ class SignalTracker:
def to_dict(self) -> Dict: def to_dict(self) -> Dict:
return { return {
"compensator": self.compensator.to_dict(), "compensator": self.compensator.to_dict(),
"hum_compensator": self.hum_compensator.to_dict(),
"filters": {k: v.to_dict() for k, v in self.filters.items()}, "filters": {k: v.to_dict() for k, v in self.filters.items()},
"last_ts": self.last_ts, "last_ts": self.last_ts,
} }
def load_dict(self, d: Dict) -> None: def load_dict(self, d: Dict) -> None:
self.compensator = ThermalCompensator.from_dict(d["compensator"]) self.compensator = ThermalCompensator.from_dict(d["compensator"])
self.filters = {k: KalmanCV.from_dict(v) for k, v in d["filters"].items()} # Absent from state files written before humidity compensation existed.
if d.get("hum_compensator"):
self.hum_compensator = HumidityCompensator.from_dict(d["hum_compensator"])
# Deliberately not KalmanCV.from_dict here: that would restore the
# persisted q and r over the configured ones. Only the estimate is
# restored, and only for filters this build still has.
for name, saved in d.get("filters", {}).items():
kf = self.filters.get(name)
if kf is not None:
kf.load_state(saved)
self.last_ts = d.get("last_ts") self.last_ts = d.get("last_ts")
+31 -5
View File
@@ -32,8 +32,14 @@ from typing import Dict, List, Tuple
import numpy as np import numpy as np
from .physics import (absolute_humidity, clear_sky_irradiance, dew_point, from .physics import (
solar_position, vapour_pressure_deficit, wet_bulb) absolute_humidity,
clear_sky_irradiance,
dew_point,
solar_position,
vapour_pressure_deficit,
wet_bulb,
)
FEATURE_NAMES: List[str] = [ FEATURE_NAMES: List[str] = [
"bias", "bias",
@@ -76,7 +82,8 @@ def _rolling(a: np.ndarray, win: int, fn) -> np.ndarray:
def build_features(grid_ts: np.ndarray, temp: np.ndarray, hum: np.ndarray, def build_features(grid_ts: np.ndarray, temp: np.ndarray, hum: np.ndarray,
press_slp: np.ndarray, lux: np.ndarray, press_slp: np.ndarray, lux: np.ndarray,
grid_s: int, latitude: float, longitude: float grid_s: int, latitude: float, longitude: float,
min_days_annual: float = 120.0
) -> Tuple[np.ndarray, np.ndarray]: ) -> Tuple[np.ndarray, np.ndarray]:
"""Return (X of shape (n, N_FEATURES), valid mask of shape (n,)).""" """Return (X of shape (n, N_FEATURES), valid mask of shape (n,))."""
n = grid_ts.size n = grid_ts.size
@@ -121,6 +128,20 @@ def build_features(grid_ts: np.ndarray, temp: np.ndarray, hum: np.ndarray,
hour = (grid_ts % 86400.0) / 86400.0 hour = (grid_ts % 86400.0) / 86400.0
doy = (grid_ts % 31557600.0) / 31557600.0 doy = (grid_ts % 31557600.0) / 31557600.0
# Annual harmonics are held at zero until the record spans enough of a year
# to excite them, exactly as the climatology fit already gates its annual
# terms. Left on from day one they are near-constant, near-collinear with
# each other and with the bias, and RLS answers that rank-deficient system
# with enormous cancelling weights. Measured on a real station after 1.5
# days: cos_doy +1191, sin_doy +1174, ||theta|| 1680 against a median |theta|
# of 1.67, cond(P) 3.1e9, and a six hour forecast of 53 C in a 24 C room.
# Zero is the honest value: with a day and a half of data the station knows
# nothing whatsoever about the season.
span_days = float(grid_ts[-1] - grid_ts[0]) / 86400.0 if n > 1 else 0.0
annual_on = 1.0 if span_days >= min_days_annual else 0.0
sin_doy = np.sin(2 * np.pi * doy) * annual_on
cos_doy = np.cos(2 * np.pi * doy) * annual_on
X = np.column_stack([ X = np.column_stack([
np.ones(n), np.ones(n),
temp, temp_rate_1h, temp_rate_3h, temp_std_3h, temp_dev_24h, temp, temp_rate_1h, temp_rate_3h, temp_std_3h, temp_dev_24h,
@@ -130,7 +151,7 @@ def build_features(grid_ts: np.ndarray, temp: np.ndarray, hum: np.ndarray,
log_lux, cloud, elev, np.clip(elev, 0.0, None), (elev > 0.0).astype(float), log_lux, cloud, elev, np.clip(elev, 0.0, None), (elev > 0.0).astype(float),
np.sin(2 * np.pi * hour), np.cos(2 * np.pi * hour), np.sin(2 * np.pi * hour), np.cos(2 * np.pi * hour),
np.sin(4 * np.pi * hour), np.cos(4 * np.pi * hour), np.sin(4 * np.pi * hour), np.cos(4 * np.pi * hour),
np.sin(2 * np.pi * doy), np.cos(2 * np.pi * doy), sin_doy, cos_doy,
press_anom * (hum - 70.0) / 100.0, press_anom * (hum - 70.0) / 100.0,
press_tend_3h * dep, press_tend_3h * dep,
]) ])
@@ -166,7 +187,12 @@ class Standardiser:
if self.n < 2: if self.n < 2:
return np.atleast_2d(X) return np.atleast_2d(X)
std = np.sqrt(self.m2 / max(self.n - 1, 1)) std = np.sqrt(self.m2 / max(self.n - 1, 1))
std = np.where(std < 1e-8, 1.0, std) # 1e-8 was a token guard: it only catches a bit-exactly constant column.
# A feature that merely barely moves sails through and gets divided by
# its own noise, which manufactures a large z-score out of nothing. A
# feature with this little spread carries no information, so scale it by
# one and let it stay near zero rather than amplifying it.
std = np.where(std < 1e-3, 1.0, std)
out = (np.atleast_2d(X) - self.mean) / std out = (np.atleast_2d(X) - self.mean) / std
out[:, 0] = 1.0 # keep the bias column intact out[:, 0] = 1.0 # keep the bias column intact
return out return out
+990 -197
View File
File diff suppressed because it is too large Load Diff
+95 -15
View File
@@ -73,9 +73,22 @@ def pipeline(cfg) -> List[Dict[str, Any]]:
"failure": "A mistyped reference drives k to its clamp and stays there " "failure": "A mistyped reference drives k to its clamp and stays there "
"across restarts, because state persists. The reset button " "across restarts, because state persists. The reset button "
"on the Models tab exists for exactly that.", "on the Models tab exists for exactly that.",
"math": r"k_{t} = k_{t-1} + \frac{P\varphi}{\lambda + \varphi P \varphi}" "math": [
r"\left[(T_{raw} - T_{ref}) - k_{t-1}\varphi\right]," r"T = T_{raw} - k\,(T_{cpu} - T_{raw}), \qquad k \ge 0",
r"\quad \varphi = T_{cpu} - T_{raw}", r"\varphi = \max(T_{cpu} - T_{raw},\,0), \qquad "
r"y = T_{raw} - T_{ref}",
r"g = \frac{P\varphi}{\lambda + \varphi^{2} P}, \qquad "
r"k_t = \operatorname{clip}\!\big(k_{t-1} + g\,(y - k_{t-1}\varphi),\;"
r"k_{\min},\,k_{\max}\big)",
r"P_t = \frac{P_{t-1} - g\,\varphi\,P_{t-1}}{\lambda}",
],
"symbols": {
r"k": "self-heating coefficient, the one estimated parameter",
r"\varphi": "regressor: the CPU-to-sensor gradient, floored at zero",
r"P": "scalar parameter variance; large means uncertain, so large steps",
r"\lambda": "forgetting factor, 0.98. Old calibrations decay",
r"g": "RLS gain. Note it is the Kalman gain for a one-dimensional state",
},
"params": {"current k": f"{s.cpu_heat_k:g} (prior)", "params": {"current k": f"{s.cpu_heat_k:g} (prior)",
"clamp": f"{s.cpu_heat_k_min:g} to {s.cpu_heat_k_max:g}"}, "clamp": f"{s.cpu_heat_k_min:g} to {s.cpu_heat_k_max:g}"},
}, },
@@ -96,9 +109,26 @@ def pipeline(cfg) -> List[Dict[str, Any]]:
"failure": "Process noise too low and the filter lags real weather; too " "failure": "Process noise too low and the filter lags real weather; too "
"high and you have an expensive passthrough. The innovation " "high and you have an expensive passthrough. The innovation "
"statistic is logged so you can tell which.", "statistic is logged so you can tell which.",
"math": r"x = \begin{bmatrix} \text{level} \\ \text{rate} \end{bmatrix}," "math": [
r"\quad Q = q\begin{bmatrix} \Delta t^3/3 & \Delta t^2/2 \\" r"x = \begin{bmatrix} \text{level} \\ \text{rate} \end{bmatrix}, \qquad "
r"\Delta t^2/2 & \Delta t \end{bmatrix}", r"F = \begin{bmatrix} 1 & \Delta t \\ 0 & 1 \end{bmatrix}, \qquad "
r"H = \begin{bmatrix} 1 & 0 \end{bmatrix}",
r"Q = q\begin{bmatrix} \Delta t^{3}/3 & \Delta t^{2}/2 \\"
r"\Delta t^{2}/2 & \Delta t \end{bmatrix}"
r"\qquad\text{(continuous white-noise acceleration)}",
r"x^{-}_t = Fx_{t-1}, \qquad P^{-}_t = FP_{t-1}F^{\top} + Q",
r"y = z - Hx^{-}_t, \qquad S = HP^{-}_tH^{\top} + r, \qquad "
r"K = P^{-}_tH^{\top}S^{-1}",
r"P_t = (I - KH)P^{-}_t(I - KH)^{\top} + KrK^{\top}"
r"\qquad\text{(Joseph form, stays positive semi-definite)}",
r"\text{NIS} = y^{\top}S^{-1}y \;\approx\; 1 \text{ when the filter is tuned}",
],
"symbols": {
r"q": "process noise density. The only knob that really matters",
r"r": "measurement noise variance, from the sensor datasheet",
r"S": "innovation covariance: how surprised the filter expects to be",
r"\text{NIS}": "normalised innovation squared. Above 1 means overconfident and lagging",
},
"params": {"q temperature": f"{s.kalman_q_temp:g}", "params": {"q temperature": f"{s.kalman_q_temp:g}",
"r temperature": f"{s.kalman_r_temp:g}", "r temperature": f"{s.kalman_r_temp:g}",
"q pressure": f"{s.kalman_q_press:g}"}, "q pressure": f"{s.kalman_q_press:g}"},
@@ -147,9 +177,32 @@ def pipeline(cfg) -> List[Dict[str, Any]]:
"through quiet nights when the regressor barely moves, and " "through quiet nights when the regressor barely moves, and "
"the model then detonates at sunrise. The trace is capped. " "the model then detonates at sunrise. The trace is capped. "
"This is the most common way a field RLS deployment dies.", "This is the most common way a field RLS deployment dies.",
"math": r"P_t = \frac{1}{\lambda}\left(P_{t-1} - " "math": [
r"\frac{P_{t-1}x x^{\top}P_{t-1}}{\lambda + x^{\top}P_{t-1}x}" r"dT_{set}(h) = (T_{set} - T_{now})\left(1 - e^{-h/\tau}\right)"
r"\right)", r"\qquad\text{(thermostat member, first-order closed loop)}",
r"RH(h) = RH_{now}\,\frac{e_s(T_{now})}{e_s(T_{now} + dT_{set}(h))}"
r"\qquad\text{(heating adds no moisture, so dew point is conserved)}",
r"\hat{\theta} = \arg\min_{\theta}\; \sum_{i=1}^{t}"
r"\lambda^{\,t-i}\big(y_i - \theta^{\top}x_i\big)^{2}"
r"\qquad\text{(exponentially weighted least squares)}",
r"g_t = \frac{P_{t-1}x_t}{\lambda + x_t^{\top}P_{t-1}x_t}, \qquad "
r"\theta_t = \theta_{t-1} + g_t\big(y_t - \theta_{t-1}^{\top}x_t\big)",
r"P_t = \frac{1}{\lambda}\Big(P_{t-1} - g_t x_t^{\top} P_{t-1}\Big), "
r"\qquad P_t \leftarrow \tfrac{1}{2}\big(P_t + P_t^{\top}\big)",
r"\operatorname{tr}(P_t) > P_{\max} \;\Longrightarrow\; "
r"P_t \leftarrow P_t\,\frac{P_{\max}}{\operatorname{tr}(P_t)}"
r"\qquad\text{(the guard that stops covariance blow-up)}",
r"N_{\text{eff}} = \frac{1}{1-\lambda}"
r"\qquad\text{effective memory in samples}",
r"\hat{y}_{t+h} = y_t + \theta_h^{\top}x_t"
r"\qquad\text{each head predicts a delta, not a level}",
],
"symbols": {
r"\theta": "33 weights, one bank per (target, horizon): 18 banks",
r"P": "parameter covariance. Its trace is the total uncertainty",
r"\lambda": "forgetting factor 0.9985, about 55 hours of memory",
r"P_{\max}": "trace cap. Without it, quiet nights inflate P until sunrise detonates the model",
},
"params": {"forgetting": f"{m.rls_forgetting:g}", "params": {"forgetting": f"{m.rls_forgetting:g}",
"effective memory": _memory(m.rls_forgetting, m.grid_s), "effective memory": _memory(m.rls_forgetting, m.grid_s),
"members": ", ".join(MEMBERS)}, "members": ", ".join(MEMBERS)},
@@ -171,8 +224,21 @@ def pipeline(cfg) -> List[Dict[str, Any]]:
"does underneath.", "does underneath.",
"failure": "If coverage sits far from target, the feedback rate is " "failure": "If coverage sits far from target, the feedback rate is "
"wrong, not the model. Both are shown on the Models tab.", "wrong, not the model. Both are shown on the Models tab.",
"math": r"\alpha_{t+1} = \alpha_t + \gamma\left(\alpha^{*} - " "math": [
r"\mathbb{1}[y_t \notin C_t]\right)", r"C_t = \big[\hat{y}_t - q_{1-\alpha_t},\; \hat{y}_t + q_{1-\alpha_t}\big], "
r"\qquad q_{1-\alpha} = \operatorname{Quantile}_{1-\alpha}\big(|e_i|\big)",
r"\alpha_{t+1} = \operatorname{clip}\Big(\alpha_t + \gamma\big(\alpha^{*} - "
r"\mathbb{1}\left[y_t \notin C_t\right]\big),\; 0.005,\; 0.75\Big)",
r"\frac{1}{T}\sum_{t=1}^{T}\mathbb{1}\left[y_t \in C_t\right] "
r"\;\xrightarrow[T\to\infty]{}\; 1-\alpha^{*}"
r"\qquad\text{without assuming exchangeability}",
],
"symbols": {
r"\alpha^{*}": "target miss rate, 0.10 for a 90% band",
r"\alpha_t": "working miss rate. It moves; the target does not",
r"\gamma": "adaptation rate. Larger reacts faster and wanders more",
r"\mathbb{1}[\cdot]": "1 when the truth fell outside the band, else 0",
},
"params": {"target coverage": f"{int((1 - m.conformal_alpha) * 100)}%", "params": {"target coverage": f"{int((1 - m.conformal_alpha) * 100)}%",
"gamma": f"{m.conformal_gamma:g}", "gamma": f"{m.conformal_gamma:g}",
"window": f"{m.conformal_window} residuals"}, "window": f"{m.conformal_window} residuals"},
@@ -196,9 +262,23 @@ def pipeline(cfg) -> List[Dict[str, Any]]:
"a 365-day sine to three weeks of data produces a " "a 365-day sine to three weeks of data produces a "
"magnificent extrapolation straight off the edge of the " "magnificent extrapolation straight off the edge of the "
"physical world.", "physical world.",
"math": r"y \sim \beta_0 + \beta_1 t + \sum_{k=1}^{3}" "math": [
r"\left[a_k\sin\tfrac{2\pi k t}{\text{day}} + " r"y(t) \approx \beta_0 + \beta_1 t + \sum_{k=1}^{K_d}"
r"b_k\cos\tfrac{2\pi k t}{\text{day}}\right] + \text{annual}", r"\left[a_k\sin\frac{2\pi k t}{\tau_d} + b_k\cos\frac{2\pi k t}{\tau_d}\right]"
r" + \sum_{j=1}^{K_a}\left[c_j\sin\frac{2\pi j t}{\tau_a} + "
r"d_j\cos\frac{2\pi j t}{\tau_a}\right]",
r"\hat{\beta} = \big(X^{\top}X + \rho I\big)^{-1}X^{\top}y"
r"\qquad\text{(ridge, because harmonics get collinear on short records)}",
r"\hat{y}(t+h) = \underbrace{\mu(t+h)}_{\text{harmonic fit}} + "
r"\underbrace{\big(y(t)-\mu(t)\big)}_{\text{today's anomaly}}\cdot"
r"\,2^{-h/h_{1/2}}",
],
"symbols": {
r"\tau_d,\ \tau_a": "one day and one tropical year",
r"K_a": "annual harmonics, held at zero below 120 days of history",
r"\rho": "ridge penalty",
r"h_{1/2}": "anomaly half-life. Today's departure decays toward climatology",
},
"params": {"diurnal harmonics": "3", "annual harmonics": "2", "params": {"diurnal harmonics": "3", "annual harmonics": "2",
"anomaly half-life": "30 h"}, "anomaly half-life": "30 h"},
}, },
@@ -221,7 +301,7 @@ def pipeline(cfg) -> List[Dict[str, Any]]:
"label abstains in the ambiguous middle rather than " "label abstains in the ambiguous middle rather than "
"guessing, because a poisoned training set costs more than " "guessing, because a poisoned training set costs more than "
"the extra samples buy. Trust grows as n/(n+25) in strong " "the extra samples buy. Trust grows as n/(n+25) in strong "
"labels, so the two buttons on the Forecast tab matter.", "labels, so the two buttons on the Live tab matter.",
"params": {"prior": "Zambretti, three-branch", "params": {"prior": "Zambretti, three-branch",
"learner": "logistic, AdaGrad", "learner": "logistic, AdaGrad",
"strong label weight": "10x proxy"}, "strong label weight": "10x proxy"},
+4 -4
View File
@@ -12,11 +12,11 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from .rls import RecursiveLeastSquares, AdaptiveConformal
from .nowcast import NowcastEnsemble
from .climatology import HarmonicClimatology
from .precip import PrecipitationModel, zambretti
from .anomaly import AnomalyMonitor from .anomaly import AnomalyMonitor
from .climatology import HarmonicClimatology
from .nowcast import NowcastEnsemble
from .precip import PrecipitationModel, zambretti
from .rls import AdaptiveConformal, RecursiveLeastSquares
__all__ = [ __all__ = [
"RecursiveLeastSquares", "AdaptiveConformal", "NowcastEnsemble", "RecursiveLeastSquares", "AdaptiveConformal", "NowcastEnsemble",
+2 -2
View File
@@ -141,9 +141,9 @@ class HarmonicClimatology:
sigma0 = self.resid_std.get(target, 1.0) sigma0 = self.resid_std.get(target, 1.0)
sigma = sigma0 * np.sqrt(1.0 + lead_h / 24.0) sigma = sigma0 * np.sqrt(1.0 + lead_h / 24.0)
return [ return [
{"ts": float(t), "lead_h": float(l), "mu": float(m), {"ts": float(t), "lead_h": float(lh), "mu": float(m),
"lo": float(m - 1.645 * s), "hi": float(m + 1.645 * s)} "lo": float(m - 1.645 * s), "hi": float(m + 1.645 * s)}
for t, l, m, s in zip(grid, lead_h, mu, sigma) for t, lh, m, s in zip(grid, lead_h, mu, sigma)
] ]
def anomaly_now(self, target: str, ts: float, observed: float) -> float: def anomaly_now(self, target: str, ts: float, observed: float) -> float:
+202 -36
View File
@@ -43,7 +43,12 @@ import numpy as np
from ..features import N_FEATURES, Standardiser, supervised_pairs from ..features import N_FEATURES, Standardiser, supervised_pairs
from .rls import AdaptiveConformal, RecursiveLeastSquares from .rls import AdaptiveConformal, RecursiveLeastSquares
MEMBERS = ("persistence", "climatology", "learned") MEMBERS = ("persistence", "climatology", "learned", "setpoint")
# Scored forecasts before the Hedge loss scale switches from this sample's
# worst loss to the running member MAE. Until member_mae has seen anything it
# is zeros, and dividing by that would hand every member the same penalty.
_SCALE_WARMUP = 20
class ForecastHead: class ForecastHead:
@@ -61,13 +66,17 @@ class ForecastHead:
self.eta = float(hedge_eta) self.eta = float(hedge_eta)
self.member_mae = np.zeros(len(MEMBERS)) self.member_mae = np.zeros(len(MEMBERS))
self.n_scored = 0 self.n_scored = 0
# Validity time of the last outcome the Hedge weights learned from.
self.last_hedge_ts = -np.inf
# -------------------------------------------------------- prediction # -------------------------------------------------------- prediction
def predict(self, x: np.ndarray, anchor: float, def predict(self, x: np.ndarray, anchor: float,
climatology_delta: float = 0.0) -> Dict[str, float]: climatology_delta: float = 0.0,
setpoint_delta: float = 0.0) -> Dict[str, float]:
learned_delta = self.model.predict(x) learned_delta = self.model.predict(x)
deltas = np.array([0.0, float(climatology_delta), float(learned_delta)]) deltas = np.array([0.0, float(climatology_delta), float(learned_delta),
float(setpoint_delta)])
blended = float(np.dot(self.weights, deltas)) blended = float(np.dot(self.weights, deltas))
mu = float(anchor + blended) mu = float(anchor + blended)
sigma = self.model.predict_std(x, self.model.noise_var) sigma = self.model.predict_std(x, self.model.noise_var)
@@ -84,26 +93,81 @@ class ForecastHead:
# ---------------------------------------------------------- learning # ---------------------------------------------------------- learning
def learn(self, x: np.ndarray, anchor: float, truth: float, def refit_step(self, x: np.ndarray, anchor: float, truth: float) -> None:
climatology_delta: float = 0.0) -> float: """One regression update from a replayed historical pair.
"""One supervised step given a matured target."""
deltas = np.array([0.0, float(climatology_delta), This deliberately touches nothing but the RLS. The blend weights and
float(self.model.predict(x))]) the conformal calibrator are statements about how this head's issued
member_pred = anchor + deltas forecasts actually turned out, and a refit is not an outcome: it is the
same week of weather being read again.
Measured on 8.2 days of real station data, the previous arrangement
(fit() calling a combined learn()) had put 977,078 Hedge updates through
the 15 minute head from 758 distinct supervised pairs, a factor of 1,289,
and 296,715 through the 1 day head from 12 pairs, a factor of 24,726.
Hedge is multiplicative, so an edge far too small to be real compounds
to certainty: twelve of twelve temperature and humidity heads had
collapsed onto climatology at a weight of 0.991 or above. The ACI
integrator, which moves by gamma per observation, had likewise pinned
against its clips, giving a 6 hour band narrower than the 3 hour one.
Feeding these two from verify() instead is worth 14.4% of MAE across
17 of 18 heads, and takes mean absolute coverage error from 0.188
to 0.059.
"""
self.model.update(x, truth - anchor)
def observe_outcome(self, members: np.ndarray, truth: float,
covered: Optional[bool] = None,
valid_ts: Optional[float] = None) -> float:
"""One matured forecast, scored against what actually happened.
`members` are the four point predictions this head issued, recovered
from the forecasts table. The learned one cannot be recomputed here
because the RLS has moved on since.
"""
member_pred = np.asarray(members, dtype=float)
losses = np.abs(member_pred - truth) losses = np.abs(member_pred - truth)
# Hedge / exponentiated gradient on normalised losses # Score the blend with the weights predict() actually used, before this
scale = max(float(np.max(losses)), 1e-6) # outcome moves them. Doing it after is look-ahead: the residual handed
# to the conformal calibrator is then better than anything the
# forecaster can produce, so the intervals come out about 2% too narrow.
# Coverage survived it only because ACI notices the extra misses and
# reopens the band, which is a correction that should not be needed.
blended = float(np.dot(self.weights, member_pred))
residual = truth - blended
self.conformal.observe(residual, covered=covered)
# The Hedge weights take an outcome only if it does not overlap the last
# one they took. Forecasts are issued every retrain tick, so at the 1 day
# horizon roughly two hundred of them per day resolve against what is
# very nearly the same outcome. The conformal window can absorb that,
# since a quantile over duplicated scores is merely over-confident about
# its sample size, but exponentiated gradient cannot: it would apply the
# same evidence two hundred times and saturate. This is the stride rule
# from fit(), applied on the scoring side.
if valid_ts is not None:
if valid_ts - self.last_hedge_ts < self.horizon_s:
return residual
self.last_hedge_ts = float(valid_ts)
# Hedge / exponentiated gradient on normalised losses.
#
# The scale must be a stable quantity, not this sample's worst loss.
# Dividing by max(losses) means that on a quiet step where every member
# agrees to within 0.01 C, whichever one happens to be worst still takes
# the full exp(-eta) penalty, exactly as if it had been wrong by 5 C, so
# the weights churn on noise. Hedge's regret bound assumes a fixed loss
# range. Normalising by the running member MAE instead is worth 1.8% of
# MAE across 106 of 126 heads on real data, with coverage unchanged.
if self.n_scored > _SCALE_WARMUP:
scale = max(float(np.mean(self.member_mae)), 1e-6)
else:
scale = max(float(np.max(losses)), 1e-6)
self.weights *= np.exp(-self.eta * losses / scale) self.weights *= np.exp(-self.eta * losses / scale)
self.weights = np.clip(self.weights, 1e-4, None) self.weights = np.clip(self.weights, 1e-4, None)
self.weights /= self.weights.sum() self.weights /= self.weights.sum()
blended = float(np.dot(self.weights, member_pred))
residual = truth - blended
self.conformal.observe(residual)
self.model.update(x, truth - anchor)
self.member_mae = 0.98 * self.member_mae + 0.02 * losses self.member_mae = 0.98 * self.member_mae + 0.02 * losses
self.n_scored += 1 self.n_scored += 1
return residual return residual
@@ -112,16 +176,56 @@ class ForecastHead:
return {"target": self.target, "horizon_s": self.horizon_s, return {"target": self.target, "horizon_s": self.horizon_s,
"model": self.model.to_dict(), "conformal": self.conformal.to_dict(), "model": self.model.to_dict(), "conformal": self.conformal.to_dict(),
"weights": self.weights.tolist(), "eta": self.eta, "weights": self.weights.tolist(), "eta": self.eta,
"member_mae": self.member_mae.tolist(), "n_scored": self.n_scored} "member_mae": self.member_mae.tolist(), "n_scored": self.n_scored,
"last_hedge_ts": (float(self.last_hedge_ts)
if np.isfinite(self.last_hedge_ts) else None)}
@classmethod @classmethod
def from_dict(cls, s: Dict) -> "ForecastHead": def from_dict(cls, s: Dict) -> "ForecastHead":
h = cls(s["target"], s["horizon_s"]) h = cls(s["target"], s["horizon_s"])
h.model = RecursiveLeastSquares.from_dict(s["model"]) h.model = RecursiveLeastSquares.from_dict(s["model"])
h.conformal = AdaptiveConformal.from_dict(s["conformal"]) h.conformal = AdaptiveConformal.from_dict(s["conformal"])
h.weights = np.array(s["weights"], dtype=float) w = np.array(s["weights"], dtype=float)
if w.size != len(MEMBERS):
# A saved head from before the setpoint member existed. Reinitialise
# uniformly rather than guessing: the Hedge weights re-converge in
# about a day, which is far cheaper than silently mismatching a
# member to the wrong loss and corrupting every blend until someone
# notices.
w = np.ones(len(MEMBERS)) / len(MEMBERS)
h.weights = w
h.eta = s["eta"] h.eta = s["eta"]
h.member_mae = np.array(s["member_mae"], dtype=float) # A state file with no last_hedge_ts was written before the Hedge
# weights and the ACI integrator were cut off from fit()'s replay, so
# everything they hold is the product of the same week of weather read
# about a thousand times: weights pinned on one member at 0.99, alpha
# against a clip, member_mae an EMA over a million duplicated steps.
# None of that is evidence, and it does not decay on its own, because
# Hedge needs about twenty independent outcomes to climb back off the
# 1e-4 floor and the 1 d head sees one a day.
#
# The conformal scores are kept. They were also fed by the replay, so
# they are biased a little narrow, but they are absolute residuals of
# roughly the right size and the window refreshes within about two days
# of real outcomes. Clearing them instead would drop the long horizons
# onto 1.645*sigma for nine days, which is how you get a plus or minus
# of 115% relative humidity.
if "last_hedge_ts" not in s:
h.weights = np.ones(len(MEMBERS)) / len(MEMBERS)
h.member_mae = np.zeros(len(MEMBERS))
h.n_scored = 0
h.conformal.alpha = h.conformal.alpha_target
h.last_hedge_ts = -np.inf
return h
lh = s.get("last_hedge_ts")
h.last_hedge_ts = -np.inf if lh is None else float(lh)
mae = np.array(s["member_mae"], dtype=float)
# Same migration as the weights. Missing this one did not fail on load,
# it failed later inside the Hedge update on a shape mismatch, which is
# a worse place to find out.
if mae.size != len(MEMBERS):
mae = np.zeros(len(MEMBERS))
h.member_mae = mae
h.n_scored = s.get("n_scored", 0) h.n_scored = s.get("n_scored", 0)
return h return h
@@ -133,6 +237,7 @@ class NowcastEnsemble:
cfg_model): cfg_model):
self.targets = tuple(targets) self.targets = tuple(targets)
self.horizons = tuple(int(h) for h in horizons_s) self.horizons = tuple(int(h) for h in horizons_s)
self.cfg = cfg_model
self.grid_s = int(cfg_model.grid_s) self.grid_s = int(cfg_model.grid_s)
self.scaler = Standardiser(N_FEATURES) self.scaler = Standardiser(N_FEATURES)
self.heads: Dict[Tuple[str, int], ForecastHead] = { self.heads: Dict[Tuple[str, int], ForecastHead] = {
@@ -144,11 +249,15 @@ class NowcastEnsemble:
for t in self.targets for h in self.horizons for t in self.targets for h in self.horizons
} }
self.trained_rows = 0 self.trained_rows = 0
self.min_pairs = int(getattr(cfg_model, "min_pairs_per_head", 12))
# Which phase of the stride this refit starts on. Rotated so that over
# successive retrains every offset is eventually trained on, rather
# than the model permanently seeing one sample in `steps` forever.
self.refit_phase = 0
# ------------------------------------------------------------ train # ------------------------------------------------------------ train
def fit(self, X: np.ndarray, valid: np.ndarray, series: Dict[str, np.ndarray], def fit(self, X: np.ndarray, valid: np.ndarray, series: Dict[str, np.ndarray],
climatology=None, grid_ts: Optional[np.ndarray] = None,
passes: int = 1, max_pairs: int = 2500) -> Dict[str, int]: passes: int = 1, max_pairs: int = 2500) -> Dict[str, int]:
"""Batch-update every head from history. """Batch-update every head from history.
@@ -161,6 +270,21 @@ class NowcastEnsemble:
"""Batch pass over history. Called on startup and every retrain tick.""" """Batch pass over history. Called on startup and every retrain tick."""
if X.shape[0] < 10: if X.shape[0] < 10:
return {"rows": 0} return {"rows": 0}
# A refit starts from the prior. Without this, every retrain tick replays
# the same history into a live filter, and RLS with forgetting reads that
# as new evidence each time: measured on a real station after 1.5 days,
# 453 grid rows had produced 64,676 updates, cond(P) of 3.1e9 and a
# weight vector of norm 1680 whose two largest entries were the annual
# harmonics the record cannot yet resolve. The result was a six hour
# forecast of 53 C in a 24 C room, with a plus or minus of 0.43.
#
# The conformal calibrators and the Hedge weights are left alone here,
# and refit_step is what enforces that. They are earned from scored
# forecasts in verify(), not from this regression. The climatology and
# setpoint members used to be evaluated in this loop purely to feed
# them, which is why this method no longer needs either.
for head in self.heads.values():
head.model.reset()
self.scaler.partial_fit(X[valid][:: max(1, X.shape[0] // 2000)]) self.scaler.partial_fit(X[valid][:: max(1, X.shape[0] // 2000)])
Xs = self.scaler.transform(X) Xs = self.scaler.transform(X)
@@ -176,26 +300,44 @@ class NowcastEnsemble:
if Xa.shape[0] > max_pairs: if Xa.shape[0] > max_pairs:
Xa, dy, anchor = Xa[-max_pairs:], dy[-max_pairs:], anchor[-max_pairs:] Xa, dy, anchor = Xa[-max_pairs:], dy[-max_pairs:], anchor[-max_pairs:]
head = self.heads[(target, h)] head = self.heads[(target, h)]
clim = np.zeros(Xa.shape[0]) # One pair per horizon, not one per grid row. Adjacent pairs at
if climatology is not None and grid_ts is not None and climatology.ready: # the 1 d horizon share 287 of their 288 samples, so training on
n = grid_ts.size # every row hands the filter the same outcome 288 times and RLS
ts_a = grid_ts[:n - steps] # with forgetting reads each as fresh evidence. A 400-score
mask_len = min(ts_a.size, Xa.shape[0]) # conformal window then holds 1.4 independent outcomes while
clim_now = climatology.predict(target, ts_a[-mask_len:]) # believing it holds 400.
clim_fut = climatology.predict(target, ts_a[-mask_len:] + h) #
clim = np.zeros(Xa.shape[0]) # This is not a compute shortcut that costs accuracy. Measured
clim[-mask_len:] = clim_fut - clim_now # walk-forward on four days of real station data, striding cut
# MAE at every horizon past an hour (temperature 6h -30%,
# humidity 6h -48%, pressure 12h -68%) with coverage unchanged,
# and made the fit 12x faster. The redundancy was not merely
# wasted work, it was collapsing P onto the repeated direction.
stride = steps
if stride > 1 and Xa.shape[0] // stride < self.min_pairs:
# A long horizon on a short record would otherwise train
# on one or two pairs, which is worse than the redundancy
# it avoids. The floor was chosen by sweeping it over five
# train splits of real data: 12 was best at every horizon,
# and the apparent 1 d regressions at other values were
# noise, since a 1 d head on four days of record is fitted
# and scored on well under two independent outcomes.
stride = max(1, Xa.shape[0] // self.min_pairs)
idx = np.arange(self.refit_phase % stride, Xa.shape[0], stride)
if idx.size > max_pairs:
idx = idx[-max_pairs:]
for _ in range(max(int(passes), 1)): for _ in range(max(int(passes), 1)):
for i in range(Xa.shape[0]): for i in idx:
head.learn(Xa[i], anchor[i], anchor[i] + dy[i], clim[i]) head.refit_step(Xa[i], anchor[i], anchor[i] + dy[i])
counts[f"{target}@{h}"] = int(Xa.shape[0]) counts[f"{target}@{h}"] = int(idx.size)
self.trained_rows = int(X.shape[0]) self.trained_rows = int(X.shape[0])
self.refit_phase += 1
return counts return counts
# --------------------------------------------------------- inference # --------------------------------------------------------- inference
def forecast(self, x_raw: np.ndarray, anchors: Dict[str, float], now: float, def forecast(self, x_raw: np.ndarray, anchors: Dict[str, float], now: float,
climatology=None) -> Dict[str, Dict[int, Dict[str, float]]]: climatology=None, setpoint_fn=None) -> Dict[str, Dict[int, Dict[str, float]]]:
x = self.scaler.transform(np.atleast_2d(x_raw))[0] x = self.scaler.transform(np.atleast_2d(x_raw))[0]
out: Dict[str, Dict[int, Dict[str, float]]] = {} out: Dict[str, Dict[int, Dict[str, float]]] = {}
for target in self.targets: for target in self.targets:
@@ -206,7 +348,8 @@ class NowcastEnsemble:
if climatology is not None and climatology.ready: if climatology is not None and climatology.ready:
clim_delta = float(climatology.predict(target, np.array([now + h]))[0] clim_delta = float(climatology.predict(target, np.array([now + h]))[0]
- climatology.predict(target, np.array([now]))[0]) - climatology.predict(target, np.array([now]))[0])
out[target][h] = self.heads[(target, h)].predict(x, anchor, clim_delta) sp = setpoint_fn(target, h, anchor) if setpoint_fn else 0.0
out[target][h] = self.heads[(target, h)].predict(x, anchor, clim_delta, sp)
return out return out
def diagnostics(self) -> List[Dict]: def diagnostics(self) -> List[Dict]:
@@ -235,11 +378,34 @@ class NowcastEnsemble:
"scaler": self.scaler.to_dict(), "scaler": self.scaler.to_dict(),
"heads": [h.to_dict() for h in self.heads.values()], "heads": [h.to_dict() for h in self.heads.values()],
"trained_rows": self.trained_rows, "trained_rows": self.trained_rows,
"refit_phase": self.refit_phase,
} }
def load_dict(self, s: Dict) -> None: def load_dict(self, s: Dict) -> None:
self.scaler = Standardiser.from_dict(s["scaler"]) self.scaler = Standardiser.from_dict(s["scaler"])
for hs in s["heads"]: for hs in s["heads"]:
head = ForecastHead.from_dict(hs) head = ForecastHead.from_dict(hs)
self.heads[(head.target, head.horizon_s)] = head key = (head.target, head.horizon_s)
if key not in self.heads:
continue # a target or horizon this build no longer has
self.heads[key] = head
self.trained_rows = s.get("trained_rows", 0) self.trained_rows = s.get("trained_rows", 0)
self.refit_phase = int(s.get("refit_phase", 0))
self._apply_config_tuning()
def _apply_config_tuning(self) -> None:
"""Tuning comes from config; only the estimate comes from the file.
Every from_dict below this point restores its knobs alongside its data:
lambda, delta and p_max in the RLS, alpha, gamma and the window in the
conformal calibrator. So each of those was immutable in the field. Edit
config.yaml, restart, and the state file quietly puts the old value
back, which looks exactly like a change that had no effect. The same
defect cost a Kalman retune here before it was found.
"""
c = self.cfg
for head in self.heads.values():
head.model.lam = float(c.rls_forgetting)
head.model.delta = float(c.rls_delta)
head.conformal.retune(c.conformal_alpha, c.conformal_gamma,
c.conformal_window)
+1 -1
View File
@@ -39,7 +39,7 @@ from __future__ import annotations
import math import math
import time import time
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional
import numpy as np import numpy as np
+50 -3
View File
@@ -47,8 +47,9 @@ class RecursiveLeastSquares:
self.d = int(n_features) self.d = int(n_features)
self.lam = float(forgetting) self.lam = float(forgetting)
self.p_max = float(p_max) self.p_max = float(p_max)
self.delta = float(delta) # kept so a refit can return to the prior
self.theta = np.zeros(self.d) self.theta = np.zeros(self.d)
self.P = np.eye(self.d) * float(delta) self.P = np.eye(self.d) * self.delta
self.n_updates = 0 self.n_updates = 0
self.ewma_sq_error = 0.0 self.ewma_sq_error = 0.0
@@ -102,14 +103,32 @@ class RecursiveLeastSquares:
def noise_var(self) -> float: def noise_var(self) -> float:
return float(max(self.ewma_sq_error, 1e-9)) return float(max(self.ewma_sq_error, 1e-9))
def reset(self) -> None:
"""Return to the prior, keeping the configuration.
A batch refit has to start from here rather than continuing, because
replaying the same history into a live filter is not the same as seeing
new data. RLS with forgetting treats every update as fresh evidence, so
feeding it the same rows on each retrain tick makes it believe it has
many times the data it has: P collapses, and the weights in directions
the data never excites drift without anything to pull them back.
"""
self.theta = np.zeros(self.d)
self.P = np.eye(self.d) * self.delta
self.n_updates = 0
self.ewma_sq_error = 0.0
def to_dict(self) -> Dict: def to_dict(self) -> Dict:
return {"d": self.d, "lam": self.lam, "p_max": self.p_max, return {"d": self.d, "lam": self.lam, "p_max": self.p_max,
"delta": self.delta,
"theta": self.theta.tolist(), "P": self.P.tolist(), "theta": self.theta.tolist(), "P": self.P.tolist(),
"n": self.n_updates, "ewma": self.ewma_sq_error} "n": self.n_updates, "ewma": self.ewma_sq_error}
@classmethod @classmethod
def from_dict(cls, s: Dict) -> "RecursiveLeastSquares": def from_dict(cls, s: Dict) -> "RecursiveLeastSquares":
m = cls(s["d"], s["lam"], 1.0, s.get("p_max", 1e6)) # delta must survive the round trip or a refit after a restart would
# return to the wrong prior.
m = cls(s["d"], s["lam"], s.get("delta", 100.0), s.get("p_max", 1e6))
m.theta = np.array(s["theta"], dtype=float) m.theta = np.array(s["theta"], dtype=float)
m.P = np.array(s["P"], dtype=float) m.P = np.array(s["P"], dtype=float)
m.n_updates = s.get("n", 0) m.n_updates = s.get("n", 0)
@@ -139,8 +158,22 @@ class AdaptiveConformal:
self.scores: Deque[float] = deque(maxlen=int(window)) self.scores: Deque[float] = deque(maxlen=int(window))
self.hits: Deque[int] = deque(maxlen=int(window)) self.hits: Deque[int] = deque(maxlen=int(window))
# Fewest scores from which a (1-alpha) empirical quantile exists at all.
# For alpha = 0.10 the band is the ceil(0.9*(k+1))-th of k order statistics,
# which needs k >= 9. Below that there is no quantile to take and the
# Gaussian fallback is the only option.
#
# This was 20, which is arbitrary and became actively harmful once training
# pairs were strided by the horizon: the long-horizon heads then earn about
# 13 scores per refit, so twelve of eighteen heads fell through to
# 1.645*sigma with sigma taken from an unconstrained x'Px. That produced
# bands of +/- 45 C and +/- 115% RH on a young station. They cover, being
# far too wide, but a plus or minus of 115% relative humidity is not a
# forecast.
MIN_SCORES = 9
def quantile(self) -> float: def quantile(self) -> float:
if len(self.scores) < 20: if len(self.scores) < self.MIN_SCORES:
return float("nan") return float("nan")
a = float(np.clip(self.alpha, 0.005, 0.75)) a = float(np.clip(self.alpha, 0.005, 0.75))
return float(np.quantile(np.asarray(self.scores), 1.0 - a, method="higher")) return float(np.quantile(np.asarray(self.scores), 1.0 - a, method="higher"))
@@ -164,6 +197,20 @@ class AdaptiveConformal:
self.alpha = float(np.clip(self.alpha + self.gamma * (self.alpha_target - err), self.alpha = float(np.clip(self.alpha + self.gamma * (self.alpha_target - err),
0.005, 0.75)) 0.005, 0.75))
def retune(self, alpha: float, gamma: float, window: int) -> None:
"""Re-apply configured tuning, keeping the observed scores.
from_dict restores alpha_target, gamma and the window alongside the
data, so editing any of them in config.yaml did nothing on a station
that already had state: the file put the old values straight back.
"""
self.alpha_target = float(alpha)
self.gamma = float(gamma)
window = int(window)
if self.scores.maxlen != window:
self.scores = deque(self.scores, maxlen=window)
self.hits = deque(self.hits, maxlen=window)
@property @property
def empirical_coverage(self) -> float: def empirical_coverage(self) -> float:
return float(np.mean(self.hits)) if self.hits else float("nan") return float(np.mean(self.hits)) if self.hits else float("nan")
+245 -5
View File
@@ -24,21 +24,56 @@ same code to the Pi unchanged.
from __future__ import annotations from __future__ import annotations
import logging
import math import math
import random import subprocess
import time import time
from typing import Any, Dict, Optional from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
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
TCS3400_CDATA = 0x94 TCS3400_CDATA = 0x94
def read_throttled() -> Optional[Dict[str, Any]]:
"""Raspberry Pi undervoltage and throttling flags, or None if all clear.
Bit 0 is undervoltage now, 16 is undervoltage since boot, 2 is arm
frequency capped, 3 is thermal throttling. A capped or browning-out board
runs its SoC at a different temperature, and the SoC temperature is the
regressor in the self-heating compensation, so the visible symptom is a
temperature bias with no apparent cause.
"""
try:
out = subprocess.run(["vcgencmd", "get_throttled"], capture_output=True,
text=True, timeout=5).stdout.strip()
except (OSError, subprocess.SubprocessError):
return None
if "=" not in out:
return None
try:
bits = int(out.split("=", 1)[1], 0)
except ValueError:
return None
if bits == 0:
return None
now = {0: "undervoltage", 1: "arm_capped", 2: "throttled", 3: "soft_temp_limit"}
ever = {16: "undervoltage_since_boot", 17: "arm_capped_since_boot",
18: "throttled_since_boot", 19: "soft_temp_limit_since_boot"}
active = [name for bit, name in now.items() if bits & (1 << bit)]
historic = [name for bit, name in ever.items() if bits & (1 << bit)]
return {"raw": hex(bits), "active": active, "since_boot": historic,
"severity": "warn" if active else "info"}
def read_cpu_temperature() -> float: def read_cpu_temperature() -> float:
"""Core temperature in C. This is the single most important nuisance """Core temperature in C. This is the single most important nuisance
variable on a Sense HAT: the HTS221 and LPS25HB sit millimetres above a variable on a Sense HAT: the HTS221 and LPS25HB sit millimetres above a
@@ -50,6 +85,55 @@ def read_cpu_temperature() -> float:
return float("nan") return float("nan")
# Per-chip thermal coupling to the SoC, and per-chip noise.
#
# The Sense HAT carries two independent thermometers at different distances
# from the SoC, and they are not equally good. Measured over 12 samples on a
# real board: HTS221 30.973 C at sd 0.060, LPS25HB 29.810 C at sd 0.443, a
# standing gradient of 1.163 C with the SoC at 44.55 C.
#
# These two couplings are chosen so their forward models average to exactly the
# k = 0.55 the compensator is tuned against. The aggregate behaviour is
# therefore unchanged and only the per-channel detail is new, which matters
# because that gradient is a second observation of self-heating.
K_HTS221, K_LPS25HB = 0.6164, 0.4889
SD_HTS221, SD_LPS25HB = 0.049, 0.007
class _ChannelNoise:
"""Running white-noise variance of one thermometer.
Taken from the first difference rather than a windowed variance. Over one
2 s sample the air moves far less than either chip's own jitter, so
var(diff)/2 is the noise and is blind to the weather underneath it. A
windowed variance would measure the weather instead and would rise, not
fall, on a calm day.
"""
def __init__(self, prior_sd: float, lam: float = 0.995, warmup: int = 200):
self.var = float(prior_sd) ** 2
self.prior = self.var
self.lam = float(lam)
self.warmup = int(warmup)
self.last: Optional[float] = None
self.n = 0
def update(self, value: float) -> float:
if not math.isfinite(value):
return max(self.var, 1e-8)
if self.last is not None:
d = value - self.last
self.var = self.lam * self.var + (1.0 - self.lam) * (d * d / 2.0)
self.n += 1
self.last = value
if self.n < self.warmup:
# Blend toward the prior while the estimate is young, so one quiet
# minute cannot hand a channel 100% of the weight on no evidence.
w = self.n / float(self.warmup)
return max(w * self.var + (1.0 - w) * self.prior, 1e-8)
return max(self.var, 1e-8)
class SimulatedBoard: class SimulatedBoard:
"""Ornstein-Uhlenbeck weather with a diurnal driver. Good enough to """Ornstein-Uhlenbeck weather with a diurnal driver. Good enough to
exercise every code path and to sanity-check a model's skill score.""" exercise every code path and to sanity-check a model's skill score."""
@@ -88,9 +172,12 @@ class SimulatedBoard:
lux = max(0.0, 60000.0 * max(math.sin(math.radians(max(elev, 0.0))), 0.0)) + 8.0 lux = max(0.0, 60000.0 * max(math.sin(math.radians(max(elev, 0.0))), 0.0)) + 8.0
cpu = temp + 22.0 + 1.5 * self.rng.normal() cpu = temp + 22.0 + 1.5 * self.rng.normal()
# forward model must invert the compensator exactly, see scripts/simulate.py # forward model must invert the compensator exactly, see scripts/simulate.py
k_true = 0.55 t_h = (temp + K_HTS221 * cpu) / (1.0 + K_HTS221) + SD_HTS221 * self.rng.normal()
t_p = (temp + K_LPS25HB * cpu) / (1.0 + K_LPS25HB) + SD_LPS25HB * self.rng.normal()
return { return {
"temp_raw": (temp + k_true * cpu) / (1.0 + k_true) + 0.05 * self.rng.normal(), "temp_raw": (t_h + t_p) / 2.0,
"temp_h": t_h,
"temp_p": t_p,
"hum": rh + 0.4 * self.rng.normal(), "hum": rh + 0.4 * self.rng.normal(),
"press": press + 0.05 * self.rng.normal(), "press": press + 0.05 * self.rng.normal(),
"cpu_temp": cpu, "cpu_temp": cpu,
@@ -106,6 +193,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."""
@@ -118,6 +294,13 @@ class SenseBoard:
self.bus = None self.bus = None
self.tcs_addr = tcs_addr self.tcs_addr = tcs_addr
self._sim = SimulatedBoard(latitude, longitude) self._sim = SimulatedBoard(latitude, longitude)
self._noise_h = _ChannelNoise(SD_HTS221)
self._noise_p = _ChannelNoise(SD_LPS25HB)
# Slow EWMA of the standing gradient between the two chips. About a
# 10-minute time constant at the 2 s cadence: long enough to ignore
# per-sample noise, short enough to follow a real change in SoC load.
self._gradient: Optional[float] = None
self._gradient_lam = 0.9967
try: try:
from sense_hat import SenseHat # type: ignore from sense_hat import SenseHat # type: ignore
@@ -154,6 +337,48 @@ class SenseBoard:
except Exception: except Exception:
return {"clear": 0, "red": 0, "green": 0, "blue": 0, "hex": "#334155", "cct": None} return {"clear": 0, "red": 0, "green": 0, "blue": 0, "hex": "#334155", "cct": None}
def _fuse(self, t_h: float, t_p: float) -> tuple[float, float]:
"""Combine the two thermometers by inverse variance.
A plain average of a quiet sensor and a noisy one throws the quiet one
away. Measured on the board at 0.5 s: the LPS25HB carries a white-noise
sd of 0.007 C against the HTS221's 0.049 C, so optimal weighting is
about 98/2 and cuts the raw noise by roughly 3.7x.
The trap is that the two chips do not agree. They sit at different
distances from the SoC and stand about 1.3 C apart, so weighting them
by variance would drag temp_raw most of the way onto the LPS25HB and
shift it by more than half a degree. The compensator's k was fitted
against the mean of the two, and after the 1.55x gain of the inverse
model that is a full degree of silent bias on every reading and every
forecast built from it.
So the gradient is tracked and removed before weighting, and only the
deviations are fused. The mean is left exactly where the average put
it, k stays valid, and the noise still falls. The gradient itself is
kept because it is a second observation of self-heating and is what
would let k be identified without a reference thermometer.
"""
if not (math.isfinite(t_h) and math.isfinite(t_p)):
good = [v for v in (t_h, t_p) if math.isfinite(v)]
return (good[0] if good else float("nan")), float("nan")
var_h = self._noise_h.update(t_h)
var_p = self._noise_p.update(t_p)
gap = t_h - t_p
if self._gradient is None:
self._gradient = gap
else:
lam = self._gradient_lam
self._gradient = lam * self._gradient + (1.0 - lam) * gap
# Centre both channels on what the plain average would have reported.
half = self._gradient / 2.0
w_h, w_p = 1.0 / var_h, 1.0 / var_p
fused = (w_h * (t_h - half) + w_p * (t_p + half)) / (w_h + w_p)
return float(fused), float(1.0 / (w_h + w_p))
def read(self) -> Dict[str, Any]: def read(self) -> Dict[str, Any]:
"""One full multi-sensor sample. Raw, uncompensated, untouched.""" """One full multi-sensor sample. Raw, uncompensated, untouched."""
if not self.available: if not self.available:
@@ -166,6 +391,7 @@ class SenseBoard:
s = self.sense s = self.sense
t_h = s.get_temperature_from_humidity() t_h = s.get_temperature_from_humidity()
t_p = s.get_temperature_from_pressure() t_p = s.get_temperature_from_pressure()
temp_raw, temp_var = self._fuse(t_h, t_p)
orientation = s.get_orientation_degrees() orientation = s.get_orientation_degrees()
accel = s.get_accelerometer_raw() accel = s.get_accelerometer_raw()
gyro = s.get_gyroscope_raw() gyro = s.get_gyroscope_raw()
@@ -175,7 +401,8 @@ class SenseBoard:
return v - 360.0 if v > 180.0 else v return v - 360.0 if v > 180.0 else v
return { return {
"temp_raw": (t_h + t_p) / 2.0, "temp_raw": temp_raw,
"temp_var": temp_var,
"temp_h": t_h, "temp_h": t_h,
"temp_p": t_p, "temp_p": t_p,
"hum": s.get_humidity(), "hum": s.get_humidity(),
@@ -192,6 +419,19 @@ class SenseBoard:
"simulated": False, "simulated": False,
} }
def stick_events(self) -> List[Tuple[str, str]]:
"""Pending joystick events as (direction, action), oldest first.
Non-blocking, and returns [] when nothing has happened. The library
buffers events, so polling slowly loses none of them.
"""
if self.sense is None:
return []
try:
return [(e.direction, e.action) for e in self.sense.stick.get_events()]
except Exception:
return []
# --------------------------------------------------------------- LED # --------------------------------------------------------------- LED
def clear(self, *args): def clear(self, *args):
+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
+380 -11
View File
@@ -37,19 +37,19 @@ import json
import math import math
import time import time
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional, Tuple
import numpy as np 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 N_FEATURES, 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, read_throttled
from .storage import Store, resample from .storage import Store, resample
STATE_VERSION = 1 STATE_VERSION = 1
@@ -66,6 +66,14 @@ 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.
# Set by the API layer once the LED display exists, so the joystick
# can acknowledge a press and cycle scenes. None when there is no HAT.
self.display = None
self._last_tilt: Optional[Tuple[float, float]] = None
self._started_at = time.time()
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(
@@ -149,6 +157,8 @@ class Station:
row = { row = {
"ts": ts, "ts": ts,
"temp_raw": raw.get("temp_raw"), "temp_raw": raw.get("temp_raw"),
"temp_h": raw.get("temp_h"),
"temp_p": raw.get("temp_p"),
"temp_c": est["temp_c"], "temp_c": est["temp_c"],
"temp_smooth": temp_c, "temp_smooth": temp_c,
"temp_rate": est["temp_rate"], "temp_rate": est["temp_rate"],
@@ -174,6 +184,7 @@ class Station:
"dew_c": dew, "cpu_temp": raw.get("cpu_temp"), "dew_c": dew, "cpu_temp": raw.get("cpu_temp"),
}) })
self.anomaly_bundle = anomaly self.anomaly_bundle = anomaly
self._check_moved(ts, row)
self.live = { self.live = {
**row, **row,
@@ -191,6 +202,9 @@ class Station:
"cloud_index": cloud, "cloud_index": cloud,
"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,
"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")),
"health": anomaly["health_overall"], "health": anomaly["health_overall"],
"novelty_d2": anomaly["novelty"].get("d2", 0.0), "novelty_d2": anomaly["novelty"].get("d2", 0.0),
} }
@@ -264,8 +278,256 @@ 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:
raw_h = self.live.get("hum")
raw_t = self.live.get("temp_raw")
temp_c = self.live.get("temp_c")
if raw_h is None or raw_t is None or temp_c is None:
return {"error": "no live reading yet"}
result = self.tracker.hum_compensator.calibrate(
float(raw_h), float(raw_t), float(temp_c), float(reference_pct))
self.save_state()
self.store.log_event("calibration", "info",
f"rh offset -> {result['offset']:+.2f}% "
f"(residual {result['residual']:+.2f}%)")
self.store.log_event("discontinuity", "warn",
f"humidity offset {result['offset']:+.4f}")
return result
def reset_humidity_calibration(self) -> Dict:
from .estimation import HumidityCompensator
self.tracker.hum_compensator = HumidityCompensator(
self.cfg.sensor.hum_offset, self.cfg.sensor.hum_offset_min,
self.cfg.sensor.hum_offset_max,
psychrometric=self.cfg.sensor.hum_psychrometric,
)
self.save_state()
self.store.log_event("calibration", "info",
f"rh offset reset to prior {self.cfg.sensor.hum_offset}")
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)
q_temp = float(self.cfg.sensor.kalman_q_temp)
q_hum = float(self.cfg.sensor.kalman_q_hum)
live_dt = float(self.cfg.sensor.sample_period_s)
temp_s = np.empty(n)
temp_r = np.empty(n)
hum_s = np.empty(n)
prev = None
for i in range(n):
dt = live_dt if prev is None else max(ts[i] - prev, 1e-3)
prev = ts[i]
# q is tuned for the live 2 s cadence and Q scales with dt^3, so
# replaying stored rows at their own spacing (30 s raw, 300 s and
# 3600 s once tiered) inflates the process noise by up to seven
# orders of magnitude. The filter then abandons smoothing and tracks
# measurement noise, which showed up as indoor rates of +/-20 C/h.
# Rescaled per step because tiers mean the cadence is not constant.
scale = (live_dt / dt) ** 3
kt.q = q_temp * scale
kh.q = q_hum * scale
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 _setpoint_delta(self, target: str, horizon_s: int, anchor: float) -> float:
"""Where a thermostatted room is heading, as a delta from now.
A controlled room is first order: the heating closes the gap to the
setpoint exponentially, so after time h the remaining error is
exp(-h/tau) of what it was. The expected change is therefore
dT(h) = (T_set - T_now) * (1 - exp(-h / tau))
which is zero at h=0 and asymptotes to the full correction. That is a
much better statement about a heated room than persistence, which claims
the room stays wherever it happens to be.
Humidity follows for free and is the part people get wrong. Heating adds
no moisture, so vapour pressure is what is conserved, not relative
humidity. Warm the air and RH falls even though nothing was dried:
RH(h) = RH_now * es(T_now) / es(T_now + dT(h))
This is why a heated house in winter is dry. Pressure is unaffected: a
thermostat cannot move the synoptic field, so that member stays at zero
and the ensemble will correctly ignore it.
Returns 0.0 when heating is off, which makes this member identical to
persistence and therefore harmless.
"""
site = self.cfg.site
if not site.heating:
return 0.0
tau_s = max(float(site.thermal_time_constant_h), 0.05) * 3600.0
closed = 1.0 - math.exp(-float(horizon_s) / tau_s)
temp_now = self.live.get("temp_smooth")
if temp_now is None:
return 0.0
d_temp = (float(site.heating_setpoint_c) - float(temp_now)) * closed
if target == "temperature":
return d_temp
if target == "humidity":
# Constant vapour pressure, so RH moves only because es(T) moved.
es_now = float(physics.saturation_vapour_pressure(temp_now))
es_fut = float(physics.saturation_vapour_pressure(temp_now + d_temp))
if es_fut <= 1e-9:
return 0.0
rh_now = float(anchor)
return float(np.clip(rh_now * es_now / es_fut, 0.0, 100.0)) - rh_now
return 0.0
# Fused tilt change that counts as the board having been picked up. The
# noise floor of the fused pitch and roll is 0.0001 degrees at p99, so a
# one degree trigger carries four decades of headroom and false positives
# are not a concern.
#
# The raw accelerometer is the obvious input and is the wrong one. Over the
# same four and a half days it fires 112 times against this detector's 4,
# because RTIMULib's gyro fusion removes exactly the desk vibration that a
# bare gravity vector picks up. Yaw and compass are excluded for the
# opposite reason: they depend on the magnetometer, which indoors is
# measuring the building.
TILT_MOVED_DEG = 1.0
# RTIMULib restarts its fusion from a default attitude when SenseHat is
# reconstructed, which put an 18 degree step in the record on every one of
# this station's service restarts. Without this guard every deploy would
# look like someone had picked the board up.
TILT_SETTLE_S = 300.0
def _check_moved(self, ts: float, row: Dict[str, Any]) -> None:
"""Detect the board being moved, and treat it as a regime change.
Measured on this station, a move steps the temperature by a median of
1.02 C against an ordinary fifteen minute change of 0.107 C, which puts
it past the 95th percentile of normal variation. The heads carry about
55 hours of memory, so an undeclared move contaminates two days of
training with a discontinuity they will try to fit rather than ignore.
This is the same treatment set_environment gives a window being opened,
because it is the same kind of event: the coupling between the sensor
and what it is measuring changed, and nothing in the data says so.
"""
p, r = row.get("pitch"), row.get("roll")
if p is None or r is None:
return
p, r = float(p), float(r)
if not (np.isfinite(p) and np.isfinite(r)):
return
prev, self._last_tilt = self._last_tilt, (p, r)
if prev is None or ts - self._started_at < self.TILT_SETTLE_S:
return
moved = float(np.hypot(p - prev[0], r - prev[1]))
if moved < self.TILT_MOVED_DEG:
return
detail = json.dumps({"tilt_deg": round(moved, 2),
"pitch": round(p, 2), "roll": round(r, 2),
"temp_c": row.get("temp_smooth")})
self.store.log_event("moved", "warn", detail, ts)
self.store.log_event("discontinuity", "warn",
f"board moved {moved:.1f} degrees, queuing a retrain", ts)
self.monitor.retrain_requested = True
def set_environment(self, environment: Optional[str] = None,
enclosure: Optional[str] = None,
note: str = "") -> Dict:
"""Record a change in the sensor's surroundings and act on it.
Not cosmetic. A door closing changes how strongly the sensor couples to
outside, which is a regime change in the very process the heads are
fitting. Their forgetting factor is 0.9985 on a five minute grid, about
55 hours of memory, so left alone they keep predicting the old room for
two days. Page-Hinkley would eventually notice from forecast error, but
it needs matured forecasts to do it, which at the longer horizons is
exactly the two days you were trying to skip.
So this does three things: writes a discontinuity marker so the record
shows where the regime changed, requests a retrain so the fit is redone
against recent data rather than drifting, and stores the new state for
the API and the Methods page to report honestly.
"""
changed = []
if environment and environment != self.cfg.site.environment:
changed.append(f"environment {self.cfg.site.environment} -> {environment}")
self.cfg.site.environment = environment
if enclosure and enclosure != self.cfg.site.enclosure:
changed.append(f"enclosure {self.cfg.site.enclosure} -> {enclosure}")
self.cfg.site.enclosure = enclosure
if not changed:
return {"changed": False, "environment": self.cfg.site.environment,
"enclosure": self.cfg.site.enclosure}
detail = "; ".join(changed) + (f" ({note})" if note else "")
self.store.log_event("environment", "info", detail)
self.store.log_event("discontinuity", "warn", detail)
self.monitor.retrain_requested = True
return {"changed": True, "environment": self.cfg.site.environment,
"enclosure": self.cfg.site.enclosure,
"retrain_requested": True, "detail": detail}
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.
@@ -285,6 +547,22 @@ class Station:
# ------------------------------------------------------------ train # ------------------------------------------------------------ train
# 2025-01-01. Any clock below this has not been set since boot, because
# this project did not exist before it.
CLOCK_FLOOR = 1735689600.0
def clock_sanity(self) -> Dict[str, Any]:
"""Is the wall clock usable for anything time-dependent?"""
now = time.time()
if now < self.CLOCK_FLOOR:
return {"ok": False, "now": now,
"reason": "clock is before 2025, so it has not been set since boot"}
newest = self.store.newest_ts()
if newest is not None and now < newest - 60.0:
return {"ok": False, "now": now, "newest": newest,
"reason": f"clock is {newest - now:.0f}s behind the newest stored row"}
return {"ok": True, "now": now}
def build_training_grid(self, hours: float = 24 * 30): def build_training_grid(self, hours: float = 24 * 30):
raw = self.store.window(hours, ["ts", "temp_smooth", "hum_smooth", raw = self.store.window(hours, ["ts", "temp_smooth", "hum_smooth",
"press_slp", "lux"]) "press_slp", "lux"])
@@ -302,11 +580,22 @@ class Station:
grid_ts, cols["temperature"], cols["humidity"], cols["pressure"], grid_ts, cols["temperature"], cols["humidity"], cols["pressure"],
cols["lux"], self.cfg.model.grid_s, cols["lux"], self.cfg.model.grid_s,
self.cfg.site.latitude, self.cfg.site.longitude, self.cfg.site.latitude, self.cfg.site.longitude,
self.cfg.model.climatology_min_days_annual,
) )
return grid_ts, cols, X, valid return grid_ts, cols, X, valid
def train(self, hours: float = 24 * 30) -> Dict: def train(self, hours: float = 24 * 30) -> Dict:
t_start = time.time() t_start = time.time()
clock = self.clock_sanity()
if not clock["ok"]:
# The board has no RTC. A power cut without a network gives a clock
# somewhere in 1970 on the next boot, and every feature that depends
# on absolute time then lies with total confidence: solar elevation,
# the diurnal harmonics, the position of a sample on the 5-minute
# grid. Training on that poisons the weights, and unlike a gap in
# the record it cannot be spotted afterwards.
self.store.log_event("clock", "error", json.dumps(clock))
return {"trained": False, "reason": clock["reason"]}
built = self.build_training_grid(hours) built = self.build_training_grid(hours)
if built is None: if built is None:
return {"trained": False, return {"trained": False,
@@ -314,7 +603,7 @@ class Station:
grid_ts, cols, X, valid = built grid_ts, cols, X, valid = built
clim_scores = self.climatology.fit(grid_ts, cols, valid) clim_scores = self.climatology.fit(grid_ts, cols, valid)
counts = self.nowcast.fit(X, valid, cols, self.climatology, grid_ts) counts = self.nowcast.fit(X, valid, cols)
self.last_train = time.time() self.last_train = time.time()
self.monitor.clear_retrain_flag() self.monitor.clear_retrain_flag()
@@ -350,7 +639,7 @@ class Station:
"humidity": float(self.live.get("hum_smooth", cols["humidity"][-1])), "humidity": float(self.live.get("hum_smooth", cols["humidity"][-1])),
"pressure": float(self.live.get("press_slp", cols["pressure"][-1])), "pressure": float(self.live.get("press_slp", cols["pressure"][-1])),
} }
fc = self.nowcast.forecast(x_now, anchors, now, self.climatology) fc = self.nowcast.forecast(x_now, anchors, now, self.climatology, setpoint_fn=self._setpoint_delta)
bundle: Dict[str, Any] = {"issued_ts": now, "anchors": anchors, "targets": {}} bundle: Dict[str, Any] = {"issued_ts": now, "anchors": anchors, "targets": {}}
for target, per_h in fc.items(): for target, per_h in fc.items():
@@ -369,7 +658,8 @@ class Station:
}) })
if persist: if persist:
self.store.insert_forecast(now, h, target, p["mu"], p["lo"], self.store.insert_forecast(now, h, target, p["mu"], p["lo"],
p["hi"], "ensemble") p["hi"], "ensemble",
members=p["members"])
bundle["targets"][target] = series bundle["targets"][target] = series
self.forecast_bundle = bundle self.forecast_bundle = bundle
@@ -390,6 +680,20 @@ class Station:
# ----------------------------------------------------------- verify # ----------------------------------------------------------- verify
@staticmethod
def _members_of(row) -> Optional[np.ndarray]:
"""The four member predictions a stored forecast was blended from.
None for rows written before the columns existed, which are still worth
giving to the conformal calibrator but cannot move the Hedge weights.
"""
vals = [row["m_persistence"], row["m_climatology"],
row["m_learned"], row["m_setpoint"]]
if any(v is None for v in vals):
return None
out = np.array([float(v) for v in vals])
return out if np.all(np.isfinite(out)) else None
def verify(self) -> Dict: def verify(self) -> Dict:
"""Score matured forecasts against truth and against persistence.""" """Score matured forecasts against truth and against persistence."""
due = self.store.due_forecasts() due = self.store.due_forecasts()
@@ -411,6 +715,7 @@ class Station:
return float(series[target][idx]) return float(series[target][idx])
buckets: Dict[tuple, Dict[str, List[float]]] = {} buckets: Dict[tuple, Dict[str, List[float]]] = {}
fed: List[tuple] = []
scored = 0 scored = 0
for row in due: for row in due:
target, h = row["target"], int(row["horizon_s"]) target, h = row["target"], int(row["horizon_s"])
@@ -423,10 +728,21 @@ class Station:
err = truth - row["mu"] err = truth - row["mu"]
b["err"].append(err) b["err"].append(err)
b["pers"].append(truth - anchor) b["pers"].append(truth - anchor)
b["cov"].append(1.0 if row["lo"] <= truth <= row["hi"] else 0.0) covered = bool(row["lo"] <= truth <= row["hi"])
b["cov"].append(1.0 if covered else 0.0)
head = self.nowcast.heads.get(key) head = self.nowcast.heads.get(key)
if head is not None: # A matured forecast stays readable for an hour so the buckets above
head.conformal.observe(err, covered=bool(row["lo"] <= truth <= row["hi"])) # can aggregate a rolling window, which means verify() sees it about
# twelve times. Aggregating it twelve times is harmless. Teaching
# the learner from it twelve times is not, so that happens once.
if head is not None and not row["scored"]:
members = self._members_of(row)
if members is None:
head.conformal.observe(err, covered=covered)
else:
head.observe_outcome(members, truth, covered=covered,
valid_ts=float(row["valid_ts"]))
fed.append((row["issued_ts"], h, target))
if h <= 10800: if h <= 10800:
self.monitor.observe_error(row["valid_ts"], abs(err)) self.monitor.observe_error(row["valid_ts"], abs(err))
scored += 1 scored += 1
@@ -448,9 +764,10 @@ class Station:
n=int(e.size), n=int(e.size),
) )
self.store.mark_forecasts_scored(fed)
with self.store._conn() as conn: with self.store._conn() as conn:
conn.execute("DELETE FROM forecasts WHERE valid_ts <= ?", (now - 3600,)) conn.execute("DELETE FROM forecasts WHERE valid_ts <= ?", (now - 3600,))
return {"scored": scored, "buckets": len(buckets)} return {"scored": scored, "learned": len(fed), "buckets": len(buckets)}
# ------------------------------------------------------------ loops # ------------------------------------------------------------ loops
@@ -492,10 +809,61 @@ class Station:
self.store.log_event("verify", "error", repr(exc)) self.store.log_event("verify", "error", repr(exc))
await asyncio.sleep(300) await asyncio.sleep(300)
# Joystick bindings. Left and right are the two answers to the only
# question the precipitation model cannot answer for itself.
STICK_LABELS = {"left": 0.0, "right": 1.0}
STICK_COLOURS = {0.0: (90, 90, 110), 1.0: (40, 110, 220)}
async def _loop_joystick(self):
"""Rain labels without a browser.
Precipitation is the weakest model in the bank and it is starved of the
only thing that would fix it. This station has 80 strong labels against
thousands of proxy ones, because the label button lives in a web page
and a web page is not where anyone is standing when it starts raining.
A button on the device is the whole difference between labelling and
intending to label.
Middle cycles the LED scene, which is the other thing you want from a
headless box and otherwise requires a laptop.
"""
while not self._stop.is_set():
try:
for direction, action in self.board.stick_events():
if action != "pressed":
continue
if direction in self.STICK_LABELS:
value = self.STICK_LABELS[direction]
result = await asyncio.to_thread(
self.add_label, "rain", value, None, "joystick")
self.store.log_event(
"joystick", "info",
json.dumps({"direction": direction, "rain": value,
"strong_labels": result.get("strong_labels")}))
if self.display is not None:
await asyncio.to_thread(
self.display.flash, self.STICK_COLOURS[value])
elif direction == "middle" and self.display is not None:
name = self.display.next_scene()
self.store.log_event("joystick", "info",
json.dumps({"scene": name}))
except Exception as exc:
self.store.log_event("joystick", "error", repr(exc))
await asyncio.sleep(0.25)
async def _loop_maintenance(self): async def _loop_maintenance(self):
while not self._stop.is_set(): while not self._stop.is_set():
await asyncio.sleep(3600) await asyncio.sleep(3600)
now = time.time() now = time.time()
# Undervoltage and thermal capping both move the SoC temperature,
# which is the input to the self-heating compensation, so a weak
# supply shows up as a temperature bias rather than as anything
# that looks like a power problem. Recorded so the anomaly is
# labelled rather than mysterious.
flags = read_throttled()
if flags:
self.store.log_event("throttled", flags["severity"],
json.dumps(flags))
if now - self.last_compact >= self.cfg.storage.vacuum_period_s: if now - self.last_compact >= self.cfg.storage.vacuum_period_s:
try: try:
removed = await asyncio.to_thread( removed = await asyncio.to_thread(
@@ -516,6 +884,7 @@ class Station:
asyncio.create_task(self._loop_train()), asyncio.create_task(self._loop_train()),
asyncio.create_task(self._loop_verify()), asyncio.create_task(self._loop_verify()),
asyncio.create_task(self._loop_maintenance()), asyncio.create_task(self._loop_maintenance()),
asyncio.create_task(self._loop_joystick()),
] ]
async def stop(self) -> None: async def stop(self) -> None:
+130 -6
View File
@@ -26,7 +26,7 @@ from __future__ import annotations
import sqlite3 import sqlite3
import threading import threading
import time import time
from typing import Any, Dict, Iterable, List, Optional from typing import Any, Dict, Iterable, List, Optional, Tuple
import numpy as np import numpy as np
@@ -35,7 +35,8 @@ TIER_5MIN = 1
TIER_HOUR = 2 TIER_HOUR = 2
COLUMNS = [ COLUMNS = [
"ts", "temp_raw", "temp_c", "temp_smooth", "temp_rate", "hum", "hum_smooth", "ts", "temp_raw", "temp_h", "temp_p", "temp_c", "temp_smooth", "temp_rate",
"hum", "hum_smooth",
"press", "press_slp", "press_smooth", "press_rate", "cpu_temp", "dew_c", "press", "press_slp", "press_smooth", "press_rate", "cpu_temp", "dew_c",
"lux", "r", "g", "b", "pitch", "roll", "yaw", "compass", "lux", "r", "g", "b", "pitch", "roll", "yaw", "compass",
"ax", "ay", "az", "gx", "gy", "gz", "ax", "ay", "az", "gx", "gy", "gz",
@@ -60,6 +61,14 @@ CREATE TABLE IF NOT EXISTS forecasts (
target TEXT NOT NULL, target TEXT NOT NULL,
mu REAL, lo REAL, hi REAL, mu REAL, lo REAL, hi REAL,
model TEXT, model TEXT,
-- What each ensemble member said at issue time. The blend weights are
-- learned from how these actually turned out, and the learned member is
-- not reconstructable after the fact: the RLS has moved on. If they are
-- not written down here the Hedge update has nothing honest to eat.
m_persistence REAL, m_climatology REAL, m_learned REAL, m_setpoint REAL,
-- A matured forecast stays readable for an hour so the scorecard can
-- aggregate a rolling window, but it must teach the learner exactly once.
scored INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (issued_ts, horizon_s, target) PRIMARY KEY (issued_ts, horizon_s, target)
); );
CREATE INDEX IF NOT EXISTS idx_forecast_valid ON forecasts(valid_ts); CREATE INDEX IF NOT EXISTS idx_forecast_valid ON forecasts(valid_ts);
@@ -91,12 +100,60 @@ CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
""" """
def _f(v: Any) -> Optional[float]:
return None if v is None else float(v)
# Columns the forecasts table has gained since the first schema, with their
# declarations. Same hazard as COLUMNS above: a live station's table already
# exists, so CREATE TABLE IF NOT EXISTS will not add them.
FORECAST_COLUMNS = [
("m_persistence", "REAL"),
("m_climatology", "REAL"),
("m_learned", "REAL"),
("m_setpoint", "REAL"),
("scored", "INTEGER NOT NULL DEFAULT 0"),
]
class Store: class Store:
def __init__(self, path: str): def __init__(self, path: str):
self.path = path self.path = path
self._local = threading.local() self._local = threading.local()
with self._conn() as conn: with self._conn() as conn:
conn.executescript(SCHEMA) conn.executescript(SCHEMA)
self._migrate(conn)
@staticmethod
def _migrate(conn: sqlite3.Connection) -> None:
"""Add any column COLUMNS has gained since this database was created.
CREATE TABLE IF NOT EXISTS is a no-op against a table that already
exists, so a new entry in COLUMNS reaches a fresh install and silently
misses every station that has been running. The failure then surfaces
as an OperationalError inside insert_telemetry, which is on the sample
loop, so a column addition would take a live station down rather than
merely leaving a gap in its record.
"""
have = {row[1] for row in conn.execute("PRAGMA table_info(telemetry)")}
if not have:
return
for col in COLUMNS:
if col in have:
continue
if not col.isidentifier():
raise ValueError(f"refusing to splice a non-identifier column: {col!r}")
conn.execute(f"ALTER TABLE telemetry ADD COLUMN {col} REAL")
have = {row[1] for row in conn.execute("PRAGMA table_info(forecasts)")}
if not have:
return
for col, decl in FORECAST_COLUMNS:
if col in have:
continue
if not col.isidentifier():
raise ValueError(f"refusing to splice a non-identifier column: {col!r}")
conn.execute(f"ALTER TABLE forecasts ADD COLUMN {col} {decl}")
def _conn(self) -> sqlite3.Connection: def _conn(self) -> sqlite3.Connection:
conn = getattr(self._local, "conn", None) conn = getattr(self._local, "conn", None)
@@ -120,16 +177,32 @@ class Store:
) )
def insert_forecast(self, issued_ts: float, horizon_s: int, target: str, def insert_forecast(self, issued_ts: float, horizon_s: int, target: str,
mu: float, lo: float, hi: float, model: str) -> None: mu: float, lo: float, hi: float, model: str,
members: Optional[Dict[str, float]] = None) -> None:
m = members or {}
with self._conn() as conn: with self._conn() as conn:
conn.execute( conn.execute(
"INSERT OR REPLACE INTO forecasts " "INSERT OR REPLACE INTO forecasts "
"(issued_ts, valid_ts, horizon_s, target, mu, lo, hi, model) " "(issued_ts, valid_ts, horizon_s, target, mu, lo, hi, model, "
"VALUES (?,?,?,?,?,?,?,?)", " m_persistence, m_climatology, m_learned, m_setpoint, scored) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,0)",
(issued_ts, issued_ts + horizon_s, horizon_s, target, (issued_ts, issued_ts + horizon_s, horizon_s, target,
float(mu), float(lo), float(hi), model), float(mu), float(lo), float(hi), model,
_f(m.get("persistence")), _f(m.get("climatology")),
_f(m.get("learned")), _f(m.get("setpoint"))),
) )
def mark_forecasts_scored(self, keys: Iterable[Tuple[float, int, str]]) -> int:
"""Flag forecasts as already fed to the learner."""
rows = [(float(i), int(h), str(t)) for i, h, t in keys]
if not rows:
return 0
with self._conn() as conn:
cur = conn.executemany(
"UPDATE forecasts SET scored = 1 "
"WHERE issued_ts = ? AND horizon_s = ? AND target = ?", rows)
return int(cur.rowcount)
def insert_label(self, ts: float, kind: str, value: float, note: str = "") -> None: def insert_label(self, ts: float, kind: str, value: float, note: str = "") -> None:
with self._conn() as conn: with self._conn() as conn:
conn.execute( conn.execute(
@@ -154,6 +227,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
@@ -348,6 +467,11 @@ class Store:
with self._conn() as conn: with self._conn() as conn:
return int(conn.execute("SELECT COUNT(*) FROM telemetry").fetchone()[0]) return int(conn.execute("SELECT COUNT(*) FROM telemetry").fetchone()[0])
def newest_ts(self) -> Optional[float]:
with self._conn() as conn:
row = conn.execute("SELECT MAX(ts) FROM telemetry").fetchone()
return float(row[0]) if row and row[0] is not None else None
def span_days(self) -> float: def span_days(self) -> float:
with self._conn() as conn: with self._conn() as conn:
row = conn.execute("SELECT MIN(ts), MAX(ts) FROM telemetry").fetchone() row = conn.execute("SELECT MIN(ts), MAX(ts) FROM telemetry").fetchone()
+12 -1
View File
@@ -14,8 +14,19 @@ sensor:
persist_period_s: 30.0 persist_period_s: 30.0
rotation_deg: 90 rotation_deg: 90
cpu_heat_k: 0.55 # starting point only, calibrate from the dashboard cpu_heat_k: 0.55 # starting point only, calibrate from the dashboard
kalman_q_temp: 2.0e-6 # raise to track faster, lower to smooth harder # Process noise. Raise to track faster, lower to smooth harder. These were
# retuned against noise measured on a real board by sweeping each q against
# the RMSE of the reported rate versus the true rate. The originals tracked
# two to three decades faster than any of these signals move: in a still room
# the temperature filter reported a median rate of 12.4 C/h while the air
# moved 0.4 C/h. All three are listed because this file shadows the defaults
# in ashvale/config.py, and a value present here silently wins.
kalman_q_temp: 1.0e-9
kalman_r_temp: 0.02 kalman_r_temp: 0.02
kalman_q_press: 1.0e-8
kalman_r_press: 0.05
kalman_q_hum: 2.0e-8
kalman_r_hum: 0.60
model: model:
grid_s: 300 grid_s: 300
+116
View File
@@ -0,0 +1,116 @@
# Getting Ashvale Station onto a Pi
Two routes. Pick the second one unless the card is empty.
| | Use when | Cost |
|---|---|---|
| **Prebuilt image** | A blank SD card, or you want a known-good starting point | ~500 MB download, reflashes the card |
| **Install script** | You already have a working Pi OS | ~2 minutes, keeps everything else |
---
## Install script (recommended)
Works on any Raspberry Pi already running Raspberry Pi OS (Bookworm or Trixie).
```bash
curl -fsSL https://raw.githubusercontent.com/lynchaos/ashvale-station/main/deploy/install.sh | sudo bash
```
Installs the apt dependencies, clones into `/opt/ashvale`, builds a venv with
`--system-site-packages`, enables I2C, and installs and starts a systemd unit.
Then it **polls the HTTP endpoint** rather than trusting systemd, because a unit
that is crash-looping on a port clash reports `active` for the instant between
exec and its first failed bind.
Re-running it upgrades in place. It never touches `data/` or `config.yaml`,
because those are your history and your coordinates and neither is recoverable.
Environment overrides, mostly useful for running a second instance beside a
live one:
```bash
ASHVALE_DEST=/opt/ashvale-2 ASHVALE_SERVICE=ashvale-2 ASHVALE_PORT=8099 \
sudo -E bash deploy/install.sh
```
Reboot afterwards if it told you it enabled I2C: the Sense HAT is not detected
until you do, and until then the station silently runs its simulator, which
looks like it is working and is measuring nothing.
---
## Prebuilt image
Download the `.img.xz` and its `.sha256` from
[Releases](https://github.com/lynchaos/ashvale-station/releases), verify, and
flash with Raspberry Pi Imager.
```bash
sha256sum -c ashvale-station-*.img.xz.sha256
```
**Set your username, password and WiFi in Imager's customisation dialog.** The
image deliberately contains none of them. It also contains no SSH host keys:
those are generated on first boot, because an image shipping real host keys
would give every person who flashed it the same identity and make them trivially
impersonable on their own network.
Boot it, wait a minute or two for the first-boot expansion, then open
`http://<your-pi>:8000`.
### What is in it
Raspberry Pi OS Lite, Trixie, arm64, plus:
- the application in `/opt/ashvale` with its venv already built
- `ashvale.service`, enabled
- I2C enabled in `config.txt`, which the Sense HAT needs
- a login banner with the address and the security caveat
- `/opt/ashvale/README.first-boot`
Lite, not Desktop: the station is headless and a desktop would eat the 512 MB
budget the whole project is designed around.
### What is deliberately not in it
No password, no WiFi credentials, no SSH host keys, no database, no trained
model state, and coordinates set to Greenwich at 0 m. That last one is wrong for
everybody on purpose: altitude feeds the sea-level pressure reduction on every
stored row, and pressure tendency is what drives the precipitation forecast, so
a plausible-looking wrong altitude is worse than an obviously wrong one.
The build copies the working tree through a `.gitignore` filter rather than a
hand-written exclude list. That is a security property: a hand-written list
missed `HANDOVER.md`, which is gitignored precisely because it holds LAN
addresses and SSH details, and a local build would have baked one person's
network into an image other people flash.
---
## Building the image yourself
CI does it on every tag via `.github/workflows/image.yml`, on a pinned pi-gen
commit so the output does not move when an upstream branch does. To build
locally you need a Linux host (or Docker) with `qemu-user-static`:
```bash
git clone --branch arm64 https://github.com/RPi-Distro/pi-gen
cp deploy/pi-image/config pi-gen/config
cp -r deploy/pi-image/stage-ashvale pi-gen/
touch pi-gen/stage3/SKIP pi-gen/stage4/SKIP pi-gen/stage5/SKIP
rm -f pi-gen/stage2/EXPORT_IMAGE
echo "ASHVALE_SRC=$PWD" >> pi-gen/config
cd pi-gen && sudo -E ./build.sh
```
Expect roughly an hour and about 10 GB of scratch space.
---
## Licensing
Ashvale Station is Apache 2.0. The image also contains Raspberry Pi OS and
Debian, which carry their own licences including some non-free firmware. It is
an unofficial image and is not endorsed by or affiliated with Raspberry Pi Ltd
or the Debian project.
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env bash
# Install Ashvale Station onto a Raspberry Pi that is already running.
#
# Most people already have a working Pi and should not have to reflash a card to
# try this. The prebuilt image exists for a fresh board; this exists for
# everything else, and it is the same install the image performs.
#
# curl -fsSL https://raw.githubusercontent.com/lynchaos/ashvale-station/main/deploy/install.sh | bash
#
# Idempotent: safe to re-run to upgrade. It never touches data/ or config.yaml
# on a machine that already has them, because those are your history and your
# coordinates and neither can be regenerated.
set -euo pipefail
REPO="${ASHVALE_REPO:-https://github.com/lynchaos/ashvale-station.git}"
DEST="${ASHVALE_DEST:-/opt/ashvale}"
BRANCH="${ASHVALE_BRANCH:-main}"
SERVICE="${ASHVALE_SERVICE:-ashvale}"
# Overridable so a second instance can be installed alongside a live one,
# which is also the only way to test this script without stopping the real
# station. Empty means "whatever config.yaml says".
PORT_OVERRIDE="${ASHVALE_PORT:-}" # overridable so the installer can be tested without clobbering a live unit
say() { printf '\n\033[1;36m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m !\033[0m %s\n' "$*"; }
die() { printf '\033[1;31m x\033[0m %s\n' "$*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "run with sudo: curl ... | sudo bash"
if ! grep -qi raspberry /proc/device-tree/model 2>/dev/null; then
warn "this does not look like a Raspberry Pi; continuing anyway"
fi
RUN_USER="${SUDO_USER:-$(getent passwd 1000 | cut -d: -f1)}"
[ -n "$RUN_USER" ] || die "could not determine a non-root user to run the service as"
say "Installing system packages"
# Hardware libraries come from apt, never pip. sense-hat pulls in RTIMULib, and
# building that inside a clean venv on ARM is a genuine ordeal.
apt-get update -qq
apt-get install -y --no-install-recommends \
git python3-venv python3-numpy python3-smbus2 sense-hat sqlite3
say "Fetching the application into $DEST"
if [ -d "$DEST/.git" ]; then
git -C "$DEST" fetch --depth 1 origin "$BRANCH"
git -C "$DEST" reset --hard "origin/$BRANCH"
else
install -d "$DEST"
git clone --depth 1 --branch "$BRANCH" "$REPO" "$DEST"
fi
say "Creating the virtual environment"
# --system-site-packages so numpy and the Sense HAT stack come from apt rather
# than being compiled here. Plain uvicorn, never uvicorn[standard]: that extra
# drags in watchfiles and uvloop, which compile Rust and C from source on ARM
# for features this does not use.
[ -d "$DEST/.venv" ] || python3 -m venv --system-site-packages "$DEST/.venv"
"$DEST/.venv/bin/pip" install -q --upgrade pip
"$DEST/.venv/bin/pip" install -q --no-cache-dir -r "$DEST/requirements.txt"
install -d -o "$RUN_USER" -g "$RUN_USER" "$DEST/data" "$DEST/data/state"
chown -R "$RUN_USER":"$RUN_USER" "$DEST"
say "Enabling I2C for the Sense HAT"
CFG=/boot/firmware/config.txt
[ -f "$CFG" ] || CFG=/boot/config.txt
if [ -f "$CFG" ] && ! grep -q '^dtparam=i2c_arm=on' "$CFG"; then
printf '\n# --- Ashvale Station ---\ndtparam=i2c_arm=on\n' >> "$CFG"
warn "I2C enabled: reboot before the Sense HAT is detected"
fi
say "Installing the service"
cat > "/etc/systemd/system/$SERVICE.service" <<UNIT
[Unit]
Description=Ashvale Station forecast service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$RUN_USER
WorkingDirectory=$DEST
${PORT_OVERRIDE:+Environment=ASHVALE_SERVER__PORT=$PORT_OVERRIDE}
ExecStart=$DEST/.venv/bin/python run.py
Restart=always
RestartSec=10
# A Zero 2 W has 512 MB. Cap the service so a runaway allocation takes the
# service down instead of the whole board.
MemoryMax=280M
CPUWeight=70
Nice=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ashvale
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable "$SERVICE" >/dev/null
systemctl restart "$SERVICE"
# Poll the endpoint, not systemd. "active" is true for the instant between
# exec and the first failed bind, so a unit that is crash-looping on a port
# clash reports healthy and the installer congratulates you on a broken install.
# Asking the thing whether it answers is the only check that means anything.
PORT="$PORT_OVERRIDE"
[ -n "$PORT" ] || PORT="$(sed -n 's/^ *port: *\([0-9]\+\).*/\1/p' "$DEST/config.yaml" | head -1)"
PORT="${PORT:-8000}"
IP=$(hostname -I 2>/dev/null | awk '{print $1}')
for _ in $(seq 1 30); do
if curl -fsS -o /dev/null --max-time 2 "http://127.0.0.1:${PORT}/api/status"; then
say "Running: http://${IP:-<this-pi>}:${PORT}"
HEALTHY=1; break
fi
sleep 2
done
if [ "${HEALTHY:-0}" != 1 ]; then
systemctl is-active --quiet "$SERVICE" \
&& warn "unit is up but nothing is answering on port ${PORT}; is it already in use?" \
|| warn "unit is not running"
die "install finished but the station is not serving: journalctl -u $SERVICE -n 40"
fi
cat <<NOTE
Three things worth doing, in order of how much they matter:
1. Settings tab: set your latitude, longitude and altitude. Altitude feeds
the sea-level pressure reduction on every row, and pressure tendency is
what drives the precipitation forecast.
2. Models and Calibration tab: put a thermometer next to the board and enter
the reading. The Sense HAT sits above a SoC running 20 C hotter than the
room; one reading fixes the bias on every forecast that follows.
3. Settings tab: say whether it is indoors, and tell it when you open a
window or turn the heating on. Those are regime changes and the models
carry about 55 hours of memory.
Forecasts need about 10 hours of history before the first training pass.
No authentication and no TLS: trusted LAN only, do not port-forward it.
NOTE
+26
View File
@@ -0,0 +1,26 @@
# pi-gen configuration for the Ashvale Station image.
#
# Built on Raspberry Pi OS Lite (Trixie, arm64), which is what a Zero 2 W
# actually runs: aarch64, Debian 13. Lite because the station is headless and a
# desktop would eat the 512 MB budget the whole project is built around.
#
# Nothing secret belongs in this file. No password, no WiFi, no SSH host keys.
# The first two are supplied by the user in Raspberry Pi Imager, and the third
# is generated on first boot: an image with baked host keys gives every user
# the same key and makes them trivially impersonable on their own network.
IMG_NAME=ashvale-station
RELEASE=trixie
# ARCH is not set here: the arm64 branch of pi-gen exports it unconditionally,
# so a value here would be silently ignored and imply a choice that is not ours.
# Lite only. stage3 and up add the desktop.
STAGE_LIST="stage0 stage1 stage2 stage-ashvale"
# No default user. Raspberry Pi OS refuses to boot to a login without one, which
# is deliberate: it forces the person flashing the card to choose credentials in
# Imager rather than inheriting ours.
DISABLE_FIRST_BOOT_USER_RENAME=0
DEPLOY_COMPRESSION=xz
COMPRESSION_LEVEL=6
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash -e
# Runs inside the target filesystem, so pip resolves against the image's Python.
# --system-site-packages so numpy and the Sense HAT stack come from apt. Building
# them in a clean venv on ARM means compiling RTIMULib and numpy from source,
# which is an ordeal on a Zero 2 W and pointless when Debian ships both.
python3 -m venv --system-site-packages /opt/ashvale/.venv
/opt/ashvale/.venv/bin/pip install --no-cache-dir --upgrade pip
# Plain uvicorn, never uvicorn[standard]: the extra pulls watchfiles and uvloop,
# both of which compile Rust and C from source on ARM for features unused here.
/opt/ashvale/.venv/bin/pip install --no-cache-dir -r /opt/ashvale/requirements.txt
install -d -o 1000 -g 1000 /opt/ashvale/data
systemctl enable ashvale-firstboot.service
systemctl enable ashvale.service
# Belt and braces. Raspberry Pi OS regenerates these on first boot, but an image
# that shipped real host keys would give every flasher the same identity, so
# make certain none are present in the artifact.
rm -f /etc/ssh/ssh_host_*
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash -e
# Runs on the build host with ${ROOTFS_DIR} pointing at the target filesystem.
install -d "${ROOTFS_DIR}/opt/ashvale"
# The application source, straight from the repository being built.
#
# The filter honours .gitignore rather than listing excludes by hand, and that
# is a security property, not tidiness. A hand-written list missed HANDOVER.md,
# which is gitignored precisely because it contains LAN addresses and SSH
# details: a local build would have baked one person's network into an image
# other people flash. Anything not fit to commit is not fit to ship.
rsync -a --delete \
--filter=':- .gitignore' \
--exclude '.git/' --exclude '.gitignore' --exclude 'deploy/' \
--exclude 'tests/' --exclude '.github/' --exclude '.DS_Store' \
"${ASHVALE_SRC}/" "${ROOTFS_DIR}/opt/ashvale/"
install -m 644 files/ashvale.service "${ROOTFS_DIR}/etc/systemd/system/ashvale.service"
install -m 755 files/ashvale-firstboot "${ROOTFS_DIR}/usr/local/sbin/ashvale-firstboot"
install -m 644 files/ashvale-firstboot.service "${ROOTFS_DIR}/etc/systemd/system/ashvale-firstboot.service"
install -m 755 files/motd.sh "${ROOTFS_DIR}/etc/update-motd.d/20-ashvale"
install -m 644 files/README.first-boot "${ROOTFS_DIR}/opt/ashvale/README.first-boot"
# I2C is not optional: without it the Sense HAT is invisible and the station
# silently falls back to its simulator, which looks like it works and is not
# measuring anything.
CONFIG_TXT="${ROOTFS_DIR}/boot/firmware/config.txt"
[ -f "$CONFIG_TXT" ] || CONFIG_TXT="${ROOTFS_DIR}/boot/config.txt"
if ! grep -q '^dtparam=i2c_arm=on' "$CONFIG_TXT"; then
cat >> "$CONFIG_TXT" <<'CFG'
# --- Ashvale Station ---
# Sense HAT sits on I2C. Without this the board is not detected at all.
dtparam=i2c_arm=on
# Uncomment for a DS18B20 outdoor probe on GPIO4. Left off by default because
# it claims that pin whether or not a sensor is attached.
#dtoverlay=w1-gpio
CFG
fi
@@ -0,0 +1,41 @@
Ashvale Station
===============
The service is already running. Open http://<this-pi>:8000 from the same network.
Three things to do, in order of how much they matter.
1. Set your location. Settings tab -> Site and model. Altitude feeds the
sea-level pressure reduction on every stored row, and pressure tendency is
what drives the precipitation forecast, so a wrong altitude quietly biases
the thing this station is best at. The image ships Greenwich at 0 m, which
is wrong for you on purpose.
2. Calibrate the temperature. Models and Calibration tab. Put any thermometer
next to the board, wait ten minutes, type the number in. The Sense HAT sits
millimetres above a SoC running 20 C hotter than the room, and one reading
fixes the bias on every forecast that follows. This is the highest value per
minute of anything you can do.
3. Tell it where it lives. Settings tab -> Surroundings. Indoors the building
governs temperature and humidity rather than the sky. Say so, and say when
you open a window or turn the heating on, because those are changes of
regime and the models carry about 55 hours of memory.
Forecasts appear after about 10 hours of history and the scorecard fills in over
the following day as each horizon matures. Until a head has been scored, its
forecast is an opinion.
Security
--------
No authentication, no TLS, binds 0.0.0.0. It is built for a trusted home
network. Do not port-forward it: /api/train, /api/calibrate, /api/label and
/api/settings all mutate model state.
Where things are
----------------
/opt/ashvale application
/opt/ashvale/config.yaml site configuration
/opt/ashvale/data database and trained state (never in the image)
systemctl status ashvale service
journalctl -u ashvale -f logs
@@ -0,0 +1,24 @@
#!/bin/bash
# Runs once, before the station starts. Everything here is deliberately absent
# from the image itself, because baking it in would mean every person who
# flashed this card shared the same secrets or the same location.
set -e
STATE=/opt/ashvale/data
CONF=/opt/ashvale/config.yaml
UID_MAIN=$(getent passwd 1000 | cut -d: -f1)
install -d -o 1000 -g 1000 "$STATE" "$STATE/state"
# Site coordinates default to Greenwich, not to the author's house. They are
# wrong for everyone, which is the point: the Methods tab and the sea-level
# reduction both depend on them, so they should be conspicuously wrong until set.
if [ -f "$CONF" ] && ! grep -q 'ASHVALE_FIRSTBOOT_DONE' "$CONF"; then
sed -i 's/^\( *latitude:\).*/\1 51.4779 # CHANGE ME: Settings tab or this file/' "$CONF"
sed -i 's/^\( *longitude:\).*/\1 0.0015 # CHANGE ME/' "$CONF"
sed -i 's/^\( *altitude_m:\).*/\1 0.0 # CHANGE ME: wrong altitude skews sea-level pressure/' "$CONF"
echo "# ASHVALE_FIRSTBOOT_DONE" >> "$CONF"
chown 1000:1000 "$CONF"
fi
logger -t ashvale-firstboot "prepared state for user ${UID_MAIN:-uid1000}"
systemctl disable ashvale-firstboot.service || true
@@ -0,0 +1,13 @@
[Unit]
Description=Ashvale Station first-boot preparation
After=local-fs.target
Before=ashvale.service
ConditionPathExists=!/opt/ashvale/data/state
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/ashvale-firstboot
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,27 @@
[Unit]
Description=Ashvale Station forecast service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=1000
Group=1000
WorkingDirectory=/opt/ashvale
ExecStart=/opt/ashvale/.venv/bin/python run.py
Restart=always
RestartSec=10
# A Zero 2 W has 512 MB. Cap the service so a runaway allocation takes the
# service down instead of the whole board.
MemoryMax=280M
CPUWeight=70
Nice=5
# The SD card is a consumable: keep journald from writing every heartbeat.
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ashvale
[Install]
WantedBy=multi-user.target
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
IP=$(hostname -I 2>/dev/null | awk '{print $1}')
printf '\n Ashvale Station -> http://%s:8000\n' "${IP:-<this-pi>}"
printf ' status: %s\n' "$(systemctl is-active ashvale 2>/dev/null || echo unknown)"
printf '\n No authentication and no TLS. Trusted LAN only: do not port-forward it.\n'
printf ' Set your coordinates and altitude on the Settings tab before trusting\n'
printf ' the pressure readings. See /opt/ashvale/README.first-boot\n\n'
@@ -0,0 +1,5 @@
python3-venv
python3-numpy
python3-smbus2
sense-hat
sqlite3
@@ -0,0 +1 @@
IMG_SUFFIX=""
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash -e
if [ ! -d "${ROOTFS_DIR}" ]; then
copy_previous
fi
+62 -1
View File
@@ -99,6 +99,27 @@ exact inverse:** `T_raw = (T + k·T_cpu)/(1 + k)`. Generating the bias as
1.2 °C of phantom noise floor that caps every skill score. This has already 1.2 °C of phantom noise floor that caps every skill score. This has already
happened once. happened once.
### Humidity compensation
`HumidityCompensator` carries an additive `offset` on relative humidity,
estimated from a trusted hygrometer by the same one-step RLS used for `k`, with
the regressor fixed at 1 so repeated calibrations converge to a weighted mean.
Clamped to +/-35% for the same reason `k` is clamped.
It also implements a psychrometric term, moving RH from the element's
temperature onto the compensated air temperature through conserved vapour
pressure, `RH_true = RH_sensor * es(T_sensor) / es(T_true)`. That term is
**off by default**, and the reason is worth recording. 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 by 25 points, and
the correction would have pushed it further the wrong way. The dominant error on
this hardware is additive element bias, not a thermal gradient.
If you enable `sensor.hum_psychrometric`, `scripts/simulate.py` applies the exact
inverse when generating synthetic humidity. It has to: the same
simulator/compensator algebra trap described above for temperature applies here,
and getting it wrong bakes in a bias no calibration can remove.
### The Kalman bank ### The Kalman bank
One constant-velocity filter per signal. State `x = [level, rate]`, standard One constant-velocity filter per signal. State `x = [level, rate]`, standard
@@ -219,6 +240,45 @@ re-weights within about a day when the season turns.
15-minute pressure it typically parks most of its weight on persistence. That 15-minute pressure it typically parks most of its weight on persistence. That
is correct behaviour surfaced honestly, not a defect to engineer away. is correct behaviour surfaced honestly, not a defect to engineer away.
### The thermostat member
A room held at a setpoint is not the same process as a room that is free to
drift. It is a closed loop, and persistence, the baseline everything here is
scored against, is simply the wrong statement about it: the truth is not "it
stays where it is", it is "it returns to the setpoint".
So when `site.heating` is on, the ensemble gains a fourth member:
```
dT_set(h) = (T_set - T_now) * (1 - exp(-h / tau))
```
First order, because that is what a controlled system is: `tau` is the time to
close about 63% of the gap. Zero at h = 0, asymptotic to the full correction.
Humidity follows and is the part that is easy to get wrong. Heating adds no
moisture, so what is conserved is vapour pressure, not relative humidity:
```
RH(h) = RH_now * es(T_now) / es(T_now + dT_set(h))
```
Warm the air and RH falls although nothing was dried. This is why a heated house
in winter is dry, and the test asserts the dew point is unchanged to 1e-6.
Pressure gets zero: a thermostat cannot move the synoptic field.
**It is offered, not imposed.** The Hedge weights score this member against the
others on realised error like any other, so a wrong `tau` or a setpoint you
forgot to update costs accuracy and gets down-weighted, rather than quietly
biasing every forecast. With heating off the member returns zero, which makes it
identical to persistence and therefore harmless.
Adding it changed the member count from three to four, so `ForecastHead.from_dict`
reinitialises `weights` **and** `member_mae` when a saved head has the old
length. Missing the second one did not fail on load: it failed later inside
`learn()` on a broadcast error, which is a much worse place to find out.
### Adaptive conformal intervals ### Adaptive conformal intervals
Split conformal is valid only under exchangeability, and weather is emphatically Split conformal is valid only under exchangeability, and weather is emphatically
@@ -302,7 +362,8 @@ consecutive readings are the only tell.
| Symptom | Knob | Direction | | Symptom | Knob | Direction |
|---|---|---| |---|---|---|
| Temperature reads consistently high | Calibrate from the Models tab, or `sensor.cpu_heat_k` | Raise | | Temperature reads consistently high | Calibrate from the Models and Calibration tab, or `sensor.cpu_heat_k` | Raise |
| Humidity reads consistently off | Calibrate against a reference hygrometer, or `sensor.hum_offset` | Either |
| Readings over-smoothed, lag real change | `sensor.kalman_q_temp` | Raise | | Readings over-smoothed, lag real change | `sensor.kalman_q_temp` | Raise |
| Rates look noisy | `sensor.kalman_q_*` down, or `kalman_r_*` up | | | Rates look noisy | `sensor.kalman_q_*` down, or `kalman_r_*` up | |
| NIS persistently much above 1 | Filter too confident, raise `q` | Raise | | NIS persistently much above 1 | Filter too confident, raise `q` | Raise |
+4 -1
View File
@@ -77,5 +77,8 @@ line-length = 100
target-version = "py39" target-version = "py39"
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B"] # UP is deliberately absent. With target-version = "py39" it fights the
# `from __future__ import annotations` style used throughout and generates
# several hundred findings for no behavioural gain. B currently finds nothing.
select = ["E", "F", "W", "I"]
ignore = ["E501"] ignore = ["E501"]
+5 -1
View File
@@ -37,9 +37,13 @@ def main() -> None:
# One worker, one event loop. The station owns mutable model state, so a # One worker, one event loop. The station owns mutable model state, so a
# second worker would give you two divergent forecasters sharing a socket. # second worker would give you two divergent forecasters sharing a socket.
# timeout_graceful_shutdown bounds the wait for in-flight requests. Without
# it, the dashboard's server-sent-events connection never completes, so a
# stop blocks until systemd's 90 s timeout and ends in SIGKILL. Measured:
# with one stream client open, shutdown went from "never" to under 2 s.
uvicorn.run("ashvale.api:app", host=args.host, port=args.port, uvicorn.run("ashvale.api:app", host=args.host, port=args.port,
reload=args.reload, workers=1, log_level="info", reload=args.reload, workers=1, log_level="info",
limit_concurrency=32) limit_concurrency=32, timeout_graceful_shutdown=5)
if __name__ == "__main__": if __name__ == "__main__":
+9 -6
View File
@@ -47,11 +47,11 @@ import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from ashvale.config import load_config # noqa: E402 from ashvale.config import load_config # noqa: E402
from ashvale.features import build_features # noqa: E402 from ashvale.features import build_features # noqa: E402
from ashvale.models.climatology import HarmonicClimatology # noqa: E402 from ashvale.models.climatology import HarmonicClimatology # noqa: E402
from ashvale.models.nowcast import NowcastEnsemble # noqa: E402 from ashvale.models.nowcast import NowcastEnsemble # noqa: E402
from ashvale.storage import Store, resample # noqa: E402 from ashvale.storage import Store, resample # noqa: E402
def horizon_label(seconds: int) -> str: def horizon_label(seconds: int) -> str:
@@ -86,7 +86,8 @@ def main() -> None:
) )
X, valid = build_features(grid_ts, cols["temperature"], cols["humidity"], X, valid = build_features(grid_ts, cols["temperature"], cols["humidity"],
cols["pressure"], cols["lux"], cfg.model.grid_s, cols["pressure"], cols["lux"], cfg.model.grid_s,
cfg.site.latitude, cfg.site.longitude) cfg.site.latitude, cfg.site.longitude,
cfg.model.climatology_min_days_annual)
n = grid_ts.size n = grid_ts.size
split = int(n * args.train_frac) split = int(n * args.train_frac)
@@ -163,7 +164,9 @@ def main() -> None:
f"{r['bias']:>+8.3f} {r['weights']}{flag}") f"{r['bias']:>+8.3f} {r['weights']}{flag}")
print() print()
print(f"units: temperature C, humidity %, pressure hPa") # Driven off the dict rather than hardcoded, so adding a target cannot leave
# the units line silently describing the wrong columns.
print("units: " + ", ".join(f"{t} {units[t]}" for t in cfg.model.targets if t in units))
print("coverage should sit near 90% if the conformal calibration is honest.") print("coverage should sit near 90% if the conformal calibration is honest.")
+47 -11
View File
@@ -50,15 +50,26 @@ import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from ashvale.config import load_config # noqa: E402 from ashvale.config import load_config # noqa: E402
from ashvale.estimation import SignalTracker # noqa: E402 from ashvale.estimation import SignalTracker # noqa: E402
from ashvale.physics import (dew_point, sea_level_pressure, # noqa: E402 from ashvale.physics import ( # noqa: E402
solar_position, clear_sky_irradiance) clear_sky_irradiance,
from ashvale.storage import Store # noqa: E402 dew_point,
sea_level_pressure,
solar_position,
)
from ashvale.sensors import ( # noqa: E402
K_HTS221,
K_LPS25HB,
SD_HTS221,
SD_LPS25HB,
)
from ashvale.storage import Store # noqa: E402
def generate(days: float, step_s: int, lat: float, lon: float, def generate(days: float, step_s: int, lat: float, lon: float,
seed: int = 11, end: float | None = None) -> dict: seed: int = 11, end: float | None = None,
psychrometric: bool = False) -> dict:
rng = np.random.default_rng(seed) rng = np.random.default_rng(seed)
n = int(days * 86400 / step_s) n = int(days * 86400 / step_s)
# Anchoring to wall clock makes a fixed seed insufficient for reproducibility: # Anchoring to wall clock makes a fixed seed insufficient for reproducibility:
@@ -125,13 +136,36 @@ def generate(days: float, step_s: int, lat: float, lon: float,
# model must be its exact inverse: T_raw = (T + k T_cpu) / (1 + k). # model must be its exact inverse: T_raw = (T + k T_cpu) / (1 + k).
# Generating it any other way bakes a bias into the synthetic data that # Generating it any other way bakes a bias into the synthetic data that
# no amount of calibration can remove, and quietly caps your skill score. # no amount of calibration can remove, and quietly caps your skill score.
k_true = 0.55 # Two thermometers, not one, because the board has two. Their forward
temp_raw = (temp + k_true * cpu) / (1.0 + k_true) + 0.05 * rng.normal(size=n) # models average to the k = 0.55 case this used to generate directly, so
# temp_raw is unchanged in expectation. Its noise is not: a real board
# averages sd 0.060 with sd 0.443 and lands at 0.223, where this used to
# claim 0.05. Simulating the quiet sensor and calling it the average is
# what let an over-optimistic measurement noise go unnoticed.
temp_h = (temp + K_HTS221 * cpu) / (1.0 + K_HTS221) + SD_HTS221 * rng.normal(size=n)
temp_p = (temp + K_LPS25HB * cpu) / (1.0 + K_LPS25HB) + SD_LPS25HB * rng.normal(size=n)
temp_raw = (temp_h + temp_p) / 2.0
# If the compensator will move RH from the element temperature onto the air
# temperature, the forward model here must be its exact inverse, or the
# synthetic data bakes in a bias no calibration can remove. Same trap as the
# thermal algebra above. Off by default, matching sensor.hum_psychrometric.
if psychrometric:
es_raw = 6.112 * np.exp(17.625 * temp_raw / (243.04 + temp_raw))
rh_sensor = np.clip(rh * es_t / es_raw, 0.0, 100.0)
else:
rh_sensor = rh
press_station = press_slp / (1.0 + 0.0) - 1.8 # nominal 15 m offset press_station = press_slp / (1.0 + 0.0) - 1.8 # nominal 15 m offset
press_station += 0.05 * rng.normal(size=n) # 0.02 hPa, measured on a real LPS25HB as the sd of the change between
# 30 s samples. The 0.05 this used to carry made simulated pressure about
# twice as noisy as the real thing, which flatters any smoother tested
# against it and understates the skill available at short lead.
press_station += 0.02 * rng.normal(size=n)
return { return {
"ts": ts, "temp": temp, "temp_raw": temp_raw, "rh": rh + 0.4 * rng.normal(size=n), "ts": ts, "temp": temp, "temp_raw": temp_raw,
"temp_h": temp_h, "temp_p": temp_p,
"rh": rh_sensor + 0.4 * rng.normal(size=n),
"press": press_station, "press_slp": press_slp, "cpu": cpu, "press": press_station, "press_slp": press_slp, "cpu": cpu,
"lux": lux * (0.85 + 0.3 * rng.random(n)), "dew": dew, "cloud": cloud, "lux": lux * (0.85 + 0.3 * rng.random(n)), "dew": dew, "cloud": cloud,
} }
@@ -172,7 +206,7 @@ def main() -> None:
print("cleared existing telemetry, forecasts and scores") print("cleared existing telemetry, forecasts and scores")
data = generate(args.days, args.step, cfg.site.latitude, cfg.site.longitude, data = generate(args.days, args.step, cfg.site.latitude, cfg.site.longitude,
args.seed, args.end) args.seed, args.end, cfg.sensor.hum_psychrometric)
tracker = SignalTracker(cfg) tracker = SignalTracker(cfg)
n = data["ts"].size n = data["ts"].size
@@ -186,6 +220,8 @@ def main() -> None:
store.insert_telemetry({ store.insert_telemetry({
"ts": ts, "ts": ts,
"temp_raw": data["temp_raw"][i], "temp_raw": data["temp_raw"][i],
"temp_h": data["temp_h"][i],
"temp_p": data["temp_p"][i],
"temp_c": est["temp_c"], "temp_c": est["temp_c"],
"temp_smooth": est["temp_smooth"], "temp_smooth": est["temp_smooth"],
"temp_rate": est["temp_rate"], "temp_rate": est["temp_rate"],
+367
View File
@@ -0,0 +1,367 @@
# 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)
# ---------------------------------------------------------------- thermostat
def test_thermostat_reversion_is_first_order_and_preserves_dew_point():
"""A heated room is a closed loop, and heating adds no moisture.
Two properties, both easy to get wrong. The temperature must close the gap
to the setpoint exponentially rather than jumping or drifting, and the
implied humidity change must leave the dew point exactly where it was: RH
falls only because es(T) rose, which is why a heated house in winter is dry.
"""
import math
from ashvale.config import load_config
from ashvale.physics import dew_point, saturation_vapour_pressure
from ashvale.station import Station
cfg = load_config()
cfg.site.heating = True
cfg.site.heating_setpoint_c = 23.0
cfg.site.thermal_time_constant_h = 1.5
st = Station(cfg)
st.live = {"temp_smooth": 18.0}
tau = 1.5 * 3600.0
for h in (900, 3600, 10800, 86400):
expected = (23.0 - 18.0) * (1.0 - math.exp(-h / tau))
assert st._setpoint_delta("temperature", h, 18.0) == pytest.approx(expected, rel=1e-9)
# monotonic toward the setpoint, never past it
deltas = [st._setpoint_delta("temperature", h, 18.0)
for h in (900, 3600, 10800, 21600, 86400)]
assert all(a < b for a, b in zip(deltas, deltas[1:]))
assert deltas[-1] <= 5.0 + 1e-9
# dew point invariant
t0, rh0 = 18.0, 55.0
d_t = st._setpoint_delta("temperature", 86400, t0)
d_rh = st._setpoint_delta("humidity", 86400, rh0)
assert float(dew_point(t0 + d_t, rh0 + d_rh)) == pytest.approx(
float(dew_point(t0, rh0)), abs=1e-6)
assert d_rh < 0.0, "warming a room at constant moisture must lower RH"
assert float(saturation_vapour_pressure(t0 + d_t)) > float(
saturation_vapour_pressure(t0))
# a thermostat cannot move the synoptic field
assert st._setpoint_delta("pressure", 86400, 1013.0) == 0.0
# and off, the member is exactly persistence
cfg.site.heating = False
assert st._setpoint_delta("temperature", 86400, 18.0) == 0.0
assert st._setpoint_delta("humidity", 86400, 55.0) == 0.0
def test_forecast_head_migrates_state_from_before_the_setpoint_member():
"""An old save has three weights where there are now four."""
from ashvale.models.nowcast import MEMBERS, ForecastHead
h = ForecastHead(target="temperature", horizon_s=900, n_features=4)
state = h.to_dict()
state["weights"] = [0.2, 0.3, 0.5] # a pre-setpoint save
state["member_mae"] = [0.4, 0.5, 0.6]
back = ForecastHead.from_dict(state)
assert back.weights.size == len(MEMBERS)
assert float(back.weights.sum()) == pytest.approx(1.0)
# member_mae must migrate too. Missing it did not fail on load, it failed
# later inside the Hedge update on a broadcast error, which is a worse place
# to discover a migration bug.
assert back.member_mae.size == len(MEMBERS)
members = np.full(len(MEMBERS), 20.0)
back.observe_outcome(members, 20.5, covered=True) # must not raise
# ------------------------------------------------- dual-thermometer fusion
def _bare_board():
from ashvale.sensors import SD_HTS221, SD_LPS25HB, SenseBoard, _ChannelNoise
b = SenseBoard.__new__(SenseBoard)
b._noise_h = _ChannelNoise(SD_HTS221)
b._noise_p = _ChannelNoise(SD_LPS25HB)
b._gradient = None
b._gradient_lam = 0.9967
return b
def _two_channels(n=4000, seed=5):
from ashvale.sensors import K_HTS221, K_LPS25HB
rng = np.random.default_rng(seed)
cpu = 43.0 + 0.5 * np.sin(np.arange(n) / 500.0)
th = (24.0 + K_HTS221 * cpu) / (1 + K_HTS221) + 0.049 * rng.normal(size=n)
tp = (24.0 + K_LPS25HB * cpu) / (1 + K_LPS25HB) + 0.007 * rng.normal(size=n)
return th, tp
def test_fusion_does_not_move_the_mean():
"""The whole point of removing the gradient first.
The two chips stand about 1.3 C apart, so weighting them by variance drags
temp_raw onto the quieter one. k was fitted against the mean of the two, and
after the 1.55x gain of the inverse model that shift becomes about a degree
of silent bias on every reading downstream.
"""
th, tp = _two_channels()
board = _bare_board()
fused = np.array([board._fuse(th[i], tp[i])[0] for i in range(th.size)])
avg = (th + tp) / 2.0
w = slice(1000, None)
assert abs(fused[w].mean() - avg[w].mean()) < 0.01, "fusion shifted the calibration"
def test_fusion_is_quieter_than_the_average():
th, tp = _two_channels()
board = _bare_board()
fused = np.array([board._fuse(th[i], tp[i])[0] for i in range(th.size)])
avg = (th + tp) / 2.0
w = slice(1000, None)
def wn(x):
return np.std(np.diff(x)) / np.sqrt(2)
assert wn(fused[w]) < wn(avg[w]) / 2.0, "fusion did not halve the noise"
def test_fusion_survives_one_dead_channel():
board = _bare_board()
value, var = board._fuse(float("nan"), 29.5)
assert value == 29.5, "a dead HTS221 must not poison the reading"
value, var = board._fuse(30.5, float("nan"))
assert value == 30.5
value, var = board._fuse(float("nan"), float("nan"))
assert not np.isfinite(value)
def test_kalman_rate_is_physical_in_a_still_room():
"""The tuning failure this guards against.
On a real station the temperature filter reported a median rate of
12.4 C/h while the room moved 0.37 C/h. Process noise was set to track
perhaps a hundred times faster than any of these signals actually move.
"""
from ashvale.config import CONFIG
from ashvale.estimation import KalmanCV
dt = CONFIG.sensor.sample_period_s
rng = np.random.default_rng(3)
n = 6000
truth = 24.0 + 0.4 * np.arange(n) * dt / 3600.0 # a real 0.4 C/h drift
z = truth + 0.0877 * rng.normal(size=n) # measured input noise
kf = KalmanCV(CONFIG.sensor.kalman_q_temp, CONFIG.sensor.kalman_r_temp)
rates = [kf.update(z[i], dt)[1] * 3600.0 for i in range(n)]
settled = np.abs(np.array(rates[600:]))
assert np.median(settled) < 3.0, (
f"median |rate| {np.median(settled):.1f} C/h in a room drifting 0.4 C/h")
assert np.percentile(settled, 95) < 10.0
def test_retuning_q_survives_a_reload():
"""Tuning lives in config, not in the state file.
q and r were persisted and restored, so a retune deployed to a running
station did nothing: the service restarted and the filters carried on with
whatever tuning was in force when the state was last written. The symptom
is a config change that appears to work and does not, which is the worst
kind.
"""
from ashvale.config import load_config
from ashvale.estimation import SignalTracker
cfg = load_config()
cfg.sensor.kalman_q_temp = 2.0e-6 # an old, badly tuned state file
old = SignalTracker(cfg)
for i in range(50):
old.step(1.7554e9 + i * 2.0, 24.0, 50.0, 1013.0, 43.0)
saved = old.to_dict()
assert saved["filters"]["temperature"]["q"] == 2.0e-6
cfg.sensor.kalman_q_temp = 1.0e-9 # the retune
fresh = SignalTracker(cfg)
fresh.load_dict(saved)
assert fresh.filters["temperature"].q == 1.0e-9, \
"the state file overrode the configured tuning"
# the estimate itself must still be carried across
assert fresh.filters["temperature"].initialised
assert fresh.filters["temperature"].x[0] == pytest.approx(
old.filters["temperature"].x[0])
+467
View File
@@ -0,0 +1,467 @@
# 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"]
# ---------------------------------------------------------------- refit safety
def test_rls_reset_returns_to_the_prior():
m = RecursiveLeastSquares(n_features=5, forgetting=0.999, delta=100.0)
rng = np.random.default_rng(9)
for _ in range(500):
m.update(rng.normal(size=5), float(rng.normal()))
assert m.n_updates == 500
m.reset()
assert m.n_updates == 0
assert np.allclose(m.theta, 0.0)
assert np.allclose(m.P, np.eye(5) * 100.0)
def test_rls_delta_survives_serialisation():
"""A refit after a restart must return to the same prior it started from."""
m = RecursiveLeastSquares(n_features=4, forgetting=0.99, delta=100.0)
m.update(np.ones(4), 1.0)
back = RecursiveLeastSquares.from_dict(m.to_dict())
back.reset()
assert np.allclose(back.P, np.eye(4) * 100.0), "reload lost the prior"
def test_repeated_refits_do_not_accumulate():
"""Refitting the same history must be idempotent, not cumulative.
This is the bug that put a 53 C six-hour forecast on a real station in a
24 C room. fit() replayed history into a live filter on every retrain tick
and never reset, so 453 grid rows had produced 64,676 updates in a day and a
half. RLS with forgetting reads each update as fresh evidence, so P
collapsed and the weights drifted without bound in the directions the data
never excited.
"""
from ashvale.config import CONFIG
from ashvale.models.nowcast import NowcastEnsemble
rng = np.random.default_rng(3)
n = 400
cols = {
"temperature": 22 + 2 * np.sin(np.arange(n) / 40.0) + 0.2 * rng.normal(size=n),
"humidity": 50 + 5 * np.cos(np.arange(n) / 33.0),
"pressure": 1013 + np.sin(np.arange(n) / 77.0),
"lux": np.clip(300 * np.sin(np.arange(n) / 120.0), 0, None),
}
X = rng.normal(size=(n, 33))
X[:, 0] = 1.0
valid = np.ones(n, dtype=bool)
ens = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
head = ens.heads[("temperature", 21600)]
ens.fit(X, valid, cols)
first_updates = head.model.n_updates
norms = []
for _ in range(15):
ens.fit(X, valid, cols)
norms.append(float(np.linalg.norm(head.model.theta)))
# Exact equality is no longer the right assertion: the stride rotates its
# phase each refit, so a given refit trains on 12 or 13 pairs depending on
# where the offset lands. One update of slack covers that. Sixteen passes
# of accumulation would show up as 16x, not as 1.
assert abs(head.model.n_updates - first_updates) <= 1, \
"updates accumulated across refits; a refit must start from the prior"
# The failure this guards against put ||theta|| at 1680 against a median
# weight of 1.67. Phase rotation moves the norm by about 25% on these
# deliberately signal-free features, so bound the magnitude rather than
# pinning the value, and check it is not climbing refit on refit.
assert max(norms) < 20.0, f"weights drifting without bound: {max(norms):.1f}"
assert np.mean(norms[-5:]) < 3.0 * np.mean(norms[:5]), "weights growing across refits"
def test_annual_harmonics_are_zero_until_the_record_spans_a_season():
"""Two near-constant, near-collinear columns are a rank-deficient regressor.
Left on from day one, sin_doy and cos_doy carried +1174 and +1191 on a real
station whose median weight was 1.67. Zero is the honest value: a day and a
half of data says nothing whatsoever about the season.
"""
from ashvale.features import FEATURE_NAMES, build_features
n = 450
ts = np.arange(n) * 300.0 + 1.7554e9 # about 1.5 days
t = 22 + 2 * np.sin(np.arange(n) / 40.0)
h = 50 + 5 * np.cos(np.arange(n) / 33.0)
p = 1013 + np.sin(np.arange(n) / 77.0)
lux = np.clip(300 * np.sin(np.arange(n) / 120.0), 0, None)
si, ci = FEATURE_NAMES.index("sin_doy"), FEATURE_NAMES.index("cos_doy")
X, _ = build_features(ts, t, h, p, lux, 300, 52.2, 0.12, min_days_annual=120.0)
assert np.all(X[:, si] == 0.0) and np.all(X[:, ci] == 0.0)
# A record that does span the year keeps them.
ts_long = np.arange(n) * (200 * 86400.0 / n) + 1.7554e9
X2, _ = build_features(ts_long, t, h, p, lux, 300, 52.2, 0.12, min_days_annual=120.0)
assert X2[:, si].std() > 0.1, "annual terms should return once the record is long enough"
def test_training_pairs_are_strided_by_the_horizon():
"""Overlapping windows must not be counted as independent observations.
At the 1 d horizon on a 5-minute grid adjacent pairs share 287 of their 288
samples. Training on every row hands the filter the same outcome 288 times
and RLS with forgetting reads each as fresh evidence, so a 400-score
conformal window ends up holding 1.4 independent outcomes while believing
it holds 400.
"""
from ashvale.config import CONFIG
from ashvale.models.nowcast import NowcastEnsemble
rng = np.random.default_rng(11)
n = 4000 # ~14 days at 5 minutes
g = CONFIG.model.grid_s
cols = {
"temperature": 20 + 4 * np.sin(np.arange(n) / 288.0) + 0.1 * rng.normal(size=n),
"humidity": 55 + 8 * np.cos(np.arange(n) / 288.0),
"pressure": 1013 + 4 * np.sin(np.arange(n) / 900.0),
"lux": np.clip(400 * np.sin(np.arange(n) / 288.0), 0, None),
}
X = rng.normal(size=(n, 33))
X[:, 0] = 1.0
valid = np.ones(n, dtype=bool)
ens = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
counts = ens.fit(X, valid, cols)
for h in CONFIG.model.horizons_s:
steps = max(round(h / g), 1)
got = counts[f"temperature@{h}"]
# fit() bounds recency to max_pairs rows before it strides them.
available = min(n - steps, 2500)
expected = available // steps
if expected >= CONFIG.model.min_pairs_per_head:
assert abs(got - expected) <= 1, (
f"horizon {h}s trained on {got} pairs, expected about {expected}")
assert got < available / 2, "pairs were not strided"
else:
# The floor relaxes the stride rather than letting a long horizon
# train on a handful of pairs.
assert got >= CONFIG.model.min_pairs_per_head
def test_the_stride_floor_protects_a_short_record():
"""A 1 d horizon on two days of data must not train on two pairs."""
from ashvale.config import CONFIG
from ashvale.models.nowcast import NowcastEnsemble
rng = np.random.default_rng(12)
n = 700 # ~2.4 days at 5 minutes
cols = {"temperature": 21 + rng.normal(size=n) * 0.1,
"humidity": 50 + rng.normal(size=n) * 0.1,
"pressure": 1013 + rng.normal(size=n) * 0.1,
"lux": np.zeros(n)}
X = rng.normal(size=(n, 33))
X[:, 0] = 1.0
ens = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
counts = ens.fit(X, np.ones(n, dtype=bool), cols)
day = counts["temperature@86400"]
assert day >= CONFIG.model.min_pairs_per_head, (
f"1 d head trained on only {day} pairs; the floor did not engage")
def test_refit_phase_rotates_and_survives_serialisation():
"""Every offset must eventually be trained on, across restarts too."""
from ashvale.config import CONFIG
from ashvale.models.nowcast import NowcastEnsemble
rng = np.random.default_rng(13)
n = 600
cols = {k: 20 + rng.normal(size=n) * 0.1 for k in CONFIG.model.targets}
cols["lux"] = np.zeros(n)
X = rng.normal(size=(n, 33))
X[:, 0] = 1.0
valid = np.ones(n, dtype=bool)
ens = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
assert ens.refit_phase == 0
ens.fit(X, valid, cols)
ens.fit(X, valid, cols)
assert ens.refit_phase == 2
back = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
back.load_dict(ens.to_dict())
assert back.refit_phase == 2, "a restart must not reset the stride to phase 0 forever"
def test_model_tuning_comes_from_config_not_from_the_state_file():
"""The same defect that made a Kalman retune silently do nothing.
RecursiveLeastSquares.from_dict and AdaptiveConformal.from_dict restore
lambda, delta, alpha, gamma and the conformal window alongside their data,
and load_dict replaces the config-built heads with those. So every one of
those knobs was immutable on any station that already had state: edit
config.yaml, restart, and the old value comes straight back.
"""
from ashvale.config import load_config
from ashvale.models.nowcast import NowcastEnsemble
cfg = load_config()
old = cfg.model
ens = NowcastEnsemble(old.targets, old.horizons_s, old)
saved = ens.to_dict()
import copy
new = copy.deepcopy(old)
new.rls_forgetting = 0.995
new.rls_delta = 55.0
new.conformal_alpha = 0.20
new.conformal_gamma = 0.05
new.conformal_window = 250
back = NowcastEnsemble(new.targets, new.horizons_s, new)
back.load_dict(saved)
head = next(iter(back.heads.values()))
assert head.model.lam == 0.995, "state file pinned the forgetting factor"
assert head.model.delta == 55.0, "state file pinned the RLS prior"
assert head.conformal.alpha_target == 0.20
assert head.conformal.gamma == 0.05
assert head.conformal.scores.maxlen == 250
def test_loading_state_ignores_heads_this_build_no_longer_has():
"""A stale state file must not resurrect a retired target or horizon."""
from ashvale.config import load_config
from ashvale.models.nowcast import NowcastEnsemble
cfg = load_config().model
ens = NowcastEnsemble(cfg.targets, cfg.horizons_s, cfg)
saved = ens.to_dict()
saved["heads"].append({**saved["heads"][0], "target": "retired_signal"})
back = NowcastEnsemble(cfg.targets, cfg.horizons_s, cfg)
back.load_dict(saved)
assert not any(t == "retired_signal" for t, _ in back.heads)
assert len(back.heads) == len(cfg.targets) * len(cfg.horizons_s)
def test_conformal_produces_a_band_from_the_fewest_scores_that_permit_one():
"""20 was arbitrary and became harmful once pairs were strided.
The (1-alpha) empirical quantile is the ceil((k+1)(1-alpha))-th of k order
statistics, so alpha = 0.10 needs k >= 9. Requiring 20 threw away a valid
band at k = 13, which is roughly what a long-horizon head earns per refit
after striding, and dropped twelve of eighteen heads onto 1.645*sigma with
sigma from an unconstrained x'Px. That produced +/- 115% relative humidity.
"""
from ashvale.models.rls import AdaptiveConformal
assert AdaptiveConformal.MIN_SCORES == 9
ac = AdaptiveConformal(alpha=0.10, gamma=0.01)
for i in range(8):
ac.observe(0.1 * (i + 1), True)
assert not np.isfinite(ac.quantile()), "8 scores cannot support a 90% band"
ac.observe(0.9, True)
q = ac.quantile()
assert np.isfinite(q), "9 scores must produce a band"
assert q > 0
def test_strided_heads_do_not_fall_back_to_the_gaussian():
"""The end-to-end version of the same thing, through a real fit."""
from ashvale.config import CONFIG
from ashvale.models.climatology import HarmonicClimatology
from ashvale.models.nowcast import MEMBERS, NowcastEnsemble
from ashvale.models.rls import AdaptiveConformal
rng = np.random.default_rng(19)
n = 700 # ~2.4 days, a young station
g = CONFIG.model.grid_s
ts = np.arange(n) * g + 1.7554e9
cols = {
"temperature": 22 + 3 * np.sin(np.arange(n) / 288.0) + 0.1 * rng.normal(size=n),
"humidity": 50 + 9 * np.cos(np.arange(n) / 288.0),
"pressure": 1013 + 2 * np.sin(np.arange(n) / 600.0),
"lux": np.clip(400 * np.sin(np.arange(n) / 288.0), 0, None),
}
X = rng.normal(size=(n, 33))
X[:, 0] = 1.0
valid = np.ones(n, dtype=bool)
clim = HarmonicClimatology(CONFIG.model.targets,
min_days_annual=CONFIG.model.climatology_min_days_annual)
clim.fit(ts, {k: cols[k] for k in CONFIG.model.targets}, valid)
ens = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
ens.fit(X, valid, {k: cols[k] for k in CONFIG.model.targets})
# A refit alone must leave the calibrators empty: they are earned from
# scored forecasts now, not from replayed history.
for head in ens.heads.values():
assert len(head.conformal.scores) == 0
# Feed each head the fewest outcomes a 90% band can be built from and the
# band must exist and be physical. MIN_SCORES was 20, which is arbitrary,
# and left twelve of eighteen heads on 1.645*sigma with sigma from an
# unconstrained x'Px: bands of +/- 45 C and +/- 115% RH.
rng2 = np.random.default_rng(21)
for (target, h), head in ens.heads.items():
base = {"temperature": 22.0, "humidity": 50.0, "pressure": 1013.0}[target]
for k in range(AdaptiveConformal.MIN_SCORES):
truth = base + float(rng2.normal(scale=0.5))
head.observe_outcome(np.full(len(MEMBERS), base), truth,
covered=True, valid_ts=1.7554e9 + k * h)
q = head.conformal.quantile()
assert np.isfinite(q), (
f"{target}@{h}s has {len(head.conformal.scores)} scores and no band, "
"so it falls back to the Gaussian")
limit = {"temperature": 25.0, "humidity": 60.0, "pressure": 40.0}[target]
assert q < limit, f"{target}@{h}s band is +/- {q:.1f}, which is not a forecast"
+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
+298
View File
@@ -0,0 +1,298 @@
# 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):
"""Recompute must never drop a row.
Asserted as "no fewer than before" rather than equality: the sample loop is
live under TestClient and legitimately inserts rows mid-test. Equality here
was flaky for that reason, and a flaky test is worse than no test because it
trains you to ignore red.
"""
if _rows() == 0:
pytest.skip("no history in the database")
before = _rows()
result = client.post("/api/recompute").json()
after = _rows()
assert after >= before, f"rows lost: {before} -> {after}"
assert result["rows"] >= before, "recompute touched fewer rows than existed"
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})
# ------------------------------------------------------------ clock guard
def test_training_refuses_a_clock_that_has_not_been_set(tmp_path, monkeypatch):
"""The board has no RTC.
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 all then lie with total confidence, and unlike a gap in the
record the damage cannot be spotted afterwards.
"""
import time as _time
from ashvale.config import load_config
from ashvale.station import Station
cfg = load_config()
cfg.storage.db_path = str(tmp_path / "clock.db")
st = Station(cfg)
assert st.clock_sanity()["ok"], "a correct clock must pass"
monkeypatch.setattr(_time, "time", lambda: 1000.0) # 1970
verdict = st.clock_sanity()
assert not verdict["ok"]
assert "2025" in verdict["reason"]
result = st.train()
assert result["trained"] is False
# and specifically for the clock, not because the database is empty
assert "2025" in result["reason"], result["reason"]
def test_training_refuses_a_clock_that_went_backwards(tmp_path, monkeypatch):
"""NTP stepping backwards past stored data is equally unusable."""
import time as _time
from ashvale.config import load_config
from ashvale.station import Station
cfg = load_config()
cfg.storage.db_path = str(tmp_path / "back.db")
st = Station(cfg)
future = _time.time() + 7200.0
st.store.insert_telemetry({"ts": future, "temp_raw": 20.0})
verdict = st.clock_sanity()
assert not verdict["ok"]
assert "behind" in verdict["reason"]
# ------------------------------------------------------------ joystick
def test_joystick_left_and_right_record_rain_labels(tmp_path):
"""The button that fixes the precipitation model.
Strong labels are the binding constraint on that head: 80 against thousands
of proxy ones on a real station, because the only label control lives in a
web page. Left is dry, right is wet.
"""
import asyncio
import sqlite3
from ashvale.config import load_config
from ashvale.station import Station
cfg = load_config()
cfg.storage.db_path = str(tmp_path / "stick.db")
st = Station(cfg)
st.sample_once()
pending = [("left", "pressed"), ("right", "pressed"),
("up", "pressed"), ("right", "released")]
def fake_events():
out, pending[:] = list(pending), []
return out
st.board.stick_events = fake_events
async def one_pass():
task = asyncio.create_task(st._loop_joystick())
await asyncio.sleep(0.6)
st._stop.set()
task.cancel()
try:
await task
except (asyncio.CancelledError, Exception):
pass
asyncio.run(one_pass())
with sqlite3.connect(cfg.storage.db_path) as c:
rows = sorted(r[0] for r in c.execute("SELECT value FROM labels WHERE kind='rain'"))
assert rows == [0.0, 1.0], f"expected one dry and one wet label, got {rows}"
# 'up' is unbound and 'released' is not a press: neither may label anything.
def test_joystick_survives_a_board_with_no_hat(tmp_path):
"""The simulator path has no stick. The loop must not spin on exceptions."""
from ashvale.config import load_config
from ashvale.station import Station
cfg = load_config()
cfg.storage.db_path = str(tmp_path / "nohat.db")
st = Station(cfg)
assert st.board.stick_events() == []
assert st.display is None
# ------------------------------------------------------- movement detection
def _moved_station(tmp_path, name):
from ashvale.config import load_config
from ashvale.station import Station
cfg = load_config()
cfg.storage.db_path = str(tmp_path / name)
st = Station(cfg)
st._started_at = 0.0 # long settled
return st
def test_a_moved_board_marks_a_discontinuity_and_queues_a_retrain(tmp_path):
"""Measured on a real station: a move steps the temperature by a median of
1.02 C against an ordinary fifteen minute change of 0.107 C. The heads
carry 55 hours of memory, so an undeclared move contaminates two days.
"""
st = _moved_station(tmp_path, "moved.db")
base = 1.7554e9
for i in range(20): # sitting still, with realistic jitter
st._check_moved(base + i, {"pitch": -64.05 + 1e-5 * i,
"roll": 1.36, "temp_smooth": 24.0})
assert not st.monitor.retrain_requested, "noise must not trigger a retrain"
st._check_moved(base + 100, {"pitch": -53.5, "roll": 1.4, "temp_smooth": 24.0})
assert st.monitor.retrain_requested, "a 10 degree move must queue a retrain"
import sqlite3
with sqlite3.connect(st.cfg.storage.db_path) as c:
kinds = [r[0] for r in c.execute("SELECT kind FROM events")]
assert "moved" in kinds and "discontinuity" in kinds
def test_restart_attitude_jump_is_not_mistaken_for_a_move(tmp_path):
"""RTIMULib restarts its fusion from a default attitude when SenseHat is
reconstructed, which put an 18 degree step in the record on every service
restart. Without the settle window every deploy looks like a move.
"""
st = _moved_station(tmp_path, "restart.db")
now = 1.7554e9
st._started_at = now # just booted
st._check_moved(now + 1, {"pitch": -46.0, "roll": 1.4, "temp_smooth": 24.0})
st._check_moved(now + 60, {"pitch": -64.1, "roll": 1.4, "temp_smooth": 24.0})
st._check_moved(now + 180, {"pitch": -46.0, "roll": 1.4, "temp_smooth": 24.0})
assert not st.monitor.retrain_requested, "startup convergence must be ignored"
# past the settle window, the same step is a real move
st._check_moved(now + st.TILT_SETTLE_S + 10, {"pitch": -64.1, "roll": 1.4,
"temp_smooth": 24.0})
assert st.monitor.retrain_requested
def test_yaw_and_compass_are_not_used_for_movement(tmp_path):
"""They depend on the magnetometer, which indoors measures the building."""
st = _moved_station(tmp_path, "yaw.db")
base = 1.7554e9
st._check_moved(base, {"pitch": -64.0, "roll": 1.4, "yaw": 10.0,
"compass": 10.0, "temp_smooth": 24.0})
st._check_moved(base + 30, {"pitch": -64.0, "roll": 1.4, "yaw": 300.0,
"compass": 300.0, "temp_smooth": 24.0})
assert not st.monitor.retrain_requested, "a 290 degree yaw swing is not a move"
+275
View File
@@ -0,0 +1,275 @@
# 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.
"""Where the blend weights and the prediction intervals are allowed to learn.
Both are statements about how this head's issued forecasts actually turned out.
A refit is not an outcome, it is the same week of weather being read again, and
a matured forecast that stays readable for an hour is one outcome and not
twelve. Getting either wrong does not raise: it quietly multiplies the evidence
until Hedge saturates and the ACI integrator pins against its clips.
"""
from __future__ import annotations
import numpy as np
from ashvale.config import CONFIG, load_config
from ashvale.models.nowcast import MEMBERS, NowcastEnsemble
from ashvale.station import Station
from ashvale.storage import Store
def _synthetic(n: int = 700, seed: int = 5):
rng = np.random.default_rng(seed)
g = CONFIG.model.grid_s
ts = np.arange(n) * g + 1.7554e9
cols = {
"temperature": 22 + 3 * np.sin(np.arange(n) / 288.0) + 0.1 * rng.normal(size=n),
"humidity": 50 + 9 * np.cos(np.arange(n) / 288.0),
"pressure": 1013 + 2 * np.sin(np.arange(n) / 600.0),
"lux": np.clip(400 * np.sin(np.arange(n) / 288.0), 0, None),
}
X = rng.normal(size=(n, 33))
X[:, 0] = 1.0
return ts, cols, X, np.ones(n, dtype=bool)
# ------------------------------------------------ a refit is not an outcome
def test_refitting_does_not_touch_the_weights_or_the_calibrator():
"""The defect this file exists for.
Measured on 8.2 days of a real station, fit() had put 977,078 Hedge updates
through the 15 minute head from 758 distinct supervised pairs, and 296,715
through the 1 day head from 12. Hedge is multiplicative, so an edge far too
small to be real compounds to certainty.
"""
_, cols, X, valid = _synthetic()
ens = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
before = {k: h.weights.copy() for k, h in ens.heads.items()}
for _ in range(5):
ens.fit(X, valid, cols)
for k, head in ens.heads.items():
assert np.allclose(head.weights, before[k]), \
f"{k} blend weights moved during a refit"
assert len(head.conformal.scores) == 0, \
f"{k} fed {len(head.conformal.scores)} scores to the calibrator from a refit"
assert head.n_scored == 0
# The regression itself must still be learning.
assert head.model.n_updates > 0
def test_an_outcome_does_move_them():
"""The other half: verify()'s channel has to work, or nothing ever learns."""
_, cols, X, valid = _synthetic()
ens = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
ens.fit(X, valid, cols)
head = ens.heads[("temperature", 900)]
before = head.weights.copy()
# Persistence right, everything else wrong, so the weights must move to it.
members = np.array([22.0, 30.0, 30.0, 30.0])
for k in range(20):
head.observe_outcome(members, 22.0, covered=True, valid_ts=1e9 + k * 900)
assert head.n_scored == 20
assert len(head.conformal.scores) == 20
assert head.weights[MEMBERS.index("persistence")] > before[MEMBERS.index("persistence")]
assert head.weights[MEMBERS.index("climatology")] < before[MEMBERS.index("climatology")]
# ------------------------------------------- overlapping outcomes are one fact
def test_hedge_ignores_outcomes_that_overlap_the_last_one_it_took():
"""Forecasts are issued every retrain tick, so at the 1 day horizon about
two hundred a day resolve against very nearly the same outcome. The
conformal window can absorb that, being an order statistic. Exponentiated
gradient cannot."""
_, cols, X, valid = _synthetic()
ens = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
head = ens.heads[("temperature", 86400)]
members = np.array([22.0, 30.0, 30.0, 30.0])
t0 = 1.7554e9
for k in range(50): # 50 forecasts, 10 minutes apart
head.observe_outcome(members, 22.0, covered=True, valid_ts=t0 + k * 600)
assert head.n_scored == 1, \
f"Hedge took {head.n_scored} of 50 overlapping outcomes at a 1 d horizon"
# The calibrator still sees all of them: a quantile over duplicated scores
# is over-confident about its sample size, not wrong about its value.
assert len(head.conformal.scores) == 50
head.observe_outcome(members, 22.0, covered=True, valid_ts=t0 + 86400)
assert head.n_scored == 2, "a genuinely new outcome must be taken"
def test_the_decimation_clock_survives_a_restart():
"""Otherwise every deploy hands the weights a free duplicate."""
ens = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
head = ens.heads[("pressure", 3600)]
head.observe_outcome(np.full(len(MEMBERS), 1013.0), 1013.5,
covered=True, valid_ts=1.7554e9)
back = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
back.load_dict(ens.to_dict())
assert back.heads[("pressure", 3600)].last_hedge_ts == head.last_hedge_ts
# A head that has never scored must not refuse its first outcome.
fresh = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
fresh.load_dict(NowcastEnsemble(CONFIG.model.targets,
CONFIG.model.horizons_s, CONFIG.model).to_dict())
h = fresh.heads[("pressure", 3600)]
h.observe_outcome(np.full(len(MEMBERS), 1013.0), 1013.5,
covered=True, valid_ts=1.7554e9)
assert h.n_scored == 1
# -------------------------------------------------------------- persistence
def test_forecast_members_round_trip(tmp_path):
"""The learned member cannot be recomputed at maturity, the RLS has moved
on, so it has to be written down at issue time."""
store = Store(str(tmp_path / "f.db"))
members = {"persistence": 21.0, "climatology": 22.0,
"learned": 23.0, "setpoint": 24.0}
store.insert_forecast(1000.0, 900, "temperature", 22.5, 21.0, 24.0,
"ensemble", members=members)
row = store.due_forecasts(now=2000.0)[0]
assert Station._members_of(row).tolist() == [21.0, 22.0, 23.0, 24.0]
assert row["scored"] == 0
store.mark_forecasts_scored([(1000.0, 900, "temperature")])
assert store.due_forecasts(now=2000.0)[0]["scored"] == 1
def test_a_forecast_from_before_the_columns_existed_is_not_fatal(tmp_path):
"""It still deserves a conformal score. It just cannot move the weights."""
store = Store(str(tmp_path / "old.db"))
store.insert_forecast(1000.0, 900, "temperature", 22.5, 21.0, 24.0, "ensemble")
row = store.due_forecasts(now=2000.0)[0]
assert Station._members_of(row) is None
def test_migration_adds_the_forecast_columns_to_a_live_table(tmp_path):
"""Same hazard as the telemetry columns: CREATE TABLE IF NOT EXISTS is a
no-op against a table that already exists."""
import sqlite3
path = str(tmp_path / "legacy.db")
with sqlite3.connect(path) as c:
c.execute("CREATE TABLE forecasts ("
"issued_ts REAL NOT NULL, valid_ts REAL NOT NULL, "
"horizon_s INTEGER NOT NULL, target TEXT NOT NULL, "
"mu REAL, lo REAL, hi REAL, model TEXT, "
"PRIMARY KEY (issued_ts, horizon_s, target))")
c.execute("INSERT INTO forecasts VALUES (1.0, 901.0, 900, 'temperature',"
" 22.0, 21.0, 23.0, 'ensemble')")
store = Store(path)
with sqlite3.connect(path) as c:
have = {r[1] for r in c.execute("PRAGMA table_info(forecasts)")}
for col in ("m_persistence", "m_climatology", "m_learned", "m_setpoint", "scored"):
assert col in have, f"migration missed {col}"
rows = store.due_forecasts(now=2000.0)
assert len(rows) == 1, "the existing row must survive the migration"
assert rows[0]["scored"] == 0
assert Station._members_of(rows[0]) is None
# --------------------------------------------------------------- end to end
def test_verify_teaches_from_a_matured_forecast_exactly_once(tmp_path):
"""A matured forecast stays readable for an hour so the scorecard can
aggregate a rolling window, which means verify() sees it about twelve
times. Aggregating it twelve times is harmless. Teaching from it twelve
times is how the calibrator ends up believing it holds four hundred
independent scores when it holds one."""
import time
cfg = load_config()
cfg.storage.db_path = str(tmp_path / "verify.db")
# Without this the station loads the developer's own saved state, whose
# heads already carry six figures of n_scored.
cfg.storage.state_dir = str(tmp_path)
st = Station(cfg)
now = time.time()
# The record has to run past the forecast's validity time, or verify()
# finds no truth to score it against.
for k in range(61):
ts = now - 1800 + k * 30
st.store.insert_telemetry({"ts": ts, "temp_raw": 21.0, "temp_smooth": 21.0,
"hum": 50.0, "hum_smooth": 50.0,
"press": 1013.0, "press_slp": 1013.0})
issued = now - 1500
st.store.insert_forecast(issued, 900, "temperature", 21.4, 20.9, 21.9,
"ensemble",
members={"persistence": 21.0, "climatology": 21.6,
"learned": 21.5, "setpoint": 21.0})
head = st.nowcast.heads[("temperature", 900)]
first = st.verify()
assert first["learned"] == 1, first
assert head.n_scored == 1
assert len(head.conformal.scores) == 1
for _ in range(5):
again = st.verify()
assert again["learned"] == 0, "the same outcome was taught again"
assert head.n_scored == 1
assert len(head.conformal.scores) == 1
# and it is still being aggregated into the scorecard
assert again["scored"] >= 1
def test_state_from_before_the_fix_is_not_trusted():
"""The saturated weights do not decay on their own.
A pre-fix state file holds weights produced by the same week of weather
read about a thousand times. Hedge needs roughly twenty independent
outcomes to climb back off its 1e-4 floor, and the 1 d head sees one a day,
so leaving them in place would mean three weeks of a forecast pinned to
whichever member the replay happened to favour.
"""
ens = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
head = ens.heads[("temperature", 86400)]
for k in range(400):
head.observe_outcome(np.array([22.0, 30.0, 30.0, 30.0]), 22.0,
covered=False, valid_ts=1.7554e9 + k * 86400)
saved = ens.to_dict()
assert head.weights.max() > 0.9 and head.conformal.alpha < 0.02
for h in saved["heads"]:
h.pop("last_hedge_ts") # a state file from before
back = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
back.load_dict(saved)
b = back.heads[("temperature", 86400)]
assert np.allclose(b.weights, 1.0 / len(MEMBERS)), "saturated weights were trusted"
assert b.n_scored == 0
assert b.conformal.alpha == b.conformal.alpha_target, "the ACI integrator kept its wind-up"
# but the residual magnitudes are kept, or the long horizons spend nine
# days on the Gaussian fallback
assert len(b.conformal.scores) == 400
# A state file written after the fix must survive untouched.
keep = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
keep.load_dict(ens.to_dict())
assert np.allclose(keep.heads[("temperature", 86400)].weights, head.weights)
+93
View File
@@ -0,0 +1,93 @@
# 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.
"""Schema migration.
The column-addition test is the important one. CREATE TABLE IF NOT EXISTS is a
no-op against a table that already exists, so every new entry in COLUMNS
reaches a fresh install and silently misses every station already running. It
then surfaces as an OperationalError inside insert_telemetry, which sits on the
sample loop, so a column addition takes a live station down rather than merely
leaving a gap in its record.
"""
from __future__ import annotations
import sqlite3
import time
import pytest
from ashvale.storage import COLUMNS, Store
def _cols(path: str) -> set[str]:
with sqlite3.connect(path) as c:
return {r[1] for r in c.execute("PRAGMA table_info(telemetry)")}
def test_fresh_database_has_every_declared_column(tmp_path):
p = str(tmp_path / "fresh.db")
Store(p)
assert not [c for c in COLUMNS if c not in _cols(p)]
def test_migration_adds_a_new_column_without_touching_the_rows(tmp_path):
"""Simulates a station that has been running since before a column existed."""
p = str(tmp_path / "old.db")
legacy = [c for c in COLUMNS if c not in ("temp_h", "temp_p")]
with sqlite3.connect(p) as c:
c.execute(f"CREATE TABLE telemetry (ts REAL PRIMARY KEY, "
f"{', '.join(f'{x} REAL' for x in legacy if x != 'ts')}, "
f"tier INTEGER NOT NULL DEFAULT 0)")
c.executemany("INSERT INTO telemetry (ts, temp_raw) VALUES (?, ?)",
[(float(i), 20.0 + i) for i in range(50)])
assert "temp_h" not in _cols(p)
store = Store(p)
assert "temp_h" in _cols(p) and "temp_p" in _cols(p)
with sqlite3.connect(p) as c:
n = c.execute("SELECT COUNT(*) FROM telemetry").fetchone()[0]
old = c.execute("SELECT temp_raw FROM telemetry WHERE ts = 7.0").fetchone()[0]
assert n == 50, "migration must not lose rows"
assert old == 27.0, "migration must not disturb existing values"
# The point of the exercise: a write using the new columns must now work.
store.insert_telemetry({"ts": 999.0, "temp_raw": 20.0, "temp_h": 20.6, "temp_p": 19.4})
with sqlite3.connect(p) as c:
row = c.execute("SELECT temp_h, temp_p FROM telemetry WHERE ts = 999.0").fetchone()
assert row == (20.6, 19.4)
def test_migration_is_idempotent(tmp_path):
p = str(tmp_path / "twice.db")
Store(p)
Store(p)
Store(p)
assert not [c for c in COLUMNS if c not in _cols(p)]
def test_both_thermometers_survive_a_round_trip(tmp_path):
"""temp_h and temp_p are logged so the self-heating gradient can be
recovered later. They cannot be backfilled, so a silent drop is permanent."""
p = str(tmp_path / "rt.db")
store = Store(p)
now = time.time()
store.insert_telemetry({"ts": now, "temp_raw": 30.39, "temp_h": 30.973,
"temp_p": 29.810, "cpu_temp": 44.55})
got = store.window(24.0, ["ts", "temp_h", "temp_p", "cpu_temp"])
assert got["temp_h"][0] == 30.973
assert got["temp_p"][0] == 29.810
assert got["temp_h"][0] - got["temp_p"][0] == pytest.approx(1.163)