Score the full model against the base model; compile the search step

Validation: the walk-forward evaluator now replays all zones together
with the full model (electrical heat, neighbour exchange, ventilation
and recovery) using the parameters frozen at the forecast start and the
electrical heat and window openings that actually happened, and scores
the base 3-parameter model on the same spans. Forecast Error sensors
add base_model_mae and improvement_vs_base (share of the base model's
error the new features remove; 0 = no gain, negative = worse). Saved
0.3 statistics load with their own error as the base column.

On synthetic apartments, 60-minute error full vs base: gaming office
0.031 vs 0.182 °C (83 % better), bedroom with ventilation 0.026 vs
0.218 °C (88 %), two coupled rooms 0.024 vs 0.037 °C (34 %; gone by
120 min, where the constant-outdoor assumption dominates). The same
ventilation week without window sensors shows no improvement, as it
should. These check the method, not real-world accuracy.

Performance: every term of a zone's 5-minute change is affine in the
zone temperatures and the radiator lag, and which layer applies depends
only on which inputs exist. compile_step_model reads the coefficients
off the real model per zone and step, so the plan search does a few
multiply-adds per step. It matches the direct model to 1e-12 and makes
a 180-minute, 5-zone search 4.5x faster (0.73 s here, close to the old
base-only search).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-27 02:55:57 +02:00
1 parent 8d197fabcd
commit b1577a0c78
7 files changed
+266 -31

No files matched your search

+10 -11
View File
@@ -21,7 +21,7 @@ from .const import (
CONF_VENT_WIND, CONF_WINDOW_OPEN_MINUTES, DEFAULTS, READY_OFF_SAMPLES, READY_ON_SAMPLES, READY_SAMPLES,
)
from .data_quality import ReportAgeHistory, freshness
from .evaluation import ForecastEvaluator
from .evaluation import ForecastEvaluator, model_snapshot
from .heat_sources import SUBENTRY_HEAT_SOURCE, HeatSource, HeatSourceTracker, build_sources, normalize_power
from .learning import FeatureLearner
from .physics import (
@@ -30,7 +30,7 @@ from .physics import (
)
from .weather_sources import choose_weather_sources, sources_for, normalize
from .windows import SUBENTRY_WINDOW, VentilationTracker, Window, build_windows, facing_cos, ventilation_vector
from .zone_model import ZoneModel, extended_features, feature_vector
from .zone_model import ZoneModel, compile_step_model, extended_features, feature_vector
from .zones import SUBENTRY_ZONE, Zone, build_zones, legacy_zone_data, zone_temperature
_LOGGER = logging.getLogger(__name__)
@@ -289,13 +289,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return None
half_life = float(self.config.get(CONF_INTERNAL_HALF_LIFE, 30))
forecast = {key: self._heat_sources.zone_forecast(key, steps, half_life) for key in temps}
def step(state: dict[str, float], lag: float, k: int) -> dict[str, float]:
k = min(k, steps - 1)
return {key: value + models[key].delta(value, {
"outside": outside, "lag": lag, "internal": forecast[key][k], "temps": state,
"ventilation": ventilation[key][k]}) for key, value in state.items()}
return step
return compile_step_model(models, list(temps), steps, lambda key, k: {
"outside": outside, "internal": forecast[key][k], "ventilation": ventilation[key][k]})
def _zone_details(self, temps: dict[str, float], outside: float, internal: dict[str, float | None],
vent_inputs: dict[str, Any]) -> dict[str, Any]:
@@ -557,8 +552,12 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"outside_source": active_sources["temperature"], "compositions": compositions,
}
# Parameters after this poll's learning: forecasts from here use only data so far.
self._evaluator.observe(now, vals, outside, self._lag["lag"], self._lag["uncertainty"],
{room: tuple(self.learners[room].theta) for room in vals})
self._evaluator.observe(
now, vals, outside, self._lag["lag"], self._lag["uncertainty"],
{room: tuple(self.learners[room].theta) for room in vals},
models={room: model_snapshot(ZoneModel(self.learners[room], self.extended.get(room),
self.ventilation.get(room))) for room in vals},
inputs={room: {"internal": internal[room], "ventilation": vent_inputs.get(room)} for room in vals})
status["forecast_accuracy"] = self._evaluator.summary(self.rooms)
if learned:
# Persist at each sample so restart does not erase hours of training.