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,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
|
||||
Reference in New Issue
Block a user