Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 51 additions & 18 deletions custom_components/roommind/control/mpc_optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -67,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,
Expand Down Expand Up @@ -97,21 +108,34 @@ 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.
#
# 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 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,
heating_lookahead = max(
heating_lookahead,
self.min_run_blocks + math.ceil(LOOKAHEAD_HORIZON_SCALE * tau_blocks),
)
else:
self._lookahead_blocks = LOOKAHEAD_BASE_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))
if n_blocks == 0 or not math.isfinite(T_room):
Expand Down Expand Up @@ -250,11 +274,20 @@ 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.

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))

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.

Q = self._action_to_Q(action)
Expand All @@ -265,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)
)
Expand Down
166 changes: 128 additions & 38 deletions tests/control/test_mpc_optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -589,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)
Expand Down Expand Up @@ -619,25 +628,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
Expand Down Expand Up @@ -679,14 +696,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(
Expand All @@ -706,8 +727,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,
Expand All @@ -716,27 +743,90 @@ 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 profilethe 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


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


# ---------------------------------------------------------------------------
Expand Down
Loading