mirror of
https://github.com/lynchaos/ashvale-station.git
synced 2026-09-12 20:52:23 +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(),
|
return {"q": self.q, "r": self.r, "x": self.x.tolist(),
|
||||||
"P": self.P.tolist(), "initialised": self.initialised}
|
"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
|
@classmethod
|
||||||
def from_dict(cls, d: Dict) -> "KalmanCV":
|
def from_dict(cls, d: Dict) -> "KalmanCV":
|
||||||
kf = cls(q=d["q"], r=d["r"])
|
kf = cls(q=d["q"], r=d["r"])
|
||||||
kf.x = np.array(d["x"], dtype=float)
|
kf.load_state(d)
|
||||||
kf.P = np.array(d["P"], dtype=float)
|
|
||||||
kf.initialised = bool(d["initialised"])
|
|
||||||
return kf
|
return kf
|
||||||
|
|
||||||
|
|
||||||
@@ -321,7 +337,13 @@ class SignalTracker:
|
|||||||
# Absent from state files written before humidity compensation existed.
|
# Absent from state files written before humidity compensation existed.
|
||||||
if d.get("hum_compensator"):
|
if d.get("hum_compensator"):
|
||||||
self.hum_compensator = HumidityCompensator.from_dict(d["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")
|
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, (
|
assert np.median(settled) < 3.0, (
|
||||||
f"median |rate| {np.median(settled):.1f} C/h in a room drifting 0.4 C/h")
|
f"median |rate| {np.median(settled):.1f} C/h in a room drifting 0.4 C/h")
|
||||||
assert np.percentile(settled, 95) < 10.0
|
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