mirror of
https://github.com/lynchaos/ashvale-station.git
synced 2026-09-12 20:52:23 +00:00
394 lines
17 KiB
Markdown
394 lines
17 KiB
Markdown
# Design notes
|
||
|
||
Internals, tuning and failure modes. This document deliberately does **not**
|
||
repeat the README: no pitch, no install instructions, no feature list, no API
|
||
table. It covers what you need when changing the code or debugging a station
|
||
that is behaving oddly, and nothing else.
|
||
|
||
Read this before touching anything under `ashvale/models/`.
|
||
|
||
---
|
||
|
||
## 1. The data model
|
||
|
||
### Schema
|
||
|
||
One wide table, `telemetry`, keyed on a float epoch `ts`. Every column is
|
||
`REAL` except `tier`. There is no normalisation, because a sensor sample is a
|
||
single denormalised event and joins on a Pi are not free.
|
||
|
||
| Column | Meaning |
|
||
|---|---|
|
||
| `temp_raw` | Straight off the HTS221/LPS25HB average, uncompensated |
|
||
| `temp_c` | After self-heating compensation, before filtering |
|
||
| `temp_smooth`, `temp_rate` | Kalman level and rate (°C, °C/h) |
|
||
| `hum`, `hum_smooth` | Raw and filtered relative humidity |
|
||
| `press`, `press_slp` | Station pressure and its sea-level reduction |
|
||
| `press_smooth`, `press_rate` | Kalman level and rate (hPa, hPa/h) |
|
||
| `cpu_temp` | SoC thermal zone. The nuisance variable |
|
||
| `dew_c` | Magnus dew point from smoothed inputs |
|
||
| `lux`, `r`, `g`, `b` | TCS3400 clear and colour channels |
|
||
| `pitch` … `gz` | IMU, nine values |
|
||
| `tier` | 0 raw, 1 five-minute mean, 2 hourly mean |
|
||
|
||
Supporting tables: `forecasts` (issued, valid, target, mu, lo, hi), `labels`
|
||
(human ground truth), `scores` (verification output), `events` (station log).
|
||
|
||
**Three columns are named `r`, `g` and `b`.** This has already caused one bug:
|
||
`GROUP BY b` in an aggregation query silently grouped by the blue channel,
|
||
because SQLite resolves an unqualified identifier to a real column in
|
||
preference to a result alias. It returned one row per sample while cheerfully
|
||
reporting the requested bucket size. Never use a single-letter alias in this
|
||
schema.
|
||
|
||
### Tiering
|
||
|
||
Nothing is deleted, only downsampled. `Store.compact()` folds raw rows older
|
||
than `raw_retention_days` into five-minute means, and five-minute rows older
|
||
than `five_min_retention_days` into hourly means. A year of history lands
|
||
around 30 MB.
|
||
|
||
The `tier` column exists so you can tell a genuine hourly observation from a
|
||
mean of twelve. Range queries deliberately mix tiers, which is right for
|
||
display but means **a query spanning the raw/five-minute boundary has
|
||
non-uniform effective resolution**. If you ever compute a statistic that
|
||
assumes equal weight per row, weight by tier or restrict to one.
|
||
|
||
### Why aggregation happens in SQL
|
||
|
||
`range_series()` buckets with `CAST(ts/bucket AS INTEGER)*bucket` and
|
||
`AVG`/`MIN`/`MAX` inside SQLite. Pulling ninety days of rows into numpy to
|
||
average them costs more resident memory than the board has. The bucket is
|
||
chosen from the span and snapped to a ladder of familiar durations, targeting
|
||
about 700 points, because neither a browser nor a human benefits from more.
|
||
|
||
Min and max travel alongside the mean so the UI can shade a true range band. A
|
||
two-hour bucket that spanned four degrees should not render as a flat line.
|
||
|
||
---
|
||
|
||
## 2. The estimation layer
|
||
|
||
### Self-heating compensation
|
||
|
||
Model: `T = T_raw − k·(T_cpu − T_raw)`, with `k ≥ 0` estimated by recursive
|
||
least squares against any trusted reference you supply.
|
||
|
||
The regressor is `φ = max(T_cpu − T_raw, 0)` and the target is
|
||
`T_raw − T_ref`, so `k·φ` should equal the observed bias. One step, forgetting
|
||
factor 0.98:
|
||
|
||
```
|
||
gain = P·φ / (λ + φ·P·φ)
|
||
k ← clip(k + gain·(target − k·φ), k_min, k_max)
|
||
P ← (P − gain·φ·P) / λ
|
||
```
|
||
|
||
Measured behaviour: recovers a true `k = 0.62` from a prior of `0.30` in a
|
||
**single sample**, and holds post-calibration bias to 0.012 °C over 200
|
||
subsequent readings.
|
||
|
||
**The clamp is not decoration.** A single mistyped reference drives `k` to its
|
||
bound, and because state persists across restarts it stays there, quietly
|
||
biasing every reading until you notice. `POST /api/calibrate {"reset": true}`
|
||
exists for exactly that.
|
||
|
||
**If you write a simulator or a test fixture, the forward model must be the
|
||
exact inverse:** `T_raw = (T + k·T_cpu)/(1 + k)`. Generating the bias as
|
||
`T + k·(T_cpu − T)` is a different relation, and the mismatch injects roughly
|
||
1.2 °C of phantom noise floor that caps every skill score. This has already
|
||
happened once.
|
||
|
||
### The Kalman bank
|
||
|
||
One constant-velocity filter per signal. State `x = [level, rate]`, standard
|
||
continuous white-noise-acceleration process model:
|
||
|
||
```
|
||
F = [[1, Δt], [0, 1]]
|
||
Q = q · [[Δt³/3, Δt²/2], [Δt²/2, Δt]]
|
||
H = [1, 0]
|
||
```
|
||
|
||
Two implementation points that matter.
|
||
|
||
**Joseph form.** The update is `P ← (I−KH)·P·(I−KH)ᵀ + K·R·Kᵀ`, not the
|
||
shorter `P ← (I−KH)·P`. The short form accumulates asymmetry and loses positive
|
||
semi-definiteness over months of continuous running, and nobody notices until
|
||
the filter quietly stops working.
|
||
|
||
**`q` is tuned for the live 2 s cadence.** Because `Q` scales with `Δt³`,
|
||
running the same filter at a 300 s step makes the process noise five orders of
|
||
magnitude larger, at which point the filter abandons smoothing and tracks
|
||
measurement noise. `scripts/simulate.py` therefore scales `q` by
|
||
`(sample_period/step)³` when backfilling. Before that fix, rate estimates blew
|
||
past anything physical and enshrined a −37 hPa/h all-time record.
|
||
|
||
`nis` (normalised innovation squared) is exposed per filter. It should hover
|
||
near 1. Persistently high means the filter is too confident and lagging real
|
||
change; persistently low means you are over-smoothing.
|
||
|
||
---
|
||
|
||
## 3. Features
|
||
|
||
33 columns, defined once in `FEATURE_NAMES`. An assertion in
|
||
`build_features()` fails loudly if the stacked matrix width drifts from that
|
||
list, which is the cheapest guard available against a silently misaligned
|
||
design matrix.
|
||
|
||
Three rules govern what goes in.
|
||
|
||
1. **Anything closed-form is computed, not learned.** Dew point, wet bulb,
|
||
VPD, absolute humidity, solar elevation and the clear-sky cloud index come
|
||
from `physics.py`. Making a linear learner rediscover the Magnus curve from
|
||
data wastes both samples and capacity.
|
||
2. **Anything periodic is a sine/cosine pair.** Two diurnal harmonics and one
|
||
annual, so phase is representable without a discontinuity at midnight.
|
||
3. **Lags are expressed in hours, not samples.** `press_tend_3h` means three
|
||
hours whatever `grid_s` is. Changing the grid must not silently change what
|
||
the model means by "three hours ago".
|
||
|
||
### Standardisation
|
||
|
||
`Standardiser` keeps streaming Welford moments and z-scores everything except
|
||
the bias column. Not optional: unscaled pressure sits near 1013 while unscaled
|
||
temperature rate sits near 0.02, and the resulting condition number will
|
||
embarrass you.
|
||
|
||
### Solar geometry as a feature
|
||
|
||
`solar_position()` is the NOAA low-precision model, accurate to a few tenths of
|
||
a degree and costing about twenty floating point operations. It gives the
|
||
diurnal cycle real physical structure rather than making the model infer it
|
||
from clock time alone. `clear_sky_irradiance()` turns measured lux into a crude
|
||
cloudiness index by ratio. Through a south-facing window that is a surprisingly
|
||
decent okta estimate; in a north-facing room it is nearly useless. Judge
|
||
accordingly.
|
||
|
||
---
|
||
|
||
## 4. The forecasting core
|
||
|
||
### Why RLS and not gradient descent
|
||
|
||
A station produces 288 grid rows a day. Sample efficiency dominates everything
|
||
else. RLS is the exact minimiser of the exponentially weighted squared error at
|
||
every step, not an approximation, so it converges in far fewer samples than
|
||
SGD. The covariance `P` is a genuine parameter-uncertainty estimate, free. One
|
||
`(33, 33)` matrix is about 8 kB, so the whole bank of 18 fits in L2 cache on a
|
||
Cortex-A53.
|
||
|
||
### The covariance trace cap
|
||
|
||
```
|
||
P ← (P − outer(gain, P·x)) / λ
|
||
P ← (P + Pᵀ)/2 # enforce symmetry
|
||
if trace(P) > p_max: P *= p_max/trace(P)
|
||
```
|
||
|
||
**This is the single most important guard in the file.** Plain exponential
|
||
forgetting inflates `P` without bound along directions the data never excites.
|
||
On a quiet night the regressor barely moves, `P` grows exponentially in the
|
||
unexcited subspace, and the model detonates on the first sunrise sample. It is
|
||
the most common way a field RLS deployment dies. If you refactor `rls.py`, keep
|
||
both the cap and the symmetrisation.
|
||
|
||
### Direct heads, not recursion
|
||
|
||
18 heads: 3 targets × 6 horizons. Each predicts a **delta from now**, and the
|
||
absolute forecast is reconstructed as `anchor + delta`.
|
||
|
||
Predicting deltas rather than levels matters more than it looks. A model that
|
||
must output 14.7 °C spends its capacity representing the mean; one that outputs
|
||
+0.4 °C spends it on the weather.
|
||
|
||
Iterating a single one-step model 288 times to reach 24 hours would compound
|
||
its own bias into a beautifully smooth lie. 18 direct heads cost about 150 kB
|
||
total and each is honest about its own horizon.
|
||
|
||
### The Hedge ensemble
|
||
|
||
Each head blends three members: persistence (delta = 0), climatology (delta
|
||
from the harmonic fit) and the learned RLS output. Weights update by
|
||
exponentiated gradient on normalised absolute loss, then renormalise. This
|
||
guarantees the ensemble is never much worse than its best member, and it
|
||
re-weights within about a day when the season turns.
|
||
|
||
**The ensemble is allowed to conclude the learned model is useless.** At
|
||
15-minute pressure it typically parks most of its weight on persistence. That
|
||
is correct behaviour surfaced honestly, not a defect to engineer away.
|
||
|
||
### Adaptive conformal intervals
|
||
|
||
Split conformal is valid only under exchangeability, and weather is emphatically
|
||
not exchangeable: a front arrives and yesterday's residual quantile becomes
|
||
fiction. Adaptive conformal inference (Gibbs and Candès) feeds realised coverage
|
||
back into the working α:
|
||
|
||
```
|
||
α ← clip(α + γ·(α_target − 1[y ∈ C]), 0.005, 0.75)
|
||
```
|
||
|
||
The band widens after each miss and narrows after each hit, so long-run
|
||
coverage tracks the target whatever the distribution does underneath.
|
||
|
||
**Coverage is the acceptance test for any change to this path.** Measured
|
||
coverage sits at 89 to 91% against a 90% target across all 18 heads. A change
|
||
that improves MAE while coverage drifts to 70% is a regression, not an
|
||
improvement, because the intervals have started lying.
|
||
|
||
---
|
||
|
||
## 5. Verification
|
||
|
||
`Station.verify()` runs every five minutes. It pulls forecasts whose validity
|
||
time has passed, looks up the truth and the anchor, and computes per bucket:
|
||
|
||
```
|
||
skill = 1 − MAE_model / MAE_persistence
|
||
```
|
||
|
||
Zero means no better than assuming nothing changes. **Negative is useful
|
||
information, not an embarrassment**: it says ship persistence at that horizon
|
||
and stop pretending.
|
||
|
||
Scored errors feed two places: the conformal calibrator for each head, and, for
|
||
horizons up to 3 h, the Page-Hinkley drift detector. Forecasts more than an
|
||
hour past validity are deleted so the table does not grow without bound.
|
||
|
||
A forecast that is never scored is an opinion. A forecast scored against
|
||
persistence is a measurement.
|
||
|
||
### Reproducible evidence
|
||
|
||
`scripts/simulate.py` takes `--seed` and `--end`. **Both must be pinned** for
|
||
run-to-run comparability. The seed fixes the OU realisation, but the wall-clock
|
||
anchor moves solar elevation and the seasonal harmonic, so an unpinned `--end`
|
||
changes temperature and humidity while leaving pressure bit-identical. That
|
||
asymmetry is a useful diagnostic in itself: if pressure differs between two runs
|
||
with the same seed, something other than the anchor has changed.
|
||
|
||
Note the interaction with `evaluate.py`: `store.window()` looks back from *now*
|
||
with a 60-day default, so a history pinned to a date in the past reports "not
|
||
enough history" unless you pass a wide `--hours`.
|
||
|
||
---
|
||
|
||
## 6. Monitoring
|
||
|
||
Three detectors, because they fail differently.
|
||
|
||
**`MahalanobisEWMA`** catches abrupt multivariate novelty: a window opening, a
|
||
heater cycling, a squall. The EWMA on the whitened residual gives
|
||
persistence-aware detection, so one odd sample is noise and ten in a row is an
|
||
event. Shrinkage toward a scaled identity is **not optional**: with six signals
|
||
the sample covariance is singular for the first hour, and a singular covariance
|
||
turns Mahalanobis distance into a random number generator with an authoritative
|
||
name.
|
||
|
||
**`PageHinkley`** catches slow change: a sensor drifting, a season turning, a
|
||
model going stale. It runs on forecast error and **triggers retraining**, which
|
||
is a far better signal than a cron schedule.
|
||
|
||
**`SensorHealth`** catches the quietest failure of all. A latched sensor looks
|
||
perfectly normal to both detectors above: the dashboard is fine, the model
|
||
trains happily, and every forecast is confidently wrong. Bit-identical
|
||
consecutive readings are the only tell.
|
||
|
||
---
|
||
|
||
## 7. Tuning
|
||
|
||
| Symptom | Knob | Direction |
|
||
|---|---|---|
|
||
| Temperature reads consistently high | Calibrate from the Models tab, or `sensor.cpu_heat_k` | Raise |
|
||
| Readings over-smoothed, lag real change | `sensor.kalman_q_temp` | Raise |
|
||
| 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 below 1 | Over-smoothing, lower `q` | Lower |
|
||
| Adapts too slowly to a season change | `model.rls_forgetting` toward 0.995 | Lower |
|
||
| Jumpy, forgets overnight | `model.rls_forgetting` toward 0.9995 | Raise |
|
||
| Coverage well below target | `model.conformal_gamma` | Raise |
|
||
| Coverage well above target, lazily wide bands | `model.conformal_gamma` | Lower |
|
||
| Drift alarms constantly | `model.drift_lambda` | Raise |
|
||
| Retrains eat the CPU | `model.train_period_s` up, `max_pairs` down | |
|
||
| Zambretti reads pessimistic everywhere | `site.altitude_m` is wrong | Fix it |
|
||
|
||
On forgetting factors: effective memory is `1/(1−λ)` samples. At `λ = 0.9985`
|
||
on a five-minute grid that is about 667 samples, roughly 55 hours. `λ = 0.99`
|
||
is about two hours and will chase noise.
|
||
|
||
---
|
||
|
||
## 8. Performance budget on a Zero 2 W
|
||
|
||
Roughly 20× slower than a modern x86 core. Measured:
|
||
|
||
| Operation | x86 | Zero 2 W |
|
||
|---|---|---|
|
||
| Backfill 4032 rows | 0.6 s | 11.8 s |
|
||
| Full retrain, 18 heads | ~5 s | 60 to 100 s |
|
||
| Resident set, steady state | | ~150 MB |
|
||
| Database, one year | | ~30 MB |
|
||
|
||
Retraining runs in a worker thread via `asyncio.to_thread`, so the sample loop,
|
||
the API and the LED never stall. A command that looks hung on the Pi is usually
|
||
just the Pi.
|
||
|
||
`max_pairs` in `NowcastEnsemble.fit` caps supervised pairs per head at the most
|
||
recent 2500. Not a shortcut: with `λ = 0.9985` the 4000th-most-recent sample
|
||
carries a weight of about `e⁻⁶`. It costs real seconds on a Cortex-A53 and buys
|
||
nothing measurable.
|
||
|
||
**One uvicorn worker, deliberately.** The station owns mutable model state; a
|
||
second worker would give you two divergent forecasters sharing a socket.
|
||
|
||
---
|
||
|
||
## 9. Extension points
|
||
|
||
**Adding a target.** Append to `model.targets` in config and ensure
|
||
`station.py` resamples it into the `series` dict; the head bank scales
|
||
automatically. Add a climatology column too, or the ensemble's climatology
|
||
member returns zero delta forever and quietly wastes a third of its weight.
|
||
|
||
**Adding a feature.** Append to `FEATURE_NAMES` **and** to the `column_stack`
|
||
in `build_features()`, in the same position. The width assertion catches
|
||
mismatches. Persisted RLS state from before the change is now the wrong
|
||
dimension: delete `data/state/station_state.json` and retrain.
|
||
|
||
**Adding a sensor.** `sensors.py` is the only module that touches hardware.
|
||
Extend `SenseBoard.read()`, add the column to `storage.COLUMNS` and the schema,
|
||
and mirror it in `SimulatedBoard` so the simulator still exercises every path.
|
||
A DS18B20 outside the window is the highest-value hardware change available: it
|
||
removes the indoor caveat entirely and improves every model at once.
|
||
|
||
**Replacing the learner.** `ForecastHead` needs only `predict(x)` and
|
||
`update(x, y)`. Anything with that interface drops in. If you swap in something
|
||
without a covariance you lose `predict_std`, and the conformal fallback `sigma`
|
||
becomes meaningless until enough residuals accumulate.
|
||
|
||
---
|
||
|
||
## 10. Things that will bite you
|
||
|
||
- **Station pressure passed where sea-level pressure is expected.** Silent, and
|
||
at 100 m elevation it shifts the Zambretti number by about two categories,
|
||
permanently.
|
||
- **Single-letter SQL aliases.** See section 1.
|
||
- **`$` as a JavaScript identifier** in `dashboard.py`. It collides with
|
||
bundled libraries and kills the entire script with one opaque syntax error.
|
||
The helper is `el()`.
|
||
- **Unconstrained Chart.js canvases.** With `maintainAspectRatio: false` a
|
||
chart expands to fill its parent. Every canvas needs an explicitly sized
|
||
relative wrapper or it swallows its panel.
|
||
- **Charts inside `display: none`.** Chart.js cannot measure a hidden canvas,
|
||
so tab switches call `resize()` on reveal.
|
||
- **Persisted state outliving a schema change.** `station_state.json` holds
|
||
trained parameters with fixed dimensions. Change the feature count without
|
||
deleting it and you get a shape error at the first update, or worse, silence.
|
||
- **Synthetic history left in the database.** After a `simulate.py` backfill,
|
||
clear the synthetic rows once real telemetry accumulates, or the model stays
|
||
anchored on a stochastic weather model rather than on your room.
|