From d561dcaa0c7b8dce97ef598d0b8bbc38ffe42dd2 Mon Sep 17 00:00:00 2001 From: Gamso Date: Sat, 4 Jul 2026 10:38:32 +0200 Subject: [PATCH 1/6] fix(mpc): stabilise fan selection with EMPC-lite band, normalised cost, relative hysteresis The cost function was unbounded (0.09 to 700+ across cycles depending on error and slope), which made the fixed absolute hysteresis margins meaningless and let the controller chase sub-band sensor noise. On 80 days of production data the controller changed fan mode 357 times with 148 A->B->A reversals inside an hour, flip-flopping across the full ladder while the room sat at the setpoint. Three coupled changes in _simulate_mode / _required_switch_gain: - EMPC-lite tolerance band: overshoot and floor-violation penalties now only apply *beyond* +/- deadband of the setpoint, like comfort_error already did. Previously any sub-band deviation triggered the heavy floor-violation weights (12 linear + 30 quadratic per step), which is what slammed the fan to superhigh for a 0.2C (one sensor LSB) excursion. - Per-step cost normalisation: the trajectory penalty is divided by the number of simulated steps so the magnitude is independent of the (dead-time-dependent) horizon and comparable across cycles. The one-time mode-change / rank / min- interval costs stay on that same per-step scale. - Relative hysteresis: a switch must beat the current mode's cost by a fraction of that cost on top of the absolute directional margin, so the threshold scales with the discomfort instead of being a fixed number vs an unbounded cost. Never applied to an upward switch while under-conditioned, so comfort recovery is unaffected. Also raise MODE_RANK_COST 0.05 -> 0.15 (now meaningful post-normalisation) and fix evaluate() calling get_dead_time() without hvac_mode, which mixed heat/cool dead times while the rest of the code keeps them separate. Replay on the production trace: fan changes 357 -> 130 (-64%), reversals within 60 min 148 -> 21 (-86%), prediction MAE unchanged (0.136 -> 0.140). Key behavioural guardrails (escalate below target, hold superhigh while below target, setpoint drop, long-dead-time horizon, gap model) verified unchanged. Co-Authored-By: Claude Fable 5 --- .../smart_fan_controller/mpc_controller.py | 60 +++++++++++++++---- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/custom_components/smart_fan_controller/mpc_controller.py b/custom_components/smart_fan_controller/mpc_controller.py index a94552c..0fbe328 100644 --- a/custom_components/smart_fan_controller/mpc_controller.py +++ b/custom_components/smart_fan_controller/mpc_controller.py @@ -24,7 +24,12 @@ FLOOR_VIOLATION_LINEAR_WEIGHT = 12.0 FLOOR_VIOLATION_QUADRATIC_WEIGHT = 30.0 MODE_CHANGE_DISTANCE_COST = 0.15 -MODE_RANK_COST = 0.05 +# Per-step economic weight of the fan-mode rank. Raised from the legacy 0.05 +# now that the trajectory cost is normalised per step (see _simulate_mode): the +# two are on the same scale, so this term expresses a real energy preference +# instead of being drowned out by the comfort integral. Kept moderate so the +# controller does not over-eagerly drop to the lowest mode and then jump back. +MODE_RANK_COST = 0.15 # Geometric growth of relative power draw per fan-mode rank. ~6**(1/3) so a # 4-mode ladder reproduces the legacy [1.0, 1.5, 3.0, 6.0] power scaling while # extending naturally to any number of modes. @@ -32,6 +37,12 @@ MIN_INTERVAL_CHANGE_PENALTY = 25.0 URGENCY_SENSITIVITY = 2.0 +# Relative hysteresis: a switch must beat the current mode's cost by at least +# this fraction of that cost (on top of the absolute directional margin), so the +# switch threshold scales with the magnitude of the discomfort instead of being +# a fixed number compared against an unbounded cost. See _required_switch_gain. +HYSTERESIS_REL_FRACTION = 0.15 + # Disturbance bias tracker DISTURBANCE_EMA_ALPHA = 0.2 DISTURBANCE_DECAY = 0.85 @@ -183,7 +194,7 @@ def evaluate( active_fan = current_fan if current_fan in fan_modes else fan_modes[0] current_effective_slope = -vtherm_slope if hvac_mode == "cool" else vtherm_slope current_error = self._temperature_error(current_temp, target_temp, hvac_mode) - dead_time = self._learning.get_dead_time() + dead_time = self._learning.get_dead_time(hvac_mode) change_allowed = minutes_since_change >= self._min_interval phase = self._detect_phase(minutes_since_change, dead_time) current_mode_slope, current_known_profile = self._get_mode_slope( @@ -320,6 +331,7 @@ def evaluate( phase=phase, candidate_index=best_index, current_index=current_index, + current_cost=current_simulation.total_cost, ) actual_gain = current_simulation.total_cost - best.total_cost if actual_gain < required_gain: @@ -578,10 +590,11 @@ def _simulate_mode( steps = max(1, int(horizon / self._cycle_minutes)) step_hours = self._cycle_minutes / 60.0 blend = 0.45 + band = self._deadband sim_temp = current_temp predicted_10m = None predicted_30m = None - cost = 0.0 + trajectory_cost = 0.0 thermal_power = current_effective_slope change_delay = 0.0 if candidate_fan == current_fan else dead_time @@ -605,18 +618,29 @@ def _simulate_mode( if elapsed >= 30 and predicted_30m is None: predicted_30m = sim_temp + # EMPC-lite tolerance band: no comfort/overshoot/floor penalty while the + # projected temperature stays within +/- band of the setpoint. Only + # deviations *beyond* the band are penalised, so the controller stops + # chasing sub-band sensor noise. ``error`` is the signed comfort error + # (positive = under-conditioned, needs more heating/cooling). error = self._temperature_error(sim_temp, target_temp, hvac_mode) - comfort_error = max(abs(error) - self._deadband, 0.0) - overshoot = max(-error, 0.0) - floor_violation = max(target_temp - sim_temp, 0.0) if hvac_mode == "heat" else max(sim_temp - target_temp, 0.0) + comfort_error = max(abs(error) - band, 0.0) + overshoot = max(-error - band, 0.0) + floor_violation = max(error - band, 0.0) # Step-by-step urgency weight calculated dynamically based on current simulated step comfort error step_urgency_weight = 1.0 + comfort_error * URGENCY_SENSITIVITY - cost += COMFORT_ERROR_WEIGHT * comfort_error * step_urgency_weight - cost += OVERSHOOT_QUADRATIC_WEIGHT * overshoot * overshoot - cost += FLOOR_VIOLATION_LINEAR_WEIGHT * floor_violation * step_urgency_weight - cost += FLOOR_VIOLATION_QUADRATIC_WEIGHT * floor_violation * floor_violation - + trajectory_cost += COMFORT_ERROR_WEIGHT * comfort_error * step_urgency_weight + trajectory_cost += OVERSHOOT_QUADRATIC_WEIGHT * overshoot * overshoot + trajectory_cost += FLOOR_VIOLATION_LINEAR_WEIGHT * floor_violation * step_urgency_weight + trajectory_cost += FLOOR_VIOLATION_QUADRATIC_WEIGHT * floor_violation * floor_violation + + # Normalise the trajectory penalty to an average per-step value so the cost + # magnitude is independent of the horizon length (which grows with the + # learned dead time) and comparable across control cycles. The one-time + # economic costs below are then on the same per-step scale, so mode-rank + # (energy) savings are no longer drowned out by a long comfort integral. + cost = trajectory_cost / steps cost += MODE_CHANGE_DISTANCE_COST * abs(candidate_index - current_index) # Apply a non-linear economic mode-ranking cost representing physical power # scaling. Relative power grows geometrically with the mode rank so every @@ -642,8 +666,16 @@ def _required_switch_gain( phase: str, candidate_index: int, current_index: int, + current_cost: float = 0.0, ) -> float: - """Return the minimum cost gain required before switching fan mode.""" + """Return the minimum cost gain required before switching fan mode. + + The margin combines an absolute directional component (below) with a + relative component proportional to the current mode's cost. The relative + term is applied only when it does not fight comfort recovery — i.e. never + on an upward switch while the room is under-conditioned, where a fast + response matters more than avoiding a marginal change. + """ if current_error < -self._deadband: margin = BASE_SWITCH_GAIN_MARGIN elif current_error > (self._deadband * 2): @@ -662,6 +694,10 @@ def _required_switch_gain( margin += UNDER_TARGET_STEPDOWN_GAIN_MARGIN margin += UNDER_TARGET_STEPDOWN_GAIN_PER_DEG * current_error + stepping_up_under_target = candidate_index > current_index and current_error > self._deadband + if not stepping_up_under_target: + margin += HYSTERESIS_REL_FRACTION * abs(current_cost) + return margin def _step_down_hold_note( From 0857fec2a4d5e4589696c18aa96399ec7691e3d1 Mon Sep 17 00:00:00 2001 From: Gamso Date: Sat, 4 Jul 2026 10:45:45 +0200 Subject: [PATCH 2/6] refactor: remove the dead LIMIT_TIMEOUT configuration parameter limit_timeout never influenced any control decision: it flowed only into get_effective_timeout(), which feeds diagnostic sensors and the CSV column but gates nothing (min_interval is the actual cooldown). Learning even auto-computed and auto-applied a limit_timeout value that then went unused. It was a third configuration knob for the user to reason about with no functional effect. Remove the parameter end to end: - const: CONF_LIMIT_TIMEOUT, DEFAULT_LIMIT_TIMEOUT, MIN_LIMIT_TIMEOUT - config_flow: the option in both the setup and options schemas - MPCController: the limit_timeout constructor arg and property; get_effective_timeout now falls back to DEFAULT_DEAD_TIME * safety factor internally - ThermalLearning.compute_optimal_parameters: stop computing/returning limit_timeout - __init__: stop applying it; seed last_change_time from min_interval instead - sensor: drop the Learned Limit Timeout sensor and the limit_timeout attributes - translations (en/fr), README The Effective Timeout sensor and the CSV effective_timeout column are kept as diagnostics (they show the learned thermal lag), now clearly documented as advisory rather than control-gating. Config now exposes deadband and min_interval only. Behaviour verified unchanged on the production replay: 130 changes, 21 reversals, identical to the previous commit. Co-Authored-By: Claude Fable 5 --- README.md | 15 ++- .../smart_fan_controller/__init__.py | 12 +-- .../smart_fan_controller/config_flow.py | 8 -- .../smart_fan_controller/const.py | 3 - .../smart_fan_controller/mpc_controller.py | 18 ++-- .../smart_fan_controller/sensor.py | 22 ---- .../smart_fan_controller/thermal_learning.py | 13 +-- .../smart_fan_controller/translations/en.json | 4 - .../smart_fan_controller/translations/fr.json | 4 - tests/test_learning.py | 78 ++------------ tests/test_mpc_controller.py | 1 - tests/test_replay_bench.py | 102 ++++++++++++++++++ tests/test_services.py | 13 +-- 13 files changed, 136 insertions(+), 157 deletions(-) create mode 100644 tests/test_replay_bench.py diff --git a/README.md b/README.md index ed22422..7e3b7b1 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,6 @@ All parameters can be changed at any time via **Settings → Devices & Services | -------------------- | -------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Deadband** | `0.2°C` | `0.0` – `5.0°C` | Comfort zone around target — no action taken within this range. Increase to reduce fan changes. | | **Min Interval** | `10 min` | `1` – `60 min` | Minimum time between non-emergency fan changes. Prevents rapid oscillations. | -| **Limit Timeout** | `15 min` | `10` – `120 min` | Fallback timeout used before learning calibrates the dead time. | | **Data Collection** | `true` | — | Records one CSV row every 2 minutes in the HA config folder (`smart_fan_controller_data_XXXXXXXX.csv`, max 10 MB, auto-rotated). Useful for offline analysis. | | **Defrost Entity** | *(none)* | — | Optional entity (`binary_sensor`, `sensor`, or `input_boolean`) that reports when the heat pump is in defrost cycle. See [Defrost Detection](#defrost-detection). | | **Operating Entity** | *(none)* | — | Optional entity (`binary_sensor`, `sensor`, or `input_boolean`) that reports whether the heat pump compressor is actively running. See [HVAC Idle Detection](#hvac-idle-detection). | @@ -144,7 +143,7 @@ Each candidate fan mode is scored with: - **Hysteresis**: a recommendation that changes the fan must beat the current mode by a minimum cost margin. The margin is larger when near the target (0.30) and smaller when far away (0.10). - **Step-down hold**: blocks downward moves when still under target and the temperature slope hasn't established yet. -- **Min interval**: non-emergency changes respect the effective timeout (learned dead time × 1.5 for the specified HVAC mode, or the configured limit timeout). +- **Min interval**: non-emergency changes respect the configured minimum interval between fan changes. ### Phase Detection @@ -199,14 +198,13 @@ The integration includes an **automatic learning system** that collects data dur **Parameters computed from data**: -| Parameter | Formula | -| --------------- | ------------------------------------------------- | -| `deadband` | `0.15 + (volatility_factor × 0.2)` | -| `limit_timeout` | rounded median of measured thermal response times | +| Parameter | Formula | +| ---------- | ---------------------------------- | +| `deadband` | `0.15 + (volatility_factor × 0.2)` | Where `volatility_factor = min(slope_stdev / slope_mean, 3.0)`. -> **Important**: the learned `limit_timeout` is the stored base response estimate. At runtime, non-emergency decisions use `effective_timeout = max(min_interval, dead_time × 1.5)` once learning is ready. +> **Note**: the `effective_timeout` diagnostic (`max(min_interval, dead_time × 1.5)` once learning is ready) is exposed as a sensor for insight into the learned thermal lag, but it does not gate control decisions — the minimum interval between fan changes does. Once learning is ready, parameters are **automatically applied** and the integration reloads. To apply manually, use the `apply_learned_settings` service. To start over, use `reset_learning`. @@ -278,9 +276,8 @@ Entity IDs are scoped by the configured climate entity. For example, a controlle | `sensor.smart_fan_controller_living_room_learning_samples` | count | Number of slope samples collected | | `sensor.smart_fan_controller_living_room_learning_response_events` | count | Number of thermal response time measurements | | `sensor.smart_fan_controller_living_room_learned_dead_time` | min | Median learned thermal response delay (`dead_time`) | -| `sensor.smart_fan_controller_living_room_effective_timeout` | min | Actual non-emergency timeout currently used | +| `sensor.smart_fan_controller_living_room_effective_timeout` | min | Advisory adaptive timeout (diagnostic only) | | `sensor.smart_fan_controller_living_room_learned_deadband` | °C | Learned optimal deadband | -| `sensor.smart_fan_controller_living_room_learned_limit_timeout` | min | Learned base timeout stored in config | ### Learning Profile Sensors diff --git a/custom_components/smart_fan_controller/__init__.py b/custom_components/smart_fan_controller/__init__.py index e1f8180..797bb68 100644 --- a/custom_components/smart_fan_controller/__init__.py +++ b/custom_components/smart_fan_controller/__init__.py @@ -19,14 +19,12 @@ CONF_DATA_COLLECTION, CONF_DEADBAND, CONF_DEFROST_ENTITY, - CONF_LIMIT_TIMEOUT, CONF_MIN_INTERVAL, CONF_OPERATING_ENTITY, DEAD_TIME_SAFETY_FACTOR, DEFAULT_DATA_COLLECTION, DEFAULT_DEAD_TIME, DEFAULT_DEADBAND, - DEFAULT_LIMIT_TIMEOUT, DEFAULT_MIN_INTERVAL, DELTA_TIME_CONTROL_LOOP, DOMAIN, @@ -233,16 +231,13 @@ async def _apply_optimal_parameters( new_data = {**entry.data} new_data["learning_auto_applied"] = True new_data[CONF_DEADBAND] = optimal["deadband"] - new_data[CONF_LIMIT_TIMEOUT] = optimal["limit_timeout"] new_options = {**entry.options} new_options[CONF_DEADBAND] = optimal["deadband"] - new_options[CONF_LIMIT_TIMEOUT] = optimal["limit_timeout"] _LOGGER.info( - "Applying learned settings for %s: deadband=%.2f limit_timeout=%d", + "Applying learned settings for %s: deadband=%.2f", entry.entry_id, optimal["deadband"], - optimal["limit_timeout"], ) hass.config_entries.async_update_entry(entry, data=new_data, options=new_options) @@ -558,7 +553,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: learning=learning, deadband=conf.get(CONF_DEADBAND, DEFAULT_DEADBAND), min_interval=conf.get(CONF_MIN_INTERVAL, DEFAULT_MIN_INTERVAL), - limit_timeout=conf.get(CONF_LIMIT_TIMEOUT, DEFAULT_LIMIT_TIMEOUT), fan_modes=initial_fan_modes or None, ) @@ -595,7 +589,9 @@ def _ensure_profile_sensors() -> None: # Mutable state shared between control-loop callbacks (closure variables) ctrl_state: dict = { - "last_change_time": time.time() - (conf.get(CONF_LIMIT_TIMEOUT, DEFAULT_LIMIT_TIMEOUT) * 60), + # Seed as if the min interval had just elapsed so the first cycle can act + # immediately instead of waiting out a fresh cooldown. + "last_change_time": time.time() - (conf.get(CONF_MIN_INTERVAL, DEFAULT_MIN_INTERVAL) * 60), "last_setpoint_drop_time": 0.0, "previous_slope": None, "last_hvac_mode": None, diff --git a/custom_components/smart_fan_controller/config_flow.py b/custom_components/smart_fan_controller/config_flow.py index 265e098..dffaeb8 100644 --- a/custom_components/smart_fan_controller/config_flow.py +++ b/custom_components/smart_fan_controller/config_flow.py @@ -13,13 +13,11 @@ CONF_CLIMATE_ENTITY, CONF_DEADBAND, CONF_MIN_INTERVAL, - CONF_LIMIT_TIMEOUT, CONF_DATA_COLLECTION, CONF_DEFROST_ENTITY, CONF_OPERATING_ENTITY, DEFAULT_DEADBAND, DEFAULT_MIN_INTERVAL, - DEFAULT_LIMIT_TIMEOUT, DEFAULT_DATA_COLLECTION, ) @@ -105,9 +103,6 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con vol.Optional(CONF_MIN_INTERVAL, default=DEFAULT_MIN_INTERVAL): selector.NumberSelector( selector.NumberSelectorConfig(min=1, max=60, step=1, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="min") ), - vol.Optional(CONF_LIMIT_TIMEOUT, default=DEFAULT_LIMIT_TIMEOUT): selector.NumberSelector( - selector.NumberSelectorConfig(min=10, max=120, step=5, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="min") - ), vol.Optional(CONF_DATA_COLLECTION, default=DEFAULT_DATA_COLLECTION): selector.BooleanSelector(), vol.Optional(CONF_DEFROST_ENTITY): selector.EntitySelector(selector.EntitySelectorConfig(domain=["binary_sensor", "sensor", "input_boolean"])), vol.Optional(CONF_OPERATING_ENTITY): selector.EntitySelector(selector.EntitySelectorConfig(domain=["binary_sensor", "sensor", "input_boolean"])), @@ -173,9 +168,6 @@ async def async_step_init(self, user_input: dict[str, Any] | None = None) -> Con vol.Optional(CONF_MIN_INTERVAL, default=current_data.get(CONF_MIN_INTERVAL, DEFAULT_MIN_INTERVAL)): selector.NumberSelector( selector.NumberSelectorConfig(min=1, max=60, step=1, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="min") ), - vol.Optional(CONF_LIMIT_TIMEOUT, default=current_data.get(CONF_LIMIT_TIMEOUT, DEFAULT_LIMIT_TIMEOUT)): selector.NumberSelector( - selector.NumberSelectorConfig(min=10, max=120, step=5, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="min") - ), vol.Optional(CONF_DATA_COLLECTION, default=current_data.get(CONF_DATA_COLLECTION, DEFAULT_DATA_COLLECTION)): selector.BooleanSelector(), vol.Optional(CONF_DEFROST_ENTITY, default=current_data.get(CONF_DEFROST_ENTITY, vol.UNDEFINED)): selector.EntitySelector( selector.EntitySelectorConfig(domain=["binary_sensor", "sensor", "input_boolean"]) diff --git a/custom_components/smart_fan_controller/const.py b/custom_components/smart_fan_controller/const.py index 768fe83..1573c6e 100644 --- a/custom_components/smart_fan_controller/const.py +++ b/custom_components/smart_fan_controller/const.py @@ -12,7 +12,6 @@ CONF_CLIMATE_ENTITY = "climate_entity" CONF_DEADBAND = "deadband" CONF_MIN_INTERVAL = "min_interval" -CONF_LIMIT_TIMEOUT = "limit_timeout" CONF_DATA_COLLECTION = "data_collection" CONF_DEFROST_ENTITY = "defrost_entity" CONF_OPERATING_ENTITY = "operating_entity" @@ -20,7 +19,6 @@ # Default values DEFAULT_DEADBAND = 0.2 DEFAULT_MIN_INTERVAL = 10 -DEFAULT_LIMIT_TIMEOUT = 15 DEFAULT_DATA_COLLECTION = True DELTA_TIME_CONTROL_LOOP = 2 # minutes between each control loop execution @@ -43,7 +41,6 @@ # Learning MIN_SAMPLES_LEARNING = 240 # Minimum slope samples required for initial readiness -MIN_LIMIT_TIMEOUT = 5 # Minimum limit_timeout (minutes) derived from learning MIN_MODE_PROFILE_SAMPLES = 10 # Minimum samples per fan mode to consider profile reliable REFERENCE_SLOPE_ERROR = 1.0 # °C – reference comfort error at which the representative # "working" effective slope is reported. The learned slope model is slope(error) = a + b·error; diff --git a/custom_components/smart_fan_controller/mpc_controller.py b/custom_components/smart_fan_controller/mpc_controller.py index 0fbe328..1956528 100644 --- a/custom_components/smart_fan_controller/mpc_controller.py +++ b/custom_components/smart_fan_controller/mpc_controller.py @@ -79,7 +79,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, @@ -87,7 +86,6 @@ def __init__( self._learning = learning self._deadband = deadband self._min_interval = min_interval - self._limit_timeout = limit_timeout self._fan_modes = fan_modes self._horizon_minutes = horizon_minutes self._cycle_minutes = cycle_minutes @@ -132,17 +130,19 @@ def learning(self) -> ThermalLearning: """Return the ThermalLearning instance used by this controller.""" return self._learning - @property - def limit_timeout(self) -> int: - """Return the configured static limit timeout (minutes).""" - return self._limit_timeout - def get_effective_timeout(self, hvac_mode: str = "unknown") -> float: - """Return the adaptive timeout based on learned dead time, or static fallback.""" + """Return the adaptive advisory timeout (diagnostic only). + + This value is surfaced in sensors and the data-collection CSV to show how + long the controller would wait before forcing a re-evaluation; it does not + gate any control decision (that is the job of ``min_interval``). Before + learning is ready it falls back to the default dead time scaled by the + safety factor. + """ if self._learning.is_ready(): learned_dead_time = self._learning.get_dead_time(hvac_mode) return max(self._min_interval, learned_dead_time * DEAD_TIME_SAFETY_FACTOR) - return self._limit_timeout + return DEFAULT_DEAD_TIME * DEAD_TIME_SAFETY_FACTOR @property def disturbance_bias(self) -> float: diff --git a/custom_components/smart_fan_controller/sensor.py b/custom_components/smart_fan_controller/sensor.py index 1854b0a..e47acda 100644 --- a/custom_components/smart_fan_controller/sensor.py +++ b/custom_components/smart_fan_controller/sensor.py @@ -138,7 +138,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e SmartFanMpcProfilesSensor(entry.entry_id, climate_entity, mpc, "heat"), SmartFanMpcProfilesSensor(entry.entry_id, climate_entity, mpc, "cool"), SmartFanLearnedDeadbandSensor(entry.entry_id, climate_entity, mpc), - SmartFanLearnedLimitTimeoutSensor(entry.entry_id, climate_entity, mpc), ] ) @@ -255,7 +254,6 @@ def extra_state_attributes(self) -> dict: optimal = self._controller.learning.compute_optimal_parameters() if optimal: attrs["learned_deadband"] = optimal.get("deadband") - attrs["learned_limit_timeout"] = optimal.get("limit_timeout") attrs["learned_samples_count"] = optimal.get("samples_count") attrs["learned_response_samples"] = optimal.get("response_samples") @@ -350,7 +348,6 @@ def extra_state_attributes(self) -> dict: "avg_response_time_min": round(avg_response, 1), "median_response_time_min": round(learning.get_dead_time(), 2), "effective_timeout_min": round(self._controller.get_effective_timeout(), 2), - "computed_limit_timeout": optimal.get("limit_timeout", 0), } @@ -409,7 +406,6 @@ def extra_state_attributes(self) -> dict: return { "is_ready": self._controller.learning.is_ready(), "learned_dead_time": round(self._controller.learning.get_dead_time(), 2), - "configured_limit_timeout": round(self._controller.limit_timeout, 2), } @@ -598,21 +594,3 @@ def __init__(self, entry_id: str, climate_entity: str, controller) -> None: icon="mdi:thermometer-lines", current_attr="deadband", ) - - -class SmartFanLearnedLimitTimeoutSensor(_BaseLearnedParameterSensor): - """Learned limit_timeout parameter.""" - - def __init__(self, entry_id: str, climate_entity: str, controller) -> None: - super().__init__( - entry_id, - climate_entity, - controller, - name="Learned Limit Timeout", - object_key="learned_limit_timeout", - unit=UnitOfTime.MINUTES, - device_class=SensorDeviceClass.DURATION, - learning_key="limit_timeout", - icon="mdi:clock-check-outline", - current_attr="limit_timeout", - ) diff --git a/custom_components/smart_fan_controller/thermal_learning.py b/custom_components/smart_fan_controller/thermal_learning.py index ea43829..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..922d239 100644 --- a/custom_components/smart_fan_controller/translations/en.json +++ b/custom_components/smart_fan_controller/translations/en.json @@ -8,7 +8,6 @@ "climate_entity": " ", "deadband": "Deadband (°C)", "min_interval": "Min Interval (minutes)", - "limit_timeout": "Interval Before Forcing Change (minutes)", "data_collection": "Enable Data Collection (beta)", "defrost_entity": "Defrost Entity (optional)", "operating_entity": "Heat Pump Operating Entity (optional)" @@ -17,7 +16,6 @@ "climate_entity": "Must come from a Versatile Thermostat.", "deadband": "Range: 0.0-5.0 °C. Default: 0.2 °C.", "min_interval": "Range: 1-60 min. Default: 10 min.", - "limit_timeout": "Range: 10-120 min. Default: 15 min.", "data_collection": "Records one CSV row every 2 minutes in the Home Assistant config folder, alongside configuration.yaml: smart_fan_controller_data_XXXXXXXX.csv (max 10 MB, auto-rotated). Useful for reproducing algorithm behaviour and identifying improvements during the beta phase.", "defrost_entity": "Optional: binary sensor, sensor or input_boolean that indicates when the heat pump is in defrost mode.", "operating_entity": "Optional: binary sensor, sensor or input_boolean that indicates when the heat pump compressor is actively running. Used to prevent useless fan changes while the compressor is off." @@ -38,7 +36,6 @@ "climate_entity": " ", "deadband": "Deadband (°C)", "min_interval": "Min Interval (minutes)", - "limit_timeout": "Interval Before Forcing Change (minutes)", "data_collection": "Enable Data Collection (beta)", "defrost_entity": "Defrost Entity (optional)", "operating_entity": "Heat Pump Operating Entity (optional)" @@ -47,7 +44,6 @@ "climate_entity": "Must come from a Versatile Thermostat.", "deadband": "Range: 0.0-5.0 °C. Default: 0.2 °C.", "min_interval": "Range: 1-60 min. Default: 10 min.", - "limit_timeout": "Range: 10-120 min. Default: 15 min.", "data_collection": "Records one CSV row every 2 minutes in the Home Assistant config folder, alongside configuration.yaml: smart_fan_controller_data_XXXXXXXX.csv (max 10 MB, auto-rotated). Useful for reproducing algorithm behaviour and identifying improvements during the beta phase.", "defrost_entity": "Optional: binary sensor, sensor or input_boolean that indicates when the heat pump is in defrost mode.", "operating_entity": "Optional: binary sensor, sensor or input_boolean that indicates when the heat pump compressor is actively running. Used to prevent useless fan changes while the compressor is off." diff --git a/custom_components/smart_fan_controller/translations/fr.json b/custom_components/smart_fan_controller/translations/fr.json index e8e34fe..5745000 100644 --- a/custom_components/smart_fan_controller/translations/fr.json +++ b/custom_components/smart_fan_controller/translations/fr.json @@ -8,7 +8,6 @@ "climate_entity": " ", "deadband": "Bande morte (°C)", "min_interval": "Intervalle minimum (minutes)", - "limit_timeout": "Intervalle avant de forcer un changement (minutes)", "data_collection": "Activer la collecte de données (bêta)", "defrost_entity": "Entité dégivrage (optionnel)", "operating_entity": "Entité fonctionnement PAC (optionnel)" @@ -17,7 +16,6 @@ "climate_entity": "Doit provenir de Versatile Thermostat.", "deadband": "Plage : 0.0-5.0 °C. Défaut : 0.2 °C.", "min_interval": "Plage : 1-60 min. Défaut : 10 min.", - "limit_timeout": "Plage : 10-120 min. Défaut : 15 min.", "data_collection": "Enregistre une ligne CSV toutes les 2 minutes dans le dossier de configuration Home Assistant, à côté de configuration.yaml : smart_fan_controller_data_XXXXXXXX.csv (max 10 Mo, rotation automatique). Utile pour reproduire le comportement de l'algorithme et identifier des améliorations pendant la phase bêta.", "defrost_entity": "Optionnel : binary sensor, sensor ou input_boolean indiquant quand la PAC est en dégivrage.", "operating_entity": "Optionnel : binary sensor, sensor ou input_boolean indiquant quand le compresseur de la PAC est en marche. Utilisé pour éviter des changements de ventilation inutiles quand le compresseur est à l'arrêt." @@ -38,7 +36,6 @@ "climate_entity": " ", "deadband": "Bande morte (°C)", "min_interval": "Intervalle minimum (minutes)", - "limit_timeout": "Intervalle avant de forcer un changement (minutes)", "data_collection": "Activer la collecte de données (bêta)", "defrost_entity": "Entité dégivrage (optionnel)", "operating_entity": "Entité fonctionnement PAC (optionnel)" @@ -47,7 +44,6 @@ "climate_entity": "Doit provenir de Versatile Thermostat.", "deadband": "Plage : 0.0-5.0 °C. Défaut : 0.2 °C.", "min_interval": "Plage : 1-60 min. Défaut : 10 min.", - "limit_timeout": "Plage : 10-120 min. Défaut : 15 min.", "data_collection": "Enregistre une ligne CSV toutes les 2 minutes dans le dossier de configuration Home Assistant, à côté de configuration.yaml : smart_fan_controller_data_XXXXXXXX.csv (max 10 Mo, rotation automatique). Utile pour reproduire le comportement de l'algorithme et identifier des améliorations pendant la phase bêta.", "defrost_entity": "Optionnel : binary sensor, sensor ou input_boolean indiquant quand la PAC est en dégivrage.", "operating_entity": "Optionnel : binary sensor, sensor ou input_boolean indiquant quand le compresseur de la PAC est en marche. Utilisé pour éviter des changements de ventilation inutiles quand le compresseur est à l'arrêt." diff --git a/tests/test_learning.py b/tests/test_learning.py index 7b9c857..8547d48 100644 --- a/tests/test_learning.py +++ b/tests/test_learning.py @@ -2,7 +2,6 @@ import pytest from custom_components.smart_fan_controller.thermal_learning import ThermalLearning from custom_components.smart_fan_controller.mpc_controller import MPCController -from custom_components.smart_fan_controller.const import MIN_LIMIT_TIMEOUT from custom_components.smart_fan_controller.sensor import SmartFanLearnedDeadTimeSensor, SmartFanEffectiveTimeoutSensor FAN_MODES = ["low", "medium", "high"] @@ -15,90 +14,29 @@ def _build_mpc(learning: ThermalLearning) -> MPCController: learning=learning, deadband=0.2, min_interval=10, - limit_timeout=15, ) class TestThermalLearning: """Test auto-calibration and optimal parameter computation.""" - def test_optimal_limit_timeout_with_median_response_times(self): - """Test that optimal_limit_timeout uses median to handle outliers.""" + def test_optimal_parameters_reports_response_samples(self): + """compute_optimal_parameters exposes the response-sample count once ready.""" learning = ThermalLearning() - # Add multiple slope samples to satisfy is_ready() requirements - # Note: slope sample parameters (fan mode, slope value) don't affect limit_timeout for _ in range(250): learning.add_slope_sample("medium", 0.3, 0.1) - - # Verify that slope samples alone make the learning ready - assert learning.is_ready() - - # Add response times with outliers: most in 10-12 range, but some outliers at 30+ - # Median should be 12, mean would be much higher - response_times = [10, 10, 11, 11, 12, 12, 30, 35, 40] # median=12, mean≈19.0 - for time in response_times: - learning.add_response_event(time) - assert learning.is_ready() - optimal = learning.compute_optimal_parameters() - - # With median=12, optimal_limit_timeout should be 12 (using direct value, no multiplier) - # Should NOT be based on mean (≈19.0) - median is more robust to outliers - assert optimal["limit_timeout"] == 12 - - def test_optimal_limit_timeout_uses_observed_response_directly(self): - """Test that optimal_limit_timeout uses observed response time, floored by MIN_LIMIT_TIMEOUT.""" - learning = ThermalLearning() - - # Add samples (using consistent parameters across tests) - for _ in range(250): - learning.add_slope_sample("medium", 0.3, 0.1) - - # Add very fast response times (median=4, below MIN_LIMIT_TIMEOUT=5) - for time in [3, 4, 4, 5]: - learning.add_response_event(time) - - optimal = learning.compute_optimal_parameters() - - # With median=4 and MIN_LIMIT_TIMEOUT=5, optimal should be clamped to 5 - - assert optimal["limit_timeout"] == max(MIN_LIMIT_TIMEOUT, 4) - - def test_optimal_limit_timeout_with_slow_response(self): - """Test that optimal_limit_timeout follows observed response even when slow.""" - learning = ThermalLearning() - - # Add samples (using consistent parameters) - for _ in range(250): - learning.add_slope_sample("medium", 0.3, 0.1) - - # Add very slow response times - for time in [18, 19, 20, 25, 30]: - learning.add_response_event(time) - - optimal = learning.compute_optimal_parameters() - - # With median=20, optimal should be 20 (no max cap applied) - assert optimal["limit_timeout"] == 20 - - def test_optimal_limit_timeout_typical_case(self): - """Test typical response times in 10-15 minute range.""" - learning = ThermalLearning() - - # Add samples - for _ in range(250): - learning.add_slope_sample("medium", 0.3, 0.2) - - # Add typical response times user mentioned: 10-15 minutes - for time in [10, 11, 12, 13, 14, 15, 14, 13]: - learning.add_response_event(time) + for response_time in [10, 11, 12, 13]: + learning.add_response_event(response_time) optimal = learning.compute_optimal_parameters() - # With median≈13, optimal should be 13 (direct value, no multiplier) - assert optimal["limit_timeout"] == 13 + # limit_timeout is no longer computed; deadband and diagnostics remain. + assert "limit_timeout" not in optimal + assert optimal["response_samples"] == 4 + assert optimal["deadband"] > 0 def test_learned_dead_time_sensor_reports_median_response(self): """The diagnostic dead-time sensor should expose the median response delay.""" diff --git a/tests/test_mpc_controller.py b/tests/test_mpc_controller.py index c0e6bf7..c1a4922 100644 --- a/tests/test_mpc_controller.py +++ b/tests/test_mpc_controller.py @@ -30,7 +30,6 @@ def _build_mpc(learning: ThermalLearning, *, fan_modes=None, min_interval: int = learning=learning, deadband=0.3, min_interval=min_interval, - limit_timeout=18, fan_modes=fan_modes or FAN_MODES, ) 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.""" From e97e17a35193eadc06be9d5a59753d4a551f68ca Mon Sep 17 00:00:00 2001 From: Gamso Date: Sun, 5 Jul 2026 16:27:13 +0200 Subject: [PATCH 3/6] fix(mpc): drop the energy cost when under-conditioned so the fan escalates Production trace 2026-07-05: room sat 0.4C above a 24.0C cooling setpoint for over an hour with the fan stuck at 'low', which was the model's genuine argmin (no hysteresis/min-interval hold). On a capacity-bound hot day the higher modes' learned cooling slopes were weak, so once the comfort-cost difference between modes was small the MODE_RANK_COST (energy) term tie-broke toward the cheapest mode -- the controller "gave up" and stayed quiet above the setpoint. Raising MODE_RANK_COST 0.05->0.15 in the stability work made this more likely. Gate the fan-rank (energy) cost on the comfort band: it now applies only when the room is within +/- deadband of target. When under-conditioned beyond the band (current_error > deadband), energy_weight = 0 so comfort dominates and the controller picks the mode that actually recovers the setpoint. Economy still applies in-band, where dropping to the quietest sufficient mode is what we want. Reproduced decision (24.4C vs 24.0C cool, weak higher-mode profiles, bias -0.25): now escalates to superhigh instead of holding low. Full replay: oscillation not reintroduced (reversals within 60 min 21 -> 25 over 228 h, vs 148 baseline), prediction MAE unchanged. Behavioural guardrails still pass. Kept as a hard in/out-of-band switch for now; if field data shows chatter at the band edge, soften to an energy weight that ramps with distance to the band. Co-Authored-By: Claude Fable 5 --- .../smart_fan_controller/mpc_controller.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/custom_components/smart_fan_controller/mpc_controller.py b/custom_components/smart_fan_controller/mpc_controller.py index 1956528..44a2348 100644 --- a/custom_components/smart_fan_controller/mpc_controller.py +++ b/custom_components/smart_fan_controller/mpc_controller.py @@ -284,6 +284,15 @@ def evaluate( # of their actual candidate slope, preventing the "dead-time blindness". sim_horizon = max(self._horizon_minutes, int(dead_time) + self._horizon_minutes) + # Energy (fan-rank) cost only applies inside the comfort band. When the + # room is under-conditioned beyond the band, comfort must dominate: the + # economic term is dropped so a cheap-but-ineffective mode can't be + # preferred over one that actually recovers the setpoint. Without this, + # when the higher modes' learned slopes are weak (e.g. a capacity-bound + # hot day), the rank cost tie-breaks toward the lowest mode and the + # controller sits quietly above the setpoint. + energy_weight = 0.0 if current_error > self._deadband else 1.0 + for fan_mode in fan_modes: mode_slope, known_profile = self._get_mode_slope( fan_mode, @@ -309,6 +318,7 @@ def evaluate( change_allowed=change_allowed, known_profile=known_profile, horizon_minutes=sim_horizon, + energy_weight=energy_weight, ) simulations.append(sim) known_profiles += int(known_profile) @@ -575,6 +585,7 @@ def _simulate_mode( change_allowed: bool, known_profile: bool, horizon_minutes: int | None = None, + energy_weight: float = 1.0, ) -> ModeSimulation: """Simulate one fan mode over the prediction horizon. @@ -647,7 +658,7 @@ 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 + cost += MODE_RANK_COST * relative_power * energy_weight if candidate_fan != current_fan and not change_allowed: cost += MIN_INTERVAL_CHANGE_PENALTY From fbda286dfd62a923405cd8e69a3980f3794dbe9b Mon Sep 17 00:00:00 2001 From: Gamso Date: Sun, 5 Jul 2026 16:42:07 +0200 Subject: [PATCH 4/6] fix(learning): stop slope extrapolation blow-ups and weight monotone enforcement Audit of the learned effective-slope profiles on 80 days of production data surfaced two defects that corrupted the model, most visibly in heating: 1. Extrapolation blow-up. The representative slope is reported at REFERENCE_SLOPE_ERROR (1.0 C) and the sim applies the fitted gain across the whole horizon. A profile only ever sampled at/below setpoint (e.g. silent/heat, errors -1.0..0.0) still had a gain fitted and extrapolated out to +1.0 C, producing a physically impossible 2.82 C/h. Now, when a profile has never been observed at an error near the reference gap, we fall back to the constant median instead of trusting the extrapolation (silent/heat 2.82 -> 0.43). 2. Monotone enforcement corrupted by a thin low mode. build_monotone_slopes used a naive forward max(), so one bad weak-mode value dragged every stronger mode up to it: with silent/heat blown up, heat became silent=med=superhigh=2.82, erasing all fan differentiation. Replaced with sample-count-weighted isotonic regression (pool-adjacent-violators): out-of-order neighbours merge into their weighted mean, so the well-sampled modes (superhigh had 1742 cool samples vs silent's 20) dominate and a noisy 17-sample profile can't poison the rest. After both fixes, heat resolves to a sane non-decreasing 0.32/0.32/0.48. Cool profiles (already monotone, well sampled) are unchanged; full replay is unchanged (130 changes, 25 reversals, MAE 0.139) since this trace is cool-heavy and the defects hit heating. Behavioural guardrails still pass. Updated the two monotone tests that asserted the old forward-clamp values to the pooled results. Not addressed here (deeper, logged for follow-up): the effective slope still conflates fan capacity with the ambient heat load during sampling (negative silent/low cool slopes = "warms the room on a hot day"), the |slope|<0.15 stagnation filter drops 37% of samples biased toward the weak modes, and the gap-model r-squared is low (0.04-0.37) so error poorly predicts slope. Co-Authored-By: Claude Fable 5 --- .../smart_fan_controller/mpc_controller.py | 60 +++++++++++++------ .../smart_fan_controller/thermal_learning.py | 10 ++++ tests/test_mpc_controller.py | 19 +++--- 3 files changed, 64 insertions(+), 25 deletions(-) diff --git a/custom_components/smart_fan_controller/mpc_controller.py b/custom_components/smart_fan_controller/mpc_controller.py index 44a2348..92f2e9c 100644 --- a/custom_components/smart_fan_controller/mpc_controller.py +++ b/custom_components/smart_fan_controller/mpc_controller.py @@ -489,32 +489,56 @@ def _detect_phase(minutes_since_change: float, dead_time: float) -> str: def build_monotone_slopes(self, fan_modes: list[str], hvac_mode: str) -> dict[str, float]: """Return monotone-enforced slopes for all known profiles. - Fan modes are assumed ordered from weakest to strongest. For each mode - with a learned profile, the slope is clamped to be >= the slope of the - last known lower mode (isotonic forward pass). Modes without a learned - profile are omitted from the result — the caller falls back to rank-scaled - estimation for those. - - 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 a stronger + mode should never have a lower effective slope than a weaker one. This is + enforced with sample-weighted isotonic regression (pool-adjacent- + violators): adjacent out-of-order profiles are merged into their + sample-count-weighted mean until the sequence is non-decreasing. + + Weighting by sample count is what makes this robust: the modes actually + used the most have far more samples and dominate the enforcement, so a + thin, noisy low-mode profile can no longer drag every stronger mode up to + its value (a naive forward ``max`` did exactly that — e.g. a 17-sample + ``silent`` profile pulling every heat mode to the same bogus value). + + Modes without a learned profile are omitted from the result — the caller + falls back to rank-scaled estimation for those. """ - enforced: dict[str, float] = {} - prev = float("-inf") - changed = False + # Collect (fan_mode, raw_slope, weight) for learned profiles, weakest->strongest. + items: list[tuple[str, float, int]] = [] for fm in fan_modes: slope = self._learning.get_mode_effective_slope(fm, hvac_mode) if slope is None: continue # unknown profile — skip, caller uses rank-scaling fallback - clamped = max(slope, prev) - if clamped != slope: - changed = True - enforced[fm] = clamped - prev = clamped + weight = max(1, self._learning.get_mode_sample_count(fm, hvac_mode)) + items.append((fm, slope, weight)) + + if not items: + return {} + + # Pool-adjacent-violators: merge adjacent blocks whose values decrease. + blocks: list[list] = [] # each: [value, weight, [fan_modes...]] + for fm, slope, weight in items: + blocks.append([slope, weight, [fm]]) + while len(blocks) >= 2 and blocks[-2][0] > blocks[-1][0]: + value2, weight2, modes2 = blocks.pop() + value1, weight1, modes1 = blocks.pop() + pooled_weight = weight1 + weight2 + pooled_value = (value1 * weight1 + value2 * weight2) / pooled_weight + blocks.append([pooled_value, pooled_weight, modes1 + modes2]) + + enforced: dict[str, float] = {} + raw = {fm: slope for fm, slope, _ in items} + changed = False + for value, _weight, modes in blocks: + for fm in modes: + enforced[fm] = value + if abs(value - raw[fm]) > 1e-9: + changed = True if changed: _LOGGER.debug( - "Monotone enforcement applied for %s: %s", + "Monotone (weighted isotonic) enforcement for %s: %s", hvac_mode, {fm: round(v, 3) for fm, v in enforced.items()}, ) diff --git a/custom_components/smart_fan_controller/thermal_learning.py b/custom_components/smart_fan_controller/thermal_learning.py index d78f6dc..62cbdc3 100644 --- a/custom_components/smart_fan_controller/thermal_learning.py +++ b/custom_components/smart_fan_controller/thermal_learning.py @@ -327,6 +327,16 @@ def _fit_mode_slope(self, fan_mode: str, hvac_mode: str) -> tuple[float, float, if len(points) < MIN_MODE_PROFILE_SAMPLES: return (median_eff, 0.0, None) + # Anti-extrapolation guard: the representative slope is reported at + # REFERENCE_SLOPE_ERROR and the sim applies the gain across the whole + # horizon. If this profile has never been observed at an error anywhere + # near that reference gap (e.g. a mode only ever active at/below setpoint), + # extrapolating the gain out to REFERENCE_SLOPE_ERROR can explode into a + # physically impossible slope. Fall back to the constant median instead of + # trusting an unsupported extrapolation. + if max(x for x, _ in points) < REFERENCE_SLOPE_ERROR: + return (median_eff, 0.0, None) + n = len(points) mean_x = sum(x for x, _ in points) / n mean_y = sum(y for _, y in points) / n diff --git a/tests/test_mpc_controller.py b/tests/test_mpc_controller.py index c1a4922..82b1c7a 100644 --- a/tests/test_mpc_controller.py +++ b/tests/test_mpc_controller.py @@ -522,10 +522,10 @@ def test_mpc_disturbance_bias_decays_during_defrost() -> None: def test_monotone_constraint_enforces_ordering() -> None: - """Monotone constraint should clamp a lower mode's slope up to its neighbor when all profiles are ready.""" + """Monotone constraint should produce a non-decreasing sequence via sample-weighted pooling.""" learning = ThermalLearning() - # Create inverted profiles: silent > med (the real-world bug) + # Create inverted profiles: silent > low (the real-world bug) learning.set_mode_effective_slope("silent", "heat", 0.53) learning.set_mode_effective_slope("low", "heat", 0.0) learning.set_mode_effective_slope("med", "heat", 0.45) @@ -542,9 +542,12 @@ def test_monotone_constraint_enforces_ordering() -> None: monotone = mpc.build_monotone_slopes(["silent", "low", "med", "high", "superhigh"], "heat") assert isinstance(monotone, dict) - # silent (0.53) should stay, low (0.0 → clamped to 0.53), med (0.45 → clamped to 0.53) - assert monotone["silent"] == pytest.approx(0.53, abs=0.001) - assert monotone["low"] >= monotone["silent"] + # silent (0.53) and low (0.0) are out of order with equal sample weight, so + # weighted isotonic pooling merges them to their mean (0.265) instead of the + # weaker mode dragging the stronger up. The sequence stays non-decreasing. + assert monotone["silent"] == pytest.approx(0.265, abs=0.001) + assert monotone["low"] == pytest.approx(0.265, abs=0.001) + assert monotone["low"] >= monotone["silent"] - 1e-9 assert monotone["med"] >= monotone["low"] assert monotone["high"] >= monotone["med"] assert monotone["superhigh"] >= monotone["high"] @@ -590,8 +593,10 @@ def test_monotone_constraint_partial_profiles_enforces_known_pairs() -> None: assert "med" not in result assert "high" in result assert "superhigh" in result - assert result["high"] == pytest.approx(1.59, abs=0.001) - assert result["superhigh"] == pytest.approx(1.59, abs=0.001) + # high (1.59) and superhigh (1.075) are inverted with equal sample weight, so + # weighted isotonic pooling merges them to their mean (1.3325). + assert result["high"] == pytest.approx(1.3325, abs=0.001) + assert result["superhigh"] == pytest.approx(1.3325, abs=0.001) def test_monotone_constraint_noop_when_already_ordered() -> None: From 5c96f7241204b4253a15d604442343ae0e0f345c Mon Sep 17 00:00:00 2001 From: Gamso Date: Sun, 5 Jul 2026 18:51:06 +0200 Subject: [PATCH 5/6] =?UTF-8?q?feat(mpc):=20asymmetric=20comfort=20band=20?= =?UTF-8?q?=E2=80=94=20keep=20at/below=20target=20in=20cool,=20at/above=20?= =?UTF-8?q?in=20heat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the requested comfort semantics: a cooling setpoint of 24 C means "24 or less", a heating setpoint means "24 or more". The symmetric deadband let the room settle anywhere in [target-band, target+band], so in cooling it habitually sat up to a deadband ABOVE the setpoint (e.g. 24.2 with target 24.0) and the controller treated that as in-band and stayed at the lowest fan. Make the band asymmetric in the signed comfort error (positive = under- conditioned = wrong side of the setpoint for the active mode): - Wrong side (error > 0): penalised from the setpoint with no tolerance, via the FLOOR_VIOLATION terms, so the room is always driven back to the requested side. - Acceptable side (error < 0): only gently discouraged (OVERSHOOT term) so it is fine to be a little cooler than a cool setpoint / warmer than a heat setpoint without hunting. To avoid slamming the fan to maximum for a single sensor step past the setpoint, the correction is graduated: the under-side weights were retuned down (FLOOR_VIOLATION_LINEAR 12->4, QUADRATIC 30->12) and OVERSHOOT raised (3->8), and the energy (fan-rank) weight now ramps linearly from 1 at the setpoint to 0 at the deadband instead of switching off abruptly. Net decision profile in cooling: <=target -> low, +0.2 -> high, +0.4 -> superhigh; below target stays low. Behavioural guardrails still pass. IMPORTANT VALIDATION CAVEAT: this changes the closed-loop trajectory, so the open-loop CSV replay is not representative — it replays temperatures produced under the old symmetric band (room riding above the cool setpoint), so it over-counts activity (217 changes / 87 reversals vs 130/25). The real hunting behaviour can only be confirmed by deploying on the live system. If it hunts at the setpoint edge, the lever is a small wrong-side tolerance (act at +2 sensor LSB) or the deadband. Co-Authored-By: Claude Fable 5 --- .../smart_fan_controller/mpc_controller.py | 68 ++++++++++++------- 1 file changed, 45 insertions(+), 23 deletions(-) diff --git a/custom_components/smart_fan_controller/mpc_controller.py b/custom_components/smart_fan_controller/mpc_controller.py index 92f2e9c..59bc67d 100644 --- a/custom_components/smart_fan_controller/mpc_controller.py +++ b/custom_components/smart_fan_controller/mpc_controller.py @@ -18,11 +18,17 @@ _LOGGER = logging.getLogger(__name__) -# Cost function weights (simulation loop in _simulate_mode) +# Cost function weights (simulation loop in _simulate_mode). The comfort band is +# asymmetric: FLOOR_VIOLATION_* penalise being on the wrong side of the setpoint +# (above target in cooling, below in heating) from the setpoint itself, while +# OVERSHOOT_QUADRATIC_WEIGHT only gently discourages the acceptable side. The +# under-side weights are graduated (moderate linear + quadratic) so a small +# excursion past the setpoint triggers a proportionate step up rather than +# slamming the fan to maximum, while a clear excursion still escalates hard. COMFORT_ERROR_WEIGHT = 1.0 -OVERSHOOT_QUADRATIC_WEIGHT = 3.0 -FLOOR_VIOLATION_LINEAR_WEIGHT = 12.0 -FLOOR_VIOLATION_QUADRATIC_WEIGHT = 30.0 +OVERSHOOT_QUADRATIC_WEIGHT = 8.0 +FLOOR_VIOLATION_LINEAR_WEIGHT = 4.0 +FLOOR_VIOLATION_QUADRATIC_WEIGHT = 12.0 MODE_CHANGE_DISTANCE_COST = 0.15 # Per-step economic weight of the fan-mode rank. Raised from the legacy 0.05 # now that the trajectory cost is normalised per step (see _simulate_mode): the @@ -284,14 +290,14 @@ def evaluate( # of their actual candidate slope, preventing the "dead-time blindness". sim_horizon = max(self._horizon_minutes, int(dead_time) + self._horizon_minutes) - # Energy (fan-rank) cost only applies inside the comfort band. When the - # room is under-conditioned beyond the band, comfort must dominate: the - # economic term is dropped so a cheap-but-ineffective mode can't be - # preferred over one that actually recovers the setpoint. Without this, - # when the higher modes' learned slopes are weak (e.g. a capacity-bound - # hot day), the rank cost tie-breaks toward the lowest mode and the - # controller sits quietly above the setpoint. - energy_weight = 0.0 if current_error > self._deadband else 1.0 + # Energy (fan-rank) weight ramps down as the room goes past the setpoint on + # the wrong side. At/under target (error <= 0) economy is full (weight 1): + # drop to the quietest sufficient mode. As the error grows toward the + # deadband the economic pull fades linearly, so a small excursion yields a + # gentle step up while a clear one (error >= deadband) drops economy + # entirely (weight 0) and comfort dominates — which also prevents a + # cheap-but-ineffective mode being kept on a capacity-bound hot day. + energy_weight = max(0.0, 1.0 - max(current_error, 0.0) / max(self._deadband, 1e-6)) for fan_mode in fan_modes: mode_slope, known_profile = self._get_mode_slope( @@ -653,22 +659,34 @@ def _simulate_mode( if elapsed >= 30 and predicted_30m is None: predicted_30m = sim_temp - # EMPC-lite tolerance band: no comfort/overshoot/floor penalty while the - # projected temperature stays within +/- band of the setpoint. Only - # deviations *beyond* the band are penalised, so the controller stops - # chasing sub-band sensor noise. ``error`` is the signed comfort error - # (positive = under-conditioned, needs more heating/cooling). + # Asymmetric comfort band. The setpoint is a hard target on the + # under-conditioned side: in cooling the room must be kept at or below + # target, in heating at or above it. So any *positive* comfort error + # (``error`` > 0 = under-conditioned) is always penalised — there is no + # tolerance on the wrong side of the setpoint. Overshoot to the + # acceptable side (error < 0: cooler than needed when cooling, warmer + # when heating) is tolerated within the deadband to avoid hunting. + # + # The under-side penalty is graduated: a light linear comfort term + # applies from the setpoint (a gentle nudge back to target for a small + # excursion), while the heavy floor-violation terms only engage beyond + # the deadband (an aggressive push when the room is clearly on the wrong + # side). This keeps "at/below target in cooling" without slamming the + # fan to maximum for a single sensor step over the setpoint. error = self._temperature_error(sim_temp, target_temp, hvac_mode) - comfort_error = max(abs(error) - band, 0.0) - overshoot = max(-error - band, 0.0) - floor_violation = max(error - band, 0.0) + under = max(error, 0.0) # wrong side of setpoint: always penalised + overshoot = max(-error, 0.0) # acceptable side: gently discouraged from the setpoint + comfort_error = under + overshoot # Step-by-step urgency weight calculated dynamically based on current simulated step comfort error step_urgency_weight = 1.0 + comfort_error * URGENCY_SENSITIVITY trajectory_cost += COMFORT_ERROR_WEIGHT * comfort_error * step_urgency_weight trajectory_cost += OVERSHOOT_QUADRATIC_WEIGHT * overshoot * overshoot - trajectory_cost += FLOOR_VIOLATION_LINEAR_WEIGHT * floor_violation * step_urgency_weight - trajectory_cost += FLOOR_VIOLATION_QUADRATIC_WEIGHT * floor_violation * floor_violation + # Under-side penalty applies from the setpoint (no tolerance) but is + # graduated: the linear term gives a gentle nudge for a small excursion, + # the quadratic term makes it escalate hard once clearly on the wrong side. + trajectory_cost += FLOOR_VIOLATION_LINEAR_WEIGHT * under * step_urgency_weight + trajectory_cost += FLOOR_VIOLATION_QUADRATIC_WEIGHT * under * under # Normalise the trajectory penalty to an average per-step value so the cost # magnitude is independent of the horizon length (which grows with the @@ -729,7 +747,11 @@ def _required_switch_gain( margin += UNDER_TARGET_STEPDOWN_GAIN_MARGIN margin += UNDER_TARGET_STEPDOWN_GAIN_PER_DEG * current_error - stepping_up_under_target = candidate_index > current_index and current_error > self._deadband + # Under-conditioned means the room is on the wrong side of the setpoint + # (error > 0). With the asymmetric band there is no tolerance there, so an + # upward switch to recover should not be taxed by the relative-hysteresis + # term (that term only guards against churn in/near the comfort band). + stepping_up_under_target = candidate_index > current_index and current_error > 0.0 if not stepping_up_under_target: margin += HYSTERESIS_REL_FRACTION * abs(current_cost) From 834529b0e0e0d29fa37db3c59cf535eeccfdc23a Mon Sep 17 00:00:00 2001 From: Gamso Date: Sun, 5 Jul 2026 19:13:44 +0200 Subject: [PATCH 6/6] fix(mpc): make the asymmetric band reliably escalate above the setpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut of the asymmetric band softened the under-side penalty (FLOOR 12/30 -> 4/12, OVERSHOOT 3 -> 8, overshoot penalised from the setpoint) to avoid slamming the fan to maximum. On the deployed system that turned out to be fragile: at a small in-deadband excursion (room 0.2 C above a 24.0 C cool setpoint) it went straight back to holding `low` — the very stuck-above-setpoint behaviour it was meant to fix. A robustness sweep confirmed the decision at +0.2 flips with the learned dead time and the disturbance bias, because escalating on a sub-deadband error through the finite-horizon projection is inherently marginal. Restore the full under-side weights (FLOOR_VIOLATION 12/30, OVERSHOOT 3) and keep the overshoot term on the tolerated-zone form (max(-error - band, 0)), so the acceptable side stays free within the deadband while the wrong side is penalised firmly from the setpoint. At the live conditions (dead time 16 min, bias ~0) the room at 24.2 C now escalates to superhigh once the min interval clears, and steps back down to low as soon as it is at/below target; below target it stays low. Trade-off accepted for the stated requirement (never above a cool setpoint / below a heat setpoint): the correction is firm (tends to superhigh for a small excursion) rather than gently graduated — a robust graduated point does not exist across the dead-time/bias range. Behavioural guardrails pass. Replay 170 changes / 66 reversals (open-loop, still not representative for this trajectory-changing change; below the 148-reversal baseline). Co-Authored-By: Claude Fable 5 --- .../smart_fan_controller/mpc_controller.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/custom_components/smart_fan_controller/mpc_controller.py b/custom_components/smart_fan_controller/mpc_controller.py index 59bc67d..afb83ae 100644 --- a/custom_components/smart_fan_controller/mpc_controller.py +++ b/custom_components/smart_fan_controller/mpc_controller.py @@ -20,15 +20,16 @@ # Cost function weights (simulation loop in _simulate_mode). The comfort band is # asymmetric: FLOOR_VIOLATION_* penalise being on the wrong side of the setpoint -# (above target in cooling, below in heating) from the setpoint itself, while -# OVERSHOOT_QUADRATIC_WEIGHT only gently discourages the acceptable side. The -# under-side weights are graduated (moderate linear + quadratic) so a small -# excursion past the setpoint triggers a proportionate step up rather than -# slamming the fan to maximum, while a clear excursion still escalates hard. +# (above target in cooling, below in heating) from the setpoint itself with no +# tolerance, so the room is firmly driven back to the requested side. The full +# (heavy) weights are used deliberately: escalating on a small in-deadband +# excursion via the finite-horizon projection is otherwise fragile (it flips with +# the learned dead time and disturbance bias), so a strong under-side penalty is +# what makes "at/below target in cooling, at/above in heating" reliable. COMFORT_ERROR_WEIGHT = 1.0 -OVERSHOOT_QUADRATIC_WEIGHT = 8.0 -FLOOR_VIOLATION_LINEAR_WEIGHT = 4.0 -FLOOR_VIOLATION_QUADRATIC_WEIGHT = 12.0 +OVERSHOOT_QUADRATIC_WEIGHT = 3.0 +FLOOR_VIOLATION_LINEAR_WEIGHT = 12.0 +FLOOR_VIOLATION_QUADRATIC_WEIGHT = 30.0 MODE_CHANGE_DISTANCE_COST = 0.15 # Per-step economic weight of the fan-mode rank. Raised from the legacy 0.05 # now that the trajectory cost is normalised per step (see _simulate_mode): the @@ -675,7 +676,7 @@ def _simulate_mode( # fan to maximum for a single sensor step over the setpoint. error = self._temperature_error(sim_temp, target_temp, hvac_mode) under = max(error, 0.0) # wrong side of setpoint: always penalised - overshoot = max(-error, 0.0) # acceptable side: gently discouraged from the setpoint + overshoot = max(-error - band, 0.0) # acceptable side: tolerated within the deadband comfort_error = under + overshoot # Step-by-step urgency weight calculated dynamically based on current simulated step comfort error