Skip to content

Fix/mpc stability empc lite - #12

Closed
Gamso wants to merge 6 commits into
mainfrom
fix/mpc-stability-empc-lite
Closed

Gamso wants to merge 6 commits into
mainfrom
fix/mpc-stability-empc-lite

Conversation

@Gamso

@Gamso Gamso commented Jul 4, 2026

Copy link
Copy Markdown
Owner

No description provided.

Gamso and others added 6 commits July 4, 2026 10:38
…t, relative hysteresis

The cost function was unbounded (0.09 to 700+ across cycles depending on error
and slope), which made the fixed absolute hysteresis margins meaningless and let
the controller chase sub-band sensor noise. On 80 days of production data the
controller changed fan mode 357 times with 148 A->B->A reversals inside an hour,
flip-flopping across the full ladder while the room sat at the setpoint.

Three coupled changes in _simulate_mode / _required_switch_gain:

- EMPC-lite tolerance band: overshoot and floor-violation penalties now only
  apply *beyond* +/- deadband of the setpoint, like comfort_error already did.
  Previously any sub-band deviation triggered the heavy floor-violation weights
  (12 linear + 30 quadratic per step), which is what slammed the fan to superhigh
  for a 0.2C (one sensor LSB) excursion.
- Per-step cost normalisation: the trajectory penalty is divided by the number of
  simulated steps so the magnitude is independent of the (dead-time-dependent)
  horizon and comparable across cycles. The one-time mode-change / rank / min-
  interval costs stay on that same per-step scale.
- Relative hysteresis: a switch must beat the current mode's cost by a fraction
  of that cost on top of the absolute directional margin, so the threshold scales
  with the discomfort instead of being a fixed number vs an unbounded cost. Never
  applied to an upward switch while under-conditioned, so comfort recovery is
  unaffected.

Also raise MODE_RANK_COST 0.05 -> 0.15 (now meaningful post-normalisation) and
fix evaluate() calling get_dead_time() without hvac_mode, which mixed heat/cool
dead times while the rest of the code keeps them separate.

Replay on the production trace: fan changes 357 -> 130 (-64%), reversals within
60 min 148 -> 21 (-86%), prediction MAE unchanged (0.136 -> 0.140). Key
behavioural guardrails (escalate below target, hold superhigh while below target,
setpoint drop, long-dead-time horizon, gap model) verified unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
limit_timeout never influenced any control decision: it flowed only into
get_effective_timeout(), which feeds diagnostic sensors and the CSV column but
gates nothing (min_interval is the actual cooldown). Learning even auto-computed
and auto-applied a limit_timeout value that then went unused. It was a third
configuration knob for the user to reason about with no functional effect.

Remove the parameter end to end:
- const: CONF_LIMIT_TIMEOUT, DEFAULT_LIMIT_TIMEOUT, MIN_LIMIT_TIMEOUT
- config_flow: the option in both the setup and options schemas
- MPCController: the limit_timeout constructor arg and property; get_effective_timeout
  now falls back to DEFAULT_DEAD_TIME * safety factor internally
- ThermalLearning.compute_optimal_parameters: stop computing/returning limit_timeout
- __init__: stop applying it; seed last_change_time from min_interval instead
- sensor: drop the Learned Limit Timeout sensor and the limit_timeout attributes
- translations (en/fr), README

The Effective Timeout sensor and the CSV effective_timeout column are kept as
diagnostics (they show the learned thermal lag), now clearly documented as
advisory rather than control-gating. Config now exposes deadband and min_interval
only. Behaviour verified unchanged on the production replay: 130 changes,
21 reversals, identical to the previous commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lates

Production trace 2026-07-05: room sat 0.4C above a 24.0C cooling setpoint for
over an hour with the fan stuck at 'low', which was the model's genuine argmin
(no hysteresis/min-interval hold). On a capacity-bound hot day the higher modes'
learned cooling slopes were weak, so once the comfort-cost difference between
modes was small the MODE_RANK_COST (energy) term tie-broke toward the cheapest
mode -- the controller "gave up" and stayed quiet above the setpoint. Raising
MODE_RANK_COST 0.05->0.15 in the stability work made this more likely.

Gate the fan-rank (energy) cost on the comfort band: it now applies only when
the room is within +/- deadband of target. When under-conditioned beyond the
band (current_error > deadband), energy_weight = 0 so comfort dominates and the
controller picks the mode that actually recovers the setpoint. Economy still
applies in-band, where dropping to the quietest sufficient mode is what we want.

Reproduced decision (24.4C vs 24.0C cool, weak higher-mode profiles, bias -0.25):
now escalates to superhigh instead of holding low. Full replay: oscillation not
reintroduced (reversals within 60 min 21 -> 25 over 228 h, vs 148 baseline),
prediction MAE unchanged. Behavioural guardrails still pass.

Kept as a hard in/out-of-band switch for now; if field data shows chatter at the
band edge, soften to an energy weight that ramps with distance to the band.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…enforcement

Audit of the learned effective-slope profiles on 80 days of production data
surfaced two defects that corrupted the model, most visibly in heating:

1. Extrapolation blow-up. The representative slope is reported at
   REFERENCE_SLOPE_ERROR (1.0 C) and the sim applies the fitted gain across the
   whole horizon. A profile only ever sampled at/below setpoint (e.g. silent/heat,
   errors -1.0..0.0) still had a gain fitted and extrapolated out to +1.0 C,
   producing a physically impossible 2.82 C/h. Now, when a profile has never been
   observed at an error near the reference gap, we fall back to the constant
   median instead of trusting the extrapolation (silent/heat 2.82 -> 0.43).

2. Monotone enforcement corrupted by a thin low mode. build_monotone_slopes used
   a naive forward max(), so one bad weak-mode value dragged every stronger mode
   up to it: with silent/heat blown up, heat became silent=med=superhigh=2.82,
   erasing all fan differentiation. Replaced with sample-count-weighted isotonic
   regression (pool-adjacent-violators): out-of-order neighbours merge into their
   weighted mean, so the well-sampled modes (superhigh had 1742 cool samples vs
   silent's 20) dominate and a noisy 17-sample profile can't poison the rest.
   After both fixes, heat resolves to a sane non-decreasing 0.32/0.32/0.48.

Cool profiles (already monotone, well sampled) are unchanged; full replay is
unchanged (130 changes, 25 reversals, MAE 0.139) since this trace is cool-heavy
and the defects hit heating. Behavioural guardrails still pass. Updated the two
monotone tests that asserted the old forward-clamp values to the pooled results.

Not addressed here (deeper, logged for follow-up): the effective slope still
conflates fan capacity with the ambient heat load during sampling (negative
silent/low cool slopes = "warms the room on a hot day"), the |slope|<0.15
stagnation filter drops 37% of samples biased toward the weak modes, and the
gap-model r-squared is low (0.04-0.37) so error poorly predicts slope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…/above in heat

Per the requested comfort semantics: a cooling setpoint of 24 C means "24 or
less", a heating setpoint means "24 or more". The symmetric deadband let the room
settle anywhere in [target-band, target+band], so in cooling it habitually sat up
to a deadband ABOVE the setpoint (e.g. 24.2 with target 24.0) and the controller
treated that as in-band and stayed at the lowest fan.

Make the band asymmetric in the signed comfort error (positive = under-
conditioned = wrong side of the setpoint for the active mode):

- Wrong side (error > 0): penalised from the setpoint with no tolerance, via the
  FLOOR_VIOLATION terms, so the room is always driven back to the requested side.
- Acceptable side (error < 0): only gently discouraged (OVERSHOOT term) so it is
  fine to be a little cooler than a cool setpoint / warmer than a heat setpoint
  without hunting.

To avoid slamming the fan to maximum for a single sensor step past the setpoint,
the correction is graduated: the under-side weights were retuned down
(FLOOR_VIOLATION_LINEAR 12->4, QUADRATIC 30->12) and OVERSHOOT raised (3->8), and
the energy (fan-rank) weight now ramps linearly from 1 at the setpoint to 0 at the
deadband instead of switching off abruptly. Net decision profile in cooling:
<=target -> low, +0.2 -> high, +0.4 -> superhigh; below target stays low.

Behavioural guardrails still pass. IMPORTANT VALIDATION CAVEAT: this changes the
closed-loop trajectory, so the open-loop CSV replay is not representative — it
replays temperatures produced under the old symmetric band (room riding above the
cool setpoint), so it over-counts activity (217 changes / 87 reversals vs 130/25).
The real hunting behaviour can only be confirmed by deploying on the live system.
If it hunts at the setpoint edge, the lever is a small wrong-side tolerance
(act at +2 sensor LSB) or the deadband.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The first cut of the asymmetric band softened the under-side penalty
(FLOOR 12/30 -> 4/12, OVERSHOOT 3 -> 8, overshoot penalised from the setpoint)
to avoid slamming the fan to maximum. On the deployed system that turned out to
be fragile: at a small in-deadband excursion (room 0.2 C above a 24.0 C cool
setpoint) it went straight back to holding `low` — the very stuck-above-setpoint
behaviour it was meant to fix. A robustness sweep confirmed the decision at +0.2
flips with the learned dead time and the disturbance bias, because escalating on
a sub-deadband error through the finite-horizon projection is inherently marginal.

Restore the full under-side weights (FLOOR_VIOLATION 12/30, OVERSHOOT 3) and keep
the overshoot term on the tolerated-zone form (max(-error - band, 0)), so the
acceptable side stays free within the deadband while the wrong side is penalised
firmly from the setpoint. At the live conditions (dead time 16 min, bias ~0) the
room at 24.2 C now escalates to superhigh once the min interval clears, and steps
back down to low as soon as it is at/below target; below target it stays low.

Trade-off accepted for the stated requirement (never above a cool setpoint /
below a heat setpoint): the correction is firm (tends to superhigh for a small
excursion) rather than gently graduated — a robust graduated point does not exist
across the dead-time/bias range. Behavioural guardrails pass. Replay 170 changes /
66 reversals (open-loop, still not representative for this trajectory-changing
change; below the 148-reversal baseline).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Gamso Gamso closed this Jul 8, 2026
@Gamso
Gamso deleted the fix/mpc-stability-empc-lite branch July 8, 2026 21:24
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.

1 participant