Explain each zone: contributions, forecast steps, coefficients

Each zone forecast entity now shows the current temperature, the
forecast at +15/+30/+60/+120 minutes (what the horizon covers), which
model layer is in use, and what drives the zone right now in °C/h:
envelope loss, boiler, bias, electrical heat, neighbours, ventilation
and recovery, plus each layer's coefficients with standard error,
confidence and whether it is identified. These attributes change every
poll and are not recorded.

Tests for inter-zone exchange on a two-room synthetic apartment (living
room and a colder bedroom, one boiler): with a true coupling of 0.01 the
learner finds a clearly positive value but, because the difference
between rooms is fairly steady and therefore collinear with each room's
bias, correctly reports it as not identified; with no coupling it stays
near zero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-27 02:50:56 +02:00
1 parent ed8f9fe2e2
commit 8d197fabcd
4 files changed
+99 -4

No files matched your search

@@ -297,6 +297,27 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"ventilation": ventilation[key][k]}) for key, value in state.items()}
return step
def _zone_details(self, temps: dict[str, float], outside: float, internal: dict[str, float | None],
vent_inputs: dict[str, Any]) -> dict[str, Any]:
"""What drives each zone right now (°C/h per effect) and how sure each layer is."""
details = {}
lag = self._lag["lag"]
for key, temp in temps.items():
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)}}
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")}
for name, info in learner.coefficients().items()}
details[key] = {"temperature": round(temp, 3),
"model_in_use": "extended" if model.uses_extended else "base",
"contributions_c_per_h": model.contributions(temp, inputs),
"coefficients": coefficients}
return details
def _window_diagnostics(self, now: float, weather: dict[str, Any], temps: dict[str, float | None],
vent_inputs: dict[str, Any], internal: dict[str, float | None]) -> dict[str, Any]:
windows = {}
@@ -581,6 +602,7 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
for room, model in self.learners.items()
},
})
status["zone_details"] = self._zone_details(vals, outside, internal, vent_inputs)
if climate.state != "heat":
status["status"] = "paused"
return status
+13 -3
View File
@@ -51,7 +51,8 @@ class MPCStatus(CoordinatorEntity, SensorEntity):
data = self.coordinator.data
# Forecast details live on their own entities.
return {key: value for key, value in data.items() if key not in (
"status", "prediction_60m", "prediction_end", "prediction_trajectory", "forecast_accuracy")}
"status", "prediction_60m", "prediction_end", "prediction_trajectory", "forecast_accuracy",
"zone_details")}
class MPCWeightedTemp(CoordinatorEntity, SensorEntity):
@@ -82,7 +83,8 @@ class MPCRoomPrediction(CoordinatorEntity, SensorEntity):
self._attr_name = f"MPC {coordinator.zones[room].name} 60min Prediction"
# Change every poll; the 60-minute value itself is recorded.
_unrecorded_attributes = frozenset({"forecast_15min", "plan_15min"})
_unrecorded_attributes = frozenset({"forecast_15min", "plan_15min", "forecast", "contributions_c_per_h",
"coefficients", "current_temperature", "model_in_use"})
@property
def native_value(self):
@@ -91,10 +93,18 @@ class MPCRoomPrediction(CoordinatorEntity, SensorEntity):
@property
def extra_state_attributes(self):
data = self.coordinator.data
trajectory = data.get("prediction_trajectory", {}).get(self.room) or []
details = data.get("zone_details", {}).get(self.room, {})
return {
"forecast_15min": data.get("prediction_trajectory", {}).get(self.room),
"current_temperature": details.get("temperature"),
"forecast": {f"{minutes}_min": trajectory[minutes // 15 - 1]
for minutes in (15, 30, 60, 120) if len(trajectory) >= minutes // 15},
"forecast_15min": trajectory or None,
"plan_15min": data.get("plan_15min"),
"basis": "proposed MPC plan; counterfactual while Generic Thermostat controls the relay",
"model_in_use": details.get("model_in_use"),
"contributions_c_per_h": details.get("contributions_c_per_h"),
"coefficients": details.get("coefficients"),
}
class MPCOutdoorTemperature(CoordinatorEntity, SensorEntity):