diff --git a/README.md b/README.md index ed22422..7e3b7b1 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,6 @@ All parameters can be changed at any time via **Settings → Devices & Services | -------------------- | -------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Deadband** | `0.2°C` | `0.0` – `5.0°C` | Comfort zone around target — no action taken within this range. Increase to reduce fan changes. | | **Min Interval** | `10 min` | `1` – `60 min` | Minimum time between non-emergency fan changes. Prevents rapid oscillations. | -| **Limit Timeout** | `15 min` | `10` – `120 min` | Fallback timeout used before learning calibrates the dead time. | | **Data Collection** | `true` | — | Records one CSV row every 2 minutes in the HA config folder (`smart_fan_controller_data_XXXXXXXX.csv`, max 10 MB, auto-rotated). Useful for offline analysis. | | **Defrost Entity** | *(none)* | — | Optional entity (`binary_sensor`, `sensor`, or `input_boolean`) that reports when the heat pump is in defrost cycle. See [Defrost Detection](#defrost-detection). | | **Operating Entity** | *(none)* | — | Optional entity (`binary_sensor`, `sensor`, or `input_boolean`) that reports whether the heat pump compressor is actively running. See [HVAC Idle Detection](#hvac-idle-detection). | @@ -144,7 +143,7 @@ Each candidate fan mode is scored with: - **Hysteresis**: a recommendation that changes the fan must beat the current mode by a minimum cost margin. The margin is larger when near the target (0.30) and smaller when far away (0.10). - **Step-down hold**: blocks downward moves when still under target and the temperature slope hasn't established yet. -- **Min interval**: non-emergency changes respect the effective timeout (learned dead time × 1.5 for the specified HVAC mode, or the configured limit timeout). +- **Min interval**: non-emergency changes respect the configured minimum interval between fan changes. ### Phase Detection @@ -199,14 +198,13 @@ The integration includes an **automatic learning system** that collects data dur **Parameters computed from data**: -| Parameter | Formula | -| --------------- | ------------------------------------------------- | -| `deadband` | `0.15 + (volatility_factor × 0.2)` | -| `limit_timeout` | rounded median of measured thermal response times | +| Parameter | Formula | +| ---------- | ---------------------------------- | +| `deadband` | `0.15 + (volatility_factor × 0.2)` | Where `volatility_factor = min(slope_stdev / slope_mean, 3.0)`. -> **Important**: the learned `limit_timeout` is the stored base response estimate. At runtime, non-emergency decisions use `effective_timeout = max(min_interval, dead_time × 1.5)` once learning is ready. +> **Note**: the `effective_timeout` diagnostic (`max(min_interval, dead_time × 1.5)` once learning is ready) is exposed as a sensor for insight into the learned thermal lag, but it does not gate control decisions — the minimum interval between fan changes does. Once learning is ready, parameters are **automatically applied** and the integration reloads. To apply manually, use the `apply_learned_settings` service. To start over, use `reset_learning`. @@ -278,9 +276,8 @@ Entity IDs are scoped by the configured climate entity. For example, a controlle | `sensor.smart_fan_controller_living_room_learning_samples` | count | Number of slope samples collected | | `sensor.smart_fan_controller_living_room_learning_response_events` | count | Number of thermal response time measurements | | `sensor.smart_fan_controller_living_room_learned_dead_time` | min | Median learned thermal response delay (`dead_time`) | -| `sensor.smart_fan_controller_living_room_effective_timeout` | min | Actual non-emergency timeout currently used | +| `sensor.smart_fan_controller_living_room_effective_timeout` | min | Advisory adaptive timeout (diagnostic only) | | `sensor.smart_fan_controller_living_room_learned_deadband` | °C | Learned optimal deadband | -| `sensor.smart_fan_controller_living_room_learned_limit_timeout` | min | Learned base timeout stored in config | ### Learning Profile Sensors diff --git a/custom_components/smart_fan_controller/__init__.py b/custom_components/smart_fan_controller/__init__.py index e1f8180..797bb68 100644 --- a/custom_components/smart_fan_controller/__init__.py +++ b/custom_components/smart_fan_controller/__init__.py @@ -19,14 +19,12 @@ CONF_DATA_COLLECTION, CONF_DEADBAND, CONF_DEFROST_ENTITY, - CONF_LIMIT_TIMEOUT, CONF_MIN_INTERVAL, CONF_OPERATING_ENTITY, DEAD_TIME_SAFETY_FACTOR, DEFAULT_DATA_COLLECTION, DEFAULT_DEAD_TIME, DEFAULT_DEADBAND, - DEFAULT_LIMIT_TIMEOUT, DEFAULT_MIN_INTERVAL, DELTA_TIME_CONTROL_LOOP, DOMAIN, @@ -233,16 +231,13 @@ async def _apply_optimal_parameters( new_data = {**entry.data} new_data["learning_auto_applied"] = True new_data[CONF_DEADBAND] = optimal["deadband"] - new_data[CONF_LIMIT_TIMEOUT] = optimal["limit_timeout"] new_options = {**entry.options} new_options[CONF_DEADBAND] = optimal["deadband"] - new_options[CONF_LIMIT_TIMEOUT] = optimal["limit_timeout"] _LOGGER.info( - "Applying learned settings for %s: deadband=%.2f limit_timeout=%d", + "Applying learned settings for %s: deadband=%.2f", entry.entry_id, optimal["deadband"], - optimal["limit_timeout"], ) hass.config_entries.async_update_entry(entry, data=new_data, options=new_options) @@ -558,7 +553,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: learning=learning, deadband=conf.get(CONF_DEADBAND, DEFAULT_DEADBAND), min_interval=conf.get(CONF_MIN_INTERVAL, DEFAULT_MIN_INTERVAL), - limit_timeout=conf.get(CONF_LIMIT_TIMEOUT, DEFAULT_LIMIT_TIMEOUT), fan_modes=initial_fan_modes or None, ) @@ -595,7 +589,9 @@ def _ensure_profile_sensors() -> None: # Mutable state shared between control-loop callbacks (closure variables) ctrl_state: dict = { - "last_change_time": time.time() - (conf.get(CONF_LIMIT_TIMEOUT, DEFAULT_LIMIT_TIMEOUT) * 60), + # Seed as if the min interval had just elapsed so the first cycle can act + # immediately instead of waiting out a fresh cooldown. + "last_change_time": time.time() - (conf.get(CONF_MIN_INTERVAL, DEFAULT_MIN_INTERVAL) * 60), "last_setpoint_drop_time": 0.0, "previous_slope": None, "last_hvac_mode": None, diff --git a/custom_components/smart_fan_controller/config_flow.py b/custom_components/smart_fan_controller/config_flow.py index 265e098..dffaeb8 100644 --- a/custom_components/smart_fan_controller/config_flow.py +++ b/custom_components/smart_fan_controller/config_flow.py @@ -13,13 +13,11 @@ CONF_CLIMATE_ENTITY, CONF_DEADBAND, CONF_MIN_INTERVAL, - CONF_LIMIT_TIMEOUT, CONF_DATA_COLLECTION, CONF_DEFROST_ENTITY, CONF_OPERATING_ENTITY, DEFAULT_DEADBAND, DEFAULT_MIN_INTERVAL, - DEFAULT_LIMIT_TIMEOUT, DEFAULT_DATA_COLLECTION, ) @@ -105,9 +103,6 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con vol.Optional(CONF_MIN_INTERVAL, default=DEFAULT_MIN_INTERVAL): selector.NumberSelector( selector.NumberSelectorConfig(min=1, max=60, step=1, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="min") ), - vol.Optional(CONF_LIMIT_TIMEOUT, default=DEFAULT_LIMIT_TIMEOUT): selector.NumberSelector( - selector.NumberSelectorConfig(min=10, max=120, step=5, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="min") - ), vol.Optional(CONF_DATA_COLLECTION, default=DEFAULT_DATA_COLLECTION): selector.BooleanSelector(), vol.Optional(CONF_DEFROST_ENTITY): selector.EntitySelector(selector.EntitySelectorConfig(domain=["binary_sensor", "sensor", "input_boolean"])), vol.Optional(CONF_OPERATING_ENTITY): selector.EntitySelector(selector.EntitySelectorConfig(domain=["binary_sensor", "sensor", "input_boolean"])), @@ -173,9 +168,6 @@ async def async_step_init(self, user_input: dict[str, Any] | None = None) -> Con vol.Optional(CONF_MIN_INTERVAL, default=current_data.get(CONF_MIN_INTERVAL, DEFAULT_MIN_INTERVAL)): selector.NumberSelector( selector.NumberSelectorConfig(min=1, max=60, step=1, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="min") ), - vol.Optional(CONF_LIMIT_TIMEOUT, default=current_data.get(CONF_LIMIT_TIMEOUT, DEFAULT_LIMIT_TIMEOUT)): selector.NumberSelector( - selector.NumberSelectorConfig(min=10, max=120, step=5, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="min") - ), vol.Optional(CONF_DATA_COLLECTION, default=current_data.get(CONF_DATA_COLLECTION, DEFAULT_DATA_COLLECTION)): selector.BooleanSelector(), vol.Optional(CONF_DEFROST_ENTITY, default=current_data.get(CONF_DEFROST_ENTITY, vol.UNDEFINED)): selector.EntitySelector( selector.EntitySelectorConfig(domain=["binary_sensor", "sensor", "input_boolean"]) diff --git a/custom_components/smart_fan_controller/const.py b/custom_components/smart_fan_controller/const.py index 768fe83..1573c6e 100644 --- a/custom_components/smart_fan_controller/const.py +++ b/custom_components/smart_fan_controller/const.py @@ -12,7 +12,6 @@ CONF_CLIMATE_ENTITY = "climate_entity" CONF_DEADBAND = "deadband" CONF_MIN_INTERVAL = "min_interval" -CONF_LIMIT_TIMEOUT = "limit_timeout" CONF_DATA_COLLECTION = "data_collection" CONF_DEFROST_ENTITY = "defrost_entity" CONF_OPERATING_ENTITY = "operating_entity" @@ -20,7 +19,6 @@ # Default values DEFAULT_DEADBAND = 0.2 DEFAULT_MIN_INTERVAL = 10 -DEFAULT_LIMIT_TIMEOUT = 15 DEFAULT_DATA_COLLECTION = True DELTA_TIME_CONTROL_LOOP = 2 # minutes between each control loop execution @@ -43,7 +41,6 @@ # Learning MIN_SAMPLES_LEARNING = 240 # Minimum slope samples required for initial readiness -MIN_LIMIT_TIMEOUT = 5 # Minimum limit_timeout (minutes) derived from learning MIN_MODE_PROFILE_SAMPLES = 10 # Minimum samples per fan mode to consider profile reliable REFERENCE_SLOPE_ERROR = 1.0 # °C – reference comfort error at which the representative # "working" effective slope is reported. The learned slope model is slope(error) = a + b·error; diff --git a/custom_components/smart_fan_controller/mpc_controller.py b/custom_components/smart_fan_controller/mpc_controller.py index a94552c..afb83ae 100644 --- a/custom_components/smart_fan_controller/mpc_controller.py +++ b/custom_components/smart_fan_controller/mpc_controller.py @@ -18,13 +18,25 @@ _LOGGER = logging.getLogger(__name__) -# Cost function weights (simulation loop in _simulate_mode) +# Cost function weights (simulation loop in _simulate_mode). The comfort band is +# asymmetric: FLOOR_VIOLATION_* penalise being on the wrong side of the setpoint +# (above target in cooling, below in heating) from the setpoint itself with no +# tolerance, so the room is firmly driven back to the requested side. The full +# (heavy) weights are used deliberately: escalating on a small in-deadband +# excursion via the finite-horizon projection is otherwise fragile (it flips with +# the learned dead time and disturbance bias), so a strong under-side penalty is +# what makes "at/below target in cooling, at/above in heating" reliable. COMFORT_ERROR_WEIGHT = 1.0 OVERSHOOT_QUADRATIC_WEIGHT = 3.0 FLOOR_VIOLATION_LINEAR_WEIGHT = 12.0 FLOOR_VIOLATION_QUADRATIC_WEIGHT = 30.0 MODE_CHANGE_DISTANCE_COST = 0.15 -MODE_RANK_COST = 0.05 +# Per-step economic weight of the fan-mode rank. Raised from the legacy 0.05 +# now that the trajectory cost is normalised per step (see _simulate_mode): the +# two are on the same scale, so this term expresses a real energy preference +# instead of being drowned out by the comfort integral. Kept moderate so the +# controller does not over-eagerly drop to the lowest mode and then jump back. +MODE_RANK_COST = 0.15 # Geometric growth of relative power draw per fan-mode rank. ~6**(1/3) so a # 4-mode ladder reproduces the legacy [1.0, 1.5, 3.0, 6.0] power scaling while # extending naturally to any number of modes. @@ -32,6 +44,12 @@ MIN_INTERVAL_CHANGE_PENALTY = 25.0 URGENCY_SENSITIVITY = 2.0 +# Relative hysteresis: a switch must beat the current mode's cost by at least +# this fraction of that cost (on top of the absolute directional margin), so the +# switch threshold scales with the magnitude of the discomfort instead of being +# a fixed number compared against an unbounded cost. See _required_switch_gain. +HYSTERESIS_REL_FRACTION = 0.15 + # Disturbance bias tracker DISTURBANCE_EMA_ALPHA = 0.2 DISTURBANCE_DECAY = 0.85 @@ -68,7 +86,6 @@ def __init__( learning: ThermalLearning, deadband: float, min_interval: int, - limit_timeout: int = 15, fan_modes: list[str] | None = None, horizon_minutes: int = 30, cycle_minutes: int = DELTA_TIME_CONTROL_LOOP, @@ -76,7 +93,6 @@ def __init__( self._learning = learning self._deadband = deadband self._min_interval = min_interval - self._limit_timeout = limit_timeout self._fan_modes = fan_modes self._horizon_minutes = horizon_minutes self._cycle_minutes = cycle_minutes @@ -121,17 +137,19 @@ def learning(self) -> ThermalLearning: """Return the ThermalLearning instance used by this controller.""" return self._learning - @property - def limit_timeout(self) -> int: - """Return the configured static limit timeout (minutes).""" - return self._limit_timeout - def get_effective_timeout(self, hvac_mode: str = "unknown") -> float: - """Return the adaptive timeout based on learned dead time, or static fallback.""" + """Return the adaptive advisory timeout (diagnostic only). + + This value is surfaced in sensors and the data-collection CSV to show how + long the controller would wait before forcing a re-evaluation; it does not + gate any control decision (that is the job of ``min_interval``). Before + learning is ready it falls back to the default dead time scaled by the + safety factor. + """ if self._learning.is_ready(): learned_dead_time = self._learning.get_dead_time(hvac_mode) return max(self._min_interval, learned_dead_time * DEAD_TIME_SAFETY_FACTOR) - return self._limit_timeout + return DEFAULT_DEAD_TIME * DEAD_TIME_SAFETY_FACTOR @property def disturbance_bias(self) -> float: @@ -183,7 +201,7 @@ def evaluate( active_fan = current_fan if current_fan in fan_modes else fan_modes[0] current_effective_slope = -vtherm_slope if hvac_mode == "cool" else vtherm_slope current_error = self._temperature_error(current_temp, target_temp, hvac_mode) - dead_time = self._learning.get_dead_time() + dead_time = self._learning.get_dead_time(hvac_mode) change_allowed = minutes_since_change >= self._min_interval phase = self._detect_phase(minutes_since_change, dead_time) current_mode_slope, current_known_profile = self._get_mode_slope( @@ -273,6 +291,15 @@ def evaluate( # of their actual candidate slope, preventing the "dead-time blindness". sim_horizon = max(self._horizon_minutes, int(dead_time) + self._horizon_minutes) + # Energy (fan-rank) weight ramps down as the room goes past the setpoint on + # the wrong side. At/under target (error <= 0) economy is full (weight 1): + # drop to the quietest sufficient mode. As the error grows toward the + # deadband the economic pull fades linearly, so a small excursion yields a + # gentle step up while a clear one (error >= deadband) drops economy + # entirely (weight 0) and comfort dominates — which also prevents a + # cheap-but-ineffective mode being kept on a capacity-bound hot day. + energy_weight = max(0.0, 1.0 - max(current_error, 0.0) / max(self._deadband, 1e-6)) + for fan_mode in fan_modes: mode_slope, known_profile = self._get_mode_slope( fan_mode, @@ -298,6 +325,7 @@ def evaluate( change_allowed=change_allowed, known_profile=known_profile, horizon_minutes=sim_horizon, + energy_weight=energy_weight, ) simulations.append(sim) known_profiles += int(known_profile) @@ -320,6 +348,7 @@ def evaluate( phase=phase, candidate_index=best_index, current_index=current_index, + current_cost=current_simulation.total_cost, ) actual_gain = current_simulation.total_cost - best.total_cost if actual_gain < required_gain: @@ -467,32 +496,56 @@ def _detect_phase(minutes_since_change: float, dead_time: float) -> str: def build_monotone_slopes(self, fan_modes: list[str], hvac_mode: str) -> dict[str, float]: """Return monotone-enforced slopes for all known profiles. - Fan modes are assumed ordered from weakest to strongest. For each mode - with a learned profile, the slope is clamped to be >= the slope of the - last known lower mode (isotonic forward pass). Modes without a learned - profile are omitted from the result — the caller falls back to rank-scaled - estimation for those. + Fan modes are assumed ordered from weakest to strongest, so a stronger + mode should never have a lower effective slope than a weaker one. This is + enforced with sample-weighted isotonic regression (pool-adjacent- + violators): adjacent out-of-order profiles are merged into their + sample-count-weighted mean until the sequence is non-decreasing. - Previously returned None when any profile was missing. Now always - returns a (possibly empty) dict so known adjacent profiles are always - monotone-enforced even during partial learning. + Weighting by sample count is what makes this robust: the modes actually + used the most have far more samples and dominate the enforcement, so a + thin, noisy low-mode profile can no longer drag every stronger mode up to + its value (a naive forward ``max`` did exactly that — e.g. a 17-sample + ``silent`` profile pulling every heat mode to the same bogus value). + + Modes without a learned profile are omitted from the result — the caller + falls back to rank-scaled estimation for those. """ - enforced: dict[str, float] = {} - prev = float("-inf") - changed = False + # Collect (fan_mode, raw_slope, weight) for learned profiles, weakest->strongest. + items: list[tuple[str, float, int]] = [] for fm in fan_modes: slope = self._learning.get_mode_effective_slope(fm, hvac_mode) if slope is None: continue # unknown profile — skip, caller uses rank-scaling fallback - clamped = max(slope, prev) - if clamped != slope: - changed = True - enforced[fm] = clamped - prev = clamped + weight = max(1, self._learning.get_mode_sample_count(fm, hvac_mode)) + items.append((fm, slope, weight)) + + if not items: + return {} + + # Pool-adjacent-violators: merge adjacent blocks whose values decrease. + blocks: list[list] = [] # each: [value, weight, [fan_modes...]] + for fm, slope, weight in items: + blocks.append([slope, weight, [fm]]) + while len(blocks) >= 2 and blocks[-2][0] > blocks[-1][0]: + value2, weight2, modes2 = blocks.pop() + value1, weight1, modes1 = blocks.pop() + pooled_weight = weight1 + weight2 + pooled_value = (value1 * weight1 + value2 * weight2) / pooled_weight + blocks.append([pooled_value, pooled_weight, modes1 + modes2]) + + enforced: dict[str, float] = {} + raw = {fm: slope for fm, slope, _ in items} + changed = False + for value, _weight, modes in blocks: + for fm in modes: + enforced[fm] = value + if abs(value - raw[fm]) > 1e-9: + changed = True if changed: _LOGGER.debug( - "Monotone enforcement applied for %s: %s", + "Monotone (weighted isotonic) enforcement for %s: %s", hvac_mode, {fm: round(v, 3) for fm, v in enforced.items()}, ) @@ -563,6 +616,7 @@ def _simulate_mode( change_allowed: bool, known_profile: bool, horizon_minutes: int | None = None, + energy_weight: float = 1.0, ) -> ModeSimulation: """Simulate one fan mode over the prediction horizon. @@ -578,10 +632,11 @@ def _simulate_mode( steps = max(1, int(horizon / self._cycle_minutes)) step_hours = self._cycle_minutes / 60.0 blend = 0.45 + band = self._deadband sim_temp = current_temp predicted_10m = None predicted_30m = None - cost = 0.0 + trajectory_cost = 0.0 thermal_power = current_effective_slope change_delay = 0.0 if candidate_fan == current_fan else dead_time @@ -605,25 +660,48 @@ def _simulate_mode( if elapsed >= 30 and predicted_30m is None: predicted_30m = sim_temp + # Asymmetric comfort band. The setpoint is a hard target on the + # under-conditioned side: in cooling the room must be kept at or below + # target, in heating at or above it. So any *positive* comfort error + # (``error`` > 0 = under-conditioned) is always penalised — there is no + # tolerance on the wrong side of the setpoint. Overshoot to the + # acceptable side (error < 0: cooler than needed when cooling, warmer + # when heating) is tolerated within the deadband to avoid hunting. + # + # The under-side penalty is graduated: a light linear comfort term + # applies from the setpoint (a gentle nudge back to target for a small + # excursion), while the heavy floor-violation terms only engage beyond + # the deadband (an aggressive push when the room is clearly on the wrong + # side). This keeps "at/below target in cooling" without slamming the + # fan to maximum for a single sensor step over the setpoint. error = self._temperature_error(sim_temp, target_temp, hvac_mode) - comfort_error = max(abs(error) - self._deadband, 0.0) - overshoot = max(-error, 0.0) - floor_violation = max(target_temp - sim_temp, 0.0) if hvac_mode == "heat" else max(sim_temp - target_temp, 0.0) + under = max(error, 0.0) # wrong side of setpoint: always penalised + overshoot = max(-error - band, 0.0) # acceptable side: tolerated within the deadband + comfort_error = under + overshoot # Step-by-step urgency weight calculated dynamically based on current simulated step comfort error step_urgency_weight = 1.0 + comfort_error * URGENCY_SENSITIVITY - cost += COMFORT_ERROR_WEIGHT * comfort_error * step_urgency_weight - cost += OVERSHOOT_QUADRATIC_WEIGHT * overshoot * overshoot - cost += FLOOR_VIOLATION_LINEAR_WEIGHT * floor_violation * step_urgency_weight - cost += FLOOR_VIOLATION_QUADRATIC_WEIGHT * floor_violation * floor_violation - + trajectory_cost += COMFORT_ERROR_WEIGHT * comfort_error * step_urgency_weight + trajectory_cost += OVERSHOOT_QUADRATIC_WEIGHT * overshoot * overshoot + # Under-side penalty applies from the setpoint (no tolerance) but is + # graduated: the linear term gives a gentle nudge for a small excursion, + # the quadratic term makes it escalate hard once clearly on the wrong side. + trajectory_cost += FLOOR_VIOLATION_LINEAR_WEIGHT * under * step_urgency_weight + trajectory_cost += FLOOR_VIOLATION_QUADRATIC_WEIGHT * under * under + + # Normalise the trajectory penalty to an average per-step value so the cost + # magnitude is independent of the horizon length (which grows with the + # learned dead time) and comparable across control cycles. The one-time + # economic costs below are then on the same per-step scale, so mode-rank + # (energy) savings are no longer drowned out by a long comfort integral. + cost = trajectory_cost / steps cost += MODE_CHANGE_DISTANCE_COST * abs(candidate_index - current_index) # Apply a non-linear economic mode-ranking cost representing physical power # scaling. Relative power grows geometrically with the mode rank so every # mode is differentiated regardless of how many the climate entity exposes # (a 4-mode system reproduces the previous 1.0 / 1.8 / 3.3 / 6.0 ramp). relative_power = MODE_POWER_RATIO ** candidate_index - cost += MODE_RANK_COST * relative_power + cost += MODE_RANK_COST * relative_power * energy_weight if candidate_fan != current_fan and not change_allowed: cost += MIN_INTERVAL_CHANGE_PENALTY @@ -642,8 +720,16 @@ def _required_switch_gain( phase: str, candidate_index: int, current_index: int, + current_cost: float = 0.0, ) -> float: - """Return the minimum cost gain required before switching fan mode.""" + """Return the minimum cost gain required before switching fan mode. + + The margin combines an absolute directional component (below) with a + relative component proportional to the current mode's cost. The relative + term is applied only when it does not fight comfort recovery — i.e. never + on an upward switch while the room is under-conditioned, where a fast + response matters more than avoiding a marginal change. + """ if current_error < -self._deadband: margin = BASE_SWITCH_GAIN_MARGIN elif current_error > (self._deadband * 2): @@ -662,6 +748,14 @@ def _required_switch_gain( margin += UNDER_TARGET_STEPDOWN_GAIN_MARGIN margin += UNDER_TARGET_STEPDOWN_GAIN_PER_DEG * current_error + # Under-conditioned means the room is on the wrong side of the setpoint + # (error > 0). With the asymmetric band there is no tolerance there, so an + # upward switch to recover should not be taxed by the relative-hysteresis + # term (that term only guards against churn in/near the comfort band). + stepping_up_under_target = candidate_index > current_index and current_error > 0.0 + if not stepping_up_under_target: + margin += HYSTERESIS_REL_FRACTION * abs(current_cost) + return margin def _step_down_hold_note( diff --git a/custom_components/smart_fan_controller/sensor.py b/custom_components/smart_fan_controller/sensor.py index 1854b0a..e47acda 100644 --- a/custom_components/smart_fan_controller/sensor.py +++ b/custom_components/smart_fan_controller/sensor.py @@ -138,7 +138,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e SmartFanMpcProfilesSensor(entry.entry_id, climate_entity, mpc, "heat"), SmartFanMpcProfilesSensor(entry.entry_id, climate_entity, mpc, "cool"), SmartFanLearnedDeadbandSensor(entry.entry_id, climate_entity, mpc), - SmartFanLearnedLimitTimeoutSensor(entry.entry_id, climate_entity, mpc), ] ) @@ -255,7 +254,6 @@ def extra_state_attributes(self) -> dict: optimal = self._controller.learning.compute_optimal_parameters() if optimal: attrs["learned_deadband"] = optimal.get("deadband") - attrs["learned_limit_timeout"] = optimal.get("limit_timeout") attrs["learned_samples_count"] = optimal.get("samples_count") attrs["learned_response_samples"] = optimal.get("response_samples") @@ -350,7 +348,6 @@ def extra_state_attributes(self) -> dict: "avg_response_time_min": round(avg_response, 1), "median_response_time_min": round(learning.get_dead_time(), 2), "effective_timeout_min": round(self._controller.get_effective_timeout(), 2), - "computed_limit_timeout": optimal.get("limit_timeout", 0), } @@ -409,7 +406,6 @@ def extra_state_attributes(self) -> dict: return { "is_ready": self._controller.learning.is_ready(), "learned_dead_time": round(self._controller.learning.get_dead_time(), 2), - "configured_limit_timeout": round(self._controller.limit_timeout, 2), } @@ -598,21 +594,3 @@ def __init__(self, entry_id: str, climate_entity: str, controller) -> None: icon="mdi:thermometer-lines", current_attr="deadband", ) - - -class SmartFanLearnedLimitTimeoutSensor(_BaseLearnedParameterSensor): - """Learned limit_timeout parameter.""" - - def __init__(self, entry_id: str, climate_entity: str, controller) -> None: - super().__init__( - entry_id, - climate_entity, - controller, - name="Learned Limit Timeout", - object_key="learned_limit_timeout", - unit=UnitOfTime.MINUTES, - device_class=SensorDeviceClass.DURATION, - learning_key="limit_timeout", - icon="mdi:clock-check-outline", - current_attr="limit_timeout", - ) diff --git a/custom_components/smart_fan_controller/thermal_learning.py b/custom_components/smart_fan_controller/thermal_learning.py index ea43829..62cbdc3 100644 --- a/custom_components/smart_fan_controller/thermal_learning.py +++ b/custom_components/smart_fan_controller/thermal_learning.py @@ -4,7 +4,6 @@ from .const import ( MIN_SAMPLES_LEARNING, - MIN_LIMIT_TIMEOUT, MIN_MODE_PROFILE_SAMPLES, DEFAULT_DEAD_TIME, REFERENCE_SLOPE_ERROR, @@ -328,6 +327,16 @@ def _fit_mode_slope(self, fan_mode: str, hvac_mode: str) -> tuple[float, float, if len(points) < MIN_MODE_PROFILE_SAMPLES: return (median_eff, 0.0, None) + # Anti-extrapolation guard: the representative slope is reported at + # REFERENCE_SLOPE_ERROR and the sim applies the gain across the whole + # horizon. If this profile has never been observed at an error anywhere + # near that reference gap (e.g. a mode only ever active at/below setpoint), + # extrapolating the gain out to REFERENCE_SLOPE_ERROR can explode into a + # physically impossible slope. Fall back to the constant median instead of + # trusting an unsupported extrapolation. + if max(x for x, _ in points) < REFERENCE_SLOPE_ERROR: + return (median_eff, 0.0, None) + n = len(points) mean_x = sum(x for x, _ in points) / n mean_y = sum(y for _, y in points) / n @@ -530,16 +539,11 @@ def compute_optimal_parameters(self) -> dict: slope_variance = self._slope_m2 / (self._slope_count - 1) if self._slope_count > 1 else 0.01 slope_stdev = slope_variance**0.5 # Standard deviation - # Analyze response times + # Analyze response times (surfaced as response_samples/avg for diagnostics) response_times = [item[1] for item in self._response_events if item[1] > 0] # Use median instead of mean to be robust against outliers avg_response = statistics.median(response_times) if response_times else 10.0 - # Compute optimal_limit_timeout based on observed response time. - # Apply a minimum floor (MIN_LIMIT_TIMEOUT) to prevent the timeout from - # being set so low that it causes continuous fan oscillations. - optimal_limit_timeout = max(MIN_LIMIT_TIMEOUT, int(round(avg_response))) - # Adapt thresholds to slope characteristics # High volatility → larger deadbands to avoid oscillations volatility_factor = min(slope_stdev / max(self._slope_mean, 0.1), 3.0) @@ -547,17 +551,16 @@ def compute_optimal_parameters(self) -> dict: optimal_deadband = 0.15 + (volatility_factor * 0.2) _LOGGER.info( - "Auto-calibration complete: avg_slope=%.2f std=%.2f max=%.2f | avg_response=%.1fmin | limit_timeout=%d", + "Auto-calibration complete: avg_slope=%.2f std=%.2f max=%.2f | avg_response=%.1fmin | deadband=%.2f", self._slope_mean, slope_stdev, self._slope_max, avg_response, - optimal_limit_timeout, + optimal_deadband, ) result = { "deadband": round(optimal_deadband, 2), - "limit_timeout": optimal_limit_timeout, "samples_count": self._slope_count, "response_samples": len(response_times), } diff --git a/custom_components/smart_fan_controller/translations/en.json b/custom_components/smart_fan_controller/translations/en.json index 17dc82e..922d239 100644 --- a/custom_components/smart_fan_controller/translations/en.json +++ b/custom_components/smart_fan_controller/translations/en.json @@ -8,7 +8,6 @@ "climate_entity": " ", "deadband": "Deadband (°C)", "min_interval": "Min Interval (minutes)", - "limit_timeout": "Interval Before Forcing Change (minutes)", "data_collection": "Enable Data Collection (beta)", "defrost_entity": "Defrost Entity (optional)", "operating_entity": "Heat Pump Operating Entity (optional)" @@ -17,7 +16,6 @@ "climate_entity": "Must come from a Versatile Thermostat.", "deadband": "Range: 0.0-5.0 °C. Default: 0.2 °C.", "min_interval": "Range: 1-60 min. Default: 10 min.", - "limit_timeout": "Range: 10-120 min. Default: 15 min.", "data_collection": "Records one CSV row every 2 minutes in the Home Assistant config folder, alongside configuration.yaml: smart_fan_controller_data_XXXXXXXX.csv (max 10 MB, auto-rotated). Useful for reproducing algorithm behaviour and identifying improvements during the beta phase.", "defrost_entity": "Optional: binary sensor, sensor or input_boolean that indicates when the heat pump is in defrost mode.", "operating_entity": "Optional: binary sensor, sensor or input_boolean that indicates when the heat pump compressor is actively running. Used to prevent useless fan changes while the compressor is off." @@ -38,7 +36,6 @@ "climate_entity": " ", "deadband": "Deadband (°C)", "min_interval": "Min Interval (minutes)", - "limit_timeout": "Interval Before Forcing Change (minutes)", "data_collection": "Enable Data Collection (beta)", "defrost_entity": "Defrost Entity (optional)", "operating_entity": "Heat Pump Operating Entity (optional)" @@ -47,7 +44,6 @@ "climate_entity": "Must come from a Versatile Thermostat.", "deadband": "Range: 0.0-5.0 °C. Default: 0.2 °C.", "min_interval": "Range: 1-60 min. Default: 10 min.", - "limit_timeout": "Range: 10-120 min. Default: 15 min.", "data_collection": "Records one CSV row every 2 minutes in the Home Assistant config folder, alongside configuration.yaml: smart_fan_controller_data_XXXXXXXX.csv (max 10 MB, auto-rotated). Useful for reproducing algorithm behaviour and identifying improvements during the beta phase.", "defrost_entity": "Optional: binary sensor, sensor or input_boolean that indicates when the heat pump is in defrost mode.", "operating_entity": "Optional: binary sensor, sensor or input_boolean that indicates when the heat pump compressor is actively running. Used to prevent useless fan changes while the compressor is off." diff --git a/custom_components/smart_fan_controller/translations/fr.json b/custom_components/smart_fan_controller/translations/fr.json index e8e34fe..5745000 100644 --- a/custom_components/smart_fan_controller/translations/fr.json +++ b/custom_components/smart_fan_controller/translations/fr.json @@ -8,7 +8,6 @@ "climate_entity": " ", "deadband": "Bande morte (°C)", "min_interval": "Intervalle minimum (minutes)", - "limit_timeout": "Intervalle avant de forcer un changement (minutes)", "data_collection": "Activer la collecte de données (bêta)", "defrost_entity": "Entité dégivrage (optionnel)", "operating_entity": "Entité fonctionnement PAC (optionnel)" @@ -17,7 +16,6 @@ "climate_entity": "Doit provenir de Versatile Thermostat.", "deadband": "Plage : 0.0-5.0 °C. Défaut : 0.2 °C.", "min_interval": "Plage : 1-60 min. Défaut : 10 min.", - "limit_timeout": "Plage : 10-120 min. Défaut : 15 min.", "data_collection": "Enregistre une ligne CSV toutes les 2 minutes dans le dossier de configuration Home Assistant, à côté de configuration.yaml : smart_fan_controller_data_XXXXXXXX.csv (max 10 Mo, rotation automatique). Utile pour reproduire le comportement de l'algorithme et identifier des améliorations pendant la phase bêta.", "defrost_entity": "Optionnel : binary sensor, sensor ou input_boolean indiquant quand la PAC est en dégivrage.", "operating_entity": "Optionnel : binary sensor, sensor ou input_boolean indiquant quand le compresseur de la PAC est en marche. Utilisé pour éviter des changements de ventilation inutiles quand le compresseur est à l'arrêt." @@ -38,7 +36,6 @@ "climate_entity": " ", "deadband": "Bande morte (°C)", "min_interval": "Intervalle minimum (minutes)", - "limit_timeout": "Intervalle avant de forcer un changement (minutes)", "data_collection": "Activer la collecte de données (bêta)", "defrost_entity": "Entité dégivrage (optionnel)", "operating_entity": "Entité fonctionnement PAC (optionnel)" @@ -47,7 +44,6 @@ "climate_entity": "Doit provenir de Versatile Thermostat.", "deadband": "Plage : 0.0-5.0 °C. Défaut : 0.2 °C.", "min_interval": "Plage : 1-60 min. Défaut : 10 min.", - "limit_timeout": "Plage : 10-120 min. Défaut : 15 min.", "data_collection": "Enregistre une ligne CSV toutes les 2 minutes dans le dossier de configuration Home Assistant, à côté de configuration.yaml : smart_fan_controller_data_XXXXXXXX.csv (max 10 Mo, rotation automatique). Utile pour reproduire le comportement de l'algorithme et identifier des améliorations pendant la phase bêta.", "defrost_entity": "Optionnel : binary sensor, sensor ou input_boolean indiquant quand la PAC est en dégivrage.", "operating_entity": "Optionnel : binary sensor, sensor ou input_boolean indiquant quand le compresseur de la PAC est en marche. Utilisé pour éviter des changements de ventilation inutiles quand le compresseur est à l'arrêt." diff --git a/tests/test_learning.py b/tests/test_learning.py index 7b9c857..8547d48 100644 --- a/tests/test_learning.py +++ b/tests/test_learning.py @@ -2,7 +2,6 @@ import pytest from custom_components.smart_fan_controller.thermal_learning import ThermalLearning from custom_components.smart_fan_controller.mpc_controller import MPCController -from custom_components.smart_fan_controller.const import MIN_LIMIT_TIMEOUT from custom_components.smart_fan_controller.sensor import SmartFanLearnedDeadTimeSensor, SmartFanEffectiveTimeoutSensor FAN_MODES = ["low", "medium", "high"] @@ -15,90 +14,29 @@ def _build_mpc(learning: ThermalLearning) -> MPCController: learning=learning, deadband=0.2, min_interval=10, - limit_timeout=15, ) class TestThermalLearning: """Test auto-calibration and optimal parameter computation.""" - def test_optimal_limit_timeout_with_median_response_times(self): - """Test that optimal_limit_timeout uses median to handle outliers.""" + def test_optimal_parameters_reports_response_samples(self): + """compute_optimal_parameters exposes the response-sample count once ready.""" learning = ThermalLearning() - # Add multiple slope samples to satisfy is_ready() requirements - # Note: slope sample parameters (fan mode, slope value) don't affect limit_timeout for _ in range(250): learning.add_slope_sample("medium", 0.3, 0.1) - - # Verify that slope samples alone make the learning ready - assert learning.is_ready() - - # Add response times with outliers: most in 10-12 range, but some outliers at 30+ - # Median should be 12, mean would be much higher - response_times = [10, 10, 11, 11, 12, 12, 30, 35, 40] # median=12, mean≈19.0 - for time in response_times: - learning.add_response_event(time) - assert learning.is_ready() - optimal = learning.compute_optimal_parameters() - - # With median=12, optimal_limit_timeout should be 12 (using direct value, no multiplier) - # Should NOT be based on mean (≈19.0) - median is more robust to outliers - assert optimal["limit_timeout"] == 12 - - def test_optimal_limit_timeout_uses_observed_response_directly(self): - """Test that optimal_limit_timeout uses observed response time, floored by MIN_LIMIT_TIMEOUT.""" - learning = ThermalLearning() - - # Add samples (using consistent parameters across tests) - for _ in range(250): - learning.add_slope_sample("medium", 0.3, 0.1) - - # Add very fast response times (median=4, below MIN_LIMIT_TIMEOUT=5) - for time in [3, 4, 4, 5]: - learning.add_response_event(time) - - optimal = learning.compute_optimal_parameters() - - # With median=4 and MIN_LIMIT_TIMEOUT=5, optimal should be clamped to 5 - - assert optimal["limit_timeout"] == max(MIN_LIMIT_TIMEOUT, 4) - - def test_optimal_limit_timeout_with_slow_response(self): - """Test that optimal_limit_timeout follows observed response even when slow.""" - learning = ThermalLearning() - - # Add samples (using consistent parameters) - for _ in range(250): - learning.add_slope_sample("medium", 0.3, 0.1) - - # Add very slow response times - for time in [18, 19, 20, 25, 30]: - learning.add_response_event(time) - - optimal = learning.compute_optimal_parameters() - - # With median=20, optimal should be 20 (no max cap applied) - assert optimal["limit_timeout"] == 20 - - def test_optimal_limit_timeout_typical_case(self): - """Test typical response times in 10-15 minute range.""" - learning = ThermalLearning() - - # Add samples - for _ in range(250): - learning.add_slope_sample("medium", 0.3, 0.2) - - # Add typical response times user mentioned: 10-15 minutes - for time in [10, 11, 12, 13, 14, 15, 14, 13]: - learning.add_response_event(time) + for response_time in [10, 11, 12, 13]: + learning.add_response_event(response_time) optimal = learning.compute_optimal_parameters() - # With median≈13, optimal should be 13 (direct value, no multiplier) - assert optimal["limit_timeout"] == 13 + # limit_timeout is no longer computed; deadband and diagnostics remain. + assert "limit_timeout" not in optimal + assert optimal["response_samples"] == 4 + assert optimal["deadband"] > 0 def test_learned_dead_time_sensor_reports_median_response(self): """The diagnostic dead-time sensor should expose the median response delay.""" diff --git a/tests/test_mpc_controller.py b/tests/test_mpc_controller.py index c0e6bf7..82b1c7a 100644 --- a/tests/test_mpc_controller.py +++ b/tests/test_mpc_controller.py @@ -30,7 +30,6 @@ def _build_mpc(learning: ThermalLearning, *, fan_modes=None, min_interval: int = learning=learning, deadband=0.3, min_interval=min_interval, - limit_timeout=18, fan_modes=fan_modes or FAN_MODES, ) @@ -523,10 +522,10 @@ def test_mpc_disturbance_bias_decays_during_defrost() -> None: def test_monotone_constraint_enforces_ordering() -> None: - """Monotone constraint should clamp a lower mode's slope up to its neighbor when all profiles are ready.""" + """Monotone constraint should produce a non-decreasing sequence via sample-weighted pooling.""" learning = ThermalLearning() - # Create inverted profiles: silent > med (the real-world bug) + # Create inverted profiles: silent > low (the real-world bug) learning.set_mode_effective_slope("silent", "heat", 0.53) learning.set_mode_effective_slope("low", "heat", 0.0) learning.set_mode_effective_slope("med", "heat", 0.45) @@ -543,9 +542,12 @@ def test_monotone_constraint_enforces_ordering() -> None: monotone = mpc.build_monotone_slopes(["silent", "low", "med", "high", "superhigh"], "heat") assert isinstance(monotone, dict) - # silent (0.53) should stay, low (0.0 → clamped to 0.53), med (0.45 → clamped to 0.53) - assert monotone["silent"] == pytest.approx(0.53, abs=0.001) - assert monotone["low"] >= monotone["silent"] + # silent (0.53) and low (0.0) are out of order with equal sample weight, so + # weighted isotonic pooling merges them to their mean (0.265) instead of the + # weaker mode dragging the stronger up. The sequence stays non-decreasing. + assert monotone["silent"] == pytest.approx(0.265, abs=0.001) + assert monotone["low"] == pytest.approx(0.265, abs=0.001) + assert monotone["low"] >= monotone["silent"] - 1e-9 assert monotone["med"] >= monotone["low"] assert monotone["high"] >= monotone["med"] assert monotone["superhigh"] >= monotone["high"] @@ -591,8 +593,10 @@ def test_monotone_constraint_partial_profiles_enforces_known_pairs() -> None: assert "med" not in result assert "high" in result assert "superhigh" in result - assert result["high"] == pytest.approx(1.59, abs=0.001) - assert result["superhigh"] == pytest.approx(1.59, abs=0.001) + # high (1.59) and superhigh (1.075) are inverted with equal sample weight, so + # weighted isotonic pooling merges them to their mean (1.3325). + assert result["high"] == pytest.approx(1.3325, abs=0.001) + assert result["superhigh"] == pytest.approx(1.3325, abs=0.001) def test_monotone_constraint_noop_when_already_ordered() -> None: diff --git a/tests/test_replay_bench.py b/tests/test_replay_bench.py new file mode 100644 index 0000000..33f436f --- /dev/null +++ b/tests/test_replay_bench.py @@ -0,0 +1,102 @@ +"""Tests for replay bench snapshot replay helpers.""" + +import importlib.util +from pathlib import Path +import sys + +from custom_components.smart_fan_controller.thermal_learning import ThermalLearning + + +_SPEC = importlib.util.spec_from_file_location( + "replay_bench", + Path(__file__).resolve().parent.parent / "scripts" / "replay_bench.py", +) +assert _SPEC is not None and _SPEC.loader is not None +replay_bench = importlib.util.module_from_spec(_SPEC) +sys.modules[_SPEC.name] = replay_bench +_SPEC.loader.exec_module(replay_bench) + + +def _write_snapshot_csv(path: Path, rows: list[tuple[str, str, str]]) -> None: + """Write a small effective-slope snapshot CSV for tests.""" + content = ["entity_id,state,last_changed"] + content.extend( + f"{entity_id},{state},{changed_at}" + for entity_id, state, changed_at in rows + ) + path.write_text("\n".join(content) + "\n", encoding="utf-8") + + +def test_load_snapshot_profiles_returns_next_replay_index(tmp_path: Path) -> None: + """Initial snapshot seeding should stop at the first event beyond the grace window.""" + _write_snapshot_csv( + tmp_path / "superhigh_effective_slope.csv", + [ + ( + "sensor.smart_fan_controller_salon_heat_superhigh_effective_slope", + "unknown", + "2026-04-16T15:58:37.509Z", + ), + ( + "sensor.smart_fan_controller_salon_heat_superhigh_effective_slope", + "1.075", + "2026-04-16T16:00:37.448Z", + ), + ( + "sensor.smart_fan_controller_salon_heat_superhigh_effective_slope", + "unknown", + "2026-04-18T06:58:39.280Z", + ), + ], + ) + + events = replay_bench.load_snapshot_events( + str(tmp_path), + default_hvac_mode="heat", + fan_modes=["superhigh"], + ) + + profiles, next_index = replay_bench.load_snapshot_profiles( + events, + trace_start=replay_bench.parse_timestamp("2026-04-16T16:00:00Z"), + ) + + assert profiles == {("superhigh", "heat"): 1.075} + assert next_index == 2 + + +def test_apply_snapshot_events_until_updates_and_clears_profile() -> None: + """Dynamic snapshot replay should set a profile value and later clear it.""" + learning = ThermalLearning() + events = [ + replay_bench.SnapshotEvent( + timestamp=replay_bench.parse_timestamp("2026-04-16T16:00:37Z"), + fan_mode="superhigh", + hvac_mode="heat", + slope=1.075, + ), + replay_bench.SnapshotEvent( + timestamp=replay_bench.parse_timestamp("2026-04-18T06:58:39Z"), + fan_mode="superhigh", + hvac_mode="heat", + slope=None, + ), + ] + + index = replay_bench.apply_snapshot_events_until( + learning, + events, + 0, + replay_bench.parse_timestamp("2026-04-16T16:04:37Z"), + ) + assert index == 1 + assert learning.get_mode_effective_slope("superhigh", "heat") == 1.075 + + index = replay_bench.apply_snapshot_events_until( + learning, + events, + index, + replay_bench.parse_timestamp("2026-04-18T07:02:39Z"), + ) + assert index == 2 + assert learning.get_mode_effective_slope("superhigh", "heat") is None \ No newline at end of file diff --git a/tests/test_services.py b/tests/test_services.py index c34bbe1..d26e449 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -23,7 +23,6 @@ CONF_CLIMATE_ENTITY, CONF_DEADBAND, CONF_DEFROST_ENTITY, - CONF_LIMIT_TIMEOUT, CONF_OPERATING_ENTITY, DOMAIN, MIN_SAMPLES_LEARNING, @@ -52,7 +51,7 @@ async def test_apply_learned_settings_calls_helper_when_ready(self): learning = _make_ready_learning() entry = MagicMock() - entry.data = {CONF_DEADBAND: 0.2, CONF_LIMIT_TIMEOUT: 15} + entry.data = {CONF_DEADBAND: 0.2} entry.options = {} applied_data = {} @@ -68,7 +67,6 @@ async def fake_apply(hass, entry, optimal): # pylint: disable=unused-argument await fake_apply(hass, entry, optimal) assert "deadband" in applied_data - assert "limit_timeout" in applied_data def test_apply_learned_settings_skipped_when_not_ready(self): """When learning is not ready, the service must not apply any parameters.""" @@ -84,7 +82,6 @@ def test_optimal_parameters_satisfy_threshold_constraints(self): optimal = learning.compute_optimal_parameters() assert optimal["deadband"] > 0, f"deadband ({optimal['deadband']}) must be positive" - assert optimal["limit_timeout"] > 0, f"limit_timeout ({optimal['limit_timeout']}) must be positive" def test_apply_optimal_sets_learning_auto_applied_flag(self): """_apply_optimal_parameters must set learning_auto_applied=True in entry.data.""" @@ -94,10 +91,10 @@ def test_apply_optimal_sets_learning_auto_applied_flag(self): hass.config_entries.async_reload = AsyncMock() entry = MagicMock() - entry.data = {CONF_DEADBAND: 0.2, CONF_LIMIT_TIMEOUT: 15} - entry.options = {CONF_DEADBAND: 0.2, CONF_LIMIT_TIMEOUT: 15, CONF_CLIMATE_ENTITY: "climate.test"} + entry.data = {CONF_DEADBAND: 0.2} + entry.options = {CONF_DEADBAND: 0.2, CONF_CLIMATE_ENTITY: "climate.test"} - optimal = {"deadband": 0.25, "limit_timeout": 12} + optimal = {"deadband": 0.25} asyncio.get_event_loop().run_until_complete(_apply_optimal_parameters(hass, entry, optimal)) @@ -107,9 +104,7 @@ def test_apply_optimal_sets_learning_auto_applied_flag(self): updated_options = call_args[1]["options"] assert updated_data.get("learning_auto_applied") is True assert updated_data[CONF_DEADBAND] == 0.25 - assert updated_data[CONF_LIMIT_TIMEOUT] == 12 assert updated_options[CONF_DEADBAND] == 0.25 - assert updated_options[CONF_LIMIT_TIMEOUT] == 12 def test_resolve_controller_entry_requires_target_when_multiple_entries(self): """Service routing must require a climate target when several entries are loaded."""