diff --git a/ashvale/estimation.py b/ashvale/estimation.py index cded302..d205108 100644 --- a/ashvale/estimation.py +++ b/ashvale/estimation.py @@ -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") diff --git a/tests/test_estimation.py b/tests/test_estimation.py index b1079bc..ba40d8a 100644 --- a/tests/test_estimation.py +++ b/tests/test_estimation.py @@ -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])