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,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