From 3ce6c1360982b024d86a094a4b1a7ede4d6585df Mon Sep 17 00:00:00 2001 From: Andrea Giannangelo Date: Wed, 5 Aug 2026 13:38:38 +0200 Subject: [PATCH 1/3] fix(mpc): extend cooling lookahead so AC pre-cools before a comfort window Cool-capable rooms kept LOOKAHEAD_BASE_BLOCKS (6 blocks / 30 min), so the optimizer could not see an upcoming comfort-window setpoint drop far enough ahead to start cooling early. Heating (UFH) already pre-heats via an extended, tau-scaled lookahead; cooling had no equivalent, so an AC only reacted once the window opened - behaving like a plain scheduled thermostat. Add LOOKAHEAD_COOLING_BLOCKS (18 / 90 min) and take the lookahead as the max of the heating tau horizon and the cooling horizon. No afterglow synthesis is added for cooling: unlike a UFH slab, an AC has negligible stored-emission afterglow, so the RC model's post-run warm-back (block_Q=0) already models decay correctly, avoiding the over-cooling that motivated keeping cooling at the base lookahead. Hybrid UFH+AC rooms now get both (max): winter pre-heating is preserved and summer pre-cooling is enabled. --- .../roommind/control/mpc_optimizer.py | 50 +++++++++++++------ 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/custom_components/roommind/control/mpc_optimizer.py b/custom_components/roommind/control/mpc_optimizer.py index f3fdb2c5..d3b56175 100644 --- a/custom_components/roommind/control/mpc_optimizer.py +++ b/custom_components/roommind/control/mpc_optimizer.py @@ -16,6 +16,14 @@ # 1.0 so UFH lookahead (24 blocks = 120 min) matches the outer horizon minimum, # avoiding silent clamping. See issue #131. LOOKAHEAD_HORIZON_SCALE = 1.0 +# Cooling pre-conditioning horizon. Cool-capable rooms extend the lookahead to at +# least this many blocks (18 = 90 min) so the optimizer can see an upcoming +# comfort-window setpoint drop and start cooling early to be at target when the +# window opens — the cooling analogue of UFH pre-heating. Bounded to cap steady- +# state AC aggressiveness. No afterglow synthesis is needed for cooling: unlike a +# UFH slab, an AC has negligible stored-emission afterglow, so the RC model's +# post-run warm-back (block_Q=0) already models decay correctly. +LOOKAHEAD_COOLING_BLOCKS = 18 @dataclass @@ -97,21 +105,27 @@ def optimize( # Clamp inverted targets: cool must be >= heat cool_target_series = [max(h, c) for h, c in zip(heat_target_series, cool_target_series, strict=False)] - # Per-system decision lookahead. UFH (tau=90min) scales up so the cost - # function can see post-heating residual afterglow; radiator / "" stay - # at LOOKAHEAD_BASE_BLOCKS for byte-identical behaviour. Hybrid UFH+AC - # rooms (can_cool=True) also keep the base lookahead: extending the - # horizon without a matching cooling-side synthesis would shift the - # energy/comfort ratio for COOLING and cause more aggressive AC use. + # Per-system decision lookahead, taken as the max of two needs: + # - slow heating systems (UFH, tau=90min) scale up so the cost function + # can value pre-heating via the synthesized post-heating afterglow; + # radiator / "" stay at LOOKAHEAD_BASE_BLOCKS. + # - cool-capable rooms extend to LOOKAHEAD_COOLING_BLOCKS so the cost + # function can see an upcoming comfort-window setpoint drop and pre-cool + # (the cooling analogue of pre-heating). Cooling needs no afterglow + # synthesis — the RC model already captures post-run warm-back. + # Hybrid UFH+AC rooms get both (max): winter pre-heating is preserved and + # summer pre-cooling is enabled. + lookahead = LOOKAHEAD_BASE_BLOCKS profile = HEATING_SYSTEM_PROFILES.get(self.heating_system_type) if self.heating_system_type else None - if profile and dt_minutes > 0 and not self.can_cool: + if profile and dt_minutes > 0: tau_blocks = math.ceil(profile["tau_minutes"] / dt_minutes) - self._lookahead_blocks = max( - LOOKAHEAD_BASE_BLOCKS, + lookahead = max( + lookahead, self.min_run_blocks + math.ceil(LOOKAHEAD_HORIZON_SCALE * tau_blocks), ) - else: - self._lookahead_blocks = LOOKAHEAD_BASE_BLOCKS + if self.can_cool and dt_minutes > 0: + lookahead = max(lookahead, LOOKAHEAD_COOLING_BLOCKS) + self._lookahead_blocks = lookahead n_blocks = min(len(T_outdoor_series), len(heat_target_series), len(cool_target_series)) if n_blocks == 0 or not math.isfinite(T_room): @@ -250,11 +264,15 @@ def _evaluate_action( """Evaluate the cost of taking an action, looking a few steps ahead. Per-system lookahead (self._lookahead_blocks) extends the window for - slow heating systems. For UFH, the HEATING hypothesis synthesizes its - own post-heating residual afterglow so the cost function values the - sustained-comfort benefit of pre-heating. Radiator / "" lookahead stays - at LOOKAHEAD_BASE_BLOCKS and synthesis is gated off — byte-identical - to pre-fix behaviour. + slow heating systems and for cool-capable rooms. For UFH, the HEATING + hypothesis synthesizes its own post-heating residual afterglow so the + cost function values the sustained-comfort benefit of pre-heating. + Cooling uses the extended lookahead to see an upcoming comfort-window + setpoint drop and pre-cool, but needs no afterglow synthesis (an AC has + negligible stored-emission afterglow; the RC model already captures + post-run warm-back). Radiator / "" rooms with no cooling stay at + LOOKAHEAD_BASE_BLOCKS with synthesis gated off — byte-identical to + pre-fix behaviour. """ lookahead = min(self._lookahead_blocks, len(future_T_outdoor)) Q = self._action_to_Q(action) From 8add2b1b01b5c898eb0f17117977fa996997c4db Mon Sep 17 00:00:00 2001 From: Andrea Giannangelo Date: Wed, 5 Aug 2026 14:28:26 +0200 Subject: [PATCH 2/3] test(mpc): update lookahead expectations for cooling pre-cool The three lookahead tests encoded the old rule that cool-capable rooms are pinned to LOOKAHEAD_BASE_BLOCKS. Update them to the new rule - the lookahead is the max of the heating tau horizon and the cooling horizon - rather than loosening or removing the assertions: - test_unknown_system_no_regression now asserts both halves of the rule: a pure-heating room (can_cool=False) still keeps the base lookahead, while a cool-capable room with no heating profile extends to LOOKAHEAD_COOLING_BLOCKS. - test_lookahead_blocks_attribute_exposed gets the recomputed expectations for all six setups, with the arithmetic behind each spelled out. - test_hybrid_ufh_ac_cooling_balance_preserved asserted exactly the balance this fix deliberately changes, so it is rewritten (and renamed) as test_hybrid_ufh_ac_gets_max_of_both_horizons: a hybrid room keeps the full UFH horizon and also clears the cooling horizon. Also refresh the comments that described the old behaviour ("cooling stays at base=6", "can_cool=False keeps the extension active") so they stop lying. No production code is touched. --- tests/control/test_mpc_optimizer.py | 102 ++++++++++++++++++---------- 1 file changed, 65 insertions(+), 37 deletions(-) diff --git a/tests/control/test_mpc_optimizer.py b/tests/control/test_mpc_optimizer.py index d11a94cf..51fffa26 100644 --- a/tests/control/test_mpc_optimizer.py +++ b/tests/control/test_mpc_optimizer.py @@ -7,7 +7,12 @@ import pytest from custom_components.roommind.const import MIN_POWER_FRACTION, MODE_COOLING, MODE_HEATING -from custom_components.roommind.control.mpc_optimizer import MPCOptimizer, MPCPlan +from custom_components.roommind.control.mpc_optimizer import ( + LOOKAHEAD_BASE_BLOCKS, + LOOKAHEAD_COOLING_BLOCKS, + MPCOptimizer, + MPCPlan, +) from custom_components.roommind.control.thermal_model import RCModel @@ -619,25 +624,33 @@ def test_radiator_plan_matches_empty_plan(): def test_unknown_system_no_regression(): - """Empty heating_system_type keeps base lookahead and synthesis off.""" + """Empty heating_system_type contributes no tau extension of its own. + + A pure-heating room (can_cool=False) therefore keeps the base lookahead and + synthesis off — byte-identical to pre-fix behaviour. A cool-capable room with + no heating profile still extends to LOOKAHEAD_COOLING_BLOCKS: the lookahead is + the max of the heating tau horizon and the cooling horizon. + """ model = RCModel(C=2.0, U=50.0, Q_heat=1000.0, Q_cool=1500.0) - opt = MPCOptimizer(model=model, heating_system_type="") - plan = opt.optimize( - T_room=19.5, - T_outdoor_series=[5.0] * 12, - heat_target_series=[21.0] * 12, - dt_minutes=5, - ) - assert plan.lookahead_blocks == 6 + kwargs = { + "T_room": 19.5, + "T_outdoor_series": [5.0] * 12, + "heat_target_series": [21.0] * 12, + "dt_minutes": 5, + } + plan_heat_only = MPCOptimizer(model=model, heating_system_type="", can_cool=False).optimize(**kwargs) + assert plan_heat_only.lookahead_blocks == LOOKAHEAD_BASE_BLOCKS + plan_cool_capable = MPCOptimizer(model=model, heating_system_type="", can_cool=True).optimize(**kwargs) + assert plan_cool_capable.lookahead_blocks == LOOKAHEAD_COOLING_BLOCKS def test_cooling_lookahead_no_afterglow_synthesis(): """COOLING uses the provided future_residual; never synthesizes afterglow. - Exercised on an extended-lookahead UFH setup (can_cool=False keeps the - extension active). With lookahead=24 and min_run=6, the 18 post-run blocks - see block_Q=0 and let residual through. Proves residual drives COOLING - cost, not synthesis. + Exercised on an extended-lookahead UFH setup (the UFH tau horizon supplies + the extension here; a cool-capable room would extend anyway). With + lookahead=24 and min_run=6, the 18 post-run blocks see block_Q=0 and let + residual through. Proves residual drives COOLING cost, not synthesis. """ model = RCModel(C=2.0, U=50.0, Q_heat=1000.0, Q_cool=200.0) opt = _ufh_optimizer(model=model) # can_cool=False by helper default @@ -679,14 +692,18 @@ def test_lookahead_clamps_to_horizon(): def test_lookahead_blocks_attribute_exposed(): """Plan exposes lookahead_blocks for guard integration; covers all setups.""" model = RCModel(C=2.0, U=50.0, Q_heat=1000.0, Q_cool=1500.0) + # Expected lookahead = max(base 6, heating tau horizon, cooling horizon 18). + # Heating tau horizon = min_run_blocks + ceil(tau_minutes / dt_minutes), only + # for a known profile: radiator tau=10min -> 2+2=4 (below base), underfloor + # tau=90min -> 6+18=24. The cooling horizon applies whenever can_cool. cases = [ # (heating_system_type, can_cool, min_run_blocks, expected_lookahead) - ("", False, 2, 6), - ("", True, 2, 6), - ("radiator", False, 2, 6), - ("radiator", True, 2, 6), - ("underfloor", False, 6, 24), - ("underfloor", True, 6, 6), # hybrid UFH+AC keeps base lookahead + ("", False, 2, 6), # no profile, no cooling -> base + ("", True, 2, 18), # cooling horizon alone extends it + ("radiator", False, 2, 6), # tau horizon 4 < base + ("radiator", True, 2, 18), # cooling horizon dominates + ("underfloor", False, 6, 24), # UFH tau horizon + ("underfloor", True, 6, 24), # hybrid UFH+AC: max(24, 18) = 24 ] for hst, can_cool, mrb, exp_blocks in cases: opt = MPCOptimizer( @@ -706,8 +723,14 @@ def test_lookahead_blocks_attribute_exposed(): ) -def test_hybrid_ufh_ac_cooling_balance_preserved(): - """Hybrid UFH+AC rooms keep base lookahead → cooling plan matches pre-fix.""" +def test_hybrid_ufh_ac_gets_max_of_both_horizons(): + """Hybrid UFH+AC rooms take the max of the heating tau and cooling horizons. + + Pre-cooling used to be sacrificed to protect UFH pre-heating: cool-capable + rooms were pinned to the base lookahead. The lookahead is now the max of both + needs, so a hybrid room keeps the full UFH horizon (winter pre-heating) while + also clearing the cooling horizon (summer pre-cooling). + """ model = RCModel(C=2.0, U=50.0, Q_heat=1000.0, Q_cool=200.0) kwargs = { "T_room": 25.0, @@ -716,27 +739,32 @@ def test_hybrid_ufh_ac_cooling_balance_preserved(): "cool_target_series": [23.0] * 24, "dt_minutes": 5, } - # Hybrid: UFH TRV + AC cooling. With the fix gated on not can_cool, the - # UFH profile must NOT extend the lookahead — cooling stays at base=6. - plan_hybrid = MPCOptimizer( - model=model, - can_heat=True, - can_cool=True, - min_run_blocks=6, - heating_system_type="underfloor", - ).optimize(**kwargs) - # Reference: same room without UFH profile — the baseline cooling plan. - plan_baseline = MPCOptimizer( + hybrid_kwargs = { + "model": model, + "can_heat": True, + "min_run_blocks": 6, + "heating_system_type": "underfloor", + } + plan_hybrid = MPCOptimizer(**hybrid_kwargs, can_cool=True).optimize(**kwargs) + # Pure UFH reference: the heating tau horizon on its own. + plan_ufh_heat_only = MPCOptimizer(**hybrid_kwargs, can_cool=False).optimize(**kwargs) + # Cool-capable room with no heating profile: the cooling horizon on its own. + plan_cool_only = MPCOptimizer( model=model, can_heat=True, can_cool=True, min_run_blocks=6, heating_system_type="", ).optimize(**kwargs) - assert plan_hybrid.lookahead_blocks == 6 - assert plan_hybrid.actions == plan_baseline.actions - assert plan_hybrid.temperatures == plan_baseline.temperatures - assert plan_hybrid.power_fractions == plan_baseline.power_fractions + + assert plan_ufh_heat_only.lookahead_blocks == 24 + assert plan_cool_only.lookahead_blocks == LOOKAHEAD_COOLING_BLOCKS + # max(24, 18) = 24: UFH pre-heating horizon preserved, cooling horizon cleared. + assert plan_hybrid.lookahead_blocks == max(plan_ufh_heat_only.lookahead_blocks, LOOKAHEAD_COOLING_BLOCKS) + assert plan_hybrid.lookahead_blocks >= LOOKAHEAD_COOLING_BLOCKS + # The hybrid room is no longer pinned to the base lookahead — that pinning is + # exactly what kept the AC from seeing an upcoming comfort window. + assert plan_hybrid.lookahead_blocks > LOOKAHEAD_BASE_BLOCKS # --------------------------------------------------------------------------- From 7787fa36534d61c115b0bef939586ef3006b2a2f Mon Sep 17 00:00:00 2001 From: Andrea Giannangelo Date: Wed, 5 Aug 2026 15:40:37 +0200 Subject: [PATCH 3/3] fix(mpc): gate afterglow synthesis on the heating horizon, not the lookahead The synthesis gate keyed off self._lookahead_blocks > LOOKAHEAD_BASE_BLOCKS. Once the cooling horizon can raise that lookahead, a room whose own heating tau does not warrant synthesis gets it anyway just by being cool-capable: a radiator room (tau=10min, horizon 2+2=4, below base) paired with an AC reaches lookahead 18 and silently switches afterglow synthesis on for its HEATING hypothesis, making heating look cheaper than before. That is a heating-side behaviour change this fix never intended. Track the heating horizon separately from the combined lookahead and gate synthesis on the heating horizon alone. The combined lookahead is unchanged - still max(heating tau horizon, cooling horizon) - so pre-cooling is unaffected; only the synthesis eligibility is now decided by the heating system's own tau. Tests: test_ufh_afterglow_visible_in_cost forces the lookahead by hand to isolate synthesis, so it now sets the heating horizon too. New regression test test_cooling_extension_does_not_enable_heating_synthesis pins the rule: a radiator+AC room's heating cost must equal an unprofiled+AC room's at the same horizon, while UFH stays synthesis-eligible. --- .../roommind/control/mpc_optimizer.py | 25 ++++++-- tests/control/test_mpc_optimizer.py | 64 ++++++++++++++++++- 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/custom_components/roommind/control/mpc_optimizer.py b/custom_components/roommind/control/mpc_optimizer.py index d3b56175..0e77ece8 100644 --- a/custom_components/roommind/control/mpc_optimizer.py +++ b/custom_components/roommind/control/mpc_optimizer.py @@ -75,8 +75,11 @@ class MPCOptimizer: def __post_init__(self) -> None: # Set before optimize() runs so callers / patched optimize() still expose - # a sensible default. optimize() refreshes this from dt_minutes per call. + # a sensible default. optimize() refreshes these from dt_minutes per call. self._lookahead_blocks = LOOKAHEAD_BASE_BLOCKS + # Heating-only horizon, kept separate from the combined lookahead so the + # afterglow synthesis gate cannot be tripped by the cooling extension. + self._heating_lookahead_blocks = LOOKAHEAD_BASE_BLOCKS def optimize( self, @@ -115,16 +118,23 @@ def optimize( # synthesis — the RC model already captures post-run warm-back. # Hybrid UFH+AC rooms get both (max): winter pre-heating is preserved and # summer pre-cooling is enabled. - lookahead = LOOKAHEAD_BASE_BLOCKS + # + # The heating horizon is tracked separately from the combined lookahead: + # the afterglow synthesis gate keys off the heating horizon alone, so a + # cooling-driven extension can never switch synthesis on for a system whose + # own tau does not warrant it (e.g. radiator + AC). + heating_lookahead = LOOKAHEAD_BASE_BLOCKS profile = HEATING_SYSTEM_PROFILES.get(self.heating_system_type) if self.heating_system_type else None if profile and dt_minutes > 0: tau_blocks = math.ceil(profile["tau_minutes"] / dt_minutes) - lookahead = max( - lookahead, + heating_lookahead = max( + heating_lookahead, self.min_run_blocks + math.ceil(LOOKAHEAD_HORIZON_SCALE * tau_blocks), ) + lookahead = heating_lookahead if self.can_cool and dt_minutes > 0: lookahead = max(lookahead, LOOKAHEAD_COOLING_BLOCKS) + self._heating_lookahead_blocks = heating_lookahead self._lookahead_blocks = lookahead n_blocks = min(len(T_outdoor_series), len(heat_target_series), len(cool_target_series)) @@ -273,6 +283,11 @@ def _evaluate_action( post-run warm-back). Radiator / "" rooms with no cooling stay at LOOKAHEAD_BASE_BLOCKS with synthesis gated off — byte-identical to pre-fix behaviour. + + Synthesis is gated on self._heating_lookahead_blocks, not on the combined + lookahead, so adding cooling to a room never enables synthesis on its + heating hypothesis: a radiator + AC room keeps the same heating cost as an + unprofiled room at the same horizon. """ lookahead = min(self._lookahead_blocks, len(future_T_outdoor)) Q = self._action_to_Q(action) @@ -283,7 +298,7 @@ def _evaluate_action( occupancy = future_occupancy or [] synthesis_enabled = ( action == MODE_HEATING - and self._lookahead_blocks > LOOKAHEAD_BASE_BLOCKS + and self._heating_lookahead_blocks > LOOKAHEAD_BASE_BLOCKS and self.min_run_blocks > 0 and bool(self.heating_system_type) ) diff --git a/tests/control/test_mpc_optimizer.py b/tests/control/test_mpc_optimizer.py index 51fffa26..c88668e3 100644 --- a/tests/control/test_mpc_optimizer.py +++ b/tests/control/test_mpc_optimizer.py @@ -594,9 +594,13 @@ def test_ufh_afterglow_visible_in_cost(): min_run_blocks=6, heating_system_type="", ) - # Force equal lookahead — isolate synthesis from horizon-size effect. + # Force equal lookahead — isolate synthesis from horizon-size effect. The + # heating horizon must be set too: the synthesis gate keys off it, not off the + # combined lookahead. opt_ufh._lookahead_blocks = 24 opt_empty._lookahead_blocks = 24 + opt_ufh._heating_lookahead_blocks = 24 + opt_empty._heating_lookahead_blocks = 24 cost_ufh = opt_ufh._evaluate_action("heating", **shared) cost_empty = opt_empty._evaluate_action("heating", **shared) @@ -767,6 +771,64 @@ def test_hybrid_ufh_ac_gets_max_of_both_horizons(): assert plan_hybrid.lookahead_blocks > LOOKAHEAD_BASE_BLOCKS +def test_cooling_extension_does_not_enable_heating_synthesis(): + """The cooling horizon must never switch afterglow synthesis on. + + Synthesis is gated on the heating horizon alone, not on the combined lookahead. + A radiator room (tau=10min, so its own horizon stays below base) that gains an + AC gets a longer combined lookahead, but its HEATING hypothesis must still cost + exactly what an unprofiled room costs at the same horizon. Only a system whose + own tau warrants synthesis (UFH) stays eligible for it. + """ + model = RCModel(C=200.0, U=50.0, Q_heat=300.0, Q_cool=1500.0) + plan_kwargs = { + "T_room": 20.0, + "T_outdoor_series": [5.0] * 30, + "heat_target_series": [20.0] * 30, + "cool_target_series": [20.0] * 30, + "dt_minutes": 5, + } + cost_kwargs = { + "T_room": 20.0, + "T_outdoor": 5.0, + "heat_target": 20.0, + "cool_target": 20.0, + "future_T_outdoor": [5.0] * 30, + "future_heat_targets": [20.0] * 30, + "future_cool_targets": [20.0] * 30, + "dt_minutes": 5.0, + } + + def cool_capable(heating_system_type): + opt = MPCOptimizer( + model=model, + can_heat=True, + can_cool=True, + min_run_blocks=2, + heating_system_type=heating_system_type, + ) + opt.optimize(**plan_kwargs) + return opt, opt._evaluate_action(MODE_HEATING, **cost_kwargs) + + opt_radiator, cost_radiator = cool_capable("radiator") + opt_empty, cost_empty = cool_capable("") + opt_ufh, _ = cool_capable("underfloor") + + # All three are cool-capable, so all three clear the cooling horizon. + assert opt_radiator._lookahead_blocks == LOOKAHEAD_COOLING_BLOCKS + assert opt_empty._lookahead_blocks == LOOKAHEAD_COOLING_BLOCKS + # Radiator's own tau horizon (2 + 2 = 4) stays below base, so it is not + # synthesis-eligible even though its combined lookahead grew to 18. + assert opt_radiator._heating_lookahead_blocks == LOOKAHEAD_BASE_BLOCKS + assert opt_empty._heating_lookahead_blocks == LOOKAHEAD_BASE_BLOCKS + assert cost_radiator == cost_empty, ( + f"radiator+AC heating cost {cost_radiator} must equal unprofiled+AC " + f"{cost_empty}: the cooling extension must not enable afterglow synthesis" + ) + # UFH's own tau horizon (2 + 18 = 20) clears base, so it stays eligible. + assert opt_ufh._heating_lookahead_blocks > LOOKAHEAD_BASE_BLOCKS + + # --------------------------------------------------------------------------- # approach_rate tests # ---------------------------------------------------------------------------