Take Kalman tuning from config, not from the saved state

q and r were written into station_state.json and restored over the configured
values, so tuning was effectively immutable in the field. This was found the
expensive way: the retune in the previous commit was deployed, the service
restarted cleanly, and the filters carried on with q = 2e-6 because that is
what the state file said. Measured median rate afterwards was 14.4 C/h against
12.4 before, which is to say nothing happened.

Only the estimate is state. x, P and initialised are restored; q and r now
come from config every time. P may be momentarily inconsistent with a changed
q, which costs a few hundred samples of reconvergence and is far cheaper than
a configuration change that appears to work and does not.

load_dict also now skips filters this build no longer has, rather than
resurrecting them from an older state file.
This commit is contained in:
2026-08-19 19:22:49 +01:00
parent b9a468542d
commit 3f41881c64
2 changed files with 57 additions and 4 deletions
+26 -4
View File
@@ -105,12 +105,28 @@ class KalmanCV:
return {"q": self.q, "r": self.r, "x": self.x.tolist(),
"P": self.P.tolist(), "initialised": self.initialised}
def load_state(self, d: Dict) -> None:
"""Restore the estimate only, leaving q and r as configured.
q and r are tuning, not something the filter learned. Taking them from
the state file pins whatever values were in force when it was written,
so editing them in config.yaml does nothing until someone thinks to
delete the state, and nobody thinks to delete the state. That cost a
retune here: the new q was deployed, the service restarted, and the
filters quietly carried on with the old one.
P may be inconsistent with a newly changed q. That is harmless: the
filter re-converges within a few hundred samples, which is far cheaper
than a tuning change that appears to work and does not.
"""
self.x = np.array(d["x"], dtype=float)
self.P = np.array(d["P"], dtype=float)
self.initialised = bool(d["initialised"])
@classmethod
def from_dict(cls, d: Dict) -> "KalmanCV":
kf = cls(q=d["q"], r=d["r"])
kf.x = np.array(d["x"], dtype=float)
kf.P = np.array(d["P"], dtype=float)
kf.initialised = bool(d["initialised"])
kf.load_state(d)
return kf
@@ -321,7 +337,13 @@ class SignalTracker:
# Absent from state files written before humidity compensation existed.
if d.get("hum_compensator"):
self.hum_compensator = HumidityCompensator.from_dict(d["hum_compensator"])
self.filters = {k: KalmanCV.from_dict(v) for k, v in d["filters"].items()}
# Deliberately not KalmanCV.from_dict here: that would restore the
# persisted q and r over the configured ones. Only the estimate is
# restored, and only for filters this build still has.
for name, saved in d.get("filters", {}).items():
kf = self.filters.get(name)
if kf is not None:
kf.load_state(saved)
self.last_ts = d.get("last_ts")
+31
View File
@@ -333,3 +333,34 @@ def test_kalman_rate_is_physical_in_a_still_room():
assert np.median(settled) < 3.0, (
f"median |rate| {np.median(settled):.1f} C/h in a room drifting 0.4 C/h")
assert np.percentile(settled, 95) < 10.0
def test_retuning_q_survives_a_reload():
"""Tuning lives in config, not in the state file.
q and r were persisted and restored, so a retune deployed to a running
station did nothing: the service restarted and the filters carried on with
whatever tuning was in force when the state was last written. The symptom
is a config change that appears to work and does not, which is the worst
kind.
"""
from ashvale.config import load_config
from ashvale.estimation import SignalTracker
cfg = load_config()
cfg.sensor.kalman_q_temp = 2.0e-6 # an old, badly tuned state file
old = SignalTracker(cfg)
for i in range(50):
old.step(1.7554e9 + i * 2.0, 24.0, 50.0, 1013.0, 43.0)
saved = old.to_dict()
assert saved["filters"]["temperature"]["q"] == 2.0e-6
cfg.sensor.kalman_q_temp = 1.0e-9 # the retune
fresh = SignalTracker(cfg)
fresh.load_dict(saved)
assert fresh.filters["temperature"].q == 1.0e-9, \
"the state file overrode the configured tuning"
# the estimate itself must still be carried across
assert fresh.filters["temperature"].initialised
assert fresh.filters["temperature"].x[0] == pytest.approx(
old.filters["temperature"].x[0])