Skip to content

Feat/hold equilibrium and adaptive interval - #13

Open
Gamso wants to merge 9 commits into
mainfrom
feat/hold-equilibrium-and-adaptive-interval
Open

Gamso wants to merge 9 commits into
mainfrom
feat/hold-equilibrium-and-adaptive-interval

Conversation

@Gamso

@Gamso Gamso commented Jul 8, 2026

Copy link
Copy Markdown
Owner

No description provided.

Gamso and others added 5 commits July 8, 2026 22:07
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>
Near the setpoint the controller previously collapsed to the lowest fan
speed, driven by the fan-rank (energy) penalty. On a running heat pump the
compressor is the dominant load and keeps producing regardless of fan speed,
so minimising fan rank there optimises the wrong term and produces a
drift-then-blast cycle instead of a steady hold.

Within one deadband of the setpoint (the "hold zone") we now:
- tolerate a small, bounded wrong-side undershoot (HOLD_UNDERSHOOT_TOLERANCE)
  so a discrete speed ladder can actually hold instead of falling back to the
  weakest mode to avoid a sub-threshold overshoot;
- shrink the fan-rank penalty to a tie-breaker (HOLD_RANK_SCALE).

The feature is dormant whenever the error exceeds the deadband, so
far-from-setpoint escalation is untouched. Enabled by default: on the 899 h
production trace it shifted ~14% of time from `low` to a steady `med` hold and
cut fan changes 357->313 with no change in predicted comfort (MAE T+10) or
average cost.

replay_bench exposes the three constants as tunables for A/B comparison.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The min-change interval was a fixed 10 min while the live learned dead time
ranges 6-31 min (median 12) on the production trace. Whenever the dead time
exceeds the fixed interval the controller could change the fan again before it
had observed the effect of the previous change — acting blind, which invites
oscillation.

The configured min_interval is now a floor: once learning is ready the
effective dwell is raised toward the learned dead time, capped at
MAX_ADAPTIVE_INTERVAL_FACTOR (3x) the floor so a spuriously large dead time
cannot stall the controller. Urgent overrides (setpoint drop, window, defrost,
idle) are evaluated before this gate and are never blocked.

Direction validated on the 899 h trace by sweeping the effective interval:
10->14 min cut fan changes 313->279 with flat cost/MAE; the adaptive coupling
applies this per-system instead of a blanket value.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ning

The min-change interval was recently coupled to the learned dead time
(~19-20 min on production traces vs the old fixed 10 min floor), so a
misjudged fan pick near the deadband can now leave the room drifting
away from target for up to 20 minutes with no way to react: the lock
forced the current fan regardless of how badly comfort was degrading.

Track the comfort error at the moment the fan last changed and allow
an escalation-only (never a step-down) bypass of the lock once the
error has worsened by more than 0.15C since then. This is trend-based
rather than a static deadband-multiple threshold, so it reacts to the
room's actual trajectory and fires consistently regardless of the
configured deadband.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ogress

Root cause of the low-fan incident: the cost forecast used to justify a
step-down is unreliable for multi-rank jumps. During the dead-time-blind
window (change_delay = learned dead time, ~20 min on this trace) every
candidate's near-term trajectory is simulated using the *current* mode's
momentum, not its own. A weak candidate several ranks below the active fan
can therefore look deceptively good right up until the switch is committed
— which is exactly what happened: the controller jumped superhigh -> low
near the deadband because low's forecast still showed the room coasting
down on superhigh's momentum, even though low's own learned profile
(-0.1C/h, i.e. net warming even 1C from target) shows it can't cool this
room at all.

Add a direct capability check: a candidate more than one rank below the
current fan is only eligible if its own learned effective slope (the same
number surfaced in the per-mode profile sensors) is positive. Profiles
that aren't learned yet are unrestricted (existing fallback behaviour).
Adjacent-rank switching is untouched, so the hysteresis/cost tuning
validated on the production trace still applies to the common case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Gamso
Gamso force-pushed the feat/hold-equilibrium-and-adaptive-interval branch 2 times, most recently from 758fc18 to 8cda3ea Compare July 11, 2026 11:05
Gamso added 4 commits July 26, 2026 16:24
… grey-box investigation

Adds an optional outdoor-temperature sensor (CONF_OUTDOOR_ENTITY), read every
cycle and logged to the data-collection CSV as pure telemetry -- no control
decision reads it, but it lets a trace be correlated with outdoor conditions
offline. Also adds two documents and a reproducible script.

Why: this branch's history up to here squashes a grey-box thermal model
(dT/dt = k_env*(T_ext-T) + u_fan) that was investigated, built, wired into a
feasibility gate, validated offline, and then removed -- none of that code is
in this diff, only what survived it. Kept for the record because the
investigation and its outcome are worth having:
- docs/effective_slope_analysis.md: the original root-cause analysis (weak
  fans are a closed-loop selection-bias problem, not a data-volume one) --
  still valid, marked superseded where it proposes the now-removed model.
- docs/audit_apprentissage.md: the k_env instability measurements that led to
  removal (rolling 72h fits gave tau=20h, then 214h, then negative, because
  k_env absorbs compressor modulation instead of measuring the envelope), and
  what remains actionable (the gap model's demand term explains ~8x more of
  dT/dt's variance than the entire envelope machinery did; active exploration
  and observing compressor modulation, if exposed, are the levers actually
  worth pursuing next).
- scripts/audit_variance_decomposition.py: the script behind that finding.

Also carries two cosmetic no-op refactors in mpc_controller.py's step-down
guard (upward/adjacent-rank branches split out, a `ranks` variable extracted),
left over from the round trip and harmless.

112 tests pass; verified end-to-end by replaying a real 10695-row production
trace through the controller.
…mpled profile

build_monotone_slopes did a forward max() pass, which always resolves a
violation upward. That silently propagates a noisy estimate from a
rarely-used speed into every stronger speed above it -- corrupting exactly
the profiles with the most data behind them. Reproduced on the real
deployment's sample distribution: a 10-sample `med` reading +1.6 dragged
145-sample `high` (+0.5) and 1656-sample `superhigh` (+0.9) both up to +1.6.

Now each adjacent violation moves the side backed by fewer samples onto the
other, and the better-sampled value is never altered. On an equal sample
count the weaker mode is clamped down rather than the stronger one raised:
over-estimating a weak fan invites a step-down it cannot sustain, while
under-estimating it is merely conservative.

Also fixes _warn_if_unordered, which only ever checked "heat" (so a
cooling-only install was never validated) and gave up at the first
not-yet-learned profile instead of skipping it.

Two existing tests encoded the old upward-propagation and are updated to the
new behaviour; two tests are added for the sample-count trust rule in both
directions.
A fan mode's index in the list is treated as its strength throughout the
controller: the energy term (MODE_POWER_RATIO ** index), the multi-rank
step-down guard, the monotone slope enforcement and the rank-scaling fallback
for unlearned profiles all read it. Until now that order came from the climate
entity's fan_modes attribute and was trusted blindly, with no way to inspect
or correct it -- a wrong order would quietly mis-price every speed.

Adds an optional "Fan Speed Order" multi-select to the options flow, listing
the detected speeds and pre-filled with the entity's own order (so the default
is a no-op). A partial selection is rejected rather than silently completed:
half a ladder is worse than none, since the user would believe the strength
order was set explicitly. Modes the entity adds later are appended and removed
ones dropped, so drift degrades gracefully instead of losing a speed.

The order in use, and the slopes actually fed to the MPC after ordering
violations are resolved, are now exposed as attributes on the MPC profiles
sensor -- previously neither was observable without reading debug logs.

Verified end-to-end against the real options schema: the field renders
pre-filled, a reordered submission survives validation with its order intact,
and an unknown mode is rejected by the selector.
…its neighbour's

Clamping a rejected estimate onto the value that rejected it left two speeds
thermally identical, so the MPC could no longer tell them apart and the energy
term alone arbitrated -- always picking the weaker rank. That also made the
failure self-reinforcing in the other direction: a thin `superhigh` pinned onto
`high` would never be selected again, so it could never collect the samples it
needed to stop being thin.

A rejected estimate is now re-synthesised one geometric step from the neighbour
that constrained it (LADDER_CAPACITY_RATIO = 1.8, calibrated on the production
ladder whose adjacent ratios measure 2.08 and 1.76). Around zero, where
multiplicative spacing collapses, an absolute fallback keeps the speeds apart.
For the reported case this puts a 10-sample med at 0.28 instead of tying it with
high at 0.5, while high and superhigh keep their measured values.

Also replaces the pairwise repair loop from the previous commit, which a fuzz
run showed does not converge: a thin profile sitting between two better-sampled
ones that are themselves inverted oscillates forever between their two bounds.
Profiles are now placed best-sampled first, each clipped into the window left by
those already placed -- a single pass, monotone by construction, nothing to
converge. The ratio only ever synthesises a replacement and is never imposed as
a minimum gap, so a real ladder spaced more tightly than 1.8 is returned
untouched.

Fuzzed over 3000 randomised ladders (missing profiles, sign changes, lopsided
sample counts) for the ordering invariant; a trimmed version of that fuzz is
kept as a regression test.
@Gamso
Gamso force-pushed the feat/hold-equilibrium-and-adaptive-interval branch from 01f304f to 66edb39 Compare August 17, 2026 09:45
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