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..31b905c 100644 --- a/custom_components/smart_fan_controller/__init__.py +++ b/custom_components/smart_fan_controller/__init__.py @@ -19,14 +19,14 @@ CONF_DATA_COLLECTION, CONF_DEADBAND, CONF_DEFROST_ENTITY, - CONF_LIMIT_TIMEOUT, + CONF_FAN_MODE_ORDER, CONF_MIN_INTERVAL, CONF_OPERATING_ENTITY, + CONF_OUTDOOR_ENTITY, DEAD_TIME_SAFETY_FACTOR, DEFAULT_DATA_COLLECTION, DEFAULT_DEAD_TIME, DEFAULT_DEADBAND, - DEFAULT_LIMIT_TIMEOUT, DEFAULT_MIN_INTERVAL, DELTA_TIME_CONTROL_LOOP, DOMAIN, @@ -69,6 +69,29 @@ def _extract_supported_fan_modes(state) -> list[str]: return _filter_supported_fan_modes(state.attributes.get("fan_modes")) +def _apply_configured_fan_order(detected: list[str], configured: list[str] | None) -> list[str]: + """Reorder *detected* fan modes to follow the user's explicit weakest-to-strongest order. + + The whole controller treats a fan mode's index as its strength: the energy + term (MODE_POWER_RATIO ** index), the step-down guard, the monotone slope + enforcement and the rank-scaling fallback for unlearned profiles all read + it. The climate entity's own ``fan_modes`` order is normally correct but is + trusted blindly, so the options flow lets it be overridden. + + Modes present in *configured* keep that order; anything detected but not + listed (a mode added by the climate entity after configuration) is appended + so it stays usable rather than silently disappearing. Entries in + *configured* that no longer exist are dropped. + """ + if not configured: + return detected + ordered = [mode for mode in configured if mode in detected] + ordered += [mode for mode in detected if mode not in ordered] + if ordered != detected: + _LOGGER.debug("Applied configured fan-mode order: %s -> %s", detected, ordered) + return ordered + + def _iter_loaded_entries(hass: HomeAssistant) -> list[tuple[str, dict]]: """Return loaded Smart Fan Controller entries, excluding internal metadata.""" domain_data = hass.data.get(DOMAIN, {}) @@ -233,16 +256,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) @@ -483,6 +503,25 @@ def _detect_disturbances(hass, conf: dict, defrost_state: dict) -> tuple[bool, b return is_defrost, is_hvac_idle +def _read_outdoor_temp(hass, conf: dict) -> float | None: + """Return the configured outdoor temperature (°C), or None when unavailable. + + Optional, telemetry only: logged to the data-collection CSV so the trace can + be correlated with outdoor conditions offline. No control decision reads it. + A missing, non-numeric, or unavailable sensor simply leaves the column empty. + """ + outdoor_entity_id = conf.get(CONF_OUTDOOR_ENTITY) + if not outdoor_entity_id: + return None + state = hass.states.get(outdoor_entity_id) + if state is None or state.state in ("unknown", "unavailable", "", None): + return None + try: + return float(state.state) + except (TypeError, ValueError): + return None + + async def _async_apply_fan_change( hass, climate_id: str, @@ -524,7 +563,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: domain_data = hass.data.setdefault(DOMAIN, {}) climate_id = conf[CONF_CLIMATE_ENTITY] current_state = hass.states.get(climate_id) - initial_fan_modes = _extract_supported_fan_modes(current_state) + configured_fan_order = conf.get(CONF_FAN_MODE_ORDER) + initial_fan_modes = _apply_configured_fan_order( + _extract_supported_fan_modes(current_state), configured_fan_order + ) _LOGGER.info( "Setting up Smart Fan Controller for %s (data_collection=%s)", @@ -558,7 +600,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 +636,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, @@ -616,7 +659,9 @@ async def _execute_control_cycle(): return if not mpc_controller.fan_modes: - detected_modes = _extract_supported_fan_modes(current_state) + detected_modes = _apply_configured_fan_order( + _extract_supported_fan_modes(current_state), configured_fan_order + ) if detected_modes: mpc_controller.fan_modes = detected_modes _LOGGER.info("Detected fan modes for %s during runtime: %s", climate_id, detected_modes) @@ -629,6 +674,7 @@ async def _execute_control_cycle(): return vtherm_slope, current_temp, target_temp, hvac_mode, current_fan, is_window_open = cycle_data is_defrost_active, is_hvac_idle = _detect_disturbances(hass, conf, ctrl_state["defrost"]) + outdoor_temp = _read_outdoor_temp(hass, conf) now = time.time() minutes_since_change = (now - ctrl_state["last_change_time"]) / 60.0 @@ -764,6 +810,7 @@ async def _execute_control_cycle(): mpc_decision=mpc_decision, defrost_active=is_defrost_active, is_hvac_idle=is_hvac_idle, + outdoor_temp=outdoor_temp, ) _update_sensors(hass, entry.entry_id, { diff --git a/custom_components/smart_fan_controller/config_flow.py b/custom_components/smart_fan_controller/config_flow.py index 265e098..087078f 100644 --- a/custom_components/smart_fan_controller/config_flow.py +++ b/custom_components/smart_fan_controller/config_flow.py @@ -11,15 +11,15 @@ from .const import ( DOMAIN, CONF_CLIMATE_ENTITY, + CONF_FAN_MODE_ORDER, CONF_DEADBAND, CONF_MIN_INTERVAL, - CONF_LIMIT_TIMEOUT, CONF_DATA_COLLECTION, CONF_DEFROST_ENTITY, CONF_OPERATING_ENTITY, + CONF_OUTDOOR_ENTITY, DEFAULT_DEADBAND, DEFAULT_MIN_INTERVAL, - DEFAULT_LIMIT_TIMEOUT, DEFAULT_DATA_COLLECTION, ) @@ -42,6 +42,31 @@ def _validate_climate_state(state) -> str | None: return None +def _extract_fan_modes(state) -> list[str]: + """Return the manual fan modes exposed by a climate state (excludes auto/off).""" + if state is None: + return [] + raw_modes = state.attributes.get("fan_modes") + if not raw_modes: + return [] + return [mode for mode in raw_modes if isinstance(mode, str) and mode.lower() not in {"auto", "off"}] + + +def _validate_fan_order(selected: list[str] | None, detected: list[str]) -> str | None: + """Return an error key when the submitted fan order is unusable, else None. + + An empty selection means "keep the climate entity's own order" and is valid. + A partial selection is rejected: a half-specified ladder is worse than none, + since the unlisted modes would be silently appended in detected order and + the user would believe they had set the strength order explicitly. + """ + if not selected: + return None + if set(selected) != set(detected): + return "fan_order_incomplete" + return None + + def _is_climate_entity_already_configured( hass, climate_entity: str, @@ -105,12 +130,10 @@ 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"])), + vol.Optional(CONF_OUTDOOR_ENTITY): selector.EntitySelector(selector.EntitySelectorConfig(domain="sensor", device_class="temperature")), } ) @@ -130,11 +153,17 @@ async def async_step_init(self, user_input: dict[str, Any] | None = None) -> Con available_climates = _get_climates_with_fan_modes_and_slope(self.hass) errors: dict[str, str] = {} current_data = {**self.config_entry.data, **self.config_entry.options} + current_climate = current_data.get(CONF_CLIMATE_ENTITY) + detected_fan_modes = _extract_fan_modes(self.hass.states.get(current_climate)) if current_climate else [] if user_input is not None: climate_id = user_input[CONF_CLIMATE_ENTITY] current_climate_id = current_data.get(CONF_CLIMATE_ENTITY) + order_error = _validate_fan_order(user_input.get(CONF_FAN_MODE_ORDER), detected_fan_modes) + if order_error: + errors[CONF_FAN_MODE_ORDER] = order_error + # Only validate the climate entity if the user is changing it if climate_id != current_climate_id: state = self.hass.states.get(climate_id) @@ -153,7 +182,6 @@ async def async_step_init(self, user_input: dict[str, Any] | None = None) -> Con # Build selector config for options flow # Always include the current climate entity even if it's not in the filtered list - current_climate = current_data.get(CONF_CLIMATE_ENTITY) selector_config_kwargs: dict[str, Any] = {"domain": CLIMATE_DOMAIN} if available_climates: selector_config_kwargs["include_entities"] = available_climates @@ -173,9 +201,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"]) @@ -183,6 +208,22 @@ async def async_step_init(self, user_input: dict[str, Any] | None = None) -> Con vol.Optional(CONF_OPERATING_ENTITY, default=current_data.get(CONF_OPERATING_ENTITY, vol.UNDEFINED)): selector.EntitySelector( selector.EntitySelectorConfig(domain=["binary_sensor", "sensor", "input_boolean"]) ), + vol.Optional(CONF_OUTDOOR_ENTITY, default=current_data.get(CONF_OUTDOOR_ENTITY, vol.UNDEFINED)): selector.EntitySelector( + selector.EntitySelectorConfig(domain="sensor", device_class="temperature") + ), + # Pick every speed, weakest first. Left empty, the climate entity's + # own fan_modes order is used (see _apply_configured_fan_order). + vol.Optional( + CONF_FAN_MODE_ORDER, + default=current_data.get(CONF_FAN_MODE_ORDER, detected_fan_modes), + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=detected_fan_modes, + multiple=True, + sort=False, + mode=selector.SelectSelectorMode.LIST, + ) + ), } ) diff --git a/custom_components/smart_fan_controller/const.py b/custom_components/smart_fan_controller/const.py index 768fe83..8061546 100644 --- a/custom_components/smart_fan_controller/const.py +++ b/custom_components/smart_fan_controller/const.py @@ -12,15 +12,15 @@ 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" +CONF_OUTDOOR_ENTITY = "outdoor_entity" +CONF_FAN_MODE_ORDER = "fan_mode_order" # explicit weakest-to-strongest order, overrides the climate entity's # 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 +43,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/data_collection.py b/custom_components/smart_fan_controller/data_collection.py index 36e5c6a..e78ab47 100644 --- a/custom_components/smart_fan_controller/data_collection.py +++ b/custom_components/smart_fan_controller/data_collection.py @@ -56,6 +56,7 @@ "mpc_disturbance", "defrost_active", "hvac_idle", + "outdoor_temp", ] # Rotate the file when it exceeds this size (bytes). 10 MB keeps ~200 000 rows. @@ -101,6 +102,7 @@ async def async_record( mpc_decision: dict | None = None, defrost_active: bool = False, is_hvac_idle: bool = False, + outdoor_temp: float | None = None, ) -> None: """Append one row to the CSV file outside the event loop.""" mpc = mpc_decision or {} @@ -141,6 +143,7 @@ async def async_record( else "", int(defrost_active), int(is_hvac_idle), + round(outdoor_temp, 2) if outdoor_temp is not None else "", ] async with self._io_lock: await self._hass.async_add_executor_job(self._write_row, row) diff --git a/custom_components/smart_fan_controller/mpc_controller.py b/custom_components/smart_fan_controller/mpc_controller.py index a94552c..804adb9 100644 --- a/custom_components/smart_fan_controller/mpc_controller.py +++ b/custom_components/smart_fan_controller/mpc_controller.py @@ -11,6 +11,7 @@ PHASE_DEAD_TIME, PHASE_ESTABLISHED, PHASE_TRANSIENT, + PROFILE_HVAC_MODES, REFERENCE_SLOPE_ERROR, THRESHOLD_TARGET_DROP, ) @@ -32,6 +33,42 @@ MIN_INTERVAL_CHANGE_PENALTY = 25.0 URGENCY_SENSITIVITY = 2.0 +# The configured min_interval is a floor; once learning is ready the effective +# dwell before a fan change is raised toward the learned dead time (you cannot +# observe a change's effect faster than the dead time, so changing sooner just +# invites oscillation). The rise is capped at this factor x the configured floor +# so a spuriously large learned dead time cannot stall the controller. +MAX_ADAPTIVE_INTERVAL_FACTOR = 3.0 + +# The dead-time lock above has no visibility into whether the current fan mode +# is actually holding comfort — a misjudged step (e.g. a multi-rank drop) can +# leave the room drifting away from target for the full adaptive interval with +# no escape. A static error threshold does not fit every deadband/system, so +# instead this tracks how much the comfort error has *grown* since the fan +# last changed (comparing against the error at that moment) — a direct read of +# the room's actual trajectory rather than an arbitrary absolute cutoff. Past +# this much growth, an *escalation only* (never a step-down) is allowed to +# bypass the lock. +DEAD_TIME_ESCALATION_GROWTH = 0.15 # degC comfort error allowed to worsen since the change + +# --- Hold-equilibrium (economic) mode ------------------------------------- +# When enabled, near the setpoint the controller matches fan output to the +# system's steady thermal production instead of collapsing to the lowest speed. +# Rationale: on a running heat pump the compressor draws the dominant power and +# keeps producing cold/heat regardless of fan speed, so the fan-rank penalty +# (which models fan watts) optimises the wrong term there. Holding the room flat +# with a steady speed avoids the drift-then-blast cycle and lets an inverter +# compressor modulate at high COP instead of short-cycling. A small, bounded cold +# undershoot is tolerated so the holding speed can slightly lead the load rather +# than lag it — this is what lets a discrete speed ladder actually hold. +# 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. The feature is dormant whenever +# the error exceeds the deadband, so far-from-setpoint escalation is untouched. +HOLD_EQUILIBRIUM = True +HOLD_UNDERSHOOT_TOLERANCE = 0.3 # °C of free wrong-side undershoot inside the hold zone +HOLD_RANK_SCALE = 0.15 # shrink the fan-rank (energy) penalty to a tie-breaker in the zone + # Disturbance bias tracker DISTURBANCE_EMA_ALPHA = 0.2 DISTURBANCE_DECAY = 0.85 @@ -47,6 +84,37 @@ UNDER_TARGET_STEPDOWN_GAIN_PER_DEG = 0.5 UNDER_TARGET_SHORTFALL_RESERVE = 0.1 +# Guard against jumping straight to a fan mode more than one rank below the +# current one when that candidate's own learned profile shows it cannot +# sustain progress (net effective slope <= 0 at the reference error). The +# cost-based forecast cannot be trusted for this: during the dead-time-blind +# window (see sim_horizon below) every candidate's near-term trajectory is +# dominated by the *current* mode's momentum, not the candidate's own +# behaviour, so a weak mode can look deceptively good right up until the +# switch is committed. Adjacent-rank switches are left untouched — this only +# blocks multi-rank plunges to a mode with no track record of holding. +MIN_VIABLE_MULTI_RANK_STEPDOWN_SLOPE = 0.0 + +# Capacity spacing between adjacent fan speeds, used when a profile's own +# estimate is discarded for violating the strength order (see +# build_monotone_slopes). Collapsing the rejected speed onto its neighbour's +# value would leave the MPC unable to tell them apart thermally, so the only +# remaining tie-breaker would be the energy term — which always prefers the +# weaker rank. That starves the stronger speed of samples, which keeps its +# profile thin, which keeps it being rejected: a self-reinforcing trap. Placing +# it one geometric step away instead keeps the ladder ordered *and* separated. +# Calibrated on the production ladder, whose measured ratios between adjacent +# speeds are 2.08 (med->high) and 1.76 (high->superhigh), geometric mean ~1.9. +# Deliberately a separate constant from MODE_POWER_RATIO: that one models +# electrical draw (~RPM^3), this one thermal delivery (~airflow), and they +# should be tunable independently even though they happen to sit close together. +LADDER_CAPACITY_RATIO = 1.8 + +# Multiplicative spacing collapses around zero (0 / anything is still 0), which +# would reintroduce the very equality the ratio exists to avoid. Below this much +# separation, fall back to an absolute step. +MIN_LADDER_SEPARATION = 0.05 # degC/h + @dataclass(slots=True) class ModeSimulation: @@ -68,7 +136,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,11 +143,11 @@ 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 self._disturbance_bias = 0.0 + self._error_at_lock_start: float | None = None @property def fan_modes(self) -> list[str] | None: @@ -100,38 +167,69 @@ def fan_modes(self, modes: list[str] | None) -> None: self._warn_if_unordered(modes) def _warn_if_unordered(self, modes: list[str]) -> None: - """Log a warning when learned slopes don't match the assumed mode ordering.""" - prev_slope = None - for mode in modes: - slope = self._learning.get_mode_effective_slope(mode, "heat") - if slope is None: - return # Not all profiles learned yet; skip check - if prev_slope is not None and slope < prev_slope: - _LOGGER.warning( - "Fan modes may not be ordered weakest-to-strongest: " - "learned slopes violate monotonicity at '%s'. " - "Check the fan_modes list in your climate entity", - mode, - ) - return - prev_slope = slope + """Log a warning when learned slopes don't match the assumed mode ordering. + + Checks every hvac mode (not just heating — a cooling-only install would + otherwise never be checked) and skips profiles that aren't learned yet + instead of giving up at the first one, so a partially-learned ladder is + still validated on the profiles that do exist. + """ + for hvac_mode in PROFILE_HVAC_MODES: + prev_mode: str | None = None + prev_slope: float | None = None + for mode in modes: + slope = self._learning.get_mode_effective_slope(mode, hvac_mode) + if slope is None: + continue # not learned yet — skip, keep checking the others + if prev_slope is not None and slope < prev_slope: + _LOGGER.warning( + "Fan modes may not be ordered weakest-to-strongest for %s: " + "'%s' (%.2f C/h) is learned as weaker than '%s' (%.2f C/h). " + "If the order is wrong, set it explicitly in the integration options", + hvac_mode, + mode, + slope, + prev_mode, + prev_slope, + ) + break + prev_mode, prev_slope = mode, slope @property 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 + + def _effective_min_interval(self, dead_time: float) -> float: + """Return the minimum dwell (minutes) before a fan change is allowed. + + The configured ``min_interval`` is a floor. Once learning is ready the + effective dwell is raised toward the learned ``dead_time`` — changing the + fan faster than the dead time means acting before the previous change's + effect can be observed, a guaranteed source of oscillation. The rise is + capped at ``MAX_ADAPTIVE_INTERVAL_FACTOR`` × the configured floor so a + spuriously large learned dead time cannot make the controller sluggish. + Urgent overrides (setpoint drop, window/defrost/idle) are handled before + this gate, so they are never blocked by a long adaptive interval. + """ + if not self._learning.is_ready(): + return float(self._min_interval) + capped = min(dead_time, self._min_interval * MAX_ADAPTIVE_INTERVAL_FACTOR) + return max(float(self._min_interval), capped) @property def disturbance_bias(self) -> float: @@ -184,7 +282,13 @@ def evaluate( 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() - change_allowed = minutes_since_change >= self._min_interval + effective_min_interval = self._effective_min_interval(dead_time) + change_allowed = minutes_since_change >= effective_min_interval + # Snapshot the comfort error at the start of each hold so growth since + # the fan last changed can be measured (see DEAD_TIME_ESCALATION_GROWTH). + if minutes_since_change < self._cycle_minutes or self._error_at_lock_start is None: + self._error_at_lock_start = current_error + error_growth_since_change = current_error - self._error_at_lock_start phase = self._detect_phase(minutes_since_change, dead_time) current_mode_slope, current_known_profile = self._get_mode_slope( active_fan, @@ -307,12 +411,42 @@ def evaluate( worst_spread = max(worst_spread, spread) current_simulation = next(sim for sim in simulations if sim.fan_mode == active_fan) - best = min(simulations, key=lambda item: item.total_cost) + unfiltered_best = min(simulations, key=lambda item: item.total_cost) + + def _stepdown_capable(sim: ModeSimulation) -> bool: + candidate_index = fan_modes.index(sim.fan_mode) + if candidate_index >= current_index: + return True # upward / same rank is always allowed + if current_index - candidate_index <= 1: + return True + raw_slope = self._learning.get_mode_effective_slope(sim.fan_mode, hvac_mode) + return raw_slope is None or raw_slope > MIN_VIABLE_MULTI_RANK_STEPDOWN_SLOPE + + eligible_simulations = [sim for sim in simulations if _stepdown_capable(sim)] + best = min(eligible_simulations, key=lambda item: item.total_cost) selection_note = "" + blocked_note = "" + + if unfiltered_best.fan_mode != best.fan_mode: + blocked_index = fan_modes.index(unfiltered_best.fan_mode) + ranks = current_index - blocked_index + blocked_slope = self._learning.get_mode_effective_slope(unfiltered_best.fan_mode, hvac_mode) + blocked_note = ( + f"Blocked {ranks}-rank drop to {unfiltered_best.fan_mode}: " + f"its own profile ({blocked_slope:.2f}C/h) can't sustain progress" + ) if not change_allowed and best.fan_mode != active_fan: - selection_note = f"Min interval holds {active_fan} until a change is allowed" - best = current_simulation + best_index = fan_modes.index(best.fan_mode) + if best_index > current_index and error_growth_since_change > DEAD_TIME_ESCALATION_GROWTH: + selection_note = ( + f"Emergency escalation to {best.fan_mode}: comfort error worsened by " + f"{error_growth_since_change:.2f}C since the change bypasses the min interval" + ) + change_allowed = True + else: + selection_note = f"Min interval holds {active_fan} until a change is allowed" + best = current_simulation elif change_allowed and best.fan_mode != active_fan: best_index = fan_modes.index(best.fan_mode) required_gain = self._required_switch_gain( @@ -363,6 +497,8 @@ def evaluate( f"MPC recommends {best.fan_mode}: cost={best.total_cost:.2f}, " f"T+10={best.predicted_temp_10m:.2f}C, T+30={best.predicted_temp_30m:.2f}C" ) + if blocked_note: + reason += f" | {blocked_note}" if selection_note: reason += f" | {selection_note}" # Surface capacity saturation: strongest fan selected yet still well short @@ -374,7 +510,7 @@ def evaluate( if not change_allowed: reason += ( f" | Min interval active ({minutes_since_change:.1f}/" - f"{self._min_interval:.1f} min)" + f"{effective_min_interval:.1f} min)" ) _LOGGER.debug( @@ -464,37 +600,106 @@ def _detect_phase(minutes_since_change: float, dead_time: float) -> str: return PHASE_TRANSIENT return PHASE_ESTABLISHED + @staticmethod + def _one_rank_weaker(value: float) -> float: + """Return a slope one ladder step below *value*, strictly smaller than it. + + Scaling is multiplicative so the step tracks the magnitude of the + neighbour, but a positive slope shrinks toward zero while a negative one + (a speed that loses ground) grows more negative — both mean "weaker". + """ + scaled = value / LADDER_CAPACITY_RATIO if value > 0 else value * LADDER_CAPACITY_RATIO + if value - scaled < MIN_LADDER_SEPARATION: + scaled = value - MIN_LADDER_SEPARATION + return scaled + + @staticmethod + def _one_rank_stronger(value: float) -> float: + """Return a slope one ladder step above *value*, strictly greater than it.""" + scaled = value * LADDER_CAPACITY_RATIO if value > 0 else value / LADDER_CAPACITY_RATIO + if scaled - value < MIN_LADDER_SEPARATION: + scaled = value + MIN_LADDER_SEPARATION + return scaled + 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. - - 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. + Fan modes are assumed ordered from weakest to strongest, so learned + slopes must be non-decreasing along that order. Modes without a learned + profile are omitted — the caller falls back to rank-scaled estimation. + + Profiles are placed **best-sampled first**, each one clipped into the + window left by those already placed. So the most trusted estimate is + never overwritten, and a noisy estimate from a rarely-used speed cannot + propagate into the profiles that have the most data behind them — which + a plain forward ``max()`` pass does, since it always resolves upward + (a 10-sample ``med`` reading +1.6 would drag a 1656-sample ``superhigh`` + from +0.9 up to +1.6). + + A profile whose own estimate falls outside that window is discarded and + **synthesised one ladder step from the neighbour that constrained it** + (see LADDER_CAPACITY_RATIO) rather than pinned onto it: two speeds + sharing an identical slope are thermally indistinguishable to the MPC, + so the energy term alone would arbitrate — always picking the weaker + rank, which then starves the stronger one of the samples it needs to + ever be trusted. + + Placement is a single pass in trust order, so the result is monotone by + construction with no iteration to converge. A ladder that is already + consistent is returned untouched, whatever its own spacing: the ratio + only ever synthesises a replacement, it is never imposed as a minimum + gap between measured values. """ - enforced: dict[str, float] = {} - prev = float("-inf") - changed = False - 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 - - if changed: + measured = { + fm: slope + for fm in fan_modes + if (slope := self._learning.get_mode_effective_slope(fm, hvac_mode)) is not None + } + if not measured: + return {} + + rank = {fm: index for index, fm in enumerate(fan_modes)} + counts = {fm: self._learning.get_mode_sample_count(fm, hvac_mode) for fm in measured} + + # Place profiles best-sampled first, so every later profile is constrained + # by evidence at least as strong as its own and the most trusted estimate + # is never overwritten. On an equal sample count the stronger rank is + # placed first, which makes a tied violation resolve by weakening the + # lower speed rather than strengthening the higher one. + placement_order = sorted(measured, key=lambda fm: (counts[fm], rank[fm]), reverse=True) + + placed: dict[str, float] = {} + for fm in placement_order: + below = [v for f, v in placed.items() if rank[f] < rank[fm]] + above = [v for f, v in placed.items() if rank[f] > rank[fm]] + floor = max(below, default=float("-inf")) + ceiling = min(above, default=float("inf")) + + value = measured[fm] + if value > ceiling: + # Own estimate claims more capacity than an already-placed stronger + # speed. Discard it and synthesise one ladder step below that speed + # rather than pinning it onto the ceiling: identical slopes are + # thermally indistinguishable to the MPC, so the energy term alone + # would arbitrate and would always pick the weaker rank. + value = self._one_rank_weaker(ceiling) + elif value < floor: + value = self._one_rank_stronger(floor) + # A synthesised value can overshoot the opposite bound when the two + # placed neighbours sit close together; the window wins, since ordering + # is the invariant the rest of the MPC depends on and separation is + # only a preference. Collapses to a bound only when there is genuinely + # no room left between the neighbours. + placed[fm] = min(max(value, floor), ceiling) + + enforced = {fm: placed[fm] for fm in fan_modes if fm in placed} + if enforced != measured: _LOGGER.debug( - "Monotone enforcement applied for %s: %s", + "Slope ordering enforced for %s: %s -> %s (samples: %s)", hvac_mode, + {fm: round(v, 3) for fm, v in measured.items()}, {fm: round(v, 3) for fm, v in enforced.items()}, + counts, ) return enforced @@ -584,6 +789,14 @@ def _simulate_mode( cost = 0.0 thermal_power = current_effective_slope change_delay = 0.0 if candidate_fan == current_fan else dead_time + # Hold zone: within one deadband of the setpoint we optimise for holding + # equilibrium (match the compressor's steady output) rather than for the + # lowest fan rank. See the HOLD_EQUILIBRIUM constant block for rationale. + hold_active = ( + HOLD_EQUILIBRIUM + and abs(self._temperature_error(current_temp, target_temp, hvac_mode)) <= self._deadband + ) + undershoot_tolerance = HOLD_UNDERSHOOT_TOLERANCE if hold_active else 0.0 for step in range(1, steps + 1): elapsed = step * self._cycle_minutes @@ -607,7 +820,7 @@ def _simulate_mode( error = self._temperature_error(sim_temp, target_temp, hvac_mode) comfort_error = max(abs(error) - self._deadband, 0.0) - overshoot = max(-error, 0.0) + overshoot = max(-error - undershoot_tolerance, 0.0) floor_violation = max(target_temp - sim_temp, 0.0) if hvac_mode == "heat" else max(sim_temp - target_temp, 0.0) # Step-by-step urgency weight calculated dynamically based on current simulated step comfort error @@ -623,7 +836,8 @@ def _simulate_mode( # 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 + rank_scale = HOLD_RANK_SCALE if hold_active else 1.0 + cost += MODE_RANK_COST * relative_power * rank_scale if candidate_fan != current_fan and not change_allowed: cost += MIN_INTERVAL_CHANGE_PENALTY diff --git a/custom_components/smart_fan_controller/sensor.py b/custom_components/smart_fan_controller/sensor.py index 1854b0a..10d73d0 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), } @@ -442,6 +438,19 @@ def extra_state_attributes(self) -> dict: "fan_modes_total": len(profiles), "known_profiles": sum(1 for profile in profiles.values() if profile["ready"]), "min_samples_required_per_profile": MIN_MODE_PROFILE_SAMPLES, + # Strength order actually in use (weakest first). Load-bearing: it drives + # the energy cost, the step-down guard and the unlearned-profile fallback, + # so it is surfaced here to be verifiable without reading the logs. + "fan_mode_order": list(self._controller.fan_modes or []), + # Learned slopes after ordering violations are resolved in favour of the + # better-sampled profile; differs from each profile's raw value when a + # thin estimate had to be clamped. + "effective_slopes_used": { + fan_mode: round(value, 3) + for fan_mode, value in self._controller.build_monotone_slopes( + self._controller.fan_modes or [], self._hvac_mode + ).items() + }, "profile_effective_slope_sensors": { fan_mode: build_scoped_entity_id( "sensor", @@ -598,21 +607,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..d78f6dc 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, @@ -530,16 +529,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 +541,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..ceb7f2e 100644 --- a/custom_components/smart_fan_controller/translations/en.json +++ b/custom_components/smart_fan_controller/translations/en.json @@ -8,19 +8,19 @@ "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)" + "operating_entity": "Heat Pump Operating Entity (optional)", + "outdoor_entity": "Outdoor Temperature Sensor (optional)" }, "data_description": { "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.", + "min_interval": "Range: 1-60 min. Default: 10 min. Acts as both the floor and (x3) the cap on the adaptive interval derived from the learned dead time.", "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." + "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.", + "outdoor_entity": "Optional, telemetry only: an outdoor temperature sensor. Its value is logged to the data-collection CSV so the trace can be correlated with outdoor conditions offline. No control decision reads it." }, "error": { "already_configured": "This climate entity is already configured.", @@ -38,24 +38,27 @@ "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)" + "operating_entity": "Heat Pump Operating Entity (optional)", + "outdoor_entity": "Outdoor Temperature Sensor (optional)", + "fan_mode_order": "Fan Speed Order (weakest first)" }, "data_description": { "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.", + "min_interval": "Range: 1-60 min. Default: 10 min. Acts as both the floor and (x3) the cap on the adaptive interval derived from the learned dead time.", "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." + "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.", + "outdoor_entity": "Optional, telemetry only: an outdoor temperature sensor. Its value is logged to the data-collection CSV so the trace can be correlated with outdoor conditions offline. No control decision reads it.", + "fan_mode_order": "The controller treats a speed's position in this list as its strength: it drives the energy cost, the step-down safety guard, and the estimate used for speeds not yet learned. This defaults to the order your climate entity reports, which is usually right — only change it if a speed is in the wrong position. Select every speed, weakest first." }, "error": { "already_configured": "This climate entity is already configured.", "invalid_climate_entity": "This entity must come from Versatile Thermostat.", - "no_versatile_thermostat": "No Versatile Thermostat entity found." + "no_versatile_thermostat": "No Versatile Thermostat entity found.", + "fan_order_incomplete": "Select every fan speed exactly once, or leave the field empty to keep the order reported by the climate entity." } } } diff --git a/custom_components/smart_fan_controller/translations/fr.json b/custom_components/smart_fan_controller/translations/fr.json index e8e34fe..f9476b1 100644 --- a/custom_components/smart_fan_controller/translations/fr.json +++ b/custom_components/smart_fan_controller/translations/fr.json @@ -8,19 +8,19 @@ "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)" + "operating_entity": "Entité fonctionnement PAC (optionnel)", + "outdoor_entity": "Capteur de température extérieure (optionnel)" }, "data_description": { "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.", + "min_interval": "Plage : 1-60 min. Défaut : 10 min. Sert à la fois de plancher et (x3) de plafond à l'intervalle adaptatif déduit du temps mort appris.", "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." + "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.", + "outdoor_entity": "Optionnel, télémétrie uniquement : un capteur de température extérieure. Sa valeur est enregistrée dans le CSV de collecte pour pouvoir corréler la trace aux conditions extérieures hors-ligne. Aucune décision de contrôle ne le lit." }, "error": { "already_configured": "Cette entité climate est déjà configurée.", @@ -38,24 +38,27 @@ "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)" + "operating_entity": "Entité fonctionnement PAC (optionnel)", + "outdoor_entity": "Capteur de température extérieure (optionnel)", + "fan_mode_order": "Ordre des vitesses (la plus faible en premier)" }, "data_description": { "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.", + "min_interval": "Plage : 1-60 min. Défaut : 10 min. Sert à la fois de plancher et (x3) de plafond à l'intervalle adaptatif déduit du temps mort appris.", "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." + "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.", + "outdoor_entity": "Optionnel, télémétrie uniquement : un capteur de température extérieure. Sa valeur est enregistrée dans le CSV de collecte pour pouvoir corréler la trace aux conditions extérieures hors-ligne. Aucune décision de contrôle ne le lit.", + "fan_mode_order": "Le contrôleur considère la position d'une vitesse dans cette liste comme sa puissance : elle pilote le coût énergétique, la garde de sécurité sur les baisses de régime, et l'estimation utilisée pour les vitesses pas encore apprises. Par défaut, l'ordre remonté par votre entité climate est utilisé, et il est généralement correct — ne le modifiez que si une vitesse est mal placée. Sélectionnez toutes les vitesses, la plus faible en premier." }, "error": { "already_configured": "Cette entité climate est déjà configurée.", "invalid_climate_entity": "Cette entité doit provenir de Versatile Thermostat.", - "no_versatile_thermostat": "Aucune entité Versatile Thermostat trouvée." + "no_versatile_thermostat": "Aucune entité Versatile Thermostat trouvée.", + "fan_order_incomplete": "Sélectionnez chaque vitesse exactement une fois, ou laissez le champ vide pour conserver l'ordre remonté par l'entité climate." } } } diff --git a/docs/audit_apprentissage.md b/docs/audit_apprentissage.md new file mode 100644 index 0000000..a627941 --- /dev/null +++ b/docs/audit_apprentissage.md @@ -0,0 +1,254 @@ +# Audit de l'apprentissage et de l'approche « débit d'air » + +_Audit du 19/07/2026, sur une trace de production en mode cool +(`smart_fan_controller_data_01KNA9CY.csv`, 2009 échantillons ESTABLISHED) +fusionnée avec l'export extérieur `sensor.gardanne_temperature` (`outdoor.csv`). +Décomposition de variance reproductible via +`scripts/audit_variance_decomposition.py` ; fits enveloppe/débit via +`scripts/analyze_effective_slope_tint.py`. Fait suite à +[`effective_slope_analysis.md`](effective_slope_analysis.md)._ + +## Question posée + +L'apprentissage par `effective_slope` semble insatisfaisant : certaines vitesses +(silent/low/med) ne sont quasiment jamais utilisées. L'approche consistant à +renseigner le débit d'air nominal par vitesse (`u_fan = a + b·débit`) est-elle +la bonne réponse ? + +## Résultat central — décomposition de la variance de dT/dt + +Séquence de modèles OLS emboîtés sur la même trace, R² de chacun : + +| Modèle | Contenu | R² | +|---|---|---| +| M0 | constante seule | 0.000 | +| M1 | `k_env·(Text−T)` (conductance enveloppe) | 0.024 | +| M2 | M1 + puissance propre par vitesse (**fit libre actuel**) | 0.029 | +| M3 | `k_env + a + b·débit` (**fit contraint par débit**) | 0.026 | +| **M4** | **M2 + erreur de confort (terme de demande)** | **0.224** | +| M5 | M2 + erreur×vitesse (pente de demande par vitesse) | singulier¹ | + +¹ singulier car `med` (4 éch.) et `high` (8 éch.) ne peuvent pas supporter leur +propre pente de demande — encore la privation de données. + +**Lecture.** Toute la machinerie enveloppe — conductance + puissance ventilateur, +que le fit soit libre (M2) ou contraint par débit (M3) — explique **~3 %** de la +variation de dT/dt. Le terme de **demande** (distance à la consigne, M4) en +explique **~22 %, soit ~8× plus**. Le débit d'air n'ajoute quasiment rien au-dessus +de la conductance seule (M3 vs M1 : +0.002). + +Autrement dit : le modèle enveloppe (et donc son raffinement par débit) est +**mal spécifié** — il a troqué le terme dominant (demande) contre un terme faible +(charge extérieure). On peaufine un modèle à 3 %. + +## Verdict sur l'approche « débit d'air » + +**Intention correcte, mais traite le mauvais problème, et de façon marginale.** + +Ce qu'elle apporte réellement : + +- Un classement **monotone** (low < med < high < superhigh) là où le fit libre + donnait l'aberration `high` > `superhigh` (bruit de sous-échantillonnage). Vrai + gain de robustesse du classement. +- Optionnelle, rétro-compatible, bien gatée. Aucun risque tant que le débit n'est + pas renseigné. + +Ses limites : + +1. **Pouvoir explicatif quasi nul** (M3 vs M1 : +0.002). Le classement monotone + vient surtout de la **structure imposée** (ordonner par débit), pas d'une + découverte des données. +2. **Le modèle enveloppe sous-jacent est mal spécifié** (voir M4) : il omet le + terme dominant. +3. **L'hypothèse `a + b·débit` est linéaire**, alors que le rendement d'un + échangeur sature à haut débit et peut s'effondrer à bas débit (relation + probablement concave). L'extrapolation linéaire est la moins fiable exactement + là où elle compte le plus : la vitesse la plus faible, celle qui alimente la + **gate de faisabilité**. + +### Le risque de sécurité concret + +Pour `low` : + +| source | `u_fan` (°C/h) | +|---|---| +| fit contraint par débit | −0.304 | +| fit libre (19 éch. réels, bruités) | −0.166 | + +À 36 °C extérieur (écart ≈ 12 °C à la consigne 24 °C), avec `k_env ≈ 0.025` : + +- débit : `0.025·12 − 0.304 = −0.004` → jugé **capable de tenir** (à la limite). +- libre : `0.025·12 − 0.166 = +0.134` → jugé **incapable** (réchauffe). + +Les deux modèles **divergent sur la décision de sécurité**. Si la vérité est +proche de −0.166, le modèle débit **surestime low et peut réintroduire la pièce +bloquée au-dessus de la consigne** (l'incident d'origine à 24.4 °C). + +## La vraie cause racine + +La gêne « certaines vitesses ne sont jamais utilisées » n'est probablement **pas +un défaut d'apprentissage**. C'est : + +1. **Un problème d'identifiabilité, pas de forme de modèle.** Une vitesse jamais + excitée sous charge ne peut pas voir son paramètre identifié — c'est un + théorème, pas un manque d'astuce. Régression contrainte, prior débit, retrait + du filtre de stagnation : rien de tout ça ne *fabrique* l'information + manquante, ça la *devine*. +2. **Peut-être un comportement correct.** superhigh = 98 % des cycles. À Gardanne + l'été (32–38 °C dehors), il est plausible que low/med **ne puissent + physiquement pas** tenir la charge — le contrôleur a alors raison de ne pas les + utiliser. Le tableau par bande de demande le montrait : `low` délivre + ~−0.03 °C/h même près de l'équilibre (ne refroidit quasiment pas). + +Le piège : le prior débit rend le contrôleur **plus enclin** à essayer les +vitesses faibles (estimations optimistes). Si elles sont réellement inadéquates, +c'est **pire**, pas mieux. + +## Nuance importante : le gap model n'est pas le coupable + +Le modèle historique `effective_slope(error) = a + b·error` **contient le terme +dominant** (la demande, M4). Ce qui déçoit dans `effective_slope`, ce n'est pas +sa *forme*, c'est le **biais de sélection sur les vitesses faibles** — un problème +de **données**, pas de modèle. C'est pourquoi le débit (qui change la forme) ne le +règle pas. + +## Ce qui serait réellement satisfaisant : l'exploration active + +Le seul moyen de savoir si `low` tient à 34 °C, c'est de **le mesurer** : quand +c'est sûr (pièce déjà à/sous la consigne, extérieur doux), descendre délibérément +d'un cran, observer 2–3 dead-times, enregistrer. Ça génère la donnée qu'aucune +régression ne synthétise — le compromis explore/exploit classique en +identification de système. Concrètement : une sonde d'exploration à faible taux, +gatée par la même sécurité que la gate de faisabilité, alimentant le modèle +enveloppe avec de vrais échantillons de vitesses faibles. + +## Architecture : trois modèles, deux régimes + +Il y a trois modèles parallèles de la même physique : + +- gap model `a + b·error` — porte la **demande** (loin de la consigne, recovery) ; +- enveloppe libre `k_env·gap + u_fan` — porte l'**extérieur** (près de la consigne, + holding / faisabilité) ; +- enveloppe débit `k_env·gap + (a + b·débit)` — variante contrainte du précédent. + +Ce n'est **pas incohérent** : le gap model et l'enveloppe sont en fait spécialisés +à deux régimes différents (la demande domine loin de la consigne, l'extérieur +domine à la consigne où la demande ≈ 0). Mais **ce découpage régime-dépendant +n'est écrit nulle part** — il mériterait d'être documenté explicitement, sinon une +évolution future risque de casser l'un en croyant améliorer l'autre. + +Le reste du pipeline (gates dead-time, disturbance bias, monotone enforcement, +fenêtre glissante avec rétention minimale par profil) est sain et couvert par les +tests. + +## Recommandations initiales (26/07 : dépassées, voir la suite) + +1. **Garder** le fit débit (inoffensif, améliore le classement) mais **ne pas + compter dessus pour la sécurité** : faire consommer à la gate de faisabilité la + valeur **la plus conservatrice** entre fit libre et fit débit pour les vitesses + faibles, plutôt que l'extrapolation optimiste. Correctif à faible risque. +2. **Investir dans l'exploration active** — seul levier qui attaque la vraie cause + (l'identifiabilité). +3. **Documenter** le découpage à deux régimes (gap = demande, enveloppe = + extérieur/faisabilité) directement dans le code. + +--- + +# Suite du 26/07/2026 — `k_env` est instable : le modèle enveloppe est supprimé + +_Nouvelle trace : 10 695 lignes du 11/07 au 26/07 (4810 en cool), avec la colonne +`outdoor_temp` désormais présente dans le CSV — plus besoin d'export externe._ + +## `k_env` dérive d'un facteur 10 et change de signe + +Fit à effets fixes en fenêtre glissante 72 h, reproduisant le gate d'échantillonnage +de l'intégration : + +| Fenêtre | `k_env` | τ | `u_superhigh` | +|---|---|---|---| +| 13 juil | 0.0506 | 20 h | −0.572 | +| 17 juil | 0.0318 | 31 h | −0.579 | +| 18 juil | 0.0047 | **214 h** | −0.535 | +| 20 juil | **−0.0160** | **négatif** | −0.356 | +| 24 juil | **−0.1071** | **négatif** | −0.535 | +| 25 juil | **−0.0808** | **négatif** | −0.805 | + +Le bâtiment n'a pas changé. `u_superhigh` reste, lui, raisonnablement stable +(−0.53 à −0.80) : **c'est `k_env` seul qui part en vrille**, et comme la gate +consomme `k_env·gap + u_fan` avec un gap de ~+10 °C, c'est lui qui injecte toute +l'instabilité dans la décision de sécurité (composite observé : +1.97 à −1.61). + +## Cause : `k_env` mesure le compresseur, pas l'enveloppe + +`k_env` est identifié à partir de la variation de `(T_ext − T)`. Or le compresseur +**module en réponse aux mêmes conditions** : quand il fait chaud, le gap est grand +*et* le compresseur pousse plus fort. La régression ne peut pas séparer les deux et +attribue le comportement du compresseur au terme « enveloppe ». `k_env` ne mesure +donc pas l'isolation du logement mais « comment le compresseur réagit à la +température extérieure » — ni constant, ni ce qu'on veut. **Contamination +inévitable en boucle fermée sans observer la modulation du compresseur.** + +## Le fit contraint par débit dégénère aussi + +Depuis le ~15/07 le contrôleur n'utilise plus que `high` et `superhigh` : le fit +trace une droite à travers **2 débits groupés** (468, 666) puis **extrapole** vers +300 (low) et 216 (silent). Résultat sur les mêmes fenêtres : + +| Fenêtre | coefficient `b` (par m³/h) | `u_low` extrapolé | +|---|---|---| +| 13 juil | −0.000065 | −0.546 | +| 16 juil | −0.021925 | **+7.493** 🚨 | +| 17 juil | **+0.006592** (signe inversé) | −2.992 | +| 24 juil | −0.006369 | **+1.796** | + +Le signe de `b` s'inverse d'une fenêtre à l'autre (`b > 0` = « plus de débit +réchauffe », absurde) et `u_low` atteint +7.5 °C/h. Le garde-fou « ≥ 2 débits +distincts » est trop faible : il autorise exactement ce cas. Ceci **corrige** la +validation initiale de ce rapport, faite sur une fenêtre où 4 débits distincts +étaient présents. + +## Contexte : le système est saturé 40 % du temps + +**1935 lignes sur 4810 (40 %)** portent « Saturated: max fan, still short of +target », pour seulement **52 changements de ventilateur en 4810 cycles**. Et +l'apprentissage des vitesses faibles est structurellement mort : en cool +ESTABLISHED, `low` a 19 échantillons (1 passe le filtre anti-stagnation), `med` 4 +(3 passent), `silent` 0 — le seuil de 10 par profil n'est jamais atteint. + +Le contrôleur n'ignore pas injustement les vitesses faibles : il est collé en haut +**parce que la pièce en a besoin**. L'absence d'apprentissage est un symptôme, pas +la maladie. + +## Décision : suppression complète du modèle enveloppe + +Un modèle non fiable qu'il faut borner pour l'empêcher de nuire ne gagne pas sa +complexité. Supprimés : `k_env`/`u_fan` et tout le fit à effets fixes, le fit +contraint par débit et sa saisie en config, la gate de faisabilité grey-box, +`USE_ENVELOPE_PROJECTION` et son entité switch, les entités τ et Envelope Power, +et les 5 colonnes CSV associées. + +**Conservé** : le capteur extérieur optionnel et sa colonne `outdoor_temp` dans le +CSV, en **télémétrie pure** (aucune décision de contrôle ne la lit) — c'est ce qui +a permis cet audit, et ça préserve la possibilité de réanalyser plus tard. + +La gate de faisabilité revient à la garde d'origine par rang brut, adossée au +filet de sécurité indépendant qu'est l'escalade sur croissance de l'erreur +(`DEAD_TIME_ESCALATION_GROWTH`) — c'est ce dernier qui protège réellement contre +l'incident d'origine (pièce bloquée au-dessus de la consigne). + +## Ce qui reste pertinent + +- **`effective_slope` (modèle gap `a + b·erreur`) est le bon modèle** : il porte le + terme dominant (la demande, 22 % de variance). Sa faiblesse est un problème de + **données** sur les vitesses faibles, pas de forme. +- **`deadband`** reste structurel : il définit la zone morte du coût, la zone de + maintien d'équilibre et les marges de commutation. 54 % du temps est passé à + moins de 0.2 °C de la consigne — c'est le régime dominant. +- **`min_interval`** reste utile, mais son rôle réel est « plancher **et** plafond + (×3) de l'intervalle adaptatif déduit du temps mort appris » (observé : 18→30 min + effectifs, actif sur 13 % des lignes). Les libellés de configuration ont été + précisés en ce sens. +- **Prochain levier de fond** : l'exploration active, et surtout **la modulation du + compresseur** si la PAC l'expose (fréquence ou puissance instantanée). C'est le + signal qui expliquerait la variance que `k_env` tentait d'absorber en vain — bien + plus utile que la température extérieure pour un MPC de vitesse de ventilateur. diff --git a/docs/effective_slope_analysis.md b/docs/effective_slope_analysis.md new file mode 100644 index 0000000..edea9fe --- /dev/null +++ b/docs/effective_slope_analysis.md @@ -0,0 +1,197 @@ +# Estimation des effective slopes par vitesse — analyse et conclusions + +> **⚠️ STATUT : le modèle grey-box décrit ici a été SUPPRIMÉ le 26/07/2026.** +> L'analyse de la cause racine (biais de sélection en boucle fermée) reste +> valable et instructive, mais la solution proposée — modèle enveloppe +> `k_env`/`u_fan`, gate de faisabilité, fit contraint par débit d'air — a été +> retirée du code : `k_env` s'est révélé instable en production (τ variant de +> 20 h à ∞ voire négatif) parce qu'il absorbe la modulation du compresseur au +> lieu de mesurer l'enveloppe du bâtiment. Voir +> [`audit_apprentissage.md`](audit_apprentissage.md) pour les mesures et la +> décision. Document conservé comme trace de l'investigation. + +_Investigation du 09/07/2026, sur la trace de production de 899 h +(`smart_fan_controller_data_01KNA9CY.csv`, mode cool) fusionnée avec un export +de température extérieure (`sensor.gardanne_temperature`). Reproductible via +`scripts/analyze_effective_slope_tint.py`._ + +## Le problème + +Seule l'effective slope de `superhigh` était apprise de façon fiable ; +`silent`/`low`/`med` ressortaient **surestimées** (elles ont dû être forcées à la +main sur le système en production). + +## Cause racine — biais de sélection en boucle fermée, pas un manque de données + +Il y a largement assez de temps passé sur chaque vitesse en cool (silent 100 h, +low 97 h, med 149 h, high 187 h, superhigh 496 h). Ce qui manque, c'est la +**diversité d'excitation** : le contrôleur passe en `superhigh` dès que l'erreur +grandit, donc les vitesses faibles ne sont observées que dans une **plage +d'erreur étroite près de l'équilibre** (silent err[-0.4..1.0], low err[0.0..2.8] +mais quasi tout groupé près de 0, med err[-0.2..1.4], contre superhigh +err[-1.2..**4.6**]). Près de l'équilibre, la vitesse de variation est ~0 et +dominée par la charge externe, pas par le ventilateur. La régression par vitesse +`pente = a + b·erreur` cale alors un gain `b` bruité sur une plage d'erreur +quasi dégénérée, puis l'**extrapole** jusqu'à l'écart de référence (1 °C) — d'où +la surestimation qu'il a fallu corriger à la main. **Aucun estimateur ne peut +retrouver la capacité d'un ventilateur à 3 °C d'écart si ce ventilateur ne tourne +jamais à 3 °C d'écart.** + +## Modèle grey-box (1R1C) avec le capteur extérieur + +Avec `T_ext` disponible, le modèle du premier ordre devient directement +identifiable : + +``` +dT/dt = k_env·(T_ext − T) + u_fan +``` + +une conductance d'enveloppe partagée `k_env` (= 1/τ) plus une puissance propre +par vitesse `u_fan`. Le terme d'enveloppe est une physique indépendante du +ventilateur : il est calé par les données riches de `superhigh` ; chaque vitesse +faible n'a plus qu'à identifier **un seul scalaire**, ce que même 30–56 +échantillons suffisent à faire. Fit (4211 échantillons) : + +| vitesse | `u_fan` (°C/h) | écart extérieur max encore tenu à plat | +|---|---|---| +| silent | +0.22 (réchauffe) | — | +| low | −0.15 | +5.3 °C | +| med | −0.29 | +10.2 °C | +| high | −0.80 | +27.9 °C | +| superhigh | −1.21 | +42.0 °C | + +`R²=0.19`, `RMSE≈0.85 ≈` écart-type du signal (0.95) : le résidu est surtout du +**bruit de quantification** sur la dérivée à 2 min d'un capteur à 0.2 °C — un +plancher de bruit, pas un défaut de modèle (la validation par bins colle bien +pour les vitesses bien échantillonnées). + +## `u_fan` n'est **pas** constant (la mise en garde sur la linéarité) + +`u_fan` est une moyenne conditionnelle — une moyenne sur les conditions où +chaque vitesse a été observée. En regroupant le refroidissement livré (`−dT/dt`) +par vitesse × erreur de confort (proxy de la demande compresseur), on voit qu'il +est fortement dépendant de la demande et **saturant** : + +| vitesse | err≈0 | [+0.2,+0.6) | [+0.6,+1.2) | [+1.2,+2.0) | +|---|---|---|---|---| +| low | +0.01 | **−0.07** | — | — | +| med | +0.11 | **−0.12** | — | — | +| high | +0.10 | +0.78 | +1.12 | +1.38 | +| superhigh | +0.14 | +0.47 | +1.11 | +1.63 (puis 1.33 : **sature**) | + +Deux conclusions robustes, à demande égale (bande d'erreur appariée) : + +1. **`low` et `med` ne refroidissent pas cette pièce sous charge réelle** — à + demande égale ils la **réchauffent** (−0.07, −0.12). Il y a un seuil net entre + `med` et `high`. C'est réel, ce n'est pas qu'un biais de sélection. +2. **`high` ≈ `superhigh`** à demande égale (+1.12 vs +1.11, +1.38 vs +1.63) : + cette pièce n'a en pratique que ~2 vraies vitesses de refroidissement, + `low`/`med` faisant office de simple recirculation. + +Le vrai plafond de capacité des vitesses faibles est **fondamentalement +inobservable** (elles ne tournent jamais à forte demande) — il ne faut donc pas +le fitter, mais on connaît le fait opérationnel important : elles ne tiennent +pas. + +## Valeurs concrètes à utiliser pour la simulation + +La simulation actuelle utilise **une** effective slope par vitesse, évaluée à +`REFERENCE_SLOPE_ERROR = 1 °C` (positive = refroidit vers la cible). La lecture +la plus directe est la table à demande appariée ci-dessus dans la bande +`[+0.6,+1.2)` (qui correspond justement à ~1 °C d'écart), complétée par le +modèle d'enveloppe pour les vitesses faibles jamais vues sous charge : + +| vitesse | effective slope à ~1 °C (°C/h) | commentaire | +|---|---|---| +| silent | **≈ 0** (voire négatif) | aucun refroidissement utile ; recirculation | +| low | **≈ 0.0** | ne tient pas sous charge — surtout **pas** une valeur positive franche | +| med | **≈ 0.1** | marginal (refroidit un peu près de l'équilibre, perd du terrain sous charge) | +| high | **≈ 1.0–1.1** | première vitesse réellement efficace | +| superhigh | **≈ 1.1** | à peine plus forte que `high` | + +C'est cohérent avec ce que le système avait appris pour `superhigh` (~1.05, sa +seule estimation fiable) et ça **valide ton forçage manuel** : les valeurs +auto-apprises pour low/med étaient trop hautes parce qu'elles extrapolaient un +gain `b` bruité depuis des échantillons tous pris près de l'équilibre. + +**Attention — ces valeurs dépendent de la température extérieure.** Le refroidis- +sement net effectif vers la cible = `−(k_env·(T_ext−T) + u_fan)`. À la consigne : + +| vitesse | doux (T_ext−T = +4) | tiède (+8) | chaud (+12) | +|---|---|---|---| +| low | +0.04 | −0.08 | −0.19 | +| med | +0.18 | +0.06 | −0.05 | +| high | +0.69 | +0.57 | +0.46 | +| superhigh | +1.10 | +0.98 | +0.86 | + +C'est précisément pourquoi une constante par vitesse est un pis-aller et pourquoi +le modèle d'enveloppe existe : `low` **tient** quand il fait doux (jusqu'à +5 °C +d'écart extérieur) mais **pas** quand il fait chaud. Si tu forces des constantes, +prends les valeurs de la colonne « tiède » (silent ≈ 0, low ≈ 0, med ≈ 0.1, +high ≈ 0.6, superhigh ≈ 1.0) ; sinon, laisse le modèle d'enveloppe (le champ +capteur extérieur) piloter la faisabilité par condition. + +## La limite du 1R1C : masse des murs, et le cas 2R2C + +La critique est **juste et importante** : le 1R1C regroupe l'air et la masse +des murs (béton) dans une seule capacité. Juste après une hausse de ventilation, +l'air réagit en quelques minutes mais les murs, non — mesurer la pente de l'air à +cet instant **surestime** la capacité soutenable, et quand la PAC module, les +murs re-cèdent (ou re-pompent) des calories et la température rebondit. Un modèle +**2R2C** — un nœud `C_air` (faible inertie, réagit au ventilateur) + un nœud +`C_murs` (grosse inertie, stockage lent) — sépare ces deux échelles de temps et +prédit ce rebond. Pour un MPC air/air, c'est effectivement le modèle de +référence, et le conseil est sain. + +Deux nuances pour **ce** projet, à garder honnêtes : + +1. **On atténue déjà la confusion air/mur à l'apprentissage.** Le code ne collecte + les échantillons de pente qu'en phase `ESTABLISHED` (après `dead_time` × + facteur), en écartant `DEAD_TIME`/`TRANSIENT`. C'est un proxy empirique de + « attendre que le système air+mur se stabilise » — donc on n'apprend pas la + pente sur le transitoire rapide de l'air. La table à demande appariée ci-dessus + confirme d'ailleurs que le refroidissement soutenu (bande établie) est plus + faible que le pic transitoire. + +2. **Identifiabilité et architecture.** Un 2R2C a deux états pour **une seule** + mesure (l'air) : sans capteur de température de paroi/rayonnante, le pôle lent + n'est identifiable que faiblement, à partir d'événements de réponse libre de + plusieurs heures (PAC coupée) — rares et pollués par le solaire. De plus notre + contrôleur n'est pas un planificateur en boucle ouverte sur plusieurs heures : + c'est une boucle de rétroaction à horizon glissant qui **re-mesure la + température de l'air toutes les 2 min** et re-décide. L'effet des murs y est + donc largement **observé** (il apparaît dans la pente mesurée et dans le + `disturbance_bias`) plutôt que devant être prédit à l'avance. Le gain du 2R2C + est maximal pour la prédiction long-horizon / l'anticipation du rebond, moins + pour une rétroaction courte. + +**Verdict.** Le 2R2C est le bon modèle sur le fond et une direction future +légitime — mais son plein bénéfice suppose soit **un capteur de paroi/rayonnant** +(ex. capteur IR sur un mur, ou une température radiante moyenne), soit un +engagement à identifier le pôle lent sur des réponses libres. Pour le contrôleur +à horizon court actuel, la garde de phase + l'hystérésis + le couplage au +dead-time atténuent déjà l'oscillation de rebond que la critique pointe. À +réévaluer si, une fois le modèle d'enveloppe déployé, une oscillation liée à +l'inertie des murs persiste sur l'appareil : le prochain pas serait alors 2R2C +avec un capteur radiant. + +## Ce qu'on en fait + +Le contrôleur n'ingérait aucune température extérieure. Deux changements, tous +deux pilotés par `T_ext` et rétro-compatibles (dormants sans capteur extérieur) : + +1. **Nettoyage d'enveloppe (apprentissage).** Le fit à effets fixes sépare la + puissance propre `u_fan` de la charge ambiante, donnant une estimation par + vitesse enfin non contaminée — attaque directement la surestimation de + `silent`/`low`/`med`. +2. **Garde de faisabilité (contrôle).** À partir de `k_env` et `u_fan`, on prédit + l'équilibre de chaque vitesse ; une vitesse incapable de tenir la consigne à la + température extérieure actuelle est écartée des candidats à la baisse (escalade + seulement). Ça remplace la garde brute par rang par un test capacité-vs-charge + physiquement fondé : on autorise `low`/`med` quand il fait doux (ils tiennent) + et on les bloque quand il fait chaud. + +Validé hors-ligne sur cette trace avant tout redéploiement. Le capteur Gardanne +est une station météo de ville (pas la façade du logement) : il porte la +tendance mais rate le solaire direct par les fenêtres ; le résiduel reste dans +l'EMA `disturbance_bias`. diff --git a/scripts/audit_variance_decomposition.py b/scripts/audit_variance_decomposition.py new file mode 100644 index 0000000..410bb62 --- /dev/null +++ b/scripts/audit_variance_decomposition.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Variance decomposition of dT/dt: what actually drives the cooling rate? + +Fits a nested sequence of OLS models on the controller trace (merged with an +outdoor-temperature export) and reports each model's R2, to quantify how much +of the variation in dT/dt is explained by the envelope model (conductance + +per-fan power, whether free or airflow-constrained) versus by the comfort-error +(demand / distance-to-setpoint) term that the envelope model omits. + + M0: intercept only + M1: + k_env*(Text-T) (envelope conductance) + M2: M1 + per-fan intercept (current free per-fan envelope fit) + M3: k_env + a + b*airflow (airflow-constrained fit) + M4: M2 + comfort_error (demand term added) + M5: M2 + comfort_error*fan (demand slope per fan) + +This is an *offline audit* helper, not part of the running integration. + +Usage: + python scripts/audit_variance_decomposition.py CONTROLLER.csv OUTDOOR.csv \\ + [--hvac cool] [--outdoor-entity sensor.gardanne_temperature] + +Rated airflow (m3/h) per fan speed is hard-coded in AIRFLOW below; edit it to +match the fan being analysed. +""" +from __future__ import annotations + +import argparse +import bisect +import csv +import datetime as dt +from collections import Counter + +# Rated airflow (m3/h) from the fan's spec sheet, for the M3 airflow-constrained fit. +AIRFLOW = {"silent": 216, "low": 300, "med": 378, "high": 468, "superhigh": 666} + + +def parse_ts(s: str) -> dt.datetime: + """Parse an ISO-8601 timestamp with optional trailing Z.""" + return dt.datetime.fromisoformat(s.replace("Z", "+00:00")) + + +def load_outdoor(path: str, entity: str | None) -> list[tuple[dt.datetime, float]]: + """Load and sort (timestamp, value) pairs from an HA history CSV.""" + series: list[tuple[dt.datetime, float]] = [] + for r in csv.reader(open(path, encoding="utf-8")): + if not r or r[0] == "entity_id": + continue + if entity and r[0] != entity: + continue + try: + series.append((parse_ts(r[2]), float(r[1]))) + except (ValueError, IndexError): + continue + series.sort() + return series + + +def interpolator(series: list[tuple[dt.datetime, float]]): + """Return a linear-interpolation function over a sorted time series.""" + times = [t for t, _ in series] + + def at(t: dt.datetime) -> float: + if t <= series[0][0]: + return series[0][1] + if t >= series[-1][0]: + return series[-1][1] + i = bisect.bisect_right(times, t) + t0, v0 = series[i - 1] + t1, v1 = series[i] + return v0 + (v1 - v0) * ((t - t0) / (t1 - t0)) + + return at + + +def fnum(x: str) -> float | None: + """Parse a float or return None.""" + try: + return float(x) + except (TypeError, ValueError): + return None + + +def solve(rows: list[tuple[list[float], float]], p: int) -> list[float] | None: + """Solve OLS beta for the design rows via the normal equations, or None if singular.""" + a = [[0.0] * p for _ in range(p)] + b = [0.0] * p + for x, y in rows: + for i in range(p): + b[i] += x[i] * y + for j in range(p): + a[i][j] += x[i] * x[j] + m = [a[i][:] + [b[i]] for i in range(p)] + for c in range(p): + piv = max(range(c, p), key=lambda r: abs(m[r][c])) + m[c], m[piv] = m[piv], m[c] + if abs(m[c][c]) < 1e-12: + return None + pv = m[c][c] + m[c] = [v / pv for v in m[c]] + for r in range(p): + if r != c and abs(m[r][c]) > 1e-12: + f = m[r][c] + m[r] = [m[r][k] - f * m[c][k] for k in range(p + 1)] + return [m[i][p] for i in range(p)] + + +def r2(rows: list[tuple[list[float], float]], beta: list[float]) -> float: + """Coefficient of determination of the fitted model over the rows.""" + ys = [y for _, y in rows] + ybar = sum(ys) / len(ys) + ss_tot = sum((y - ybar) ** 2 for y in ys) + ss_res = sum((y - sum(bi * xi for bi, xi in zip(beta, x))) ** 2 for x, y in rows) + return 1 - ss_res / ss_tot if ss_tot else float("nan") + + +def fit_report(name: str, rows: list[tuple[list[float], float]], p: int) -> None: + """Fit one model and print its R2 (or 'singular').""" + beta = solve(rows, p) + print(f"{name:48s} singular" if beta is None else f"{name:48s} R2 = {r2(rows, beta):.4f}") + + +def main() -> None: + """Entry point.""" + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("controller_csv") + ap.add_argument("outdoor_csv") + ap.add_argument("--hvac", default="cool") + ap.add_argument("--outdoor-entity", default=None, help="Filter outdoor CSV to this entity_id") + args = ap.parse_args() + + ext = load_outdoor(args.outdoor_csv, args.outdoor_entity) + if not ext: + raise SystemExit("No outdoor samples loaded (check --outdoor-entity).") + iext = interpolator(ext) + + rows = [r for r in csv.DictReader(open(args.controller_csv, newline="", encoding="utf-8")) if r["hvac_mode"] == args.hvac] + # (fan, Text-T, dT/dt, comfort_error) + S: list[tuple[str, float, float, float]] = [] + for r in rows: + if r["phase"] != "ESTABLISHED": + continue + if r.get("is_window_open") in ("1", "True", "true"): + continue + if r.get("hvac_idle") in ("1", "True", "true"): + continue + T = fnum(r["current_temp"]) + vs = fnum(r["vtherm_slope"]) + er = fnum(r["current_error"]) + if None in (T, vs, er): + continue + S.append((r["current_fan"], iext(parse_ts(r["timestamp"])) - T, vs, er)) + + fans: list[str] = [] + for f, *_ in S: + if f not in fans: + fans.append(f) + idx = {f: i for i, f in enumerate(fans)} + print(f"n = {len(S)} per fan: {dict(Counter(s[0] for s in S))}\n") + + fit_report("M0: intercept only", [([1.0], vs) for _, _, vs, _ in S], 1) + fit_report("M1: + k_env*(Text-T)", [([1.0, g], vs) for _, g, vs, _ in S], 2) + + def m2row(s): + f, g, vs, _ = s + x = [0.0] * (1 + len(fans)) + x[0] = g + x[1 + idx[f]] = 1.0 + return (x, vs) + + fit_report("M2: k_env + per-fan intercept (free fit)", [m2row(s) for s in S], 1 + len(fans)) + fit_report("M3: k_env + a + b*airflow", [([g, 1.0, AIRFLOW[f]], vs) for f, g, vs, _ in S if f in AIRFLOW], 3) + + def m4row(s): + f, g, vs, er = s + x = [0.0] * (2 + len(fans)) + x[0] = g + x[1] = max(er, 0.0) + x[2 + idx[f]] = 1.0 + return (x, vs) + + fit_report("M4: M2 + comfort_error (shared demand term)", [m4row(s) for s in S], 2 + len(fans)) + + def m5row(s): + f, g, vs, er = s + x = [0.0] * (1 + 2 * len(fans)) + x[0] = g + x[1 + idx[f]] = 1.0 + x[1 + len(fans) + idx[f]] = max(er, 0.0) + return (x, vs) + + fit_report("M5: M2 + comfort_error*fan (per-fan demand)", [m5row(s) for s in S], 1 + 2 * len(fans)) + + +if __name__ == "__main__": + main() diff --git a/scripts/replay_bench.py b/scripts/replay_bench.py index 3145c0d..beb1b67 100644 --- a/scripts/replay_bench.py +++ b/scripts/replay_bench.py @@ -22,6 +22,7 @@ from __future__ import annotations import argparse +import bisect import csv from datetime import datetime from importlib import import_module @@ -61,6 +62,9 @@ "MODE_RANK_COST", "MIN_INTERVAL_CHANGE_PENALTY", "URGENCY_SENSITIVITY", + "HOLD_EQUILIBRIUM", + "HOLD_UNDERSHOOT_TOLERANCE", + "HOLD_RANK_SCALE", ] _DEFAULTS = {k: getattr(_mpc_mod, k) for k in TUNABLE} _PROFILE_ENTITY_RE = re.compile(r"_(heat|cool)_([^_]+)_effective_slope$") @@ -156,6 +160,10 @@ def load_csv(path: str) -> list[Row]: with open(path, newline="", encoding="utf-8") as fh: for r in csv.DictReader(fh): try: + # Guard against occasional corrupted export rows (e.g. embedded + # null bytes in the timestamp) so a single bad line can't abort + # an outdoor-temperature lookup mid-replay. + parse_timestamp(r["timestamp"]) rows.append( Row( timestamp=r["timestamp"], diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 49e4628..28c71c9 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -2,7 +2,12 @@ from unittest.mock import MagicMock -from custom_components.smart_fan_controller.config_flow import _is_climate_entity_already_configured +from custom_components.smart_fan_controller import _apply_configured_fan_order +from custom_components.smart_fan_controller.config_flow import ( + _extract_fan_modes, + _is_climate_entity_already_configured, + _validate_fan_order, +) from custom_components.smart_fan_controller.const import CONF_CLIMATE_ENTITY @@ -46,3 +51,59 @@ def test_duplicate_climate_guard_ignores_current_entry() -> None: ) is False ) + + +def test_extract_fan_modes_filters_auto_and_off() -> None: + """Only manual speeds can be ordered; auto/off are not strength levels.""" + state = MagicMock() + state.attributes = {"fan_modes": ["Auto", "silent", "low", "med", "high", "superhigh", "off"]} + + assert _extract_fan_modes(state) == ["silent", "low", "med", "high", "superhigh"] + + +def test_extract_fan_modes_handles_missing_state() -> None: + """A climate entity with no state yet yields no options, not a crash.""" + assert _extract_fan_modes(None) == [] + + state = MagicMock() + state.attributes = {} + assert _extract_fan_modes(state) == [] + + +def test_validate_fan_order_accepts_empty_and_complete() -> None: + """Empty means 'keep the entity's order'; a complete permutation is valid.""" + detected = ["silent", "low", "med"] + assert _validate_fan_order(None, detected) is None + assert _validate_fan_order([], detected) is None + assert _validate_fan_order(["med", "silent", "low"], detected) is None + + +def test_validate_fan_order_rejects_partial_selection() -> None: + """A half-specified ladder is rejected rather than silently completed.""" + detected = ["silent", "low", "med"] + assert _validate_fan_order(["silent", "low"], detected) == "fan_order_incomplete" + assert _validate_fan_order(["silent", "low", "med", "ghost"], detected) == "fan_order_incomplete" + + +def test_apply_configured_fan_order_reorders() -> None: + """The configured order wins over the order reported by the climate entity.""" + detected = ["high", "low", "superhigh", "med"] # entity reports them jumbled + configured = ["low", "med", "high", "superhigh"] + + assert _apply_configured_fan_order(detected, configured) == configured + + +def test_apply_configured_fan_order_without_config_is_passthrough() -> None: + """No configured order means the detected order is used unchanged.""" + detected = ["low", "med", "high"] + + assert _apply_configured_fan_order(detected, None) == detected + assert _apply_configured_fan_order(detected, []) == detected + + +def test_apply_configured_fan_order_tolerates_drift() -> None: + """A speed added by the entity later is appended; a removed one is dropped.""" + detected = ["low", "med", "high", "turbo"] # 'turbo' appeared after configuration + configured = ["low", "med", "high", "retired"] # 'retired' no longer exists + + assert _apply_configured_fan_order(detected, configured) == ["low", "med", "high", "turbo"] diff --git a/tests/test_data_collection.py b/tests/test_data_collection.py index 53cb9ec..e5edcf9 100644 --- a/tests/test_data_collection.py +++ b/tests/test_data_collection.py @@ -106,6 +106,7 @@ async def test_async_record_appends_row(tmp_path: Path) -> None: "", "0", "0", + "", ] assert hass.async_add_executor_job.await_count == 2 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..535089d 100644 --- a/tests/test_mpc_controller.py +++ b/tests/test_mpc_controller.py @@ -1,11 +1,13 @@ """Tests for the MPC diagnostics and guardrails.""" import csv +import random from pathlib import Path from unittest.mock import AsyncMock, MagicMock import pytest +from custom_components.smart_fan_controller import mpc_controller as mpc_module from custom_components.smart_fan_controller.data_collection import DataCollector from custom_components.smart_fan_controller.mpc_controller import MPCController from custom_components.smart_fan_controller.sensor import ( @@ -30,7 +32,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 +524,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.""" + """An inverted weak profile is clamped down, leaving the stronger ones untouched.""" learning = ThermalLearning() - # Create inverted profiles: silent > med (the real-world bug) + # Create inverted profiles: silent reads stronger than 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,12 +544,17 @@ 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"] - assert monotone["med"] >= monotone["low"] - assert monotone["high"] >= monotone["med"] - assert monotone["superhigh"] >= monotone["high"] + # Equal sample counts, so the violation resolves downward: silent's estimate is + # discarded and re-synthesised strictly below low rather than dragging low up. + assert monotone["silent"] < monotone["low"] + # low sits at exactly 0.0, where multiplicative spacing would collapse, so the + # absolute fallback separation applies. + assert monotone["silent"] == pytest.approx(-0.05, abs=0.001) + # Profiles that were already ordered keep their learned values exactly. + assert monotone["low"] == pytest.approx(0.0, abs=0.001) + assert monotone["med"] == pytest.approx(0.45, abs=0.001) + assert monotone["high"] == pytest.approx(0.96, abs=0.001) + assert monotone["superhigh"] == pytest.approx(1.35, abs=0.001) def test_monotone_constraint_returns_partial_dict_for_partial_profiles() -> None: @@ -591,8 +597,124 @@ 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) + # Equal sample counts: the weaker mode is the one rewritten, so superhigh keeps + # its own learned value instead of being inflated to high's, and high lands one + # ladder step below it rather than tied with it. + assert result["superhigh"] == pytest.approx(1.075, abs=0.001) + assert result["high"] == pytest.approx(1.075 / mpc_module.LADDER_CAPACITY_RATIO, abs=0.001) + assert result["high"] < result["superhigh"] + + +def test_monotone_constraint_trusts_the_better_sampled_profile() -> None: + """A noisy estimate from a rarely-used speed must not corrupt well-sampled ones. + + Mirrors the real deployment: superhigh runs constantly (thousands of + samples), high often, med almost never. A plain forward max() pass would + propagate med's noisy high reading into both stronger profiles. + """ + learning = ThermalLearning() + for _ in range(1656): + learning.add_slope_sample("superhigh", -0.9, 1.0, "cool") + for _ in range(145): + learning.add_slope_sample("high", -0.5, 1.0, "cool") + for _ in range(10): + learning.add_slope_sample("med", -1.6, 1.0, "cool") # noisy: looks stronger than superhigh + + mpc = MPCController( + learning=learning, + deadband=0.3, + min_interval=10, + fan_modes=["silent", "low", "med", "high", "superhigh"], + ) + result = mpc.build_monotone_slopes(["silent", "low", "med", "high", "superhigh"], "cool") + + # The two well-sampled profiles keep their learned values... + assert result["high"] == pytest.approx(0.5, abs=0.001) + assert result["superhigh"] == pytest.approx(0.9, abs=0.001) + # ...and the 10-sample outlier is rewritten one ladder step below high, not + # pinned onto it: an exact tie would leave the MPC unable to tell med from + # high thermally, so the energy term would always pick med. + assert result["med"] == pytest.approx(0.5 / mpc_module.LADDER_CAPACITY_RATIO, abs=0.001) + assert result["med"] < result["high"] + + +def test_monotone_constraint_raises_a_poorly_sampled_stronger_mode() -> None: + """The trust rule is symmetric: a thin *stronger* profile is raised, not trusted.""" + learning = ThermalLearning() + for _ in range(800): + learning.add_slope_sample("high", -0.9, 1.0, "cool") + for _ in range(10): + learning.add_slope_sample("superhigh", -0.3, 1.0, "cool") # thin, reads weaker than high + + mpc = MPCController( + learning=learning, + deadband=0.3, + min_interval=10, + fan_modes=["silent", "low", "med", "high", "superhigh"], + ) + result = mpc.build_monotone_slopes(["silent", "low", "med", "high", "superhigh"], "cool") + + assert result["high"] == pytest.approx(0.9, abs=0.001) # 800 samples: untouched + # Lifted a ladder step *above* high, not tied with it: tying them would make the + # energy term always prefer high, so superhigh would never run again and its + # profile could never recover from being thin. + assert result["superhigh"] == pytest.approx(0.9 * mpc_module.LADDER_CAPACITY_RATIO, abs=0.001) + assert result["superhigh"] > result["high"] + + +def test_monotone_constraint_keeps_a_consistent_ladder_untouched() -> None: + """A ladder that is already ordered is returned verbatim, whatever its spacing. + + The ladder ratio only ever synthesises a replacement for a rejected estimate; + it must never be imposed as a minimum gap between measured values, or a real + ladder spaced more tightly than the ratio would be silently rewritten. + """ + learning = ThermalLearning() + # The production ladder: adjacent ratios are 2.08 and 1.76, i.e. both sides of + # LADDER_CAPACITY_RATIO, and the bottom two speeds are negative. + real = {"silent": -0.56, "low": -0.1, "med": 0.24, "high": 0.5, "superhigh": 0.879} + for fan_mode, slope in real.items(): + learning.set_mode_effective_slope(fan_mode, "cool", slope) + + mpc = MPCController( + learning=learning, + deadband=0.3, + min_interval=10, + fan_modes=["silent", "low", "med", "high", "superhigh"], + ) + result = mpc.build_monotone_slopes(["silent", "low", "med", "high", "superhigh"], "cool") + + for fan_mode, slope in real.items(): + assert result[fan_mode] == pytest.approx(slope, abs=0.001) + + +def test_monotone_constraint_always_returns_an_ordered_ladder() -> None: + """Fuzz the invariant the rest of the MPC relies on: the result is never inverted. + + Covers ladders with missing profiles, wildly inconsistent estimates, sign + changes and lopsided sample counts — including the case that broke an earlier + pairwise implementation, where a thin profile sat between two better-sampled + ones that were themselves inverted. + """ + fan_modes = ["silent", "low", "med", "high", "superhigh"] + rng = random.Random(7) + + for _ in range(300): + learning = ThermalLearning() + for fan_mode in fan_modes: + if rng.random() < 0.25: + continue # profile never learned + slope = rng.uniform(-1.5, 2.0) + # Only the *relative* sample counts steer placement order, so modest + # spreads exercise the same branches without a slow suite. + for _ in range(rng.choice([10, 10, 14, 40])): + learning.add_slope_sample(fan_mode, -slope, 1.0, "cool") + + mpc = MPCController(learning=learning, deadband=0.3, min_interval=10, fan_modes=fan_modes) + result = mpc.build_monotone_slopes(fan_modes, "cool") + + values = [result[fan_mode] for fan_mode in fan_modes if fan_mode in result] + assert values == sorted(values), f"inverted ladder produced: {result}" def test_monotone_constraint_noop_when_already_ordered() -> None: @@ -732,3 +854,271 @@ def test_learning_response_sensor_with_mixed_tuple_lengths() -> None: assert attrs["response_samples"] == 0 # because is_ready() is False, returns fallback 0 assert attrs["avg_response_time_min"] == pytest.approx(13.5) + +# --- Hold-equilibrium (economic) mode ------------------------------------ +_HOLD_FAN_MODES = ["low", "medium", "high"] + + +def _build_hold_cool_mpc() -> MPCController: + """Build an MPC with cool profiles where low barely conditions and med/high hold.""" + learning = ThermalLearning() + learning.set_mode_effective_slope("low", "cool", 0.05) + learning.set_mode_effective_slope("medium", "cool", 0.9) + learning.set_mode_effective_slope("high", "cool", 1.8) + learning.add_response_event(10.0, "cool") + return MPCController( + learning=learning, + deadband=0.3, + min_interval=10, + fan_modes=_HOLD_FAN_MODES, + ) + + +def _evaluate_hold(current_temp: float) -> dict: + """Run a cool evaluation at ``current_temp`` against a 24.0 setpoint.""" + return _build_hold_cool_mpc().evaluate( + current_temp=current_temp, + target_temp=24.0, + vtherm_slope=0.0, + hvac_mode="cool", + current_fan="low", + minutes_since_change=60.0, + ) + + +def test_hold_equilibrium_enabled_by_default() -> None: + """The economic hold mode ships enabled after validation on the production trace.""" + assert mpc_module.HOLD_EQUILIBRIUM is True + + +def test_hold_equilibrium_dormant_beyond_deadband(monkeypatch: pytest.MonkeyPatch) -> None: + """Far from the setpoint (error > deadband) the flag must not change anything.""" + # 25.0 vs 24.0 target => 1.0 C error, well outside the 0.3 deadband. + monkeypatch.setattr(mpc_module, "HOLD_EQUILIBRIUM", False) + off = _evaluate_hold(25.0) + monkeypatch.setattr(mpc_module, "HOLD_EQUILIBRIUM", True) + on = _evaluate_hold(25.0) + + assert on["mpc_fan_mode"] == off["mpc_fan_mode"] + assert on["mpc_cost"] == pytest.approx(off["mpc_cost"]) + + +def test_hold_equilibrium_holds_steady_instead_of_coasting_near_setpoint( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Inside the hold zone the controller never coasts to a weaker mode and never + costs more; here it holds harder (medium -> high) rather than let the room drift.""" + monkeypatch.setattr(mpc_module, "HOLD_EQUILIBRIUM", False) + off = _evaluate_hold(24.2) + monkeypatch.setattr(mpc_module, "HOLD_EQUILIBRIUM", True) + on = _evaluate_hold(24.2) + + off_rank = _HOLD_FAN_MODES.index(off["mpc_fan_mode"]) + on_rank = _HOLD_FAN_MODES.index(on["mpc_fan_mode"]) + + # Never weaker than baseline, and the relaxed penalties never raise the cost. + assert on_rank >= off_rank + assert on["mpc_cost"] <= off["mpc_cost"] + 1e-9 + # On this trace the tolerance lets it commit to a firmer steady hold. + assert (off["mpc_fan_mode"], on["mpc_fan_mode"]) == ("medium", "high") + + +# --- Adaptive min interval (coupled to learned dead time) ----------------- +def _ready_learning_with_dead_time(dead_time: float) -> ThermalLearning: + """Build a ready ThermalLearning whose learned dead time is ``dead_time``.""" + learning = ThermalLearning() + for _ in range(90): # 270 samples > MIN_SAMPLES_LEARNING (240) => is_ready() + learning.add_slope_sample("low", 0.3, 0.8, "heat") + learning.add_slope_sample("medium", 0.9, 0.8, "heat") + learning.add_slope_sample("high", 1.5, 0.8, "heat") + for _ in range(3): + learning.add_response_event(dead_time, "heat") + assert learning.is_ready() + return learning + + +def test_effective_min_interval_rises_to_learned_dead_time() -> None: + """When the dead time exceeds the configured floor, the effective dwell follows it.""" + learning = _ready_learning_with_dead_time(20.0) + mpc = _build_mpc(learning, min_interval=10) + assert mpc._effective_min_interval(learning.get_dead_time()) == pytest.approx(20.0) + + +def test_effective_min_interval_is_floored_by_config() -> None: + """A short learned dead time never lowers the effective dwell below the config floor.""" + learning = _ready_learning_with_dead_time(6.0) + mpc = _build_mpc(learning, min_interval=10) + assert mpc._effective_min_interval(learning.get_dead_time()) == pytest.approx(10.0) + + +def test_effective_min_interval_is_capped() -> None: + """A spuriously large dead time is capped at MAX_ADAPTIVE_INTERVAL_FACTOR x floor.""" + learning = _ready_learning_with_dead_time(40.0) + mpc = _build_mpc(learning, min_interval=10) + cap = 10 * mpc_module.MAX_ADAPTIVE_INTERVAL_FACTOR + assert mpc._effective_min_interval(learning.get_dead_time()) == pytest.approx(cap) + + +def test_effective_min_interval_uses_config_before_learning_ready() -> None: + """Before learning is ready the fixed configured interval is used unchanged.""" + learning = ThermalLearning() + learning.add_response_event(25.0, "heat") + mpc = _build_mpc(learning, min_interval=10) + assert not learning.is_ready() + assert mpc._effective_min_interval(learning.get_dead_time()) == pytest.approx(10.0) + + +def test_adaptive_interval_holds_change_until_dead_time_elapses() -> None: + """A beneficial change is held until the dead-time-based interval elapses.""" + learning = _ready_learning_with_dead_time(20.0) + mpc = _build_mpc(learning, min_interval=10) + # First call establishes the comfort-error baseline for this hold (small + # growth budget below). + mpc.evaluate( + current_temp=19.7, + target_temp=20.0, + vtherm_slope=0.25, + hvac_mode="heat", + current_fan="low", + minutes_since_change=1.0, + ) + # 15 min since last change: allowed under the old fixed 10-min rule, but the + # learned 20-min dead time means the previous change is not observable yet. + # Error only grew 0.05C since the baseline, well under the 0.15C escalation + # budget, so the hold is not bypassed. + held = mpc.evaluate( + current_temp=19.65, + target_temp=20.0, + vtherm_slope=0.25, + hvac_mode="heat", + current_fan="low", + minutes_since_change=15.0, + ) + assert held["mpc_would_change_now"] == "no" + assert "Min interval active" in held["mpc_reason"] + + allowed = mpc.evaluate( + current_temp=19.65, + target_temp=20.0, + vtherm_slope=0.25, + hvac_mode="heat", + current_fan="low", + minutes_since_change=25.0, + ) + assert allowed["mpc_would_change_now"] == "yes" + + +def test_dead_time_lock_escalates_on_growing_error() -> None: + """Comfort error worsening since the change bypasses the dead-time lock. + + Regression test: a misjudged step-down (e.g. hold-equilibrium picking a + fan mode that turns out too weak) used to leave the room drifting away + from target for the full ~20 min adaptive interval with no way out. The + escalation is trend-based (growth since the change) rather than a static + error threshold, so it fires however small the deadband is. + """ + learning = _ready_learning_with_dead_time(20.0) + mpc = _build_mpc(learning, min_interval=10) + # First call establishes the baseline right after the change (small error). + mpc.evaluate( + current_temp=19.8, + target_temp=20.0, + vtherm_slope=0.25, + hvac_mode="heat", + current_fan="low", + minutes_since_change=1.0, + ) + # 8 min later the room has drifted 0.2C further from target (> the 0.15C + # growth budget) while still inside the 20-min learned dead time. + escalated = mpc.evaluate( + current_temp=19.6, + target_temp=20.0, + vtherm_slope=0.25, + hvac_mode="heat", + current_fan="low", + minutes_since_change=8.0, + ) + assert escalated["mpc_would_change_now"] == "yes" + assert escalated["mpc_fan_mode"] != "low" + assert "Emergency escalation" in escalated["mpc_reason"] + + +def test_dead_time_lock_does_not_escalate_downward() -> None: + """The emergency escalation never lets the lock be bypassed to step down.""" + learning = _ready_learning_with_dead_time(20.0) + mpc = _build_mpc(learning, min_interval=10) + # Overshoot past the setpoint in heat mode, still growing further past + # target - a large and worsening error, but the unconstrained candidate is + # weaker than the current fan, so this must stay held rather than treated + # as an emergency. + mpc.evaluate( + current_temp=20.9, + target_temp=20.0, + vtherm_slope=1.5, + hvac_mode="heat", + current_fan="high", + minutes_since_change=1.0, + ) + held = mpc.evaluate( + current_temp=21.0, + target_temp=20.0, + vtherm_slope=1.5, + hvac_mode="heat", + current_fan="high", + minutes_since_change=8.0, + ) + assert held["mpc_would_change_now"] == "no" + assert held["mpc_fan_mode"] == "high" + assert "Emergency escalation" not in held["mpc_reason"] + assert "Min interval active" in held["mpc_reason"] + + +def test_multi_rank_stepdown_blocked_when_candidate_cannot_sustain_progress() -> None: + """A 2+ rank drop to a mode with a non-positive own profile is blocked. + + Regression test for a real incident: with fan_modes ordered weakest to + strongest and learned profiles silent=-0.56, low=-0.1, med=0.24, high=0.5, + superhigh=1.05 C/h, the controller jumped straight from superhigh to low + near the deadband. Low's own profile shows it cannot cool this room at + all (negative slope even 1C from target) - the pick only looked good + because the forecast used for the switch-down check is dominated by + superhigh's momentum for the whole dead-time window, masking low's real + (in)capability. Once switched, the room drifted away from target. + """ + fan_modes = ["silent", "low", "med", "high", "superhigh"] + slopes = {"silent": -0.56, "low": -0.1, "med": 0.24, "high": 0.5, "superhigh": 1.05} + learning = ThermalLearning() + for mode, slope in slopes.items(): + learning.set_mode_effective_slope(mode, "cool", slope) + learning.add_response_event(20.0, "cool") + + mpc = MPCController(learning=learning, deadband=0.2, min_interval=10, fan_modes=fan_modes) + decision = mpc.evaluate( + current_temp=24.2, + target_temp=24.0, + vtherm_slope=-1.02, + hvac_mode="cool", + current_fan="superhigh", + minutes_since_change=300.0, + ) + assert decision["mpc_fan_mode"] not in ("low", "silent") + assert "Blocked 3-rank drop to low" in decision["mpc_reason"] + + # Adjacent-rank switching stays untouched: high (1 rank down) is a + # legitimate cost-minimising pick when its own profile is sound. + slopes_adjacent_only = dict(slopes) + learning2 = ThermalLearning() + for mode, slope in slopes_adjacent_only.items(): + learning2.set_mode_effective_slope(mode, "cool", slope) + learning2.add_response_event(20.0, "cool") + mpc2 = _build_mpc(learning2, fan_modes=fan_modes, min_interval=10) + decision2 = mpc2.evaluate( + current_temp=24.6, + target_temp=24.0, + vtherm_slope=-0.4, + hvac_mode="cool", + current_fan="superhigh", + minutes_since_change=300.0, + ) + assert "Blocked" not in decision2["mpc_reason"] + 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."""