mirror of
https://github.com/lynchaos/ashvale-station.git
synced 2026-09-12 12:47:49 +00:00
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.
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
# 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"]
|
||||
Reference in New Issue
Block a user