Symptom
LP solver returns Infeasible on every solve when OPTIMIZATION_PRESET=guests AND DHW_FIXED_SCHEDULE_ENABLED=True AND the LP horizon ends inside the evening shower window (20:00-22:00 BST).
Observed in prod run 1154 at 2026-05-27 19:55 UTC. Error:
LP returned Infeasible; defensive fallback held previous schedule; appliance-drop retry also infeasible (0.75 kWh)
Reverting OPTIMIZATION_PRESET to normal makes solves Optimal again. Pre-fire reconciliation + _check_tank_target_drift kept the tank safe; no hardware impact, only loss of LP-driven optimisation while in guests.
Root cause
PR #406 (K2) introduced DHW pinning: when DHW_FIXED_SCHEDULE_ENABLED=True, e_dhw[i] and tank[i+1] are pinned by equality to the dhw_policy.forecast_dhw_load_per_slot trajectory. K2 correctly added not _dhw_pinned guards on the soft shower floor (src/scheduler/lp_optimizer.py:842) and on the legionella floor (line 912), but missed the terminal floor at line 1067.
In guests:
dhw_policy pins tank[n] == DHW_TEMP_NORMAL_C = 45.0
t_min_dhw = TARGET_DHW_TEMP_MIN_GUESTS_C = 55.0 → terminal_dhw_floor = 53.0 when last slot is in shower window
- LP adds
prob += tank[n] >= 53.0 → contradicts tank[n] == 45.0 → Infeasible
In normal the analogous chain is tank[n] == 45 (warmup window) AND tank[n] >= 43 → feasible, hence the bug never fired before the user toggled to guests.
Bisection (replay of run 1154)
| Scenario |
Status |
| guests + pinning ON (prod) |
Infeasible |
| guests + pinning OFF |
Optimal |
| normal + pinning ON |
Optimal |
TARGET_DHW_TEMP_MIN_GUESTS_C=47 → terminal floor 45 |
Optimal |
TARGET_DHW_TEMP_MIN_GUESTS_C=48 → terminal floor 46 |
Infeasible |
Fix
Mirror the K2 guard pattern from the shower floor — skip the terminal DHW floor when _dhw_pinned is True. The pinned trajectory fully owns tank[n].
PR:
Symptom
LP solver returns
Infeasibleon every solve whenOPTIMIZATION_PRESET=guestsANDDHW_FIXED_SCHEDULE_ENABLED=TrueAND the LP horizon ends inside the evening shower window (20:00-22:00 BST).Observed in prod run 1154 at 2026-05-27 19:55 UTC. Error:
Reverting
OPTIMIZATION_PRESETtonormalmakes solves Optimal again. Pre-fire reconciliation +_check_tank_target_driftkept the tank safe; no hardware impact, only loss of LP-driven optimisation while in guests.Root cause
PR #406 (K2) introduced DHW pinning: when
DHW_FIXED_SCHEDULE_ENABLED=True,e_dhw[i]andtank[i+1]are pinned by equality to thedhw_policy.forecast_dhw_load_per_slottrajectory. K2 correctly addednot _dhw_pinnedguards on the soft shower floor (src/scheduler/lp_optimizer.py:842) and on the legionella floor (line 912), but missed the terminal floor at line 1067.In
guests:dhw_policypinstank[n] == DHW_TEMP_NORMAL_C = 45.0t_min_dhw = TARGET_DHW_TEMP_MIN_GUESTS_C = 55.0→terminal_dhw_floor = 53.0when last slot is in shower windowprob += tank[n] >= 53.0→ contradictstank[n] == 45.0→ InfeasibleIn
normalthe analogous chain istank[n] == 45(warmup window) ANDtank[n] >= 43→ feasible, hence the bug never fired before the user toggled to guests.Bisection (replay of run 1154)
TARGET_DHW_TEMP_MIN_GUESTS_C=47→ terminal floor 45TARGET_DHW_TEMP_MIN_GUESTS_C=48→ terminal floor 46Fix
Mirror the K2 guard pattern from the shower floor — skip the terminal DHW floor when
_dhw_pinnedis True. The pinned trajectory fully ownstank[n].PR: