Skip to content

fix(mpc): pre-cool before a scheduled comfort window (cooling lookahead) - #404

Open
andgian wants to merge 3 commits into
snazzybean:mainfrom
andgian:fix/cooling-precool-lookahead
Open

fix(mpc): pre-cool before a scheduled comfort window (cooling lookahead)#404
andgian wants to merge 3 commits into
snazzybean:mainfrom
andgian:fix/cooling-precool-lookahead

Conversation

@andgian

@andgian andgian commented Aug 5, 2026

Copy link
Copy Markdown

Problem

Cool-capable rooms use LOOKAHEAD_BASE_BLOCKS (6 blocks / 30 min) for the MPC decision lookahead. That horizon is too short to see an upcoming comfort-window setpoint drop (e.g. a schedule lowering the cool target from an eco value to a comfort value), so the optimizer only starts cooling once the window opens — the room reaches comfort well after you need it, behaving like a plain scheduled thermostat.

Heating already solves the symmetric problem: slow systems (UFH) get an extended, tau-scaled lookahead so the cost function values pre-heating before the window. Cooling had no equivalent.

The existing code deliberately kept cooling at the base lookahead, with the comment that extending it "without a matching cooling-side synthesis would shift the energy/comfort ratio for COOLING and cause more aggressive AC use."

Fix

  • Add LOOKAHEAD_COOLING_BLOCKS = 18 (90 min) and take the per-plan lookahead as the max of the heating tau horizon (when a heating profile exists) and the cooling horizon (when can_cool).
  • No afterglow synthesis is added for cooling. The heating synthesis exists because a UFH slab keeps emitting heat after the run — which the RC model (with block_Q = 0 post-run) does not capture. An AC has negligible stored-emission afterglow: when it stops, cooling stops, and the room warms back per its RC time constant, which the model already captures. So the over-cooling the original comment worried about does not arise — the missing piece was the horizon, not a synthesis.
  • Hybrid UFH+AC rooms now get both (via max): winter pre-heating is preserved and summer pre-cooling is enabled. (Previously not self.can_cool excluded hybrid rooms from the heating lookahead extension too; the max keeps their winter preheat.)
  • The afterglow synthesis gate is keyed off the heating horizon, not the combined lookahead. The gate read self._lookahead_blocks > LOOKAHEAD_BASE_BLOCKS, so once cooling can raise that lookahead, a room whose own heating tau does not warrant synthesis would get it just by being cool-capable — a radiator room (tau=10 min, horizon 2+2=4, below base) paired with an AC would reach lookahead 18 and silently enable synthesis on its HEATING hypothesis. The heating horizon is now tracked separately (_heating_lookahead_blocks) and gates synthesis on its own; the combined lookahead is unchanged, so pre-cooling is unaffected.

Behaviour

  • Rooms constructed with an explicit can_cool=False: byte-identical (cooling branch skipped, heating branch unchanged in effect). This is narrower than "pure-heating rooms": MPCOptimizer declares can_cool: bool = True as its dataclass default, so a room only takes the unchanged path when can_cool=False is passed explicitly.
  • Pure-cooling rooms: lookahead 6 → 18, enabling pre-cool before a scheduled comfort window.
  • Hybrid rooms: max(base, heating tau horizon, cooling horizon).

Testing

Deployed on a live Airstage (AC) + underfloor (UFH) hybrid setup; RoomMind loads cleanly and steady-state cooling control is unchanged. Pre-cool-before-window behaviour validated against a daily comfort schedule.

Full suite green with the CI command (pytest tests/ -v --cov=custom_components/roommind --cov-report=term --cov-fail-under=95): 2018 passed, total coverage 95.83%, mpc_optimizer.py at 100%. ruff check, ruff format --check and mypy clean.

The three lookahead tests that encoded the old "cool-capable rooms stay at base" rule are updated in a separate commit to assert the new rule (the max of the two horizons) rather than being loosened or removed. A regression test (test_cooling_extension_does_not_enable_heating_synthesis) pins the synthesis gate: a radiator+AC room's heating cost must equal an unprofiled+AC room's at the same horizon, while UFH stays synthesis-eligible.

@andgian
andgian requested a review from snazzybean as a code owner August 5, 2026 12:30
Facens added 3 commits August 19, 2026 20:01
…indow

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.
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.
…okahead

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.
@snazzybean
snazzybean force-pushed the fix/cooling-precool-lookahead branch from 7e3006c to 7787fa3 Compare August 19, 2026 18:01
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))

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_evaluate_action() still uses the combined self._lookahead_blocks for the actual cost window (lookahead = min(self._lookahead_blocks, len(future_T_outdoor))); only the synthesis gate moved to _heating_lookahead_blocks.

So a radiator (or unprofiled) room with an AC now integrates HEATING/IDLE cost over 18 blocks instead of 6, even though this PR is framed as cooling-only. That shifts the DP action selection for heating too.

test_cooling_extension_does_not_enable_heating_synthesis only compares two rooms that both already have lookahead=18; nothing checks against the pre-fix baseline (radiator, can_cool=False, lookahead=6), so this cost shift isn't caught by any test.

Might be cleaner to use _heating_lookahead_blocks for HEATING/IDLE cost and reserve the combined lookahead for COOLING.

@snazzybean

Copy link
Copy Markdown
Owner

custom_components/roommind/control/mpc_controller.py:962 (not touched by this diff, but affected by it):

This guard window got a lot wider for any AC-equipped room, and I don't think that's intentional.

plan.lookahead_blocks now includes LOOKAHEAD_COOLING_BLOCKS=18 for every can_cool=True room, not just when a comfort-window transition falls inside it. guard_blocks = max(SAFETY_GUARD_MIN_BLOCKS, min_run, plan.lookahead_blocks) jumps to 18 (90 min) instead of 6 for a radiator+AC room.

near_heat = heat_target_series[:guard_blocks] feeds the hard overshoot ceiling from #152, which catches runaway heating from a miscalibrated model. A schedule bump inside that wider window raises max(near_heat), so the ceiling trips later, for every AC-equipped room, not just ones where pre-cooling matters.

No test covers guard_blocks for a can_cool=True room. Worth decoupling the ceiling's window from the optimizer's lookahead, or adding a regression test for it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants