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,179 @@
|
||||
# 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)
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -0,0 +1,115 @@
|
||||
# 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):
|
||||
if _rows() == 0:
|
||||
pytest.skip("no history in the database")
|
||||
before = _rows()
|
||||
client.post("/api/recompute")
|
||||
assert _rows() == before
|
||||
|
||||
|
||||
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})
|
||||
Reference in New Issue
Block a user