mirror of
https://github.com/lynchaos/ashvale-station.git
synced 2026-09-12 12:47:49 +00:00
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:
+26
-4
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user