diff --git a/custom_components/heating_mpc/coordinator.py b/custom_components/heating_mpc/coordinator.py index 6ca878e..1d510fa 100644 --- a/custom_components/heating_mpc/coordinator.py +++ b/custom_components/heating_mpc/coordinator.py @@ -431,8 +431,10 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): model = ZoneModel(self.learners[key], self.extended.get(key), self.ventilation.get(key)) inputs = {"outside": outside, "lag": lag, "internal": internal[key], "temps": temps, "ventilation": vent_inputs.get(key) or {}} - a, b, c = self.learners[key].theta - coefficients = {"base": {"loss": round(a, 6), "heat": round(b, 6), "bias": round(c, 6)}} + base = self.learners[key] + confidence = base.confidence() + coefficients = {"base": {name: {"value": round(value, 6), "confidence": confidence[name]} + for name, value in zip(("loss", "heat", "bias"), base.theta)}} for layer, learner in (("extended", model.extended), ("ventilation", model.ventilation)): if learner is not None: coefficients[layer] = {name: {k: info[k] for k in ("value", "std", "confidence", "identified")} @@ -740,6 +742,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]): "loss": round(model.theta[0], 6), "heat": round(model.theta[1], 6), "bias": round(model.theta[2], 6), + # How well the data pins each coefficient down, not how many samples. + "confidence": model.confidence(), "samples": model.count, "on_samples": model.on_count, "off_samples": model.off_count, "rejected_samples": model.rejected_count, diff --git a/custom_components/heating_mpc/physics.py b/custom_components/heating_mpc/physics.py index 6a15dde..605a57c 100644 --- a/custom_components/heating_mpc/physics.py +++ b/custom_components/heating_mpc/physics.py @@ -134,6 +134,17 @@ class Learner: def mae(self) -> float | None: return self.error_sum / self.count if self.count else None + def confidence(self) -> dict[str, float]: + """Per coefficient, the share of its information that comes from data (0 = prior only). + + Taken from the coefficient's own uncertainty, not from the sample count: it + only grows while the data varies that coefficient's input independently of + the others. Weeks with the relay always off leave `heat` at 0; a steady + indoor-outdoor difference cannot separate `loss` from `bias`. + """ + return {name: round(1 - min(prior, max(0.0, self.p[i][i])) / prior, 3) + for i, (name, prior) in enumerate(zip(("loss", "heat", "bias"), PRIOR_VARIANCE))} + def as_dict(self) -> dict[str, Any]: return {"theta": self.theta, "p": self.p, "count": self.count, "error_sum": self.error_sum, "on_count": self.on_count, "off_count": self.off_count, diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 5886912..799cc99 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -785,6 +785,11 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(details['model_in_use'], 'extended') self.assertGreater(details['contributions_c_per_h']['internal'], 0.2) self.assertTrue(details['coefficients']['extended']['internal']['identified']) + # The base layer reports how well each coefficient is pinned down too. + base = details['coefficients']['base'] + self.assertEqual(set(base), {'loss', 'heat', 'bias'}) + self.assertEqual(base['heat']['confidence'], data['models']['office']['confidence']['heat']) + self.assertGreater(base['heat']['confidence'], 0.8) # Replayed against what happened, the electrical term removes most of the base error. accuracy = data['forecast_accuracy'][60] self.assertLess(accuracy['mae'], 0.06) diff --git a/tests/test_physics.py b/tests/test_physics.py index 54b0570..3c8c5a8 100644 --- a/tests/test_physics.py +++ b/tests/test_physics.py @@ -140,6 +140,31 @@ class ModelTests(unittest.TestCase): self.assertEqual(restored.count, 30000) self.assertEqual(restored.p, learn.p) + def test_confidence_reflects_excitation_not_sample_count(self): + # Two weeks of samples, relay always off, a steady 15 degC difference to outside. + steady = m.Learner() + for _ in range(4000): + steady.learn(20.0, 20.0 + 0.003 * -15 + 0.001, 5.0, 0.0, command=False) + confidence = steady.confidence() + self.assertEqual(steady.count, 4000) + self.assertEqual(confidence['heat'], 0) # never exercised + self.assertLess(confidence['loss'], 0.8) # not separable from bias + self.assertLess(confidence['bias'], 0.8) + # Fewer samples, with the relay cycling and a cold and a mild spell each day. + varied = m.Learner() + t, lag = 20.0, 0.0 + for step in range(1200): + heater = int(step % 96 < 35) + lag = lag + m.HEAT_LAG * (heater - lag) + outside = -2 + 14 * ((step // 144) % 2) + value = t + 0.003 * (outside - t) + 0.055 * lag + 0.001 + varied.learn(t, value, outside, lag, command=bool(heater)) + t = value + self.assertTrue(all(value > 0.8 for value in varied.confidence().values()), varied.confidence()) + # Recalibration starts confidence over. + varied.recalibrate() + self.assertEqual(set(varied.confidence().values()), {0}) + def test_restore_tolerates_round_off_and_bounds_wound_up_covariance(self): # Shape of a v0.2.1 model after a summer: huge heat variance, tiny asymmetry. data = m.Learner(count=40000, off_count=40000).as_dict()