From e6f9062f927f31eae6502465ae01fb620c3cd237 Mon Sep 17 00:00:00 2001 From: Gamso Date: Mon, 6 Apr 2026 11:58:02 +0200 Subject: [PATCH 01/11] feat: add MPC controller with observation and production modes - Runs an MPCController in observation mode every control cycle (shadow learning) - Adds a production mode toggle switch; when enabled, the MPC decision replaces the rule-based heuristic for direct fan control - Shares the ThermalLearning backend with the heuristic controller; idle, defrost, and window-open periods are excluded from learning and MPC evaluation - Exposes 12 diagnostic sensors (status, reason, fan mode, cost, confidence, predicted temperatures at 10 and 30 min, dead time, known profiles, disturbance bias, match, would-change-now) - Adds CSV data-collection columns for offline MPC analysis - Entity migration mapping included so existing HA installations keep history --- README.md | 62 +- analyze.py | 17 + .../smart_fan_controller/__init__.py | 288 +++++--- .../smart_fan_controller/config_flow.py | 4 + .../smart_fan_controller/const.py | 30 + .../smart_fan_controller/controller.py | 5 + .../smart_fan_controller/data_collection.py | 33 +- .../smart_fan_controller/mpc_controller.py | 654 ++++++++++++++++++ .../smart_fan_controller/sensor.py | 90 ++- .../smart_fan_controller/switch.py | 43 ++ .../smart_fan_controller/translations/en.json | 4 + .../smart_fan_controller/translations/fr.json | 6 +- docs/mpc_mode.md | 206 ++++++ scripts/analyze_mpc_viability.py | 531 ++++++++++++++ tests/test_data_collection.py | 10 + tests/test_hvac_idle.py | 104 +++ tests/test_mpc_controller.py | 534 ++++++++++++++ 17 files changed, 2455 insertions(+), 166 deletions(-) create mode 100644 analyze.py create mode 100644 custom_components/smart_fan_controller/mpc_controller.py create mode 100644 docs/mpc_mode.md create mode 100644 scripts/analyze_mpc_viability.py create mode 100644 tests/test_mpc_controller.py diff --git a/README.md b/README.md index 57d058d..95e51b9 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ Predictive fan speed control for HVAC systems, designed to work with Versatile T - [Sensors \& Entities](#sensors--entities) - [Main Entities](#main-entities) - [Diagnostic Sensors](#diagnostic-sensors) + - [MPC Sensors](#mpc-sensors) + - [Learning Profile Sensors](#learning-profile-sensors) - [Defrost Diagnostic](#defrost-diagnostic) - [Services](#services) - [`smart_fan_controller.apply_learned_settings`](#smart_fan_controllerapply_learned_settings) @@ -78,9 +80,9 @@ Smart Fan Controller is a custom Home Assistant integration that **adjusts HVAC 4. Evaluates six priority zones (A–F) and selects the first matching action 5. Applies safety guards (step-down limit, min interval) and changes fan speed if needed 6. Collects learning data to automatically calibrate parameters over time -7. Optionally runs an MPC shadow controller in the background for dry-run comparison +7. Runs an MPC controller in the background; optionally activates MPC production mode for direct fan control -> Experimental: **MPC Shadow Mode** runs a learned temperature-state model and MPC-lite in observation-only mode. It owns its own runtime parameters (`deadband`, `min_interval`, fan modes), never applies real fan commands, pauses itself during disturbed periods such as an open window, and keeps those periods out of dead-time learning. See [docs/mpc_shadow_mode.md](docs/mpc_shadow_mode.md). +> **MPC Controller** always runs a learned temperature-state model and MPC-lite in observation mode. When **MPC Production Mode** is enabled, the MPC directly controls the fan instead of the rule-based algorithm. It owns its own runtime parameters (`deadband`, `min_interval`, fan modes), pauses itself during disturbed periods such as an open window, and keeps those periods out of dead-time learning. See [docs/mpc_mode.md](docs/mpc_mode.md). > It supports both `heat` and `cool`, and includes a hysteresis guard so tiny cost differences do not create fan yo-yo around the setpoint. --- @@ -113,8 +115,10 @@ All parameters can be changed at any time via **Settings → Devices & Services | **Hard Error** | `0.6°C` | `0.0` – `10.0°C` | Error threshold that triggers emergency mode (max fan, bypasses min interval). | | **Limit Timeout** | `15 min` | `10` – `120 min` | Maximum time before forcing a re-evaluation, even without significant slope change. | | **Learning Enabled** | `true` | — | Enables the automatic learning system. Disable for fully manual tuning. | -| **MPC Shadow Mode** | `false` | — | Runs the learned model + MPC-lite in the background for comparison only. No real fan command is applied. | +| **MPC Production Mode** | `false` | — | When enabled, the MPC controller directly controls the fan mode instead of the rule-based algorithm. Shadow observation always runs. | +| **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. When active, defrost protection is applied. 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. Used to block fan increases and exclude learning data while the compressor is off. See [HVAC Idle Detection](#hvac-idle-detection). | > **Tip — recommended ratios**: `deadband < soft_error < hard_error`, e.g. `0.2 / 0.3 / 0.6`. @@ -245,7 +249,7 @@ Response events are only recorded when the delay is between 2 and 60 minutes (fi When Versatile Thermostat reports a window as open (via the `window_manager.window_state` attribute), the controller: - **Continues making live heuristic fan decisions** normally (the HVAC system is still running) - **Stops collecting learning data**, including both per-mode slope samples and response-time events used to learn `dead_time` -- **Pauses the MPC shadow model**, marking it as disturbed instead of trusting predictions during the perturbation +- **Pauses the MPC model**, marking it as disturbed instead of trusting predictions during the perturbation This prevents window-open periods from corrupting the learned profiles. @@ -268,7 +272,7 @@ Slope samples and response-time events collected while the compressor is detecte | `sensor.smart_fan_controller_fan_mode` | Sensor | Current fan mode selected by the controller | | `sensor.smart_fan_controller_status` | Sensor | Current control zone and decision reason | | `switch.smart_fan_controller_learning_enabled` | Switch | Enable / disable the learning system | -| `switch.smart_fan_controller_mpc_shadow_mode` | Switch | Enable / disable observation-only MPC shadow mode | +| `switch.smart_fan_controller_mpc_production_mode` | Switch | Enable / disable MPC production mode (MPC controls the fan) | ### Diagnostic Sensors @@ -288,19 +292,41 @@ Slope samples and response-time events collected while the compressor is detecte | `sensor.smart_fan_controller_learned_soft_error` | °C | Learned optimal soft error threshold | | `sensor.smart_fan_controller_learned_hard_error` | °C | Learned optimal hard error threshold | | `sensor.smart_fan_controller_learned_limit_timeout` | min | Learned base timeout stored in config | -| `sensor.smart_fan_controller_mpc_shadow_status` | — | Shadow controller state (`Disabled`, `Ready`, etc.) | -| `sensor.smart_fan_controller_mpc_shadow_reason` | — | Explanation of the current shadow recommendation | -| `sensor.smart_fan_controller_mpc_shadow_fan_mode` | — | Fan mode the MPC shadow would choose | -| `sensor.smart_fan_controller_mpc_shadow_match` | — | Whether the shadow recommendation matches the live heuristic | -| `sensor.smart_fan_controller_mpc_shadow_would_change_now` | — | Whether the shadow controller would actively change the fan right now | -| `sensor.smart_fan_controller_mpc_shadow_confidence` | % | Confidence derived from learned profile coverage | -| `sensor.smart_fan_controller_mpc_shadow_predicted_temperature_10_min` | °C | Predicted temperature after 10 minutes with the recommended mode | -| `sensor.smart_fan_controller_mpc_shadow_predicted_temperature_30_min` | °C | Predicted temperature after 30 minutes with the recommended mode | -| `sensor.smart_fan_controller_mpc_shadow_dead_time` | min | Dead time currently used by the shadow simulator | -| `sensor.smart_fan_controller_mpc_shadow_known_profiles` | count | Number of reliable learned fan-mode profiles used by the shadow controller | -| `sensor.smart_fan_controller_mpc_shadow_disturbance_bias` | °C/h | Learned disturbance correction currently applied by the shadow model | - -See [docs/mpc_shadow_mode.md](docs/mpc_shadow_mode.md) for the full technical design of the learned model and MPC-lite shadow mode. + +### MPC Sensors + +| Entity | Unit | Description | +| --------------------------------------------------------------------- | ----- | -------------------------------------------------------------------------- | +| `sensor.smart_fan_controller_mpc_status` | — | Shadow controller state (`Disabled`, `Ready`, `Disturbed`, etc.) | +| `sensor.smart_fan_controller_mpc_reason` | — | Explanation of the current MPC recommendation | +| `sensor.smart_fan_controller_mpc_fan_mode` | — | Fan mode the MPC would choose | +| `sensor.smart_fan_controller_mpc_match` | — | Whether the MPC recommendation matches the live heuristic | +| `sensor.smart_fan_controller_mpc_would_change_now` | — | Whether the MPC would actively change the fan right now | +| `sensor.smart_fan_controller_mpc_cost` | — | Lowest simulation cost returned by the MPC optimizer | +| `sensor.smart_fan_controller_mpc_confidence` | % | Confidence derived from learned profile coverage | +| `sensor.smart_fan_controller_mpc_predicted_temperature_10_min` | °C | Predicted temperature after 10 minutes with the recommended mode | +| `sensor.smart_fan_controller_mpc_predicted_temperature_30_min` | °C | Predicted temperature after 30 minutes with the recommended mode | +| `sensor.smart_fan_controller_mpc_dead_time` | min | Dead time currently used by the MPC simulator | +| `sensor.smart_fan_controller_mpc_known_profiles` | count | Number of reliable learned fan-mode profiles used by the MPC controller | +| `sensor.smart_fan_controller_mpc_disturbance_bias` | °C/h | Learned disturbance correction currently applied by the MPC model | + +See [docs/mpc_mode.md](docs/mpc_mode.md) for the full technical design of the learned model and MPC-lite observation mode. + +### Learning Profile Sensors + +Once fan modes are detected, the integration creates per-HVAC-mode profile summary sensors and one effective slope sensor per fan mode: + +| Entity (example with `low`/`medium`/`high` fan modes) | Unit | Description | +| -------------------------------------------------------------------------- | ----- | ------------------------------------------------------- | +| `sensor.smart_fan_controller_mpc_heat_profiles` | — | JSON summary of learned heat profiles per fan mode | +| `sensor.smart_fan_controller_mpc_cool_profiles` | — | JSON summary of learned cool profiles per fan mode | +| `sensor.smart_fan_controller_heat_low_effective_slope` | °C/h | Effective slope learned for `low` in heat mode | +| `sensor.smart_fan_controller_heat_medium_effective_slope` | °C/h | Effective slope learned for `medium` in heat mode | +| `sensor.smart_fan_controller_heat_high_effective_slope` | °C/h | Effective slope learned for `high` in heat mode | +| `sensor.smart_fan_controller_cool_low_effective_slope` | °C/h | Effective slope learned for `low` in cool mode | +| … (one per fan mode × HVAC mode combination) | … | … | + +These sensors appear automatically when the climate entity's fan modes become known and require at least 10 samples per mode to show reliable data. ### Defrost Diagnostic diff --git a/analyze.py b/analyze.py new file mode 100644 index 0000000..f53f62c --- /dev/null +++ b/analyze.py @@ -0,0 +1,17 @@ +import csv +from datetime import datetime + +with open("smart_fan_controller_data_01KNA9CY.csv", "r") as f: + reader = csv.DictReader(f) + print("time | live_fan | mpc_fan | err | slope | mpc_stat | window | defrost | idle | mpc_conf") + last = None + for row in reader: + try: + dt = datetime.fromisoformat(row['timestamp'].replace('T', ' ').split('.')[0]) + except: continue + + if dt.month == 4 and dt.day == 6 and 7 <= dt.hour <= 8: + curr = (row['current_fan'], row['mpc_shadow_fan'], row['mpc_shadow_status']) + if curr != last: + print(f"{dt.strftime('%H:%M:%S')} | {row['current_fan']:<8} | {row['mpc_shadow_fan']:<7} | {row['current_error']} | {row['effective_slope']} | {row['mpc_shadow_status']:<9} | {row['is_window_open']} | {row['defrost_active']} | {row['hvac_idle']} | conf:{row['mpc_shadow_confidence']}") + last = curr diff --git a/custom_components/smart_fan_controller/__init__.py b/custom_components/smart_fan_controller/__init__.py index dc7f3b5..b35ffcf 100644 --- a/custom_components/smart_fan_controller/__init__.py +++ b/custom_components/smart_fan_controller/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations import logging -import time from datetime import timedelta from homeassistant.config_entries import ConfigEntry @@ -21,6 +20,7 @@ CONF_LEARNING_ENABLED, CONF_LIMIT_TIMEOUT, CONF_MIN_INTERVAL, + CONF_MPC_PRODUCTION_ENABLED, CONF_OPERATING_ENTITY, CONF_SOFT_ERROR, DEFAULT_DATA_COLLECTION, @@ -29,6 +29,7 @@ DEFAULT_LEARNING_ENABLED, DEFAULT_LIMIT_TIMEOUT, DEFAULT_MIN_INTERVAL, + DEFAULT_MPC_PRODUCTION_ENABLED, DEFAULT_SOFT_ERROR, DELTA_TIME_CONTROL_LOOP, DOMAIN, @@ -40,6 +41,7 @@ ) from .controller import SmartFanController from .data_collection import DataCollector +from .mpc_controller import MPCController _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR, Platform.SWITCH] @@ -164,6 +166,121 @@ async def _apply_optimal_parameters( await hass.config_entries.async_reload(entry.entry_id) +# MPC statuses that mean the controller is paused; in production mode the +# rule-based heuristic is used as fallback when the MPC is in one of these states. +_MPC_PAUSED_STATUSES = frozenset({"Disabled", "Idle", "Disturbed", "Not ready"}) + + +def _read_climate_cycle_data( + state, + climate_id: str, +) -> tuple[float, float, float, str, str | None, bool] | None: + """Return cycle inputs parsed from a climate entity state. + + Returns (vtherm_slope, current_temp, target_temp, hvac_mode, current_fan, is_window_open) + or None when mandatory data is missing. + """ + attrs = state.attributes + vtherm_slope = attrs.get("specific_states", {}).get("temperature_slope", 0) + current_temp = attrs.get("current_temperature") + target_temp = attrs.get("temperature") + hvac_mode = attrs.get("hvac_mode") + current_fan = attrs.get("fan_mode") + + if vtherm_slope is None: + _LOGGER.warning("%s is missing VTherm temperature_slope; skipping control cycle", climate_id) + return None + if current_temp is None or target_temp is None: + _LOGGER.debug( + "Skipping control cycle for %s because temperature data is incomplete (current=%s, target=%s)", + climate_id, + current_temp, + target_temp, + ) + return None + + window_mgr = attrs.get("window_manager", {}) + is_window_open = window_mgr.get("window_state") == "on" or window_mgr.get("window_auto_state") == "on" or attrs.get("specific_states", {}).get("hvac_off_reason") == "Window" + return ( + float(vtherm_slope), + float(current_temp), + float(target_temp), + str(hvac_mode), + current_fan, + is_window_open, + ) + + +def _detect_disturbances(hass, conf: dict, controller: SmartFanController) -> bool: + """Check external entities and update the controller defrost state. + + Returns True when the HVAC compressor is reported as idle. + """ + defrost_entity_id = conf.get(CONF_DEFROST_ENTITY) + if defrost_entity_id: + defrost_state = hass.states.get(defrost_entity_id) + if defrost_state and defrost_state.state in ("on", "true", "True", "1"): + if not controller.is_defrost_active: + _LOGGER.info("External defrost entity %s reports active defrost", defrost_entity_id) + controller.activate_defrost() + + operating_entity_id = conf.get(CONF_OPERATING_ENTITY) + if operating_entity_id: + operating_state = hass.states.get(operating_entity_id) + if operating_state and operating_state.state in ("off", "false", "False", "0"): + return True + return False + + +def _pick_effective_fan( + mpc_controller: MPCController, + decision: dict, + shadow_decision: dict, +) -> tuple[str, str]: + """Return (fan_mode, reason) for the current control cycle. + + When MPC production mode is active and the MPC is not paused, the MPC + decision takes precedence; otherwise the rule-based heuristic is used. + """ + if mpc_controller.production_mode and shadow_decision.get("mpc_status") not in _MPC_PAUSED_STATUSES and shadow_decision.get("mpc_fan_mode"): + return shadow_decision["mpc_fan_mode"], f"MPC: {shadow_decision.get('mpc_reason', 'MPC')}" + return decision["fan_mode"], decision["reason"] + + +async def _async_apply_fan_change( + hass, + climate_id: str, + effective_fan: str, + current_fan: str | None, + reason: str, + controller: SmartFanController, +) -> None: + """Send a fan-mode command to the climate entity and confirm on success.""" + _LOGGER.info( + "Changing %s fan mode from %s to %s (%s)", + climate_id, + current_fan, + effective_fan, + reason, + ) + try: + await hass.services.async_call( + "climate", + "set_fan_mode", + {"entity_id": climate_id, "fan_mode": effective_fan}, + blocking=True, + ) + except Exception: # pylint: disable=broad-exception-caught # pragma: no cover + _LOGGER.exception( + "Failed to change %s fan mode from %s to %s", + climate_id, + current_fan, + effective_fan, + ) + else: + controller.confirm_fan_change() + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the integration from a config entry.""" conf = {**entry.data, **entry.options} @@ -172,9 +289,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: initial_fan_modes = _extract_supported_fan_modes(current_state) _LOGGER.info( - "Setting up Smart Fan Controller for %s (learning=%s, data_collection=%s)", + "Setting up Smart Fan Controller for %s (learning=%s, mpc_production=%s, data_collection=%s)", climate_id, conf.get(CONF_LEARNING_ENABLED, DEFAULT_LEARNING_ENABLED), + conf.get(CONF_MPC_PRODUCTION_ENABLED, DEFAULT_MPC_PRODUCTION_ENABLED), conf.get(CONF_DATA_COLLECTION, DEFAULT_DATA_COLLECTION), ) @@ -186,17 +304,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: store = Store(hass, STORAGE_VERSION, f"{STORAGE_KEY}.{entry.entry_id}") learning_data = await store.async_load() - learning_data_source = "storage" if not learning_data: learning_data = entry.data.get("learning_data") - learning_data_source = "config_entry" _LOGGER.debug("No persistent storage found for %s, using config entry data", entry.entry_id) if learning_data: _LOGGER.info( - "Restored learning data for %s from %s (%d slope samples, %d response events)", + "Restored learning data for %s (%d slope samples, %d response events)", entry.entry_id, - learning_data_source, len(learning_data.get("slope_samples", [])), len(learning_data.get("response_events", [])), ) @@ -212,18 +327,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: learning_enabled=conf.get(CONF_LEARNING_ENABLED, DEFAULT_LEARNING_ENABLED), ) - data_collection_enabled = conf.get(CONF_DATA_COLLECTION, DEFAULT_DATA_COLLECTION) - collector: DataCollector | None = ( - DataCollector(hass, hass.config.config_dir, entry.entry_id) - if data_collection_enabled - else None + mpc_controller = MPCController( + learning=controller.learning, + deadband=conf.get(CONF_DEADBAND, DEFAULT_DEADBAND), + min_interval=conf.get(CONF_MIN_INTERVAL, DEFAULT_MIN_INTERVAL), + fan_modes=initial_fan_modes or None, + enabled=True, ) + mpc_controller.production_mode = conf.get(CONF_MPC_PRODUCTION_ENABLED, DEFAULT_MPC_PRODUCTION_ENABLED) + + collector: DataCollector | None = DataCollector(hass, hass.config.config_dir, entry.entry_id) if conf.get(CONF_DATA_COLLECTION, DEFAULT_DATA_COLLECTION) else None if collector: await collector.async_initialize() _LOGGER.info("DataCollector enabled for %s, writing to %s", entry.entry_id, collector.path) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { "controller": controller, + "mpc_controller": mpc_controller, "climate_entity": climate_id, "sensors": [], "ensure_profile_sensors": None, @@ -242,7 +362,6 @@ def _ensure_profile_sensors() -> None: async def run_control_loop(_): """Main control loop executed every 2 minutes.""" current_state = hass.states.get(climate_id) - if not current_state: _LOGGER.warning("Climate entity %s not found; skipping control cycle", climate_id) return @@ -250,56 +369,17 @@ async def run_control_loop(_): if not controller.fan_modes: detected_modes = _extract_supported_fan_modes(current_state) if detected_modes: - controller.fan_modes = detected_modes + controller.fan_modes = mpc_controller.fan_modes = detected_modes _LOGGER.info("Detected fan modes for %s during runtime: %s", climate_id, detected_modes) _ensure_profile_sensors() else: - _LOGGER.debug("Climate entity %s has no supported fan modes yet, will retry next cycle", climate_id) - - attrs = current_state.attributes - vtherm_slope = attrs.get("specific_states", {}).get("temperature_slope", 0) - current_temp = attrs.get("current_temperature") - target_temp = attrs.get("temperature") - hvac_mode = attrs.get("hvac_mode") - current_fan = attrs.get("fan_mode") - - window_mgr = attrs.get("window_manager", {}) - is_window_open = ( - window_mgr.get("window_state") == "on" - or window_mgr.get("window_auto_state") == "on" - or attrs.get("specific_states", {}).get("hvac_off_reason") == "Window" - ) - - # External defrost entity (optional): force defrost state when the PAC reports it - defrost_entity_id = conf.get(CONF_DEFROST_ENTITY) - if defrost_entity_id: - defrost_state = hass.states.get(defrost_entity_id) - if defrost_state and defrost_state.state in ("on", "true", "True", "1"): - if not controller.is_defrost_active: - _LOGGER.info("External defrost entity %s reports active defrost", defrost_entity_id) - controller._defrost_active = True - controller._defrost_start_time = time.time() - - # HVAC idle detection: compressor not running (optional entities) - is_hvac_idle = False - operating_entity_id = conf.get(CONF_OPERATING_ENTITY) - if operating_entity_id: - operating_state = hass.states.get(operating_entity_id) - if operating_state and operating_state.state in ("off", "false", "False", "0"): - is_hvac_idle = True - - if vtherm_slope is None: - _LOGGER.warning("%s is missing VTherm temperature_slope; skipping control cycle", climate_id) - return + _LOGGER.debug("No fan modes available yet for %s, retrying next cycle", climate_id) - if current_temp is None or target_temp is None: - _LOGGER.debug( - "Skipping control cycle for %s because temperature data is incomplete (current=%s, target=%s)", - climate_id, - current_temp, - target_temp, - ) + cycle_data = _read_climate_cycle_data(current_state, climate_id) + if cycle_data is None: return + vtherm_slope, current_temp, target_temp, hvac_mode, current_fan, is_window_open = cycle_data + is_hvac_idle = _detect_disturbances(hass, conf, controller) _LOGGER.debug( "Cycle start for %s: temp=%.2f target=%.2f slope=%.3f fan=%s hvac=%s window_open=%s", @@ -313,84 +393,75 @@ async def run_control_loop(_): ) decision = controller.calculate_decision( - float(current_temp), - float(target_temp), - float(vtherm_slope), - str(hvac_mode), + current_temp, + target_temp, + vtherm_slope, + hvac_mode, current_fan, is_window_open, is_hvac_idle, ) + shadow_decision = mpc_controller.evaluate( + current_temp=current_temp, + target_temp=target_temp, + vtherm_slope=vtherm_slope, + hvac_mode=hvac_mode, + current_fan=current_fan, + live_decision_fan=decision.get("fan_mode"), + is_window_open=is_window_open, + is_defrost_active=controller.is_defrost_active, + is_hvac_idle=is_hvac_idle, + minutes_since_change=decision.get("minutes_since_last_change", 0.0), + ) _LOGGER.debug( - "Cycle result for %s: live=%s reason=%s", + "Cycle result for %s: live=%s mpc=%s mpc_status=%s mpc_confidence=%s%%", climate_id, decision.get("fan_mode"), - decision.get("reason"), + shadow_decision.get("mpc_fan_mode"), + shadow_decision.get("mpc_status"), + shadow_decision.get("mpc_confidence"), ) if collector: - effective_slope = -float(vtherm_slope) if str(hvac_mode) == "cool" else float(vtherm_slope) - minutes_since = decision.get("minutes_since_last_change", 0.0) await collector.async_record( - hvac_mode=str(hvac_mode), - current_temp=float(current_temp), - target_temp=float(target_temp), - vtherm_slope=float(vtherm_slope), + hvac_mode=hvac_mode, + current_temp=current_temp, + target_temp=target_temp, + vtherm_slope=vtherm_slope, is_window_open=is_window_open, decision={**decision, "current_fan": current_fan}, - phase=controller.detect_phase(minutes_since), - effective_slope=effective_slope, + phase=controller.detect_phase(decision.get("minutes_since_last_change", 0.0)), + effective_slope=-float(vtherm_slope) if hvac_mode == "cool" else float(vtherm_slope), effective_timeout=controller.get_effective_timeout(), force=decision.get("reason", "").startswith(("Emergency", "Setpoint drop")), learning_ready=controller.learning_enabled and controller.learning.is_ready(), dead_time=controller.learning.get_dead_time() if controller.learning_enabled else 0.0, + shadow=shadow_decision, defrost_active=controller.is_defrost_active, is_hvac_idle=is_hvac_idle, ) - sensors = hass.data[DOMAIN][entry.entry_id].get("sensors") - if sensors: - _LOGGER.debug("Updating %d Smart Fan Controller sensors for %s", len(sensors), climate_id) - for sensor in sensors: - if hasattr(sensor, "update_from_controller"): - sensor.update_from_controller(decision) - sensor.async_write_ha_state() + for sensor in hass.data[DOMAIN][entry.entry_id].get("sensors") or []: + if hasattr(sensor, "update_from_controller"): + sensor.update_from_controller({**decision, **shadow_decision}) + sensor.async_write_ha_state() if ( controller.learning_enabled and controller.learning.is_ready() and not entry.data.get("learning_auto_applied", False) ): - optimal = controller.learning.compute_optimal_parameters() - if optimal: + learned_params = controller.learning.compute_optimal_parameters() + if learned_params: _LOGGER.info("Learning is ready for %s, scheduling auto-apply of learned parameters", climate_id) - hass.async_create_task(_apply_optimal_parameters(hass, entry, optimal)) + hass.async_create_task(_apply_optimal_parameters(hass, entry, learned_params)) - if decision["fan_mode"] != current_fan: - _LOGGER.info( - "Changing %s fan mode from %s to %s (%s)", - climate_id, - current_fan, - decision["fan_mode"], - decision["reason"], - ) - try: - await hass.services.async_call( - "climate", - "set_fan_mode", - {"entity_id": climate_id, "fan_mode": decision["fan_mode"]}, - blocking=True, - ) - controller.confirm_fan_change() - except Exception: # pylint: disable=broad-except - _LOGGER.exception( - "Failed to set fan mode %s on %s; cooldown not advanced", - decision["fan_mode"], - climate_id, - ) + effective_fan, effective_reason = _pick_effective_fan(mpc_controller, decision, shadow_decision) + if effective_fan != current_fan: + await _async_apply_fan_change(hass, climate_id, effective_fan, current_fan, effective_reason, controller) else: - _LOGGER.debug("No fan mode change required for %s (%s)", climate_id, decision["reason"]) + _LOGGER.debug("No fan mode change required for %s (%s)", climate_id, effective_reason) async def _handle_manual_change(event): """Track manual fan mode changes to reset the controller cooldown.""" @@ -415,10 +486,8 @@ async def _handle_manual_change(event): sensor.update_from_controller(manual_data) sensor.async_write_ha_state() - remove_timer = async_track_time_interval(hass, run_control_loop, timedelta(minutes=DELTA_TIME_CONTROL_LOOP)) - manual_change = async_track_state_change_event(hass, [climate_id], _handle_manual_change) - entry.async_on_unload(remove_timer) - entry.async_on_unload(manual_change) + entry.async_on_unload(async_track_time_interval(hass, run_control_loop, timedelta(minutes=DELTA_TIME_CONTROL_LOOP))) + entry.async_on_unload(async_track_state_change_event(hass, [climate_id], _handle_manual_change)) hass.async_create_task(run_control_loop(None)) @@ -433,8 +502,7 @@ async def periodic_save_learning(_): len(learning_data_to_save.get("response_events", [])), ) - remove_periodic_save = async_track_time_interval(hass, periodic_save_learning, LEARNING_DATA_SAVE_INTERVAL) - entry.async_on_unload(remove_periodic_save) + entry.async_on_unload(async_track_time_interval(hass, periodic_save_learning, LEARNING_DATA_SAVE_INTERVAL)) async def apply_learned_settings(_): """Service to apply optimal parameters from learning.""" diff --git a/custom_components/smart_fan_controller/config_flow.py b/custom_components/smart_fan_controller/config_flow.py index c4ec5e4..3c6f4de 100644 --- a/custom_components/smart_fan_controller/config_flow.py +++ b/custom_components/smart_fan_controller/config_flow.py @@ -17,6 +17,7 @@ CONF_LIMIT_TIMEOUT, CONF_LEARNING_ENABLED, CONF_DATA_COLLECTION, + CONF_MPC_PRODUCTION_ENABLED, CONF_DEFROST_ENTITY, CONF_OPERATING_ENTITY, DEFAULT_DEADBAND, @@ -26,6 +27,7 @@ DEFAULT_LIMIT_TIMEOUT, DEFAULT_LEARNING_ENABLED, DEFAULT_DATA_COLLECTION, + DEFAULT_MPC_PRODUCTION_ENABLED, ) @@ -119,6 +121,7 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con ), vol.Optional(CONF_LEARNING_ENABLED, default=DEFAULT_LEARNING_ENABLED): selector.BooleanSelector(), vol.Optional(CONF_DATA_COLLECTION, default=DEFAULT_DATA_COLLECTION): selector.BooleanSelector(), + vol.Optional(CONF_MPC_PRODUCTION_ENABLED, default=DEFAULT_MPC_PRODUCTION_ENABLED): 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"])), } @@ -195,6 +198,7 @@ async def async_step_init(self, user_input: dict[str, Any] | None = None) -> Con ), vol.Optional(CONF_LEARNING_ENABLED, default=current_data.get(CONF_LEARNING_ENABLED, DEFAULT_LEARNING_ENABLED)): selector.BooleanSelector(), vol.Optional(CONF_DATA_COLLECTION, default=current_data.get(CONF_DATA_COLLECTION, DEFAULT_DATA_COLLECTION)): selector.BooleanSelector(), + vol.Optional(CONF_MPC_PRODUCTION_ENABLED, default=current_data.get(CONF_MPC_PRODUCTION_ENABLED, DEFAULT_MPC_PRODUCTION_ENABLED)): 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"])), vol.Optional(CONF_OPERATING_ENTITY, default=current_data.get(CONF_OPERATING_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 ac8682b..1429f41 100644 --- a/custom_components/smart_fan_controller/const.py +++ b/custom_components/smart_fan_controller/const.py @@ -16,6 +16,7 @@ CONF_LIMIT_TIMEOUT = "limit_timeout" CONF_LEARNING_ENABLED = "learning_enabled" CONF_DATA_COLLECTION = "data_collection" +CONF_MPC_PRODUCTION_ENABLED = "mpc_production_enabled" CONF_DEFROST_ENTITY = "defrost_entity" CONF_OPERATING_ENTITY = "operating_entity" @@ -27,6 +28,7 @@ DEFAULT_LIMIT_TIMEOUT = 15 DEFAULT_LEARNING_ENABLED = True DEFAULT_DATA_COLLECTION = True +DEFAULT_MPC_PRODUCTION_ENABLED = False DELTA_TIME_CONTROL_LOOP = 2 # minutes between each control loop execution @@ -59,12 +61,40 @@ "projected_temperature": "temperature_projected_10_min", "projected_temperature_error": "temperature_projected_error_10_min", "temperature_error": "temperature_error", + # MPC sensors — old object_keys (mpc_shadow_*) → new canonical (mpc_*) + "mpc_shadow_status": "mpc_status", + "mpc_shadow_reason": "mpc_reason", + "mpc_shadow_fan_mode": "mpc_fan_mode", + "mpc_shadow_matches_live": "mpc_match", + "mpc_shadow_would_change_now": "mpc_would_change_now", + "mpc_shadow_cost": "mpc_cost", + "mpc_shadow_confidence": "mpc_confidence", + "mpc_shadow_predicted_temperature_10m": "mpc_predicted_temperature_10_min", + "mpc_shadow_predicted_temperature_30m": "mpc_predicted_temperature_30_min", + "mpc_shadow_dead_time": "mpc_dead_time", + "mpc_shadow_known_profiles": "mpc_known_profiles", + "mpc_shadow_disturbance_bias": "mpc_disturbance_bias", + # Current object_keys (identity mapping — required for extract_object_key_from_unique_id) + "mpc_status": "mpc_status", + "mpc_reason": "mpc_reason", + "mpc_fan_mode": "mpc_fan_mode", + "mpc_match": "mpc_match", + "mpc_would_change_now": "mpc_would_change_now", + "mpc_cost": "mpc_cost", + "mpc_confidence": "mpc_confidence", + "mpc_predicted_temperature_10_min": "mpc_predicted_temperature_10_min", + "mpc_predicted_temperature_30_min": "mpc_predicted_temperature_30_min", + "mpc_dead_time": "mpc_dead_time", + "mpc_known_profiles": "mpc_known_profiles", + "mpc_disturbance_bias": "mpc_disturbance_bias", "learning_progress": "learning_progress", "learning_status": "learning_status", "learning_samples": "learning_samples", "learning_response_events": "learning_response_events", "learned_dead_time": "learned_dead_time", "effective_timeout": "effective_timeout", + "mpc_profiles_heat": "mpc_heat_profiles", + "mpc_profiles_cool": "mpc_cool_profiles", "deadband": "learned_deadband", "soft_error": "learned_soft_error", "hard_error": "learned_hard_error", diff --git a/custom_components/smart_fan_controller/controller.py b/custom_components/smart_fan_controller/controller.py index b8d46df..92c79a5 100644 --- a/custom_components/smart_fan_controller/controller.py +++ b/custom_components/smart_fan_controller/controller.py @@ -126,6 +126,11 @@ def is_defrost_active(self) -> bool: return False return True + def activate_defrost(self) -> None: + """Activate defrost protection from an external entity trigger.""" + self._defrost_active = True + self._defrost_start_time = self._now + @property def _projected_error_threshold(self) -> float: """Calculate projected error threshold as midpoint between soft and hard error.""" diff --git a/custom_components/smart_fan_controller/data_collection.py b/custom_components/smart_fan_controller/data_collection.py index 52662bf..b1ed0a2 100644 --- a/custom_components/smart_fan_controller/data_collection.py +++ b/custom_components/smart_fan_controller/data_collection.py @@ -8,7 +8,10 @@ vtherm_slope, effective_slope, projected_temp, projected_error, phase, minutes_since_change, effective_timeout, current_fan, decided_fan, force, reason, learning_ready, dead_time, is_window_open, - defrost_active, hvac_idle + mpc_status, mpc_fan, mpc_match, + mpc_would_change, mpc_cost, mpc_confidence, + mpc_temp_10m, mpc_temp_30m, mpc_known_profiles, + mpc_disturbance, defrost_active, hvac_idle """ import asyncio @@ -42,6 +45,16 @@ "learning_ready", "dead_time", "is_window_open", + "mpc_status", + "mpc_fan", + "mpc_match", + "mpc_would_change", + "mpc_cost", + "mpc_confidence", + "mpc_temp_10m", + "mpc_temp_30m", + "mpc_known_profiles", + "mpc_disturbance", "defrost_active", "hvac_idle", ] @@ -79,10 +92,12 @@ async def async_record( force: bool, learning_ready: bool, dead_time: float, + shadow: dict | None = None, defrost_active: bool = False, is_hvac_idle: bool = False, ) -> None: """Append one row to the CSV file outside the event loop.""" + shadow = shadow or {} row = [ datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), hvac_mode, @@ -103,6 +118,22 @@ async def async_record( int(learning_ready), round(dead_time, 2), int(is_window_open), + shadow.get("mpc_status", "Disabled"), + shadow.get("mpc_fan_mode", ""), + shadow.get("mpc_matches_live", "n/a"), + shadow.get("mpc_would_change_now", "no"), + round(shadow.get("mpc_cost", 0.0), 3) if shadow.get("mpc_cost") is not None else "", + round(shadow.get("mpc_confidence", 0.0), 1) if shadow.get("mpc_confidence") is not None else "", + round(shadow.get("mpc_predicted_temperature_10m", 0.0), 3) + if shadow.get("mpc_predicted_temperature_10m") is not None + else "", + round(shadow.get("mpc_predicted_temperature_30m", 0.0), 3) + if shadow.get("mpc_predicted_temperature_30m") is not None + else "", + shadow.get("mpc_known_profiles", 0), + round(shadow.get("mpc_disturbance_bias", 0.0), 3) + if shadow.get("mpc_disturbance_bias") is not None + else "", int(defrost_active), int(is_hvac_idle), ] diff --git a/custom_components/smart_fan_controller/mpc_controller.py b/custom_components/smart_fan_controller/mpc_controller.py new file mode 100644 index 0000000..8051c74 --- /dev/null +++ b/custom_components/smart_fan_controller/mpc_controller.py @@ -0,0 +1,654 @@ +"""MPC controller: observation mode with optional production fan control.""" +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from .const import ( + DEAD_TIME_SAFETY_FACTOR, + DEFAULT_DEAD_TIME, + DELTA_TIME_CONTROL_LOOP, + PHASE_DEAD_TIME, + PHASE_ESTABLISHED, + PHASE_TRANSIENT, + THRESHOLD_TARGET_DROP, +) +from .thermal_learning import ThermalLearning + +_LOGGER = logging.getLogger(__name__) + +MIN_INTERVAL_CHANGE_PENALTY = 25.0 +DISTURBANCE_EMA_ALPHA = 0.2 +DISTURBANCE_DECAY = 0.85 +MAX_DISTURBANCE_BIAS = 2.0 +BASE_SWITCH_GAIN_MARGIN = 0.1 +NEAR_TARGET_SWITCH_GAIN_MARGIN = 0.3 +APPROACHING_TARGET_SWITCH_GAIN_MARGIN = 0.15 +PHASE_SWITCH_MARGIN_BONUS = 0.1 +STEP_SWITCH_MARGIN = 0.05 +UNDER_TARGET_STEPDOWN_GAIN_MARGIN = 0.2 +UNDER_TARGET_STEPDOWN_GAIN_PER_DEG = 0.5 +UNDER_TARGET_SHORTFALL_RESERVE = 0.1 +FLOOR_VIOLATION_LINEAR_WEIGHT = 12.0 +FLOOR_VIOLATION_QUADRATIC_WEIGHT = 30.0 + + +@dataclass(slots=True) +class ModeSimulation: + """One candidate fan-mode simulation over the MPC horizon.""" + + fan_mode: str + total_cost: float + predicted_temp_10m: float + predicted_temp_30m: float + known_profile: bool + + +class MPCController: + """Background-only learned model + MPC-lite scaffold.""" + + def __init__( + self, + *, + learning: ThermalLearning, + deadband: float, + min_interval: int, + fan_modes: list[str] | None = None, + enabled: bool = False, + horizon_minutes: int = 30, + cycle_minutes: int = DELTA_TIME_CONTROL_LOOP, + ) -> None: + self._learning = learning + self._deadband = deadband + self._min_interval = min_interval + self._fan_modes = fan_modes + self._enabled = enabled + self._production_mode = False + self._horizon_minutes = horizon_minutes + self._cycle_minutes = cycle_minutes + self._disturbance_bias = 0.0 + + @property + def enabled(self) -> bool: + """Return whether MPC observation mode is enabled.""" + return self._enabled + + @enabled.setter + def enabled(self, value: bool) -> None: + """Enable or disable MPC observation mode.""" + self._enabled = value + + @property + def production_mode(self) -> bool: + """Return whether production mode is active (MPC controls the fan).""" + return self._production_mode + + @production_mode.setter + def production_mode(self, value: bool) -> None: + """Enable or disable MPC production mode.""" + self._production_mode = value + + @property + def fan_modes(self) -> list[str] | None: + """Return the currently known fan modes.""" + return self._fan_modes + + @fan_modes.setter + def fan_modes(self, modes: list[str] | None) -> None: + """Update the available fan modes.""" + self._fan_modes = modes + + def evaluate( + self, + *, + current_temp: float, + target_temp: float, + vtherm_slope: float, + hvac_mode: str, + current_fan: str | None, + live_decision_fan: str | None, + is_window_open: bool = False, + is_defrost_active: bool = False, + is_hvac_idle: bool = False, + minutes_since_change: float = 0.0, + ) -> dict: + """Evaluate the best shadow fan mode for the current cycle.""" + _LOGGER.debug( + "Shadow evaluate: enabled=%s hvac=%s current_temp=%.2f target=%.2f slope=%.3f current_fan=%s live_decision=%s minutes_since_change=%.1f window_open=%s", + self._enabled, + hvac_mode, + current_temp, + target_temp, + vtherm_slope, + current_fan, + live_decision_fan, + minutes_since_change, + is_window_open, + ) + + if not self._enabled: + return self._payload( + status="Disabled", + fan_mode=current_fan, + reason="Shadow mode disabled", + matches_live="disabled", + would_change_now="no", + ) + + if hvac_mode in ("off", "dry", "fan_only"): + return self._payload( + status="Idle", + fan_mode=current_fan, + reason=f"HVAC mode '{hvac_mode}' is not simulated", + matches_live="n/a", + would_change_now="no", + ) + + fan_modes = self._fan_modes or ([] if current_fan is None else [current_fan]) + if not fan_modes: + return self._payload( + status="Unavailable", + fan_mode=current_fan, + reason="No fan modes available yet", + matches_live="n/a", + would_change_now="no", + ) + + 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 + dead_time = self._learning.get_dead_time() + 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( + active_fan, + hvac_mode, + active_fan, + current_effective_slope, + fan_modes, + ) + self._update_disturbance_bias( + observed_effective_slope=current_effective_slope, + expected_effective_slope=current_mode_slope, + known_profile=current_known_profile, + phase=phase, + is_window_open=is_window_open, + is_defrost_active=is_defrost_active, + is_hvac_idle=is_hvac_idle, + ) + + if is_window_open: + return self._payload( + status="Disturbed", + fan_mode=active_fan, + reason="Window open detected: shadow model paused", + matches_live="n/a", + would_change_now="no", + dead_time=dead_time, + disturbance_bias=self._disturbance_bias, + ) + + if is_defrost_active: + return self._payload( + status="Disturbed", + fan_mode=active_fan, + reason="Defrost active: shadow model paused", + matches_live="n/a", + would_change_now="no", + dead_time=dead_time, + disturbance_bias=self._disturbance_bias, + ) + + if is_hvac_idle: + return self._payload( + status="Disturbed", + fan_mode=active_fan, + reason="HVAC idle: compressor off, shadow model paused", + matches_live="n/a", + would_change_now="no", + dead_time=dead_time, + disturbance_bias=self._disturbance_bias, + ) + + # Setpoint drop: mirror the live controller's immediate minimum-speed rule. + # When the target moves far away (e.g. night setpoint), there is no point + # running the full MPC cost optimisation — the answer is always the lowest mode. + current_error = self._temperature_error(current_temp, target_temp, hvac_mode) + if current_error < THRESHOLD_TARGET_DROP: + lowest_fan = fan_modes[0] + matches_live = "n/a" if live_decision_fan is None else ("yes" if live_decision_fan == lowest_fan else "no") + would_change = "yes" if active_fan != lowest_fan else "no" + return self._payload( + status="Setpoint drop", + fan_mode=lowest_fan, + reason=f"Setpoint drop: target moved away ({current_error:.1f}°C), minimum speed", + matches_live=matches_live, + would_change_now=would_change, + dead_time=dead_time, + disturbance_bias=self._disturbance_bias, + ) + + simulations: list[ModeSimulation] = [] + known_profiles = 0 + current_index = fan_modes.index(active_fan) + + for fan_mode in fan_modes: + mode_slope, known_profile = self._get_mode_slope( + fan_mode, + hvac_mode, + active_fan, + current_effective_slope, + fan_modes, + ) + sim = self._simulate_mode( + current_temp=current_temp, + target_temp=target_temp, + hvac_mode=hvac_mode, + current_fan=active_fan, + candidate_fan=fan_mode, + current_effective_slope=current_effective_slope, + candidate_mode_slope=mode_slope, + dead_time=dead_time, + candidate_index=fan_modes.index(fan_mode), + current_index=current_index, + change_allowed=change_allowed, + known_profile=known_profile, + ) + simulations.append(sim) + known_profiles += int(known_profile) + + current_simulation = next(sim for sim in simulations if sim.fan_mode == active_fan) + best = min(simulations, key=lambda item: item.total_cost) + selection_note = "" + + 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 + elif change_allowed and best.fan_mode != active_fan: + best_index = fan_modes.index(best.fan_mode) + required_gain = self._required_switch_gain( + current_error=current_error, + phase=phase, + candidate_index=best_index, + current_index=current_index, + ) + actual_gain = current_simulation.total_cost - best.total_cost + if actual_gain < required_gain: + selection_note = ( + f"Hysteresis holds {active_fan}: {best.fan_mode} only improves by " + f"{actual_gain:.2f} < {required_gain:.2f}" + ) + best = current_simulation + else: + hold_note = self._step_down_hold_note( + candidate=best, + active_fan=active_fan, + hvac_mode=hvac_mode, + target_temp=target_temp, + current_error=current_error, + candidate_index=best_index, + current_index=current_index, + phase=phase, + ) + if hold_note: + selection_note = hold_note + best = current_simulation + + if change_allowed and best.fan_mode != active_fan: + limited_best = self._apply_step_down_limit( + best=best, + active_fan=active_fan, + simulations=simulations, + fan_modes=fan_modes, + ) + if limited_best.fan_mode != best.fan_mode: + if selection_note: + selection_note += " | " + selection_note += f"Step-down limited to {limited_best.fan_mode}" + best = limited_best + + confidence = self._compute_confidence(known_profiles, len(fan_modes), phase) + matches_live = "n/a" if live_decision_fan is None else ("yes" if live_decision_fan == best.fan_mode else "no") + would_change_now = "yes" if change_allowed and best.fan_mode != active_fan else "no" + status = "Ready" if confidence >= 0.5 else "Low confidence" + _LOGGER.debug( + "Shadow candidates: %s", + [ + ( + sim.fan_mode, + round(sim.total_cost, 3), + round(sim.predicted_temp_10m, 2), + round(sim.predicted_temp_30m, 2), + sim.known_profile, + ) + for sim in simulations + ], + ) + reason = ( + f"Shadow 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 selection_note: + reason += f" | {selection_note}" + if abs(self._disturbance_bias) >= 0.05: + reason += f" | Bias={self._disturbance_bias:+.2f}C/h" + if not change_allowed: + reason += ( + f" | Min interval active ({minutes_since_change:.1f}/" + f"{self._min_interval:.1f} min)" + ) + + _LOGGER.debug( + "Shadow result: active=%s best=%s status=%s confidence=%.2f known_profiles=%d/%d bias=%.3f note=%s", + active_fan, + best.fan_mode, + status, + confidence, + known_profiles, + len(fan_modes), + self._disturbance_bias, + selection_note or "none", + ) + + return self._payload( + status=status, + fan_mode=best.fan_mode, + reason=reason, + predicted_10m=best.predicted_temp_10m, + predicted_30m=best.predicted_temp_30m, + cost=best.total_cost, + confidence=confidence * 100.0, + matches_live=matches_live, + would_change_now=would_change_now, + dead_time=dead_time, + known_profiles=known_profiles, + disturbance_bias=self._disturbance_bias, + ) + + def _get_mode_slope( + self, + fan_mode: str, + hvac_mode: str, + current_fan: str, + current_effective_slope: float, + fan_modes: list[str], + ) -> tuple[float, bool]: + """Return the effective slope estimate for a candidate fan mode.""" + learned = self._learning.get_mode_effective_slope(fan_mode, hvac_mode) + if learned is not None: + _LOGGER.debug( + "Shadow slope model: using learned profile for %s/%s = %.3f", + hvac_mode, + fan_mode, + learned, + ) + return learned, True + + baseline_slope = max(current_effective_slope, 0.2) + current_rank = fan_modes.index(current_fan) + 1 + candidate_rank = fan_modes.index(fan_mode) + 1 + scaled = baseline_slope * (candidate_rank / max(current_rank, 1)) + _LOGGER.debug( + "Shadow slope model: using fallback for %s/%s = %.3f (baseline=%.3f current_fan=%s)", + hvac_mode, + fan_mode, + scaled, + baseline_slope, + current_fan, + ) + return scaled, False + + @staticmethod + def _detect_phase(minutes_since_change: float, dead_time: float) -> str: + """Classify the current prediction phase without relying on the live controller.""" + effective_dead_time = max(dead_time, DEFAULT_DEAD_TIME if dead_time <= 0 else dead_time) + if minutes_since_change < effective_dead_time: + return PHASE_DEAD_TIME + if minutes_since_change < effective_dead_time * DEAD_TIME_SAFETY_FACTOR: + return PHASE_TRANSIENT + return PHASE_ESTABLISHED + + def _update_disturbance_bias( + self, + *, + observed_effective_slope: float, + expected_effective_slope: float, + known_profile: bool, + phase: str, + is_window_open: bool, + is_defrost_active: bool = False, + is_hvac_idle: bool = False, + ) -> None: + """Track slow external disturbances such as solar gains or occupancy.""" + if is_window_open or is_defrost_active or is_hvac_idle: + self._disturbance_bias *= DISTURBANCE_DECAY + if is_window_open: + decay_reason = "window is open" + elif is_defrost_active: + decay_reason = "defrost is active" + else: + decay_reason = "HVAC compressor is idle" + _LOGGER.debug( + "Shadow disturbance bias decayed to %.3f because %s", + self._disturbance_bias, + decay_reason, + ) + return + + if not known_profile or phase != PHASE_ESTABLISHED: + self._disturbance_bias *= DISTURBANCE_DECAY + _LOGGER.debug( + "Shadow disturbance bias decayed to %.3f because known_profile=%s phase=%s", + self._disturbance_bias, + known_profile, + phase, + ) + return + + residual = observed_effective_slope - expected_effective_slope + updated = ((1 - DISTURBANCE_EMA_ALPHA) * self._disturbance_bias) + (DISTURBANCE_EMA_ALPHA * residual) + self._disturbance_bias = max(-MAX_DISTURBANCE_BIAS, min(MAX_DISTURBANCE_BIAS, updated)) + _LOGGER.debug( + "Shadow disturbance bias updated to %.3f (observed=%.3f expected=%.3f residual=%.3f)", + self._disturbance_bias, + observed_effective_slope, + expected_effective_slope, + residual, + ) + + def _simulate_mode( + self, + *, + current_temp: float, + target_temp: float, + hvac_mode: str, + current_fan: str, + candidate_fan: str, + current_effective_slope: float, + candidate_mode_slope: float, + dead_time: float, + candidate_index: int, + current_index: int, + change_allowed: bool, + known_profile: bool, + ) -> ModeSimulation: + """Simulate one constant fan mode over the prediction horizon.""" + steps = max(1, int(self._horizon_minutes / self._cycle_minutes)) + step_hours = self._cycle_minutes / 60.0 + blend = 0.45 + shadow_temp = current_temp + predicted_10m = None + predicted_30m = None + cost = 0.0 + thermal_power = current_effective_slope + change_delay = 0.0 if candidate_fan == current_fan else dead_time + candidate_effective_slope = candidate_mode_slope + self._disturbance_bias + + current_error = self._temperature_error(current_temp, target_temp, hvac_mode) + urgency_weight = 1.0 + max(current_error - self._deadband, 0.0) * 2.0 + + for step in range(1, steps + 1): + elapsed = step * self._cycle_minutes + if elapsed <= change_delay: + target_effective_slope = current_effective_slope + else: + target_effective_slope = candidate_effective_slope + + thermal_power += blend * (target_effective_slope - thermal_power) + raw_slope = -thermal_power if hvac_mode == "cool" else thermal_power + shadow_temp += step_hours * raw_slope + + if elapsed >= 10 and predicted_10m is None: + predicted_10m = shadow_temp + if elapsed >= 30 and predicted_30m is None: + predicted_30m = shadow_temp + + error = self._temperature_error(shadow_temp, target_temp, hvac_mode) + comfort_error = max(abs(error) - self._deadband, 0.0) + overshoot = max(-error, 0.0) + floor_violation = max(target_temp - shadow_temp, 0.0) if hvac_mode == "heat" else max(shadow_temp - target_temp, 0.0) + cost += comfort_error * urgency_weight + cost += 3.0 * overshoot * overshoot + cost += FLOOR_VIOLATION_LINEAR_WEIGHT * floor_violation * urgency_weight + cost += FLOOR_VIOLATION_QUADRATIC_WEIGHT * floor_violation * floor_violation + + cost += 0.15 * abs(candidate_index - current_index) + cost += 0.05 * (candidate_index + 1) + if candidate_fan != current_fan and not change_allowed: + cost += MIN_INTERVAL_CHANGE_PENALTY + + return ModeSimulation( + fan_mode=candidate_fan, + total_cost=cost, + predicted_temp_10m=current_temp if predicted_10m is None else predicted_10m, + predicted_temp_30m=current_temp if predicted_30m is None else predicted_30m, + known_profile=known_profile, + ) + + def _required_switch_gain( + self, + *, + current_error: float, + phase: str, + candidate_index: int, + current_index: int, + ) -> float: + """Return the minimum cost gain required before switching fan mode.""" + if current_error < -self._deadband: + margin = BASE_SWITCH_GAIN_MARGIN + elif current_error > (self._deadband * 2): + margin = BASE_SWITCH_GAIN_MARGIN + elif current_error > self._deadband: + margin = APPROACHING_TARGET_SWITCH_GAIN_MARGIN + else: + margin = NEAR_TARGET_SWITCH_GAIN_MARGIN + + if phase != PHASE_ESTABLISHED: + margin += PHASE_SWITCH_MARGIN_BONUS + + margin += STEP_SWITCH_MARGIN * abs(candidate_index - current_index) + + if candidate_index < current_index and current_error > 0: + margin += UNDER_TARGET_STEPDOWN_GAIN_MARGIN + margin += UNDER_TARGET_STEPDOWN_GAIN_PER_DEG * current_error + + return margin + + def _step_down_hold_note( + self, + *, + candidate: ModeSimulation, + active_fan: str, + hvac_mode: str, + target_temp: float, + current_error: float, + candidate_index: int, + current_index: int, + phase: str, + ) -> str | None: + """Return a note when a downward switch should be held despite lower cost.""" + if candidate_index >= current_index or current_error <= 0: + return None + + if phase != PHASE_ESTABLISHED: + return f"Below target: holding {active_fan} until the current response is established" + + predicted_error_10m = self._temperature_error(candidate.predicted_temp_10m, target_temp, hvac_mode) + reserve = max(self._deadband * 0.5, UNDER_TARGET_SHORTFALL_RESERVE) + if predicted_error_10m > reserve: + return ( + f"Below target: holding {active_fan} because {candidate.fan_mode} still leaves " + f"{predicted_error_10m:.2f}C shortfall at 10 min" + ) + + return None + + @staticmethod + def _apply_step_down_limit( + *, + best: ModeSimulation, + active_fan: str, + simulations: list[ModeSimulation], + fan_modes: list[str], + ) -> ModeSimulation: + """Allow at most one downward fan step per cycle, like the live controller. + + Upward moves are unrestricted so the MPC can ramp up aggressively. + """ + current_index = fan_modes.index(active_fan) + best_index = fan_modes.index(best.fan_mode) + + # Upward moves: no limit + if best_index >= current_index: + return best + + # Downward moves: limit to one step + if best_index >= current_index - 1: + return best + + limited_fan = fan_modes[current_index - 1] + return next(sim for sim in simulations if sim.fan_mode == limited_fan) + + def _compute_confidence(self, known_profiles: int, total_profiles: int, phase: str) -> float: + """Return a coarse confidence score for the current recommendation.""" + coverage = known_profiles / max(total_profiles, 1) + base = 1.0 if self._learning.is_ready() else 0.45 + phase_factor = 1.0 if phase == PHASE_ESTABLISHED else 0.85 + disturbance_penalty = min(abs(self._disturbance_bias) / MAX_DISTURBANCE_BIAS, 0.35) + return max(0.1, min(1.0, (base * (0.5 + 0.5 * coverage) * phase_factor) - disturbance_penalty)) + + def _payload( + self, + *, + status: str, + fan_mode: str | None, + reason: str, + predicted_10m: float | None = None, + predicted_30m: float | None = None, + cost: float | None = None, + confidence: float | None = None, + matches_live: str = "n/a", + would_change_now: str = "no", + dead_time: float | None = None, + known_profiles: int = 0, + disturbance_bias: float | None = None, + ) -> dict: + """Build the shadow payload injected into sensors and CSV logs.""" + return { + "mpc_status": status, + "mpc_fan_mode": fan_mode, + "mpc_reason": reason, + "mpc_predicted_temperature_10m": round(predicted_10m, 2) if predicted_10m is not None else None, + "mpc_predicted_temperature_30m": round(predicted_30m, 2) if predicted_30m is not None else None, + "mpc_cost": round(cost, 3) if cost is not None else None, + "mpc_confidence": round(confidence, 1) if confidence is not None else None, + "mpc_matches_live": matches_live, + "mpc_would_change_now": would_change_now, + "mpc_dead_time": round(dead_time, 2) if dead_time is not None else None, + "mpc_known_profiles": known_profiles, + "mpc_disturbance_bias": round(disturbance_bias, 3) if disturbance_bias is not None else None, + } + + @staticmethod + def _temperature_error(temp: float, target_temp: float, hvac_mode: str) -> float: + """Return the signed comfort error aligned with the active HVAC mode.""" + return (temp - target_temp) if hvac_mode == "cool" else (target_temp - temp) diff --git a/custom_components/smart_fan_controller/sensor.py b/custom_components/smart_fan_controller/sensor.py index 467952a..7d26b79 100644 --- a/custom_components/smart_fan_controller/sensor.py +++ b/custom_components/smart_fan_controller/sensor.py @@ -68,42 +68,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e sensor_definitions = [ ("Status", "status", "reason", None, None, "mdi:information-outline", EntityCategory.DIAGNOSTIC), ("Fan Mode", "fan_mode", "fan_mode", None, SensorDeviceClass.ENUM, "mdi:fan", None), - ( - "Fan Mode Last Change", - "fan_mode_last_change", - "minutes_since_last_change", - UnitOfTime.MINUTES, - SensorDeviceClass.DURATION, - "mdi:clock-outline", - EntityCategory.DIAGNOSTIC, - ), - ( - "Temperature Projected 10 Min", - "temperature_projected_10_min", - "projected_temperature", - UnitOfTemperature.CELSIUS, - SensorDeviceClass.TEMPERATURE, - "mdi:chart-bell-curve", - EntityCategory.DIAGNOSTIC, - ), - ( - "Temperature Projected Error 10 Min", - "temperature_projected_error_10_min", - "projected_temperature_error", - UnitOfTemperature.CELSIUS, - SensorDeviceClass.TEMPERATURE, - "mdi:chart-bell-curve", - EntityCategory.DIAGNOSTIC, - ), - ( - "Temperature Error", - "temperature_error", - "temperature_error", - UnitOfTemperature.CELSIUS, - SensorDeviceClass.TEMPERATURE, - "mdi:thermometer-lines", - EntityCategory.DIAGNOSTIC, - ), + ("Fan Mode Last Change", "fan_mode_last_change", "minutes_since_last_change", UnitOfTime.MINUTES, SensorDeviceClass.DURATION, "mdi:clock-outline", EntityCategory.DIAGNOSTIC), + ("Temperature Projected 10 Min", "temperature_projected_10_min", "projected_temperature", UnitOfTemperature.CELSIUS, SensorDeviceClass.TEMPERATURE, "mdi:chart-bell-curve", EntityCategory.DIAGNOSTIC), + ("Temperature Projected Error 10 Min", "temperature_projected_error_10_min", "projected_temperature_error", UnitOfTemperature.CELSIUS, SensorDeviceClass.TEMPERATURE, "mdi:chart-bell-curve", EntityCategory.DIAGNOSTIC), + ("Temperature Error", "temperature_error", "temperature_error", UnitOfTemperature.CELSIUS, SensorDeviceClass.TEMPERATURE, "mdi:thermometer-lines", EntityCategory.DIAGNOSTIC), + ("MPC Status", "mpc_status", "mpc_status", None, None, "mdi:robot-outline", EntityCategory.DIAGNOSTIC), + ("MPC Reason", "mpc_reason", "mpc_reason", None, None, "mdi:text-box-search-outline", EntityCategory.DIAGNOSTIC), + ("MPC Fan Mode", "mpc_fan_mode", "mpc_fan_mode", None, None, "mdi:fan-chevron-up", EntityCategory.DIAGNOSTIC), + ("MPC Match", "mpc_match", "mpc_matches_live", None, None, "mdi:compare", EntityCategory.DIAGNOSTIC), + ("MPC Would Change Now", "mpc_would_change_now", "mpc_would_change_now", None, None, "mdi:swap-horizontal", EntityCategory.DIAGNOSTIC), + ("MPC Cost", "mpc_cost", "mpc_cost", None, None, "mdi:calculator", EntityCategory.DIAGNOSTIC), + ("MPC Confidence", "mpc_confidence", "mpc_confidence", PERCENTAGE, None, "mdi:chart-line", EntityCategory.DIAGNOSTIC), + ("MPC Predicted Temperature 10 Min", "mpc_predicted_temperature_10_min", "mpc_predicted_temperature_10m", UnitOfTemperature.CELSIUS, SensorDeviceClass.TEMPERATURE, "mdi:chart-timeline-variant", EntityCategory.DIAGNOSTIC), + ("MPC Predicted Temperature 30 Min", "mpc_predicted_temperature_30_min", "mpc_predicted_temperature_30m", UnitOfTemperature.CELSIUS, SensorDeviceClass.TEMPERATURE, "mdi:chart-timeline-variant", EntityCategory.DIAGNOSTIC), + ("MPC Dead Time", "mpc_dead_time", "mpc_dead_time", UnitOfTime.MINUTES, SensorDeviceClass.DURATION, "mdi:timer-sand", EntityCategory.DIAGNOSTIC), + ("MPC Known Profiles", "mpc_known_profiles", "mpc_known_profiles", None, None, "mdi:database-search-outline", EntityCategory.DIAGNOSTIC), + ("MPC Disturbance Bias", "mpc_disturbance_bias", "mpc_disturbance_bias", EFFECTIVE_SLOPE_UNIT, None, "mdi:weather-windy", EntityCategory.DIAGNOSTIC), ] entities: list[SensorEntity] = [ @@ -119,6 +99,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e SmartFanLearningResponseSensor(entry.entry_id, controller), SmartFanLearnedDeadTimeSensor(entry.entry_id, controller), SmartFanEffectiveTimeoutSensor(entry.entry_id, controller), + SmartFanMpcProfilesSensor(entry.entry_id, controller, "heat"), + SmartFanMpcProfilesSensor(entry.entry_id, controller, "cool"), SmartFanLearnedDeadbandSensor(entry.entry_id, controller), SmartFanLearnedSoftErrorSensor(entry.entry_id, controller), SmartFanLearnedHardErrorSensor(entry.entry_id, controller), @@ -381,6 +363,42 @@ def extra_state_attributes(self) -> dict: } +class SmartFanMpcProfilesSensor(_SmartFanEntity): + """Sensor exposing the learned per-mode profiles used by the shadow MPC.""" + + def __init__(self, entry_id: str, controller, hvac_mode: str) -> None: + self._entry_id = entry_id + self._controller = controller + self._hvac_mode = hvac_mode + self._attr_name = f"MPC {hvac_mode.title()} Profiles" + self._attr_unique_id = build_unique_id(f"mpc_{hvac_mode}_profiles", entry_id) + self._attr_icon = "mdi:chart-timeline-variant-shimmer" + self._attr_entity_category = EntityCategory.DIAGNOSTIC + self.entity_id = build_entity_id("sensor", f"mpc_{hvac_mode}_profiles") + + @property + def native_value(self) -> int: + """Return the number of reliable profiles currently available.""" + profiles = self._controller.learning.get_mode_profiles(self._hvac_mode, self._controller.fan_modes) + return sum(1 for profile in profiles.values() if profile["ready"]) + + @property + def extra_state_attributes(self) -> dict: + """Expose the learned effective slope for each fan mode.""" + profiles = self._controller.learning.get_mode_profiles(self._hvac_mode, self._controller.fan_modes) + return { + "hvac_mode": self._hvac_mode, + "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, + "profile_effective_slope_sensors": { + fan_mode: build_entity_id("sensor", _profile_effective_slope_object_key(self._hvac_mode, fan_mode)) + for fan_mode in profiles + }, + "profiles": profiles, + } + + class SmartFanProfileEffectiveSlopeSensor(_SmartFanEntity): """Historized per-profile effective slope sensor for one HVAC/fan combination.""" diff --git a/custom_components/smart_fan_controller/switch.py b/custom_components/smart_fan_controller/switch.py index 6b7b2cc..f151423 100644 --- a/custom_components/smart_fan_controller/switch.py +++ b/custom_components/smart_fan_controller/switch.py @@ -12,6 +12,7 @@ from .const import ( CONF_LEARNING_ENABLED, + CONF_MPC_PRODUCTION_ENABLED, DEVICE_NAME, DOMAIN, build_entity_id, @@ -40,9 +41,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e """Set up the switch platform from a config entry.""" data = hass.data[DOMAIN][entry.entry_id] controller = data["controller"] + mpc_controller = data["mpc_controller"] entities = [ SmartFanLearningSwitch(entry.entry_id, controller, entry, hass), + SmartFanMpcProductionSwitch(entry.entry_id, mpc_controller, entry, hass), ] async_add_entities(entities) @@ -88,3 +91,43 @@ async def async_turn_off(self, **kwargs) -> None: _LOGGER.info("Learning disabled for %s", self._entry.entry_id) self.async_write_ha_state() + +class SmartFanMpcProductionSwitch(_SmartFanSwitchEntity): # pylint: disable=abstract-method + """Switch to enable MPC production mode (MPC controls the fan).""" + + def __init__(self, entry_id: str, shadow_controller, entry: ConfigEntry, hass: HomeAssistant) -> None: + self._entry_id = entry_id + self._shadow_controller = shadow_controller + self._entry = entry + self._hass = hass + + self._attr_name = "MPC Production Mode" + self._attr_unique_id = build_unique_id("mpc_production_mode", entry_id) + self._attr_icon = "mdi:robot-outline" + self._attr_entity_category = EntityCategory.CONFIG + self.entity_id = build_entity_id("switch", "mpc_production_mode") + + @property + def is_on(self) -> bool: + """Return true if MPC production mode is enabled.""" + return self._shadow_controller.production_mode + + async def async_turn_on(self, **kwargs) -> None: + """Enable MPC production mode (MPC controls the fan).""" + self._shadow_controller.production_mode = True + self._hass.config_entries.async_update_entry( + self._entry, + options={**self._entry.options, CONF_MPC_PRODUCTION_ENABLED: True}, + ) + _LOGGER.info("MPC production mode enabled for %s", self._entry.entry_id) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs) -> None: + """Disable MPC production mode (shadow only).""" + self._shadow_controller.production_mode = False + self._hass.config_entries.async_update_entry( + self._entry, + options={**self._entry.options, CONF_MPC_PRODUCTION_ENABLED: False}, + ) + _LOGGER.info("MPC production mode disabled for %s", self._entry.entry_id) + self.async_write_ha_state() diff --git a/custom_components/smart_fan_controller/translations/en.json b/custom_components/smart_fan_controller/translations/en.json index cd4fd1c..bd358f4 100644 --- a/custom_components/smart_fan_controller/translations/en.json +++ b/custom_components/smart_fan_controller/translations/en.json @@ -13,6 +13,7 @@ "limit_timeout": "Interval Before Forcing Change (minutes)", "learning_enabled": "Enable Automatic Learning", "data_collection": "Enable Data Collection (beta)", + "mpc_production_enabled": "Enable MPC Production Mode", "defrost_entity": "Defrost Entity (optional)", "operating_entity": "Heat Pump Operating Entity (optional)" }, @@ -25,6 +26,7 @@ "limit_timeout": "Range: 10-120 min. Default: 15 min.", "learning_enabled": "Enable automatic learning of optimal parameters. When ready, settings will be applied automatically.", "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.", + "mpc_production_enabled": "When enabled, the MPC controller directly controls the fan mode instead of the rule-based algorithm. The MPC shadow observation always runs regardless of this setting.", "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." }, @@ -49,6 +51,7 @@ "limit_timeout": "Interval Before Forcing Change (minutes)", "learning_enabled": "Enable Automatic Learning", "data_collection": "Enable Data Collection (beta)", + "mpc_production_enabled": "Enable MPC Production Mode", "defrost_entity": "Defrost Entity (optional)", "operating_entity": "Heat Pump Operating Entity (optional)" }, @@ -61,6 +64,7 @@ "limit_timeout": "Range: 10-120 min. Default: 15 min.", "learning_enabled": "Enable automatic learning of optimal parameters. When ready, settings will be applied automatically.", "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.", + "mpc_production_enabled": "When enabled, the MPC controller directly controls the fan mode instead of the rule-based algorithm. The MPC shadow observation always runs regardless of this setting.", "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 5efa2df..2912a08 100644 --- a/custom_components/smart_fan_controller/translations/fr.json +++ b/custom_components/smart_fan_controller/translations/fr.json @@ -13,6 +13,7 @@ "limit_timeout": "Intervalle avant de forcer un changement (minutes)", "learning_enabled": "Activer l'apprentissage automatique", "data_collection": "Activer la collecte de données (bêta)", + "mpc_production_enabled": "Activer le mode production MPC", "defrost_entity": "Entité dégivrage (optionnel)", "operating_entity": "Entité fonctionnement PAC (optionnel)" }, @@ -25,6 +26,7 @@ "limit_timeout": "Plage : 10-120 min. Défaut : 15 min.", "learning_enabled": "Activer l'apprentissage automatique des paramètres optimaux. Quand prêt, les paramètres seront appliqués automatiquement.", "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.", + "mpc_production_enabled": "Lorsqu'activé, le contrôleur MPC pilote directement le mode ventilateur au lieu de l'algorithme à règles. L'observation MPC shadow fonctionne toujours, quel que soit ce réglage.", "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." }, @@ -49,6 +51,7 @@ "limit_timeout": "Intervalle avant de forcer un changement (minutes)", "learning_enabled": "Activer l'apprentissage automatique", "data_collection": "Activer la collecte de données (bêta)", + "mpc_production_enabled": "Activer le mode production MPC", "defrost_entity": "Entité dégivrage (optionnel)", "operating_entity": "Entité fonctionnement PAC (optionnel)" }, @@ -61,6 +64,7 @@ "limit_timeout": "Plage : 10-120 min. Défaut : 15 min.", "learning_enabled": "Activer l'apprentissage automatique des paramètres optimaux. Quand prêt, les paramètres seront appliqués automatiquement.", "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.", + "mpc_production_enabled": "Lorsqu'activé, le contrôleur MPC pilote directement le mode ventilateur au lieu de l'algorithme à règles. L'observation MPC shadow fonctionne toujours, quel que soit ce réglage.", "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." }, @@ -72,4 +76,4 @@ } } } -} +} \ No newline at end of file diff --git a/docs/mpc_mode.md b/docs/mpc_mode.md new file mode 100644 index 0000000..45bb045 --- /dev/null +++ b/docs/mpc_mode.md @@ -0,0 +1,206 @@ +# MPC Controller — Shadow Observation and Production Mode + +## Purpose + +The MPC controller runs alongside the rule-based heuristic every cycle. +It maintains a learned thermal model, scores every candidate fan mode, and produces a recommendation. + +The integration offers two operating modes: + +- **MPC observation mode** (default): the MPC recommendation is logged and exposed as diagnostics only. + The rule-based heuristic remains solely responsible for sending `set_fan_mode` commands. + This is the low-risk path to validate the predictive controller before activating it. + +- **Production mode**: when enabled via `switch.smart_fan_controller_mpc_production_mode`, the MPC + recommendation overrides the heuristic fan decision for every cycle where the MPC controller + produces a confident, actionable result. The heuristic decision is still computed and logged for + comparison, but the real `set_fan_mode` command uses the MPC-selected mode. + +## Goals + +- Learn the thermal behavior of a specific room with minimal manual tuning. +- Reuse the data already collected by the integration. +- Compare heuristic decisions and predictive decisions side by side. +- Keep the controller explainable and easy to debug from Home Assistant diagnostics. + +## Non-Goals + +- No reinforcement learning or opaque black-box model is introduced. +- No second-order slope terms: VTherm slope is already EMA-smoothed, so the model avoids parabolic + projection that would amplify noise. + +## Runtime Architecture + +Each control cycle follows this flow: + +1. Read VTherm temperature, target, slope, HVAC mode, and current fan mode. +2. Run the existing heuristic controller and record its decision. +3. Run `MPCController.evaluate(...)` with the same inputs. +4. Merge the MPC diagnostics into the sensor payload. +5. Append both heuristic and MPC information to the CSV log. +6. **MPC observation mode**: apply the heuristic fan change, if any. + **Production mode**: apply the MPC fan recommendation when the MPC status is `Ready` or + `Setpoint drop`; fall back to the heuristic otherwise (e.g. status `Low confidence`, + `Disturbed`, `Disabled`, `Idle`). + +### HA Entities + +| Platform | Entity ID | Purpose | +|----------|-----------|---------| +| `switch` | `switch.smart_fan_controller_mpc_production_mode` | Enable/disable MPC production override | +| `sensor` | `sensor.smart_fan_controller_mpc_status` | Current MPC recommendation status | +| `sensor` | `sensor.smart_fan_controller_mpc_reason` | Human-readable reason for the recommendation | +| `sensor` | `sensor.smart_fan_controller_mpc_fan_mode` | Recommended fan mode | +| `sensor` | `sensor.smart_fan_controller_mpc_match` | Whether MPC and heuristic agree | +| `sensor` | `sensor.smart_fan_controller_mpc_would_change_now` | Whether MPC would change the fan this cycle | +| `sensor` | `sensor.smart_fan_controller_mpc_cost` | Best cost score for the recommended mode | +| `sensor` | `sensor.smart_fan_controller_mpc_confidence` | Confidence percentage | +| `sensor` | `sensor.smart_fan_controller_mpc_predicted_temperature_10_min` | 10-minute temperature forecast | +| `sensor` | `sensor.smart_fan_controller_mpc_predicted_temperature_30_min` | 30-minute temperature forecast | +| `sensor` | `sensor.smart_fan_controller_mpc_dead_time` | Dead time used by the simulator (min) | +| `sensor` | `sensor.smart_fan_controller_mpc_known_profiles` | Number of reliable learned profiles | +| `sensor` | `sensor.smart_fan_controller_mpc_disturbance_bias` | Slow disturbance correction term | +| `sensor` | `sensor.smart_fan_controller_mpc_heat_profiles` | Per-mode learned profiles for heat | +| `sensor` | `sensor.smart_fan_controller_mpc_cool_profiles` | Per-mode learned profiles for cool | + +CSV log fields are prefixed with `mpc_*`. + +Even while it shares the same learning backend today, the MPC controller owns its own runtime parameters (`deadband`, `min_interval`, `fan_modes`) and no longer depends on protected members of the live heuristic controller. + +## Learned Thermal Model + +The current version uses a temperature-state model driven by learned fan-mode gains, a learned dead time, and a disturbance correction term. +It still consumes `temperature_slope`, but the slope is treated as an observation that helps estimate thermal power, not as the final state to optimize directly. + +Definitions: + +- `T_hat[k]`: predicted room temperature at step `k` +- `q_hat[k]`: predicted effective thermal power at step `k` (expressed in equivalent `°C/h`) +- `u`: candidate fan mode held constant over the horizon +- `dead_time`: learned thermal delay between a fan change and a visible sensor response +- `bias`: slow disturbance correction term for unmodeled effects such as solar gain or occupancy + +For each candidate mode: + +```text +if elapsed <= dead_time: + target_power = current_effective_power +else: + target_power = learned_mode_power(candidate_mode) + bias + +q_hat[k+1] = q_hat[k] + alpha * (target_power - q_hat[k]) +T_hat[k+1] = T_hat[k] + delta_t_hours * raw_slope(q_hat[k+1], hvac_mode) +``` + +Where: + +- `alpha = 0.35` in the current scaffold +- `delta_t = 2 min` +- `raw_slope = +q_hat` in heating and `-q_hat` in cooling + +### Source of Learned Parameters + +The model reuses the current learning subsystem: + +- `learning.get_dead_time()` for thermal delay +- `learning.get_mode_effective_slope(fan_mode, hvac_mode)` for reliable per-mode profiles + +If a reliable profile is not yet available for a fan mode, the MPC model falls back to a coarse rank-based estimate derived from the current slope. Confidence is lowered accordingly. + +### Disturbance Handling + +When the current fan mode has a reliable learned profile and the controller is in `ESTABLISHED` phase, the MPC model estimates a slow disturbance bias from the residual: + +```text +residual = observed_effective_power - expected_effective_power +bias[k+1] = (1 - beta) * bias[k] + beta * residual +``` + +This helps the MPC model remain usable when the room is slightly helped or hindered by effects not directly modeled by the fan itself. + +When a window is detected as open, the MPC model does not trust its own predictions and switches to a `Disturbed` status instead of producing an actionable recommendation. + +## MPC-lite Decision Rule + +The controller simulates every available fan mode on a short fixed horizon of 30 minutes. +The current scaffold keeps the action constant over the horizon to stay simple and fast. +The simulator supports both `heat` and `cool`; cooling uses the same learned effective-power model with the sign inverted back to room-temperature evolution. + +Each candidate mode gets a scalar cost: + +```text +J(mode) = + sum(comfort_error) + + 4 * sum(overshoot^2) + + 0.4 * fan_step_distance + + 0.15 * fan_energy_rank + + min_interval_penalty +``` + +Where: + +- `comfort_error = max(abs(error) - deadband, 0)` +- `overshoot = max(-error, 0)` +- `fan_step_distance` penalizes unnecessary fan jumps +- `fan_energy_rank` lightly discourages staying on the highest modes all the time +- `min_interval_penalty` keeps the MPC recommendation aligned with the same actuator guardrail as the live controller + +In addition, the current implementation applies a mode-independent floor penalty when the predicted room temperature drops below the setpoint. This reflects a conservative comfort rule: if the target is `20°C`, predictions below `20°C` are considered increasingly unacceptable in both `heat` and `cool`. + +The selected mode is the one with the lowest total cost. +To avoid fan yo-yo near the setpoint, a recommendation that changes the fan must also beat the current mode by a minimum gain. If the gain is only marginal, the MPC controller keeps the current fan and reports that hysteresis blocked the switch. + +## Shadow Diagnostics + +The current implementation exposes: + +- recommendation status +- recommendation reason +- recommended fan mode +- whether it matches the live heuristic decision +- whether it would actually change the fan now +- 10-minute and 30-minute predicted temperatures +- confidence percentage +- dead time used by the simulator +- number of reliable learned profiles available +- current disturbance bias estimate + +The CSV log also stores the MPC recommendation so we can replay and compare decisions offline. + +## Validation Strategy + +The MPC controller should stay in observation mode until it meets clear quality gates. +Recommended promotion criteria: + +- stable behavior over at least 7 days +- lower or equal overshoot than the heuristic controller +- no increase in fan oscillations +- acceptable prediction quality on live data +- confidence high enough on the modes frequently used by the room + +Suggested metrics to track: + +- MAE at 10 minutes +- MAE at 30 minutes +- percentage of cycles where MPC and heuristic agree +- number of hypothetical fan changes per day +- overshoot duration above target + +## Implementation Map + +Current files involved: + +- `custom_components/smart_fan_controller/mpc_controller.py`: MPC thermal model and MPC-lite scorer +- `custom_components/smart_fan_controller/controller.py`: filters disturbed response-time learning at the source +- `custom_components/smart_fan_controller/__init__.py`: runtime coexistence and sensor payload merge +- `custom_components/smart_fan_controller/sensor.py`: MPC diagnostics exposed in Home Assistant +- `custom_components/smart_fan_controller/switch.py`: switch to enable or disable MPC production mode (`switch.smart_fan_controller_mpc_production_mode`) +- `custom_components/smart_fan_controller/data_collection.py`: MPC fields appended to the CSV log + +## Next Steps + +After enough MPC validation and before enabling production mode: + +1. improve the thermal model with residual correction and better disturbance handling +2. add offline replay tooling against recorded CSV traces +3. verify MPC metrics are consistently good before enabling `switch.smart_fan_controller_mpc_production_mode` diff --git a/scripts/analyze_mpc_viability.py b/scripts/analyze_mpc_viability.py new file mode 100644 index 0000000..df206f2 --- /dev/null +++ b/scripts/analyze_mpc_viability.py @@ -0,0 +1,531 @@ +#!/usr/bin/env python3 +"""Analyse approfondie de la viabilité du MPC shadow vs algo de prod. + +Charge les données CSV du dossier data/ et produit: +- Statistiques globales sur les décisions live vs MPC shadow +- Analyse de la réactivité (temps pour atteindre la consigne) +- Distribution des vitesses de ventilateur +- Épisodes de divergence live/MPC +- Évaluation de l'agressivité du MPC +""" +import csv +import os +import sys +from collections import Counter, defaultdict +from datetime import datetime, timezone + +DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data") +MAIN_CSV = os.path.join(DATA_DIR, "smart_fan_controller_data_01KK6PQT.csv") + +FAN_ORDER = ["silent", "low", "med", "high", "superhigh"] + + +def fan_rank(mode: str) -> int: + try: + return FAN_ORDER.index(mode) + except ValueError: + return -1 + + +def load_main_data(): + rows = [] + with open(MAIN_CSV, newline="") as f: + reader = csv.DictReader(f) + for row in reader: + try: + row["_ts"] = datetime.fromisoformat(row["timestamp"].replace("Z", "+00:00")) + row["_current_temp"] = float(row["current_temp"]) + row["_target_temp"] = float(row["target_temp"]) + row["_current_error"] = float(row["current_error"]) + row["_effective_slope"] = float(row["effective_slope"]) + row["_vtherm_slope"] = float(row["vtherm_slope"]) + row["_minutes_since_change"] = float(row["minutes_since_change"]) + row["_mpc_shadow_cost"] = float(row["mpc_shadow_cost"]) if row.get("mpc_shadow_cost") else None + row["_mpc_shadow_confidence"] = float(row["mpc_shadow_confidence"]) if row.get("mpc_shadow_confidence") else None + row["_mpc_shadow_temp_10m"] = float(row["mpc_shadow_temp_10m"]) if row.get("mpc_shadow_temp_10m") else None + row["_mpc_shadow_temp_30m"] = float(row["mpc_shadow_temp_30m"]) if row.get("mpc_shadow_temp_30m") else None + row["_projected_temp"] = float(row["projected_temp"]) if row.get("projected_temp") else None + row["_projected_error"] = float(row["projected_error"]) if row.get("projected_error") else None + except (ValueError, KeyError): + continue + rows.append(row) + return rows + + +def load_slope_data(): + slopes = {} + for fname in os.listdir(DATA_DIR): + if fname.startswith("mpc_") and fname.endswith("_slope.csv"): + mode = fname.removeprefix("mpc_").removesuffix("_slope.csv") + entries = [] + with open(os.path.join(DATA_DIR, fname), newline="") as f: + reader = csv.DictReader(f) + for row in reader: + try: + entries.append({ + "ts": datetime.fromisoformat(row["last_changed"].replace("Z", "+00:00")), + "slope": float(row["state"]), + }) + except (ValueError, KeyError): + continue + slopes[mode] = entries + return slopes + + +def print_separator(title=""): + print(f"\n{'=' * 70}") + if title: + print(f" {title}") + print(f"{'=' * 70}") + + +def analyze_basic_stats(rows): + print_separator("1. STATISTIQUES GÉNÉRALES") + ts_start = rows[0]["_ts"] + ts_end = rows[-1]["_ts"] + duration_h = (ts_end - ts_start).total_seconds() / 3600 + print(f" Période : {ts_start.strftime('%Y-%m-%d %H:%M')} → {ts_end.strftime('%Y-%m-%d %H:%M')}") + print(f" Durée : {duration_h:.1f} h ({duration_h / 24:.1f} jours)") + print(f" Nombre points : {len(rows)}") + print(f" Intervalle : ~{(duration_h * 60) / len(rows):.1f} min") + + hvac_modes = Counter(r["hvac_mode"] for r in rows) + print(f" Modes HVAC : {dict(hvac_modes)}") + + phases = Counter(r["phase"] for r in rows) + print(f" Phases : {dict(phases)}") + + temps = [r["_current_temp"] for r in rows] + targets = list(set(r["_target_temp"] for r in rows)) + print(f" Temp range : {min(temps):.1f}°C → {max(temps):.1f}°C") + print(f" Consignes : {sorted(targets)}") + + +def analyze_fan_distribution(rows): + print_separator("2. DISTRIBUTION DES VITESSES") + + live_fans = Counter(r["current_fan"] for r in rows) + mpc_fans = Counter(r["mpc_shadow_fan"] for r in rows if r.get("mpc_shadow_fan")) + decided_fans = Counter(r["decided_fan"] for r in rows) + + print(" Ventilateur actuel (current_fan):") + total = sum(live_fans.values()) + for mode in FAN_ORDER: + cnt = live_fans.get(mode, 0) + pct = (cnt / total * 100) if total else 0 + bar = "█" * int(pct / 2) + print(f" {mode:12s}: {cnt:5d} ({pct:5.1f}%) {bar}") + + print("\n Décision live (decided_fan):") + total = sum(decided_fans.values()) + for mode in FAN_ORDER: + cnt = decided_fans.get(mode, 0) + pct = (cnt / total * 100) if total else 0 + bar = "█" * int(pct / 2) + print(f" {mode:12s}: {cnt:5d} ({pct:5.1f}%) {bar}") + + print("\n Recommandation MPC shadow (mpc_shadow_fan):") + total = sum(mpc_fans.values()) + for mode in FAN_ORDER: + cnt = mpc_fans.get(mode, 0) + pct = (cnt / total * 100) if total else 0 + bar = "█" * int(pct / 2) + print(f" {mode:12s}: {cnt:5d} ({pct:5.1f}%) {bar}") + + # Aggressiveness index: mean rank + live_mean_rank = sum(fan_rank(r["current_fan"]) for r in rows if fan_rank(r["current_fan"]) >= 0) / max(1, len(rows)) + mpc_mean_rank = sum(fan_rank(r["mpc_shadow_fan"]) for r in rows if r.get("mpc_shadow_fan") and fan_rank(r["mpc_shadow_fan"]) >= 0) / max(1, sum(1 for r in rows if r.get("mpc_shadow_fan"))) + decided_mean_rank = sum(fan_rank(r["decided_fan"]) for r in rows if fan_rank(r["decided_fan"]) >= 0) / max(1, len(rows)) + + print(f"\n Rang moyen vitesse (0=silent, 4=superhigh):") + print(f" Actuel : {live_mean_rank:.2f}") + print(f" Décidé : {decided_mean_rank:.2f}") + print(f" MPC shadow : {mpc_mean_rank:.2f}") + + +def analyze_agreement(rows): + print_separator("3. CONCORDANCE LIVE vs MPC SHADOW") + + match_counter = Counter(r.get("mpc_shadow_match", "n/a") for r in rows) + total_valid = match_counter.get("yes", 0) + match_counter.get("no", 0) + print(f" Correspondances : {match_counter}") + if total_valid > 0: + print(f" Taux d'accord : {match_counter.get('yes', 0) / total_valid * 100:.1f}%") + + would_change = Counter(r.get("mpc_shadow_would_change", "n/a") for r in rows) + print(f" MPC voudrait changer: {would_change}") + + # When MPC disagrees, what direction? + disagree_higher = 0 + disagree_lower = 0 + disagree_details = defaultdict(int) + for r in rows: + if r.get("mpc_shadow_match") == "no": + live_rank = fan_rank(r["decided_fan"]) + mpc_rank = fan_rank(r.get("mpc_shadow_fan", "")) + if mpc_rank > live_rank: + disagree_higher += 1 + elif mpc_rank < live_rank: + disagree_lower += 1 + disagree_details[f"{r['decided_fan']}→{r.get('mpc_shadow_fan', '?')}"] += 1 + + print(f"\n Quand le MPC désaccorde:") + print(f" MPC recommande PLUS haut : {disagree_higher}") + print(f" MPC recommande PLUS bas : {disagree_lower}") + print(f" Détails des divergences:") + for k, v in sorted(disagree_details.items(), key=lambda x: -x[1])[:10]: + print(f" {k}: {v} fois") + + +def analyze_reactivity(rows): + print_separator("4. ANALYSE DE RÉACTIVITÉ / AGRESSIVITÉ") + + # Find episodes where error > 0.3 and track time to reach target + episodes = [] + current_episode = None + + for i, r in enumerate(rows): + error = r["_current_error"] + if error > 0.3 and current_episode is None: + current_episode = { + "start_idx": i, + "start_ts": r["_ts"], + "start_temp": r["_current_temp"], + "target_temp": r["_target_temp"], + "max_error": error, + "fan_modes_live": [], + "fan_modes_mpc": [], + "errors": [], + } + elif current_episode is not None: + current_episode["max_error"] = max(current_episode["max_error"], error) + current_episode["errors"].append(error) + current_episode["fan_modes_live"].append(r["current_fan"]) + current_episode["fan_modes_mpc"].append(r.get("mpc_shadow_fan", "?")) + + if error <= 0.05 or i == len(rows) - 1: + current_episode["end_idx"] = i + current_episode["end_ts"] = r["_ts"] + current_episode["duration_min"] = (r["_ts"] - current_episode["start_ts"]).total_seconds() / 60 + episodes.append(current_episode) + current_episode = None + + print(f" Nombre d'épisodes de rattrapage (error > 0.3°C) : {len(episodes)}") + if episodes: + durations = [e["duration_min"] for e in episodes] + print(f" Durée min/moy/max : {min(durations):.0f} / {sum(durations)/len(durations):.0f} / {max(durations):.0f} min") + + for j, ep in enumerate(episodes): + print(f"\n --- Épisode {j+1} ---") + print(f" Début : {ep['start_ts'].strftime('%Y-%m-%d %H:%M')}") + print(f" Durée : {ep['duration_min']:.0f} min") + print(f" Temp départ : {ep['start_temp']:.1f}°C → consigne {ep['target_temp']:.1f}°C") + print(f" Erreur max : {ep['max_error']:.2f}°C") + + live_counter = Counter(ep["fan_modes_live"]) + mpc_counter = Counter(ep["fan_modes_mpc"]) + live_mean = sum(fan_rank(m) for m in ep["fan_modes_live"] if fan_rank(m) >= 0) / max(1, len(ep["fan_modes_live"])) + mpc_mean = sum(fan_rank(m) for m in ep["fan_modes_mpc"] if fan_rank(m) >= 0) / max(1, len(ep["fan_modes_mpc"])) + + print(f" Vitesses live : {dict(live_counter)} (rang moy={live_mean:.2f})") + print(f" Vitesses MPC : {dict(mpc_counter)} (rang moy={mpc_mean:.2f})") + + if mpc_mean > live_mean: + print(f" → MPC est PLUS agressif (+{mpc_mean - live_mean:.2f} rangs)") + elif mpc_mean < live_mean: + print(f" → MPC est MOINS agressif ({mpc_mean - live_mean:.2f} rangs)") + else: + print(f" → MPC et live ont la même agressivité") + + +def analyze_comfort_quality(rows): + print_separator("5. QUALITÉ DE CONFORT") + + errors = [r["_current_error"] for r in rows] + abs_errors = [abs(e) for e in errors] + + print(f" Erreur moyenne : {sum(errors)/len(errors):+.3f}°C") + print(f" Erreur absolue moyenne : {sum(abs_errors)/len(abs_errors):.3f}°C") + print(f" Erreur max : {max(errors):.3f}°C") + print(f" Erreur min : {min(errors):.3f}°C") + + in_deadband = sum(1 for e in abs_errors if e <= 0.2) + in_soft = sum(1 for e in errors if e > 0.3) + in_hard = sum(1 for e in errors if e > 0.6) + print(f"\n Temps dans deadband (|err|≤0.2) : {in_deadband / len(rows) * 100:.1f}%") + print(f" Temps en soft error (err>0.3) : {in_soft / len(rows) * 100:.1f}%") + print(f" Temps en hard error (err>0.6) : {in_hard / len(rows) * 100:.1f}%") + + # Fan change frequency + changes = 0 + for i in range(1, len(rows)): + if rows[i]["current_fan"] != rows[i-1]["current_fan"]: + changes += 1 + duration_h = (rows[-1]["_ts"] - rows[0]["_ts"]).total_seconds() / 3600 + print(f"\n Changements de vitesse : {changes} sur {duration_h:.1f}h ({changes / duration_h:.1f}/h)") + + +def analyze_mpc_predictions_accuracy(rows): + print_separator("6. PRÉCISION DES PRÉDICTIONS MPC") + + # Compare mpc_shadow_temp_10m predictions with actual temperature 10 min later + prediction_errors_10m = [] + prediction_errors_30m = [] + + for i, r in enumerate(rows): + if r["_mpc_shadow_temp_10m"] is None: + continue + # Find the actual temp ~10 min later + target_ts_10 = r["_ts"].timestamp() + 600 + target_ts_30 = r["_ts"].timestamp() + 1800 + for j in range(i + 1, len(rows)): + dt = rows[j]["_ts"].timestamp() - r["_ts"].timestamp() + if abs(dt - 600) < 150: # within 2.5 min of 10 min + pred_err = r["_mpc_shadow_temp_10m"] - rows[j]["_current_temp"] + prediction_errors_10m.append(pred_err) + break + if r["_mpc_shadow_temp_30m"] is not None: + for j in range(i + 1, len(rows)): + dt = rows[j]["_ts"].timestamp() - r["_ts"].timestamp() + if abs(dt - 1800) < 150: + pred_err = r["_mpc_shadow_temp_30m"] - rows[j]["_current_temp"] + prediction_errors_30m.append(pred_err) + break + + if prediction_errors_10m: + abs_errs = [abs(e) for e in prediction_errors_10m] + print(f" Prédictions à T+10min ({len(prediction_errors_10m)} comparaisons):") + print(f" Erreur moyenne : {sum(prediction_errors_10m)/len(prediction_errors_10m):+.3f}°C") + print(f" Erreur abs moyenne : {sum(abs_errs)/len(abs_errs):.3f}°C") + print(f" Erreur abs max : {max(abs_errs):.3f}°C") + print(f" Erreur abs p90 : {sorted(abs_errs)[int(0.9*len(abs_errs))]:.3f}°C") + else: + print(" Aucune donnée de prédiction T+10m disponible") + + if prediction_errors_30m: + abs_errs = [abs(e) for e in prediction_errors_30m] + print(f"\n Prédictions à T+30min ({len(prediction_errors_30m)} comparaisons):") + print(f" Erreur moyenne : {sum(prediction_errors_30m)/len(prediction_errors_30m):+.3f}°C") + print(f" Erreur abs moyenne : {sum(abs_errs)/len(abs_errs):.3f}°C") + print(f" Erreur abs max : {max(abs_errs):.3f}°C") + print(f" Erreur abs p90 : {sorted(abs_errs)[int(0.9*len(abs_errs))]:.3f}°C") + else: + print(" Aucune donnée de prédiction T+30m disponible") + + +def analyze_slopes(slopes): + print_separator("7. PROFILS DE PENTE EFFECTIVE PAR MODE") + + for mode in FAN_ORDER: + if mode not in slopes or not slopes[mode]: + print(f" {mode:12s}: pas de données") + continue + vals = [e["slope"] for e in slopes[mode]] + mean_val = sum(vals) / len(vals) + min_val = min(vals) + max_val = max(vals) + print(f" {mode:12s}: n={len(vals):4d} moy={mean_val:+.3f}°C/h min={min_val:+.3f} max={max_val:+.3f}") + + +def analyze_time_in_error_by_fan(rows): + print_separator("8. TEMPS EN ERREUR PAR VITESSE DE VENTILATEUR") + + mode_errors = defaultdict(list) + for r in rows: + mode_errors[r["current_fan"]].append(r["_current_error"]) + + for mode in FAN_ORDER: + if mode not in mode_errors: + continue + errs = mode_errors[mode] + mean_err = sum(errs) / len(errs) + pct_above_soft = sum(1 for e in errs if e > 0.3) / len(errs) * 100 + pct_above_hard = sum(1 for e in errs if e > 0.6) / len(errs) * 100 + print(f" {mode:12s}: n={len(errs):5d} err_moy={mean_err:+.3f} soft%={pct_above_soft:5.1f} hard%={pct_above_hard:5.1f}") + + +def analyze_mpc_vs_live_detail(rows): + print_separator("9. PÉRIODES DE DIVERGENCE MAJEURE (MPC ≠ LIVE, >5 min)") + + divergence_start = None + divergences = [] + + for i, r in enumerate(rows): + is_divergent = r.get("mpc_shadow_match") == "no" + if is_divergent and divergence_start is None: + divergence_start = i + elif not is_divergent and divergence_start is not None: + duration = (rows[i]["_ts"] - rows[divergence_start]["_ts"]).total_seconds() / 60 + if duration > 5: + segment = rows[divergence_start:i] + live_ranks = [fan_rank(r["decided_fan"]) for r in segment] + mpc_ranks = [fan_rank(r.get("mpc_shadow_fan", "")) for r in segment if fan_rank(r.get("mpc_shadow_fan", "")) >= 0] + divergences.append({ + "start": rows[divergence_start]["_ts"], + "duration": duration, + "live_mean": sum(live_ranks) / max(len(live_ranks), 1), + "mpc_mean": sum(mpc_ranks) / max(len(mpc_ranks), 1), + "mean_error": sum(r["_current_error"] for r in segment) / len(segment), + "max_error": max(r["_current_error"] for r in segment), + "sample_reasons": list(set(r.get("reason", "") for r in segment))[:3], + }) + divergence_start = None + + print(f" Nombre de périodes de divergence (>5 min) : {len(divergences)}") + for j, d in enumerate(divergences[:15]): # top 15 + direction = "MPC PLUS haut" if d["mpc_mean"] > d["live_mean"] else "MPC PLUS bas" + print(f"\n --- Divergence {j+1} ---") + print(f" Début : {d['start'].strftime('%Y-%m-%d %H:%M')}") + print(f" Durée : {d['duration']:.0f} min") + print(f" Live rang : {d['live_mean']:.2f} vs MPC rang : {d['mpc_mean']:.2f} ({direction})") + print(f" Erreur moy : {d['mean_error']:+.3f}°C max : {d['max_error']:+.3f}°C") + print(f" Raisons : {d['sample_reasons'][:2]}") + + +def analyze_decision_reasons(rows): + print_separator("10. DISTRIBUTION DES RAISONS DE DÉCISION") + + # Group reasons by prefix + reason_groups = defaultdict(int) + for r in rows: + reason = r.get("reason", "Unknown") + # Take the prefix before ':' + prefix = reason.split(":")[0].strip() if ":" in reason else reason + reason_groups[prefix] += 1 + + total = sum(reason_groups.values()) + for k, v in sorted(reason_groups.items(), key=lambda x: -x[1]): + print(f" {k:40s}: {v:5d} ({v/total*100:5.1f}%)") + + +def analyze_hysteresis_blocking(rows): + print_separator("11. ANALYSE DE L'HYSTÉRÉSIS / BLOCAGES MPC") + + # Count how often MPC is blocked by min_interval vs hysteresis + min_interval_blocks = 0 + hysteresis_blocks = 0 + step_down_holds = 0 + other_blocks = 0 + + for r in rows: + if r.get("mpc_shadow_would_change") != "no": + continue + reason = r.get("mpc_shadow_reason", "") # this doesn't exist, use mpc_shadow_match + # The reason is embedded in mpc_shadow fields... we don't have it directly + # But we can infer from mpc_shadow_match and would_change + + # Instead, analyze: when MPC says different fan but would_change=no + blocked_count = 0 + for r in rows: + mpc_fan = r.get("mpc_shadow_fan", "") + live_fan = r.get("decided_fan", "") + would_change = r.get("mpc_shadow_would_change", "n/a") + if mpc_fan != live_fan and would_change == "no": + blocked_count += 1 + + agrees_and_no_change = sum(1 for r in rows if r.get("mpc_shadow_match") == "yes") + disagrees = sum(1 for r in rows if r.get("mpc_shadow_match") == "no") + would_change_yes = sum(1 for r in rows if r.get("mpc_shadow_would_change") == "yes") + would_change_no = sum(1 for r in rows if r.get("mpc_shadow_would_change") == "no") + + print(f" MPC agree avec live : {agrees_and_no_change}") + print(f" MPC désaccorde : {disagrees}") + print(f" MPC voudrait changer : {would_change_yes}") + print(f" MPC bloqué : {would_change_no}") + print(f" MPC fan ≠ live et bloqué : {blocked_count}") + + +def analyze_speed_rampup_delay(rows): + print_separator("12. ANALYSE DU DÉLAI DE MONTÉE EN VITESSE") + + # When error starts growing, how fast does the controller ramp up? + ramp_events = [] + i = 0 + while i < len(rows) - 1: + r = rows[i] + # Detect moment where error crosses above 0.3 + if r["_current_error"] <= 0.3 and i + 1 < len(rows) and rows[i+1]["_current_error"] > 0.3: + start_fan = r["current_fan"] + start_rank = fan_rank(start_fan) + start_ts = r["_ts"] + # Track how long until fan goes up + max_rank_reached = start_rank + time_to_first_increase = None + for j in range(i + 1, min(i + 60, len(rows))): # look ahead ~2h + jr = rows[j] + jr_rank = fan_rank(jr["current_fan"]) + if jr_rank > start_rank and time_to_first_increase is None: + time_to_first_increase = (jr["_ts"] - start_ts).total_seconds() / 60 + max_rank_reached = max(max_rank_reached, jr_rank) + ramp_events.append({ + "ts": start_ts, + "start_fan": start_fan, + "time_to_increase_min": time_to_first_increase, + "max_rank_delta": max_rank_reached - start_rank, + "error_at_start": rows[i+1]["_current_error"], + }) + i += 1 + + print(f" Nombre d'événements de montée d'erreur : {len(ramp_events)}") + if ramp_events: + increased = [e for e in ramp_events if e["time_to_increase_min"] is not None] + no_increase = [e for e in ramp_events if e["time_to_increase_min"] is None] + print(f" Ayant provoqué une augmentation de vitesse : {len(increased)}") + print(f" Sans augmentation (dans 2h) : {len(no_increase)}") + + if increased: + times = [e["time_to_increase_min"] for e in increased] + print(f" Délai première augmentation : min={min(times):.0f} moy={sum(times)/len(times):.0f} max={max(times):.0f} min") + + for j, ev in enumerate(ramp_events[:10]): + delay_str = f"{ev['time_to_increase_min']:.0f} min" if ev['time_to_increase_min'] else "jamais" + print(f" [{ev['ts'].strftime('%m-%d %H:%M')}] fan={ev['start_fan']} err={ev['error_at_start']:.2f} → augmenté après {delay_str} (Δrank={ev['max_rank_delta']})") + + +def main(): + print("=" * 70) + print(" ANALYSE DE VIABILITÉ MPC - Smart Fan Controller") + print("=" * 70) + + rows = load_main_data() + slopes = load_slope_data() + + if not rows: + print("ERREUR: Aucune donnée chargée") + sys.exit(1) + + analyze_basic_stats(rows) + analyze_fan_distribution(rows) + analyze_agreement(rows) + analyze_reactivity(rows) + analyze_comfort_quality(rows) + analyze_mpc_predictions_accuracy(rows) + analyze_slopes(slopes) + analyze_time_in_error_by_fan(rows) + analyze_mpc_vs_live_detail(rows) + analyze_decision_reasons(rows) + analyze_hysteresis_blocking(rows) + analyze_speed_rampup_delay(rows) + + print_separator("CONCLUSION PRÉLIMINAIRE") + # Quick assessment + mpc_rows = [r for r in rows if r.get("mpc_shadow_fan")] + if mpc_rows: + mpc_avg_rank = sum(fan_rank(r["mpc_shadow_fan"]) for r in mpc_rows if fan_rank(r["mpc_shadow_fan"]) >= 0) / len(mpc_rows) + live_avg_rank = sum(fan_rank(r["current_fan"]) for r in rows if fan_rank(r["current_fan"]) >= 0) / len(rows) + delta = mpc_avg_rank - live_avg_rank + if abs(delta) < 0.1: + print(f" Le MPC shadow et l'algo live ont une agressivité similaire (Δ={delta:+.2f} rangs)") + print(f" → Le MPC ne résoudra probablement PAS le manque de réactivité") + elif delta > 0: + print(f" Le MPC shadow est PLUS agressif que le live (Δ={delta:+.2f} rangs)") + print(f" → Le MPC pourrait améliorer la réactivité") + else: + print(f" Le MPC shadow est MOINS agressif que le live (Δ={delta:+.2f} rangs)") + print(f" → Le MPC aggraverait le manque de réactivité") + + print() + + +if __name__ == "__main__": + main() diff --git a/tests/test_data_collection.py b/tests/test_data_collection.py index 8f9bff0..c88b264 100644 --- a/tests/test_data_collection.py +++ b/tests/test_data_collection.py @@ -92,6 +92,16 @@ async def test_async_record_appends_row(tmp_path: Path) -> None: "0", "10.99", "0", + "Disabled", + "", + "n/a", + "no", + "", + "", + "", + "", + "0", + "", "0", "0", ] diff --git a/tests/test_hvac_idle.py b/tests/test_hvac_idle.py index f58b0e5..4d5b1d3 100644 --- a/tests/test_hvac_idle.py +++ b/tests/test_hvac_idle.py @@ -5,6 +5,7 @@ import pytest from custom_components.smart_fan_controller.controller import SmartFanController +from custom_components.smart_fan_controller.mpc_controller import MPCController from custom_components.smart_fan_controller.const import ( DEFAULT_DEADBAND, DEFAULT_MIN_INTERVAL, @@ -196,6 +197,109 @@ def test_response_events_excluded_during_idle(self, controller): assert controller.learning.response_event_count() == initial_events +class TestHvacIdleMpc: + """MPC shadow should pause during HVAC idle.""" + + def test_shadow_pauses_during_hvac_idle(self): + """Shadow should return Disturbed when compressor is off.""" + ctrl = SmartFanController(fan_modes=FAN_MODES, **DEFAULT_CONFIG) + _prime_learning_profiles(ctrl) + shadow = MPCController( + learning=ctrl.learning, + deadband=0.3, + min_interval=10, + fan_modes=FAN_MODES, + enabled=True, + ) + + result = shadow.evaluate( + current_temp=19.5, + target_temp=20.0, + vtherm_slope=0.2, + hvac_mode="heat", + current_fan="high", + live_decision_fan="high", + is_window_open=False, + is_defrost_active=False, + is_hvac_idle=True, + minutes_since_change=12.0, + ) + + assert result["mpc_status"] == "Disturbed" + assert result["mpc_fan_mode"] == "high" + assert result["mpc_would_change_now"] == "no" + assert "HVAC idle" in result["mpc_reason"] + + def test_shadow_active_when_not_idle(self): + """Shadow should produce a normal recommendation when compressor is running.""" + ctrl = SmartFanController(fan_modes=FAN_MODES, **DEFAULT_CONFIG) + _prime_learning_profiles(ctrl) + shadow = MPCController( + learning=ctrl.learning, + deadband=0.3, + min_interval=10, + fan_modes=FAN_MODES, + enabled=True, + ) + + result = shadow.evaluate( + current_temp=19.5, + target_temp=20.0, + vtherm_slope=0.2, + hvac_mode="heat", + current_fan="high", + live_decision_fan="high", + is_window_open=False, + is_defrost_active=False, + is_hvac_idle=False, + minutes_since_change=12.0, + ) + + assert result["mpc_status"] != "Disturbed" + + def test_shadow_disturbance_bias_decays_during_idle(self): + """Disturbance bias should decay, not update, during HVAC idle.""" + ctrl = SmartFanController(fan_modes=FAN_MODES, **DEFAULT_CONFIG) + _prime_learning_profiles(ctrl) + shadow = MPCController( + learning=ctrl.learning, + deadband=0.3, + min_interval=10, + fan_modes=FAN_MODES, + enabled=True, + ) + + # Prime the disturbance bias with a normal cycle + shadow.evaluate( + current_temp=19.5, + target_temp=20.0, + vtherm_slope=0.5, + hvac_mode="heat", + current_fan="med", + live_decision_fan="med", + is_window_open=False, + minutes_since_change=20.0, + ) + bias_before = shadow._disturbance_bias + + # HVAC idle cycle — should NOT poison the bias + shadow.evaluate( + current_temp=19.5, + target_temp=20.0, + vtherm_slope=-0.5, + hvac_mode="heat", + current_fan="med", + live_decision_fan="med", + is_window_open=False, + is_hvac_idle=True, + minutes_since_change=25.0, + ) + bias_after = shadow._disturbance_bias + + # Bias should have decayed, not grown from the negative slope residual + assert abs(bias_after) <= abs(bias_before) + + class TestHvacIdleCoolMode: """HVAC idle also works in cooling mode.""" diff --git a/tests/test_mpc_controller.py b/tests/test_mpc_controller.py new file mode 100644 index 0000000..47482d5 --- /dev/null +++ b/tests/test_mpc_controller.py @@ -0,0 +1,534 @@ +"""Tests for the MPC shadow diagnostics and guardrails.""" + +import csv +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from custom_components.smart_fan_controller.controller import SmartFanController +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 ( + SmartFanMpcProfilesSensor, + SmartFanProfileEffectiveSlopeSensor, + SmartFanSensor, +) + +FAN_MODES = ["low", "medium", "high"] + + +def _build_controller(*, fan_modes=None, min_interval: int = 10) -> SmartFanController: + """Build a SmartFanController with default test parameters.""" + return SmartFanController( + fan_modes=fan_modes or FAN_MODES, + deadband=0.3, + min_interval=min_interval, + soft_error=0.5, + hard_error=0.9, + limit_timeout=18, + ) + + +def _prime_learning_profiles(controller: SmartFanController) -> None: + """Feed enough slope samples for all profiles to become ready.""" + for _ in range(60): + controller.learning.add_slope_sample("low", 0.25, 0.8, "heat") + controller.learning.add_slope_sample("medium", 0.9, 0.8, "heat") + controller.learning.add_slope_sample("high", 1.5, 0.8, "heat") + controller.learning.add_response_event(8.0) + controller.learning.add_response_event(10.0) + controller.learning.add_response_event(12.0) + + +def _make_executor_hass() -> MagicMock: + """Create a mock HomeAssistant with synchronous executor.""" + hass = MagicMock() + + async def run_in_executor(target, *args): + """Run target synchronously for tests.""" + return target(*args) + + hass.async_add_executor_job = AsyncMock(side_effect=run_in_executor) + return hass + + +def test_shadow_disabled_reports_disabled_status() -> None: + """Shadow reports disabled status when not enabled.""" + controller = _build_controller() + shadow = MPCController( + learning=controller.learning, + deadband=0.3, + min_interval=10, + fan_modes=FAN_MODES, + enabled=False, + ) + + result = shadow.evaluate( + current_temp=19.2, + target_temp=20.0, + vtherm_slope=0.4, + hvac_mode="heat", + current_fan="medium", + live_decision_fan="high", + is_window_open=False, + ) + + assert result["mpc_status"] == "Disabled" + assert result["mpc_fan_mode"] == "medium" + assert result["mpc_matches_live"] == "disabled" + assert result["mpc_would_change_now"] == "no" + + +def test_shadow_prefers_stronger_fan_when_profiles_support_it() -> None: + """Shadow picks a stronger fan mode when learned profiles support it.""" + controller = _build_controller() + _prime_learning_profiles(controller) + shadow = MPCController( + learning=controller.learning, + deadband=0.3, + min_interval=10, + fan_modes=FAN_MODES, + enabled=True, + ) + + result = shadow.evaluate( + current_temp=19.0, + target_temp=20.0, + vtherm_slope=0.25, + hvac_mode="heat", + current_fan="low", + live_decision_fan="medium", + is_window_open=False, + minutes_since_change=20.0, + ) + + assert result["mpc_fan_mode"] == "high" + assert result["mpc_matches_live"] == "no" + assert result["mpc_would_change_now"] == "yes" + assert result["mpc_known_profiles"] == 3 + + +def test_shadow_holds_superhigh_while_still_below_target() -> None: + """Shadow holds superhigh when temperature is still below target.""" + fan_modes = ["low", "medium", "high", "superhigh"] + controller = _build_controller(fan_modes=fan_modes) + for _ in range(60): + controller.learning.add_slope_sample("low", 0.2, 0.4, "heat") + controller.learning.add_slope_sample("medium", 0.5, 0.4, "heat") + controller.learning.add_slope_sample("high", 0.8, 0.4, "heat") + controller.learning.add_slope_sample("superhigh", 1.0, 0.4, "heat") + controller.learning.add_response_event(30.0) + + shadow = MPCController( + learning=controller.learning, + deadband=0.2, + min_interval=10, + fan_modes=fan_modes, + enabled=True, + ) + + result = shadow.evaluate( + current_temp=19.95, + target_temp=20.0, + vtherm_slope=0.2, + hvac_mode="heat", + current_fan="superhigh", + live_decision_fan="superhigh", + is_window_open=False, + minutes_since_change=40.0, + ) + + assert result["mpc_fan_mode"] == "superhigh" + assert result["mpc_would_change_now"] == "no" + assert "Below target: holding superhigh" in result["mpc_reason"] + + +def test_shadow_pauses_when_window_is_open() -> None: + """Shadow pauses evaluation when a window is open.""" + controller = _build_controller() + _prime_learning_profiles(controller) + shadow = MPCController( + learning=controller.learning, + deadband=0.3, + min_interval=10, + fan_modes=FAN_MODES, + enabled=True, + ) + + result = shadow.evaluate( + current_temp=19.3, + target_temp=20.0, + vtherm_slope=0.2, + hvac_mode="heat", + current_fan="medium", + live_decision_fan="high", + is_window_open=True, + minutes_since_change=12.0, + ) + + assert result["mpc_status"] == "Disturbed" + assert result["mpc_fan_mode"] == "medium" + assert result["mpc_would_change_now"] == "no" + assert "paused" in result["mpc_reason"] + + +def test_shadow_sensor_can_clear_to_none() -> None: + """Shadow sensor value can be cleared to None.""" + sensor = SmartFanSensor( + "entry-1", + "MPC Shadow Cost", + "mpc_cost", + "mpc_cost", + None, + None, + "mdi:calculator", + ) + + sensor.update_from_controller({"mpc_cost": 3.2}) + assert sensor.native_value == 3.2 + + sensor.update_from_controller({"mpc_cost": None}) + assert sensor.native_value is None + + +def test_mpc_profiles_sensor_exposes_per_mode_values() -> None: + """MPC profiles sensor exposes per-mode effective slope values.""" + controller = _build_controller() + controller.fan_modes = FAN_MODES + for _ in range(15): + controller.learning.add_slope_sample("medium", 0.5, 0.3, "heat") + for _ in range(8): + controller.learning.add_slope_sample("high", 0.9, 0.3, "heat") + for _ in range(12): + controller.learning.add_slope_sample("low", -0.4, 0.3, "cool") + + sensor = SmartFanMpcProfilesSensor("entry-1", controller, "heat") + attrs = sensor.extra_state_attributes + + assert sensor.native_value == 1 + assert attrs["known_profiles"] == 1 + assert attrs["profiles"]["medium"]["effective_slope"] == 0.5 + assert attrs["profiles"]["medium"]["samples"] == 15 + assert attrs["profiles"]["medium"]["ready"] is True + assert attrs["profile_effective_slope_sensors"]["medium"] == "sensor.smart_fan_controller_heat_medium_effective_slope" + assert attrs["profiles"]["high"]["effective_slope"] is None + assert attrs["profiles"]["high"]["samples"] == 8 + + +def test_profile_effective_slope_sensor_exposes_historizable_state() -> None: + """Profile effective slope sensor is historizable with correct attributes.""" + controller = _build_controller() + for _ in range(12): + controller.learning.add_slope_sample("high", 0.9, 0.3, "heat") + + sensor = SmartFanProfileEffectiveSlopeSensor("entry-1", controller, "heat", "high") + + assert sensor.entity_id == "sensor.smart_fan_controller_heat_high_effective_slope" + assert sensor.native_value == 0.9 + assert sensor.extra_state_attributes["samples"] == 12 + assert sensor.extra_state_attributes["ready"] is True + + +def test_live_controller_holds_favorable_slope_until_close_to_target() -> None: + """Live controller holds current fan while slope is favorable near target.""" + controller = _build_controller(fan_modes=["low", "high"]) + controller.previous_slope = 0.0 + controller.now = 0.0 + controller.last_change_time = 0.0 + + with patch("time.time", return_value=3600.0): + result = controller.calculate_decision(19.6, 20.0, 0.5, "heat", "high") + + assert result["fan_mode"] == "high" + assert result["reason"] == "Maintenance: Favorable slope, holding" + + +def test_response_time_learning_skips_window_open_disturbances() -> None: + """Response-time learning skips events disturbed by open window.""" + controller = _build_controller() + for _ in range(250): + controller.learning.add_slope_sample("medium", 0.3, 0.1) + + base_time = 1_000_000.0 + + with patch("time.time", return_value=base_time): + controller.now = base_time + controller.save_states("high", "low", 0.5, 0.5, False, is_window_open=False) + controller.confirm_fan_change() + + events_before = len(controller.learning.response_events) + with patch("time.time", return_value=base_time + 600): + controller.now = base_time + 600 + controller.save_states("high", "high", 0.2, 0.2, True, is_window_open=True) + + assert len(controller.learning.response_events) == events_before + + +@pytest.mark.asyncio +async def test_data_collector_records_shadow_columns(tmp_path: Path) -> None: + """Data collector CSV includes shadow-specific columns.""" + hass = _make_executor_hass() + collector = DataCollector(hass, str(tmp_path), "entry123456") + + await collector.async_initialize() + await collector.async_record( + hvac_mode="heat", + current_temp=19.0, + target_temp=20.0, + vtherm_slope=0.25, + is_window_open=False, + decision={ + "temperature_error": 1.0, + "projected_temperature": 19.2, + "projected_temperature_error": 0.8, + "minutes_since_last_change": 20.0, + "current_fan": "low", + "fan_mode": "high", + "reason": "Emergency: High error (1.0C)", + }, + phase="ESTABLISHED", + effective_slope=0.25, + effective_timeout=15.0, + force=True, + learning_ready=True, + dead_time=10.0, + shadow={ + "mpc_status": "Ready", + "mpc_fan_mode": "high", + "mpc_matches_live": "yes", + "mpc_would_change_now": "yes", + "mpc_cost": 4.321, + "mpc_confidence": 75.0, + "mpc_predicted_temperature_10m": 19.3, + "mpc_predicted_temperature_30m": 19.8, + "mpc_known_profiles": 3, + "mpc_disturbance_bias": -0.25, + }, + ) + + with open(collector.path, newline="", encoding="utf-8") as fh: + rows = list(csv.reader(fh)) + + header = rows[0] + row = rows[1] + + assert "mpc_would_change" in header + assert "mpc_known_profiles" in header + assert "mpc_disturbance" in header + assert row[header.index("mpc_would_change")] == "yes" + assert row[header.index("mpc_known_profiles")] == "3" + assert row[header.index("mpc_disturbance")] == "-0.25" + + +def test_shadow_setpoint_drop_forces_lowest_mode() -> None: + """When target drops significantly, shadow should go to the lowest fan mode.""" + controller = _build_controller() + _prime_learning_profiles(controller) + shadow = MPCController( + learning=controller.learning, + deadband=0.3, + min_interval=10, + fan_modes=FAN_MODES, + enabled=True, + ) + + result = shadow.evaluate( + current_temp=20.4, + target_temp=17.5, + vtherm_slope=0.0, + hvac_mode="heat", + current_fan="high", + live_decision_fan="low", + is_window_open=False, + minutes_since_change=5.0, + ) + + assert result["mpc_status"] == "Setpoint drop" + assert result["mpc_fan_mode"] == "low" + assert "Setpoint drop" in result["mpc_reason"] + + +def test_shadow_setpoint_drop_matches_live() -> None: + """Setpoint drop should report match with live when both choose lowest.""" + controller = _build_controller() + shadow = MPCController( + learning=controller.learning, + deadband=0.3, + min_interval=10, + fan_modes=FAN_MODES, + enabled=True, + ) + + result = shadow.evaluate( + current_temp=20.0, + target_temp=17.5, + vtherm_slope=-0.2, + hvac_mode="heat", + current_fan="medium", + live_decision_fan="low", + is_window_open=False, + minutes_since_change=15.0, + ) + + assert result["mpc_fan_mode"] == "low" + assert result["mpc_matches_live"] == "yes" + + +def test_shadow_no_setpoint_drop_when_error_above_threshold() -> None: + """Normal over-target should NOT trigger setpoint drop.""" + controller = _build_controller() + _prime_learning_profiles(controller) + shadow = MPCController( + learning=controller.learning, + deadband=0.3, + min_interval=10, + fan_modes=FAN_MODES, + enabled=True, + ) + + result = shadow.evaluate( + current_temp=20.3, + target_temp=20.0, + vtherm_slope=0.0, + hvac_mode="heat", + current_fan="high", + live_decision_fan="medium", + is_window_open=False, + minutes_since_change=15.0, + ) + + assert result["mpc_status"] != "Setpoint drop" + + +def test_shadow_pauses_during_defrost() -> None: + """Shadow should pause when defrost is active, like window-open.""" + controller = _build_controller() + _prime_learning_profiles(controller) + shadow = MPCController( + learning=controller.learning, + deadband=0.3, + min_interval=10, + fan_modes=FAN_MODES, + enabled=True, + ) + + result = shadow.evaluate( + current_temp=19.3, + target_temp=20.0, + vtherm_slope=0.2, + hvac_mode="heat", + current_fan="high", + live_decision_fan="high", + is_window_open=False, + is_defrost_active=True, + minutes_since_change=12.0, + ) + + assert result["mpc_status"] == "Disturbed" + assert result["mpc_fan_mode"] == "high" + assert result["mpc_would_change_now"] == "no" + assert "Defrost" in result["mpc_reason"] + + +def test_shadow_disturbance_bias_decays_during_defrost() -> None: + """Disturbance bias should decay, not update, during defrost.""" + controller = _build_controller() + _prime_learning_profiles(controller) + shadow = MPCController( + learning=controller.learning, + deadband=0.3, + min_interval=10, + fan_modes=FAN_MODES, + enabled=True, + ) + + # Prime the disturbance bias with a normal cycle + shadow.evaluate( + current_temp=19.5, + target_temp=20.0, + vtherm_slope=0.5, + hvac_mode="heat", + current_fan="medium", + live_decision_fan="medium", + is_window_open=False, + minutes_since_change=20.0, + ) + bias_before = shadow._disturbance_bias # pylint: disable=protected-access + + # Defrost cycle with sharp slope drop — should NOT poison the bias + shadow.evaluate( + current_temp=19.5, + target_temp=20.0, + vtherm_slope=-1.0, + hvac_mode="heat", + current_fan="high", + live_decision_fan="high", + is_window_open=False, + is_defrost_active=True, + minutes_since_change=25.0, + ) + bias_after = shadow._disturbance_bias # pylint: disable=protected-access + + # Bias should have decayed, not grown from the -1.0 slope residual + assert abs(bias_after) <= abs(bias_before) + + +def test_production_mode_property_defaults_to_false() -> None: + """Production mode should default to False (shadow only).""" + controller = _build_controller() + shadow = MPCController( + learning=controller.learning, + deadband=0.3, + min_interval=10, + fan_modes=FAN_MODES, + enabled=True, + ) + + assert shadow.production_mode is False + + +def test_production_mode_can_be_toggled() -> None: + """Production mode can be enabled and disabled.""" + controller = _build_controller() + shadow = MPCController( + learning=controller.learning, + deadband=0.3, + min_interval=10, + fan_modes=FAN_MODES, + enabled=True, + ) + + shadow.production_mode = True + assert shadow.production_mode is True + + shadow.production_mode = False + assert shadow.production_mode is False + + +def test_shadow_always_evaluates_regardless_of_production_mode() -> None: + """Shadow evaluates and produces results whether production mode is on or off.""" + controller = _build_controller() + _prime_learning_profiles(controller) + shadow = MPCController( + learning=controller.learning, + deadband=0.3, + min_interval=10, + fan_modes=FAN_MODES, + enabled=True, + ) + + for prod_mode in (False, True): + shadow.production_mode = prod_mode + result = shadow.evaluate( + current_temp=19.0, + target_temp=20.0, + vtherm_slope=0.25, + hvac_mode="heat", + current_fan="low", + live_decision_fan="low", + is_window_open=False, + minutes_since_change=20.0, + ) + assert result["mpc_status"] != "Disabled" + assert result["mpc_fan_mode"] is not None From 8b5944c2939da6f90fa647871c5ecd6a85eb4e79 Mon Sep 17 00:00:00 2001 From: Gamso Date: Sun, 12 Apr 2026 14:59:16 +0200 Subject: [PATCH 02/11] robust learning filters + monotone MPC constraint + set_effective_slope service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Learning data integrity: - Use median (not mean) for effective slope computation, resisting outlier contamination from VTherm EMA inertia after mode transitions - Add 30-minute learning cooldown after large setpoint drops (SETPOINT_DROP_LEARNING_COOLDOWN) to avoid recording stale slopes - Require fan mode to be active for ≥ 2× dead_time before accepting slope samples (MIN_ESTABLISHED_RATIO), ensuring the EMA has fully converged to the current mode's thermal response MPC monotone constraint: - Enforce slope(mode_i) ≤ slope(mode_i+1) via isotonic forward pass when all fan-mode profiles are learned; skip when profiles are partial - Prevents contaminated profiles from causing MPC to rank a weaker fan mode above a stronger one New service — set_effective_slope: - Manually override the effective slope for a specific fan mode / HVAC mode profile by replacing stored samples with synthetic ones - Useful for correcting contaminated profiles without full learning reset --- .github/copilot-instructions.md | 7 +- .github/skills/modify-mpc-shadow/SKILL.md | 109 +++++++++---- README.md | 31 +++- .../smart_fan_controller/__init__.py | 23 +++ .../smart_fan_controller/const.py | 2 + .../smart_fan_controller/controller.py | 37 ++++- .../smart_fan_controller/mpc_controller.py | 53 +++++- .../smart_fan_controller/services.yaml | 34 ++++ .../smart_fan_controller/thermal_learning.py | 35 +++- tests/test_cool.py | 1 + tests/test_defrost.py | 3 + tests/test_heat.py | 1 + tests/test_hvac_idle.py | 2 + tests/test_learning.py | 153 +++++++++++++++++- tests/test_learning_enabled.py | 7 + tests/test_learning_persistence.py | 2 +- tests/test_mpc_controller.py | 130 +++++++++++---- tests/test_phase_detection.py | 15 +- tests/test_services.py | 5 +- tests/test_system.py | 1 + 20 files changed, 565 insertions(+), 86 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 68580ef..4ae83ab 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -7,7 +7,7 @@ The integration lives under `custom_components/smart_fan_controller/`. Key modul | File | Role | |------|------| | `controller.py` | `SmartFanController` — rule-based decision engine (zones A–F) | -| `mpc_shadow.py` | `MPCShadowController` — observation-only MPC-lite, never writes fan commands | +| `mpc_controller.py` | `MPCController` — MPC-lite with observation & production modes, monotone constraint on slopes | | `thermal_learning.py` | `ThermalLearning` — slope samples, response-time events, profile calibration | | `__init__.py` | HA integration entry point: config, control loop, services | | `config_flow.py` | Config and options UI flows | @@ -65,10 +65,13 @@ python -m pytest tests/test_X.py -q # run one file - `DEFAULT_DEADBAND`, `DEFAULT_SOFT_ERROR`, `DEFAULT_HARD_ERROR` — all tunable via options flow - `CONF_DEFROST_ENTITY` — optional entity for external defrost signal - `CONF_OPERATING_ENTITY` — optional entity for heat-pump compressor running state +- `SETPOINT_DROP_LEARNING_COOLDOWN = 30.0` — minutes to suppress learning after a large setpoint drop +- `MIN_ESTABLISHED_RATIO = 2.0` — multiplier on dead_time; fan mode must be active this long before learning ## Important Constraints - **Avoid over-engineering**: only add code that directly addresses the requirement - **No second-order slope terms**: VTherm slope is already EMA-smoothed; parabolic projection amplifies noise - **Step-limited fan changes**: braking is limited to -1 step; recovery is usually +1 step, but Zone C may step up by +2 when error exceeds `0.75*hard_error` -- **Learning data integrity**: exclude window-open, defrost, and HVAC idle periods from slope samples and response-time events +- **Learning data integrity**: exclude window-open, defrost, HVAC idle, setpoint-drop cooldown (30 min), and insufficiently-stable periods (< 2× dead_time) from slope samples; effective slope uses **median** (not mean) for outlier robustness +- **Monotone constraint (MPC)**: when all fan-mode profiles are learned, MPC enforces slope(mode_i) ≤ slope(mode_i+1) via isotonic forward pass; partial profiles skip the constraint diff --git a/.github/skills/modify-mpc-shadow/SKILL.md b/.github/skills/modify-mpc-shadow/SKILL.md index bb7cc68..54f2ea6 100644 --- a/.github/skills/modify-mpc-shadow/SKILL.md +++ b/.github/skills/modify-mpc-shadow/SKILL.md @@ -1,14 +1,36 @@ --- name: modify-mpc-shadow -description: "Modify the MPC shadow controller: cost function tuning, hysteresis thresholds, simulation model, disturbance tracking, or adding new pausing conditions. Use when: changing how the shadow evaluates or recommends fan modes; adjusting cost weights or switch-gain margins; adding new disturbance sources; modifying the step-down hold logic; changing how confidence is computed." -argument-hint: "Description of the MPC shadow behavior to change (e.g. 'reduce overshoot penalty weight')" +description: "Modify the MPC controller: cost function tuning, hysteresis thresholds, simulation model, disturbance tracking, monotone constraint, or adding new pausing conditions. Use when: changing how the MPC evaluates or recommends fan modes; adjusting cost weights or switch-gain margins; adding new disturbance sources; modifying the step-down hold logic; changing how confidence is computed; toggling production mode." +argument-hint: "Description of the MPC behavior to change (e.g. 'reduce overshoot penalty weight')" --- -# Modify MPC Shadow Controller +# Modify MPC Controller + +## Background: MPC at a Glance + +MPC (Model Predictive Control) optimizes a control action by: +1. Sampling the current plant state +2. Rolling out candidate actions over a finite prediction horizon +3. Picking the action that minimizes a cost function subject to constraints +4. Applying **only the first step**, then re-solving next cycle (receding horizon) + +This project uses a **discrete MPC-lite** adapted for residential heat-pump HVAC: +- **State**: room temperature (scalar) +- **Control input**: fan speed mode (discrete: silent → low → med → high → superhigh) +- **Model**: linear `T(t+Δ) = T(t) + slope × Δt + disturbance_bias × Δt` +- **Horizon**: 30 min (configurable), step size = control loop cadence (~2 min) +- **Optimizer**: exhaustive enumeration over discrete fan modes (small action space) +- **Cost**: comfort error + overshoot penalty + mode-change cost (see table below) + +Unlike classic MPC, the model parameters (slopes) are **learned online** by `ThermalLearning`. This makes it a self-calibrating, data-driven MPC with dead-time handling. ## Architecture -The MPC shadow lives in `mpc_shadow.py` as `MPCShadowController`. It is **read-only**: it never calls HA services or modifies the live controller. It runs every control cycle (~2 min) and produces a recommendation dict that feeds sensors and CSV logs. +The MPC controller lives in `mpc_controller.py` as `MPCController`. It runs every control cycle (~2 min) and produces a recommendation dict that feeds sensors and CSV logs. + +**Operating modes**: +- **Observation mode** (`production_mode=False`): runs silently alongside the rule-based controller; its recommendation is logged but never applied +- **Production mode** (`production_mode=True`): the integration reads `mpc_fan_mode` from `evaluate()` and applies it instead of the rule-based decision ### Key Flow in `evaluate()` @@ -17,12 +39,13 @@ The MPC shadow lives in `mpc_shadow.py` as `MPCShadowController`. It is **read-o 2. Resolve fan modes and active fan 3. Compute effective slope, dead time, phase 4. Update disturbance bias (EMA tracking) -5. Pause conditions: window-open, defrost → return "Disturbed" +5. Pause conditions: window-open, defrost, HVAC idle → return "Disturbed" 6. Setpoint drop → return lowest mode immediately -7. Simulate ALL fan modes over the horizon (30 min default) -8. Select best by lowest cost -9. Apply guards: min-interval hold, hysteresis, step-down hold, step-down limit -10. Build and return payload +7. Build monotone slope map (if all profiles learned) +8. Simulate ALL fan modes over the horizon (30 min default) +9. Select best by lowest cost +10. Apply guards: min-interval hold, hysteresis, step-down hold, step-down limit +11. Build and return payload ``` ### Cost Function (`_simulate_mode`) @@ -39,9 +62,11 @@ Each candidate fan mode is simulated step-by-step over the horizon: | `mode_rank_cost` | 0.05 × (rank+1) | Slight preference for lower fan speeds | | `min_interval_penalty` | 25.0 | Blocks changes before min_interval | +Cost weights are module-level constants (e.g. `FLOOR_VIOLATION_LINEAR_WEIGHT = 12.0`). + ### Hysteresis (`_required_switch_gain`) -The shadow requires a minimum cost improvement before switching: +The MPC requires a minimum cost improvement before switching: | Situation | Base Margin | |---|---| @@ -58,15 +83,33 @@ Plus bonuses for non-established phase (+0.10) and step distance (+0.05/step). ### Disturbance Bias (`_update_disturbance_bias`) -Tracks slow external perturbations (solar, occupancy). Only updates during ESTABLISHED phase with a known profile. Decays during window-open or defrost. Clamped to ±2.0 °C/h. +Tracks slow external perturbations (solar gains, occupancy). EMA of residual `observed_slope − expected_slope`. Only updates during ESTABLISHED phase with a known profile. Decays during window-open, defrost, or HVAC idle. Clamped to ±2.0 °C/h. + +Access via the `disturbance_bias` property (read-only). + +### Monotone Constraint (`build_monotone_slopes`) + +When **all** fan-mode profiles are learned, `build_monotone_slopes()` enforces `slope(mode_i) ≤ slope(mode_i+1)` via a left-to-right isotonic pass. Returns `None` when any profile is missing (fresh install). This prevents a contaminated low-speed profile from ranking above a stronger mode in cost comparison. ### Pause Conditions -The shadow pauses (returns "Disturbed") during: +The MPC pauses (returns "Disturbed") during: - **Window open**: thermal model unreliable -- **Defrost active**: slope data is corrupted by heat-pump defrost cycle +- **Defrost active**: slope data corrupted by heat-pump defrost cycle +- **HVAC idle**: compressor off, no thermal slope to predict -Both conditions decay the disturbance bias without updating it. +All three conditions decay, not update, the disturbance bias. + +## Public API Surface + +| Member | Type | Purpose | +|--------|------|---------| +| `enabled` | property (r/w) | Enable/disable MPC cycles | +| `production_mode` | property (r/w) | Switch between shadow and live control | +| `fan_modes` | property (r/w) | Update available fan modes | +| `disturbance_bias` | property (read-only) | Current external disturbance estimate | +| `evaluate(...)` | method | Run one MPC cycle; returns result dict | +| `build_monotone_slopes(fan_modes, hvac_mode)` | method | Isotonic slope map or None | ## Procedure @@ -74,34 +117,36 @@ Both conditions decay the disturbance bias without updating it. | Change | File/Method | |---|---| -| Cost weights | `_simulate_mode()` constants at module top | -| Hysteresis margins | `_required_switch_gain()` constants at module top | +| Cost weights | module-level constants in `mpc_controller.py` | +| Hysteresis margins | module-level constants + `_required_switch_gain()` | | Step-down guard | `_step_down_hold_note()` | | New pause condition | `evaluate()` after disturbance update, before setpoint-drop check | | Disturbance tracking | `_update_disturbance_bias()` | | Simulation model | `_simulate_mode()` inner loop | | Confidence | `_compute_confidence()` | +| Monotone constraint | `build_monotone_slopes()` | ### 2. Make the Change -Constants are at the module level (e.g., `FLOOR_VIOLATION_LINEAR_WEIGHT = 12.0`). If a new constant is user-tunable, add it to `const.py` with `DEFAULT_` prefix and expose in `config_flow.py`. +Module-level constants (e.g., `FLOOR_VIOLATION_LINEAR_WEIGHT = 12.0`) control all weights. If a new constant is user-tunable, add it to `const.py` with `DEFAULT_` prefix and expose in `config_flow.py`. Key constraints: -- **Never call HA services** from the shadow -- **Never modify controller state** — the shadow is observation-only -- **Respect the payload format** — all keys must start with `mpc_shadow_` -- **Log at DEBUG level** — shadow logs are verbose by design +- **Never call HA services** from the MPC controller +- **Never modify rule-based controller state** — observation mode is read-only +- **Respect the payload format** — result keys use `mpc_` prefix (e.g. `mpc_fan_mode`) +- **Log at DEBUG level** — MPC logs are verbose by design +- **disturbance_bias and build_monotone_slopes are public** — do not prefix with `_` ### 3. Write Tests -Tests go in `tests/test_mpc_shadow.py`. Use the existing helpers: +Tests go in `tests/test_mpc_controller.py`. Use the existing helpers: ```python -def test_shadow_() -> None: +def test_mpc_() -> None: """.""" controller = _build_controller() _prime_learning_profiles(controller) - shadow = MPCShadowController( + mpc = MPCController( learning=controller.learning, deadband=0.3, min_interval=10, @@ -109,26 +154,28 @@ def test_shadow_() -> None: enabled=True, ) - result = shadow.evaluate( + result = mpc.evaluate( current_temp=..., target_temp=..., vtherm_slope=..., hvac_mode="heat", current_fan="medium", live_decision_fan="medium", is_window_open=False, minutes_since_change=20.0, ) - assert result["mpc_shadow_fan_mode"] == ... - assert result["mpc_shadow_status"] == ... + assert result["mpc_fan_mode"] == ... + assert result["mpc_status"] == ... ``` +To test `build_monotone_slopes` or `disturbance_bias` directly, call them on the `mpc` instance — no `_` prefix. + ### 4. Update Documentation -- **`README.md`** — MPC Shadow section if user-visible behavior changes -- **`docs/mpc_shadow_mode.md`** — technical design document +- **`README.md`** — MPC section if user-visible behavior changes +- **`docs/mpc_mode.md`** — technical design document - **`.github/copilot-instructions.md`** — if adding new constraints ### 5. Validate ```bash -python -m pytest tests/test_mpc_shadow.py -q # shadow tests -python -m pytest tests/ -q # all tests +python -m pytest tests/test_mpc_controller.py -q # MPC tests +python -m pytest tests/ -q # all tests ``` diff --git a/README.md b/README.md index 95e51b9..7427896 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ Smart Fan Controller is a custom Home Assistant integration that **adjusts HVAC > **MPC Controller** always runs a learned temperature-state model and MPC-lite in observation mode. When **MPC Production Mode** is enabled, the MPC directly controls the fan instead of the rule-based algorithm. It owns its own runtime parameters (`deadband`, `min_interval`, fan modes), pauses itself during disturbed periods such as an open window, and keeps those periods out of dead-time learning. See [docs/mpc_mode.md](docs/mpc_mode.md). > It supports both `heat` and `cool`, and includes a hysteresis guard so tiny cost differences do not create fan yo-yo around the setpoint. +> When all fan-mode profiles are learned, a **monotone constraint** is enforced: higher fan modes are guaranteed to have slopes ≥ lower modes. This prevents contaminated profiles from causing the MPC to rank a weaker mode above a stronger one. --- @@ -236,7 +237,14 @@ Once learning is ready, parameters are **automatically applied** and the integra The learning system tracks the **effective slope per fan mode and HVAC mode** (e.g., "medium in heat" vs "high in cool"). This data provides visibility into which fan speeds are most effective for each mode. -Profiles require at least 10 samples per mode to be considered reliable. Samples collected during window-open periods or large setpoint drops (night mode) are automatically filtered out. +Profiles require at least 10 samples per mode to be considered reliable. Samples are automatically filtered out when: +- Window is open (external disturbance) +- Large setpoint drop occurred (night mode) — including a **30-minute cooldown** after the drop to avoid EMA inertia +- HVAC is idle or defrost is active +- The fan mode hasn't been active long enough (**2× dead time**) for the VTherm EMA to fully reflect the current mode +- The phase is not yet ESTABLISHED + +The effective slope is computed as the **median** (not mean) of collected samples, providing robustness against occasional outlier readings caused by thermal inertia from previous high-speed modes. ### Dead Time Calibration @@ -348,6 +356,27 @@ Manually apply the parameters computed by the learning system. Useful when auto- Clear all learning data and start fresh. Use after HVAC maintenance or a significant system change. +### `smart_fan_controller.set_effective_slope` + +Manually set the effective slope for a specific fan mode / HVAC mode profile without resetting all learning data. Replaces existing samples for that profile with synthetic ones matching the provided slope. + +**Parameters**: + +| Parameter | Required | Example | Description | +| ----------------- | -------- | -------- | -------------------------------------------------------- | +| `hvac_mode` | Yes | `heat` | The HVAC mode (`heat` or `cool`) | +| `fan_mode` | Yes | `silent` | The fan mode name | +| `effective_slope` | Yes | `0.15` | Target effective slope in °C/h (positive = towards target) | + +**Example** (Developer Tools → Services): +```yaml +service: smart_fan_controller.set_effective_slope +data: + hvac_mode: heat + fan_mode: silent + effective_slope: 0.15 +``` + --- ## Troubleshooting diff --git a/custom_components/smart_fan_controller/__init__.py b/custom_components/smart_fan_controller/__init__.py index b35ffcf..17f4a5f 100644 --- a/custom_components/smart_fan_controller/__init__.py +++ b/custom_components/smart_fan_controller/__init__.py @@ -540,6 +540,29 @@ async def reset_learning(_): hass.services.async_register(DOMAIN, "reset_learning", reset_learning) + async def set_effective_slope(call): + """Service to manually set the effective slope for a fan/HVAC profile.""" + hvac_mode = call.data["hvac_mode"] + fan_mode = call.data["fan_mode"] + effective_slope = float(call.data["effective_slope"]) + + controller.learning.set_mode_effective_slope(fan_mode, hvac_mode, effective_slope) + + # Persist immediately + learning_data_to_save = controller.learning.to_dict() + await store.async_save(learning_data_to_save) + + # Refresh sensors + sensors = hass.data[DOMAIN][entry.entry_id].get("sensors", []) + for sensor in sensors: + sensor.async_write_ha_state() + + _LOGGER.info( + "Set effective slope for %s/%s to %.3f", hvac_mode, fan_mode, effective_slope + ) + + hass.services.async_register(DOMAIN, "set_effective_slope", set_effective_slope) + return True diff --git a/custom_components/smart_fan_controller/const.py b/custom_components/smart_fan_controller/const.py index 1429f41..79d6b24 100644 --- a/custom_components/smart_fan_controller/const.py +++ b/custom_components/smart_fan_controller/const.py @@ -53,6 +53,8 @@ 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 +SETPOINT_DROP_LEARNING_COOLDOWN = 30.0 # Minutes to block learning after a setpoint drop +MIN_ESTABLISHED_RATIO = 2.0 # Minimum factor × dead_time the fan mode must be active before learning LEGACY_OBJECT_KEY_MAP = { "reason": "status", diff --git a/custom_components/smart_fan_controller/controller.py b/custom_components/smart_fan_controller/controller.py index 92c79a5..e198e5c 100644 --- a/custom_components/smart_fan_controller/controller.py +++ b/custom_components/smart_fan_controller/controller.py @@ -11,6 +11,8 @@ PHASE_DEAD_TIME, PHASE_TRANSIENT, PHASE_ESTABLISHED, + SETPOINT_DROP_LEARNING_COOLDOWN, + MIN_ESTABLISHED_RATIO, ) from .thermal_learning import ThermalLearning @@ -54,6 +56,7 @@ def __init__( self._last_change_time: float = self._now - (self._limit_timeout * 60) self._last_slope_significant_change: float = self._now self._last_hvac_mode: str | None = None + self._last_setpoint_drop_time: float = 0.0 # timestamp of last setpoint-drop event (init: far in the past) # Defrost state (set externally via CONF_DEFROST_ENTITY; 20-min cooldown applied by __init__.py) self._defrost_active: bool = False @@ -86,6 +89,15 @@ def previous_slope(self) -> float | None: def previous_slope(self, value: float | None) -> None: self._previous_slope = value + @property + def slope_at_last_change(self) -> float: + """Slope snapshot taken at the moment of the last confirmed fan-speed change.""" + return self._slope_at_last_change + + @slope_at_last_change.setter + def slope_at_last_change(self, value: float) -> None: + self._slope_at_last_change = value + @property def last_change_time(self) -> float: """Timestamp of the last confirmed fan-speed change.""" @@ -373,6 +385,7 @@ def calculate_decision( elif current_temperature_error < THRESHOLD_TARGET_DROP: new_index = 0 force = True + self._last_setpoint_drop_time = self._now reason = f"Setpoint drop: Target moved away ({round(current_temperature_error, 2)}°C)" # B. BRAKING ANTICIPATION (Overshoot predicted) @@ -455,8 +468,30 @@ def calculate_decision( # Collect learning data using the fan mode CURRENTLY active (not the decided one), # so the slope observation is correctly attributed to the mode that produced it. # Skip during defrost and HVAC idle: the slope is distorted and would corrupt learned profiles. + # Only learn from ESTABLISHED phase to avoid inheriting residual inertia from previous modes. + # Also require the mode to be active for at least MIN_ESTABLISHED_RATIO × dead_time to ensure + # the EMA slope has fully decayed from the previous mode. + # Block learning for SETPOINT_DROP_LEARNING_COOLDOWN minutes after a setpoint drop event, + # because the VTherm EMA slope retains inertia from the pre-drop high-speed mode. if self.learning_enabled and current_fan is not None and current_fan in self._fan_modes and not defrost_protection and not is_hvac_idle: - self.learning.add_slope_sample(current_fan, vtherm_slope, current_temperature_error, hvac_mode, is_window_open) + learned_dead_time = self.learning.get_dead_time() if self.learning.is_ready() else DEFAULT_DEAD_TIME + min_stable_minutes = learned_dead_time * MIN_ESTABLISHED_RATIO + minutes_since_setpoint_drop = (self._now - self._last_setpoint_drop_time) / 60.0 + + if phase != PHASE_ESTABLISHED: + pass # Too early: sensor hasn't stabilized yet + elif minutes_since_change < min_stable_minutes: + _LOGGER.debug( + "Learning: Skipped sample (mode only active %.1f min, need %.1f min)", + minutes_since_change, min_stable_minutes, + ) + elif self._last_setpoint_drop_time > 0 and minutes_since_setpoint_drop < SETPOINT_DROP_LEARNING_COOLDOWN: + _LOGGER.debug( + "Learning: Skipped sample (setpoint drop cooldown, %.1f min ago)", + minutes_since_setpoint_drop, + ) + else: + self.learning.add_slope_sample(current_fan, vtherm_slope, current_temperature_error, hvac_mode, is_window_open) _LOGGER.debug( ( diff --git a/custom_components/smart_fan_controller/mpc_controller.py b/custom_components/smart_fan_controller/mpc_controller.py index 8051c74..1ff07e8 100644 --- a/custom_components/smart_fan_controller/mpc_controller.py +++ b/custom_components/smart_fan_controller/mpc_controller.py @@ -98,6 +98,11 @@ def fan_modes(self, modes: list[str] | None) -> None: """Update the available fan modes.""" self._fan_modes = modes + @property + def disturbance_bias(self) -> float: + """Return the current disturbance bias estimate (°C/h).""" + return self._disturbance_bias + def evaluate( self, *, @@ -231,6 +236,12 @@ def evaluate( known_profiles = 0 current_index = fan_modes.index(active_fan) + # Build monotone-enforced slope map so higher fan modes are never + # assigned a lower slope than lower modes. Only applied when ALL + # profiles are learned; on a fresh install (partial profiles) the + # raw learned / fallback values are used as-is. + monotone_slopes = self.build_monotone_slopes(fan_modes, hvac_mode) + for fan_mode in fan_modes: mode_slope, known_profile = self._get_mode_slope( fan_mode, @@ -238,6 +249,7 @@ def evaluate( active_fan, current_effective_slope, fan_modes, + monotone_slopes, ) sim = self._simulate_mode( current_temp=current_temp, @@ -371,9 +383,13 @@ def _get_mode_slope( current_fan: str, current_effective_slope: float, fan_modes: list[str], + monotone_slopes: dict[str, float] | None = None, ) -> tuple[float, bool]: """Return the effective slope estimate for a candidate fan mode.""" - learned = self._learning.get_mode_effective_slope(fan_mode, hvac_mode) + if monotone_slopes is not None and fan_mode in monotone_slopes: + learned = monotone_slopes[fan_mode] + else: + learned = self._learning.get_mode_effective_slope(fan_mode, hvac_mode) if learned is not None: _LOGGER.debug( "Shadow slope model: using learned profile for %s/%s = %.3f", @@ -407,6 +423,41 @@ def _detect_phase(minutes_since_change: float, dead_time: float) -> str: return PHASE_TRANSIENT return PHASE_ESTABLISHED + def build_monotone_slopes(self, fan_modes: list[str], hvac_mode: str) -> dict[str, float] | None: + """Return monotone-enforced slopes keyed by fan mode, or None if not all profiles are ready. + + Fan modes are assumed ordered from weakest to strongest. If every mode + has a learned profile, we walk left-to-right and clamp each value to be + >= the previous one (isotonic regression, pool-adjacent-violators with + a simple forward pass). This prevents a contaminated low-speed profile + from being ranked above a mid-speed one in the MPC cost comparison. + + Returns None when any profile is missing — on a fresh installation not + all modes have data yet and the constraint could mask real measurements. + """ + raw: list[tuple[str, float]] = [] + for fm in fan_modes: + slope = self._learning.get_mode_effective_slope(fm, hvac_mode) + if slope is None: + return None + raw.append((fm, slope)) + + enforced: dict[str, float] = {} + prev = float("-inf") + for fm, val in raw: + clamped = max(val, prev) + enforced[fm] = clamped + prev = clamped + + if enforced != {fm: val for fm, val in raw}: + _LOGGER.debug( + "Monotone enforcement applied for %s: raw=%s → enforced=%s", + hvac_mode, + {fm: round(val, 3) for fm, val in raw}, + {fm: round(val, 3) for fm, val in enforced.items()}, + ) + return enforced + def _update_disturbance_bias( self, *, diff --git a/custom_components/smart_fan_controller/services.yaml b/custom_components/smart_fan_controller/services.yaml index d4a7b9b..2106995 100644 --- a/custom_components/smart_fan_controller/services.yaml +++ b/custom_components/smart_fan_controller/services.yaml @@ -5,3 +5,37 @@ apply_learned_settings: reset_learning: name: Reset learning description: Clear all learning data and restart the learning process from scratch. This removes all collected samples and statistics, allowing the integration to begin learning optimal parameters again. + +set_effective_slope: + name: Set effective slope + description: Manually set the effective slope for a specific fan mode / HVAC mode profile. Replaces existing samples for that profile with synthetic ones so the learned value matches the provided slope. + fields: + hvac_mode: + name: HVAC mode + description: The HVAC mode (heat or cool). + required: true + example: "heat" + selector: + select: + options: + - heat + - cool + fan_mode: + name: Fan mode + description: The fan mode name (e.g. silent, low, med, high, superhigh). + required: true + example: "silent" + selector: + text: + effective_slope: + name: Effective slope + description: The desired effective slope value in °C/h (positive = towards target). + required: true + example: 0.15 + selector: + number: + min: -2.0 + max: 5.0 + step: 0.001 + mode: box + unit_of_measurement: "°C/h" diff --git a/custom_components/smart_fan_controller/thermal_learning.py b/custom_components/smart_fan_controller/thermal_learning.py index 33047b4..fe9774e 100644 --- a/custom_components/smart_fan_controller/thermal_learning.py +++ b/custom_components/smart_fan_controller/thermal_learning.py @@ -222,7 +222,10 @@ def get_dead_time(self) -> float: return statistics.median(response_times) def get_mode_effective_slope(self, fan_mode: str, hvac_mode: str) -> float | None: - """Return the average effective slope for a fan mode in a given HVAC mode. + """Return the median effective slope for a fan mode in a given HVAC mode. + + Uses median instead of mean for robustness against outlier samples + (e.g. residual inertia from a previous high-speed mode). Effective slope is positive when moving towards target: - In heating: positive raw slope is good @@ -233,8 +236,34 @@ def get_mode_effective_slope(self, fan_mode: str, hvac_mode: str) -> float | Non matching_slopes = [sl for (_, fm, sl, hm) in self._slope_samples if fm == fan_mode and hm == hvac_mode] if len(matching_slopes) < MIN_MODE_PROFILE_SAMPLES: return None - avg = statistics.mean(matching_slopes) - return -avg if hvac_mode == "cool" else avg + med = statistics.median(matching_slopes) + return -med if hvac_mode == "cool" else med + + def set_mode_effective_slope(self, fan_mode: str, hvac_mode: str, target_slope: float) -> None: + """Replace all samples for a fan/HVAC profile with synthetic ones producing target_slope. + + The raw slope stored in samples is the signed VTherm value: + - In heating: raw slope == effective slope + - In cooling: raw slope == -effective slope (inverted on read) + """ + raw_slope = -target_slope if hvac_mode == "cool" else target_slope + + # Remove existing samples for this profile + before = len(self._slope_samples) + self._slope_samples = [s for s in self._slope_samples if not (s[1] == fan_mode and s[3] == hvac_mode)] + removed = before - len(self._slope_samples) + + # Insert MIN_MODE_PROFILE_SAMPLES synthetic samples at current time + now = time.time() + for i in range(MIN_MODE_PROFILE_SAMPLES): + self._slope_samples.append((now + i, fan_mode, raw_slope, hvac_mode)) + + self.recompute_slope_stats() + + _LOGGER.info( + "Learning: set_mode_effective_slope %s/%s = %.3f (removed %d, inserted %d synthetic samples)", + hvac_mode, fan_mode, target_slope, removed, MIN_MODE_PROFILE_SAMPLES, + ) def get_mode_sample_count(self, fan_mode: str, hvac_mode: str) -> int: """Return the number of collected samples for one fan/HVAC profile.""" diff --git a/tests/test_cool.py b/tests/test_cool.py index 0293133..4975435 100644 --- a/tests/test_cool.py +++ b/tests/test_cool.py @@ -1,4 +1,5 @@ """Tests for SmartFanController logic - COOL mode.""" +# pylint: disable=redefined-outer-name import pytest from custom_components.smart_fan_controller.controller import SmartFanController diff --git a/tests/test_defrost.py b/tests/test_defrost.py index b771b8f..5d057b1 100644 --- a/tests/test_defrost.py +++ b/tests/test_defrost.py @@ -39,6 +39,7 @@ def test_defrost_blocks_braking(self, controller): controller._last_change_time = 0.0 controller._defrost_active = True controller._defrost_start_time = 0.0 + controller._last_change_time = 0.0 with patch("time.time", return_value=1200.0): # 20 min later result = controller.calculate_decision( @@ -60,6 +61,7 @@ def test_defrost_blocks_favorable_slope_reduction(self, controller): controller._last_change_time = 0.0 controller._defrost_active = True controller._defrost_start_time = 0.0 + controller._last_change_time = 0.0 with patch("time.time", return_value=1200.0): result = controller.calculate_decision( @@ -149,6 +151,7 @@ def test_learning_resumes_after_defrost_expires(self, controller): """Learning should resume after defrost cooldown expires.""" controller._defrost_active = True controller._defrost_start_time = 0.0 + controller._last_change_time = 0.0 initial_count = controller.learning.slope_sample_count() # 25 minutes after defrost start → cooldown expired diff --git a/tests/test_heat.py b/tests/test_heat.py index 8aff3d2..9c8d918 100644 --- a/tests/test_heat.py +++ b/tests/test_heat.py @@ -1,4 +1,5 @@ """Tests for SmartFanController logic - HEAT mode.""" +# pylint: disable=redefined-outer-name import pytest from custom_components.smart_fan_controller.controller import SmartFanController diff --git a/tests/test_hvac_idle.py b/tests/test_hvac_idle.py index 4d5b1d3..2a693e1 100644 --- a/tests/test_hvac_idle.py +++ b/tests/test_hvac_idle.py @@ -155,6 +155,7 @@ class TestHvacIdleLearningExclusion: def test_slope_samples_excluded_during_idle(self, controller): """Slope samples should NOT be recorded when compressor is off.""" initial_samples = controller.learning.slope_sample_count() + controller._last_change_time = 0.0 with patch("time.time", return_value=3600.0): controller.calculate_decision( current_temp=19.8, @@ -169,6 +170,7 @@ def test_slope_samples_excluded_during_idle(self, controller): def test_slope_samples_collected_when_not_idle(self, controller): """Slope samples should be recorded when compressor is running.""" initial_samples = controller.learning.slope_sample_count() + controller._last_change_time = 0.0 with patch("time.time", return_value=3600.0): controller.calculate_decision( current_temp=19.8, diff --git a/tests/test_learning.py b/tests/test_learning.py index 6d6ebba..7ae74d3 100644 --- a/tests/test_learning.py +++ b/tests/test_learning.py @@ -1,5 +1,6 @@ """Tests for ThermalLearning auto-calibration.""" import pytest +import time as time_mod from unittest.mock import patch from custom_components.smart_fan_controller.controller import SmartFanController, ThermalLearning from custom_components.smart_fan_controller.const import MIN_LIMIT_TIMEOUT @@ -15,7 +16,7 @@ def test_optimal_limit_timeout_with_median_response_times(self): # Add multiple slope samples to satisfy is_ready() requirements # Note: slope sample parameters (fan mode, slope value) don't affect limit_timeout - for i in range(250): + for _ in range(250): learning.add_slope_sample("medium", 0.3, 0.1) # Verify that slope samples alone make the learning ready @@ -40,7 +41,7 @@ def test_optimal_limit_timeout_uses_observed_response_directly(self): learning = ThermalLearning() # Add samples (using consistent parameters across tests) - for i in range(250): + 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) @@ -58,7 +59,7 @@ def test_optimal_limit_timeout_with_slow_response(self): learning = ThermalLearning() # Add samples (using consistent parameters) - for i in range(250): + for _ in range(250): learning.add_slope_sample("medium", 0.3, 0.1) # Add very slow response times @@ -75,7 +76,7 @@ def test_optimal_limit_timeout_typical_case(self): learning = ThermalLearning() # Add samples - for i in range(250): + for _ in range(250): learning.add_slope_sample("medium", 0.3, 0.2) # Add typical response times user mentioned: 10-15 minutes @@ -99,7 +100,7 @@ def test_response_time_validation_rejects_unreasonable_values(self): ) # Add enough slope samples to make learning ready - for i in range(250): + for _ in range(250): controller.learning.add_slope_sample("medium", 0.3, 0.1) base_time = 1000000.0 @@ -144,7 +145,6 @@ def test_response_time_validation_rejects_unreasonable_values(self): events_after = len(controller.learning.response_events) assert events_after == events_before, "65-minute response should be filtered out" - def test_learned_dead_time_sensor_reports_median_response(self): """The diagnostic dead-time sensor should expose the median response delay.""" controller = SmartFanController( @@ -180,3 +180,144 @@ def test_effective_timeout_sensor_shows_runtime_timeout(self): sensor = SmartFanEffectiveTimeoutSensor("entry", controller) assert sensor.native_value == 12.0 + + def test_set_mode_effective_slope_replaces_samples(self): + """set_mode_effective_slope should replace existing samples and produce the target slope.""" + learning = ThermalLearning() + + # Add some initial samples for silent/heat + for _ in range(15): + learning.add_slope_sample("silent", 0.8, 0.2, hvac_mode="heat") + + assert learning.get_mode_effective_slope("silent", "heat") == pytest.approx(0.8, abs=0.01) + + # Override to a lower value + learning.set_mode_effective_slope("silent", "heat", 0.15) + + assert learning.get_mode_effective_slope("silent", "heat") == pytest.approx(0.15, abs=0.001) + assert learning.get_mode_sample_count("silent", "heat") == 10 + + def test_set_mode_effective_slope_cool_inverts(self): + """In cool mode, effective slope sign is inverted vs raw slope.""" + learning = ThermalLearning() + + learning.set_mode_effective_slope("high", "cool", 0.5) + + # effective_slope should be 0.5 (positive = towards target) + assert learning.get_mode_effective_slope("high", "cool") == pytest.approx(0.5, abs=0.001) + + def test_set_mode_effective_slope_preserves_other_profiles(self): + """Overriding one profile should not affect other profiles.""" + learning = ThermalLearning() + + for _ in range(15): + learning.add_slope_sample("silent", 0.8, 0.2, hvac_mode="heat") + for _ in range(15): + learning.add_slope_sample("med", 0.5, 0.2, hvac_mode="heat") + + learning.set_mode_effective_slope("silent", "heat", 0.15) + + assert learning.get_mode_effective_slope("silent", "heat") == pytest.approx(0.15, abs=0.001) + assert learning.get_mode_effective_slope("med", "heat") == pytest.approx(0.5, abs=0.01) + + def test_median_resists_outliers(self): + """Median should resist a single extreme outlier sample.""" + learning = ThermalLearning() + + # 12 normal samples at ~0.15, plus 3 outlier at 1.29 (inertia contamination) + for _ in range(12): + learning.add_slope_sample("silent", 0.15, 0.2, hvac_mode="heat") + for _ in range(3): + learning.add_slope_sample("silent", 1.29, 0.2, hvac_mode="heat") + + slope = learning.get_mode_effective_slope("silent", "heat") + # Median of [0.15]*12 + [1.29]*3 = 0.15 (most values are 0.15) + assert slope is not None + assert slope == pytest.approx(0.15, abs=0.01) + + def test_setpoint_drop_cooldown_blocks_learning(self): + """Samples should be blocked for 30 min after a setpoint-drop event.""" + controller = SmartFanController( + fan_modes=["low", "medium", "high"], + deadband=0.2, + min_interval=10, + soft_error=0.3, + hard_error=0.6, + limit_timeout=15, + ) + + now = time_mod.time() + + # Trigger a setpoint drop (error < -1°C: target moved away from current) + # In heat mode: error = target - current = 18.0 - 20.5 = -2.5 + with patch("time.time", return_value=now): + controller.last_change_time = now - 1800 + decision = controller.calculate_decision( + current_temp=20.5, + target_temp=18.0, + vtherm_slope=0.5, + hvac_mode="heat", + current_fan="medium", + ) + assert "Setpoint drop" in decision.get("reason", "") + + # 15 min later (within cooldown), ESTABLISHED slope should NOT be learned + with patch("time.time", return_value=now + 900): + controller.last_change_time = now - 1800 # ensure ESTABLISHED + min stable + controller.calculate_decision( + current_temp=19.5, + target_temp=20.0, + vtherm_slope=0.5, + hvac_mode="heat", + current_fan="medium", + ) + assert controller.learning.slope_sample_count() == 0 + + # 35 min later (after cooldown), learning should resume + with patch("time.time", return_value=now + 2100): + controller.last_change_time = now # ~35 min ago, ESTABLISHED + min stable + controller.calculate_decision( + current_temp=19.5, + target_temp=20.0, + vtherm_slope=0.5, + hvac_mode="heat", + current_fan="medium", + ) + assert controller.learning.slope_sample_count() > 0 + + def test_min_stable_duration_blocks_early_samples(self): + """Samples should be blocked when fan mode hasn't been active long enough.""" + controller = SmartFanController( + fan_modes=["low", "medium", "high"], + deadband=0.2, + min_interval=10, + soft_error=0.3, + hard_error=0.6, + limit_timeout=15, + ) + + now = time_mod.time() + + # Set last change to only 16 min ago (ESTABLISHED but < 2 × 10 min dead_time = 20 min) + with patch("time.time", return_value=now): + controller.last_change_time = now - 960 # 16 min ago + controller.calculate_decision( + current_temp=22.0, + target_temp=21.0, + vtherm_slope=0.5, + hvac_mode="heat", + current_fan="medium", + ) + assert controller.learning.slope_sample_count() == 0 + + # Set last change to 25 min ago (> 20 min), should learn + with patch("time.time", return_value=now + 600): + controller.last_change_time = now - 900 # 25 min ago + controller.calculate_decision( + current_temp=22.0, + target_temp=21.0, + vtherm_slope=0.5, + hvac_mode="heat", + current_fan="medium", + ) + assert controller.learning.slope_sample_count() > 0 diff --git a/tests/test_learning_enabled.py b/tests/test_learning_enabled.py index 952dc73..7a05444 100644 --- a/tests/test_learning_enabled.py +++ b/tests/test_learning_enabled.py @@ -1,5 +1,6 @@ """Test for learning enabled switch functionality.""" import pytest +import time from custom_components.smart_fan_controller.controller import SmartFanController @@ -67,6 +68,9 @@ def test_learning_enabled_allows_slope_collection(self): learning_enabled=True, ) + # Ensure the fan mode has been active long enough for the stable-duration filter + controller._last_change_time = time.time() - 1800 # 30 min ago + # Make a decision that would collect learning data decision = controller.calculate_decision( current_temp=22.0, @@ -91,6 +95,9 @@ def test_learning_can_be_toggled_at_runtime(self): learning_enabled=True, ) + # Ensure the fan mode has been active long enough for the stable-duration filter + controller._last_change_time = time.time() - 1800 # 30 min ago + # Collect some samples with learning enabled (positive error so it's not skipped) controller.calculate_decision( current_temp=20.0, diff --git a/tests/test_learning_persistence.py b/tests/test_learning_persistence.py index 8aaa596..98532d8 100644 --- a/tests/test_learning_persistence.py +++ b/tests/test_learning_persistence.py @@ -189,4 +189,4 @@ async def test_integration_storage_persistence(self, hass: HomeAssistant): controller = hass.data[DOMAIN][entry.entry_id]["controller"] # After window cleanup, data should still be there (timestamps are recent) assert len(controller.learning.slope_samples) > 0 - assert len(controller.learning._response_events) > 0 + assert len(controller.learning.response_events) > 0 diff --git a/tests/test_mpc_controller.py b/tests/test_mpc_controller.py index 47482d5..0d2981b 100644 --- a/tests/test_mpc_controller.py +++ b/tests/test_mpc_controller.py @@ -56,7 +56,7 @@ async def run_in_executor(target, *args): def test_shadow_disabled_reports_disabled_status() -> None: """Shadow reports disabled status when not enabled.""" controller = _build_controller() - shadow = MPCController( + mpc = MPCController( learning=controller.learning, deadband=0.3, min_interval=10, @@ -64,7 +64,7 @@ def test_shadow_disabled_reports_disabled_status() -> None: enabled=False, ) - result = shadow.evaluate( + result = mpc.evaluate( current_temp=19.2, target_temp=20.0, vtherm_slope=0.4, @@ -84,7 +84,7 @@ def test_shadow_prefers_stronger_fan_when_profiles_support_it() -> None: """Shadow picks a stronger fan mode when learned profiles support it.""" controller = _build_controller() _prime_learning_profiles(controller) - shadow = MPCController( + mpc = MPCController( learning=controller.learning, deadband=0.3, min_interval=10, @@ -92,7 +92,7 @@ def test_shadow_prefers_stronger_fan_when_profiles_support_it() -> None: enabled=True, ) - result = shadow.evaluate( + result = mpc.evaluate( current_temp=19.0, target_temp=20.0, vtherm_slope=0.25, @@ -120,7 +120,7 @@ def test_shadow_holds_superhigh_while_still_below_target() -> None: controller.learning.add_slope_sample("superhigh", 1.0, 0.4, "heat") controller.learning.add_response_event(30.0) - shadow = MPCController( + mpc = MPCController( learning=controller.learning, deadband=0.2, min_interval=10, @@ -128,7 +128,7 @@ def test_shadow_holds_superhigh_while_still_below_target() -> None: enabled=True, ) - result = shadow.evaluate( + result = mpc.evaluate( current_temp=19.95, target_temp=20.0, vtherm_slope=0.2, @@ -148,7 +148,7 @@ def test_shadow_pauses_when_window_is_open() -> None: """Shadow pauses evaluation when a window is open.""" controller = _build_controller() _prime_learning_profiles(controller) - shadow = MPCController( + mpc = MPCController( learning=controller.learning, deadband=0.3, min_interval=10, @@ -156,7 +156,7 @@ def test_shadow_pauses_when_window_is_open() -> None: enabled=True, ) - result = shadow.evaluate( + result = mpc.evaluate( current_temp=19.3, target_temp=20.0, vtherm_slope=0.2, @@ -325,7 +325,7 @@ def test_shadow_setpoint_drop_forces_lowest_mode() -> None: """When target drops significantly, shadow should go to the lowest fan mode.""" controller = _build_controller() _prime_learning_profiles(controller) - shadow = MPCController( + mpc = MPCController( learning=controller.learning, deadband=0.3, min_interval=10, @@ -333,7 +333,7 @@ def test_shadow_setpoint_drop_forces_lowest_mode() -> None: enabled=True, ) - result = shadow.evaluate( + result = mpc.evaluate( current_temp=20.4, target_temp=17.5, vtherm_slope=0.0, @@ -352,7 +352,7 @@ def test_shadow_setpoint_drop_forces_lowest_mode() -> None: def test_shadow_setpoint_drop_matches_live() -> None: """Setpoint drop should report match with live when both choose lowest.""" controller = _build_controller() - shadow = MPCController( + mpc = MPCController( learning=controller.learning, deadband=0.3, min_interval=10, @@ -360,7 +360,7 @@ def test_shadow_setpoint_drop_matches_live() -> None: enabled=True, ) - result = shadow.evaluate( + result = mpc.evaluate( current_temp=20.0, target_temp=17.5, vtherm_slope=-0.2, @@ -379,7 +379,7 @@ def test_shadow_no_setpoint_drop_when_error_above_threshold() -> None: """Normal over-target should NOT trigger setpoint drop.""" controller = _build_controller() _prime_learning_profiles(controller) - shadow = MPCController( + mpc = MPCController( learning=controller.learning, deadband=0.3, min_interval=10, @@ -387,7 +387,7 @@ def test_shadow_no_setpoint_drop_when_error_above_threshold() -> None: enabled=True, ) - result = shadow.evaluate( + result = mpc.evaluate( current_temp=20.3, target_temp=20.0, vtherm_slope=0.0, @@ -405,7 +405,7 @@ def test_shadow_pauses_during_defrost() -> None: """Shadow should pause when defrost is active, like window-open.""" controller = _build_controller() _prime_learning_profiles(controller) - shadow = MPCController( + mpc = MPCController( learning=controller.learning, deadband=0.3, min_interval=10, @@ -413,7 +413,7 @@ def test_shadow_pauses_during_defrost() -> None: enabled=True, ) - result = shadow.evaluate( + result = mpc.evaluate( current_temp=19.3, target_temp=20.0, vtherm_slope=0.2, @@ -435,7 +435,7 @@ def test_shadow_disturbance_bias_decays_during_defrost() -> None: """Disturbance bias should decay, not update, during defrost.""" controller = _build_controller() _prime_learning_profiles(controller) - shadow = MPCController( + mpc = MPCController( learning=controller.learning, deadband=0.3, min_interval=10, @@ -444,7 +444,7 @@ def test_shadow_disturbance_bias_decays_during_defrost() -> None: ) # Prime the disturbance bias with a normal cycle - shadow.evaluate( + mpc.evaluate( current_temp=19.5, target_temp=20.0, vtherm_slope=0.5, @@ -454,10 +454,10 @@ def test_shadow_disturbance_bias_decays_during_defrost() -> None: is_window_open=False, minutes_since_change=20.0, ) - bias_before = shadow._disturbance_bias # pylint: disable=protected-access + bias_before = mpc.disturbance_bias # Defrost cycle with sharp slope drop — should NOT poison the bias - shadow.evaluate( + mpc.evaluate( current_temp=19.5, target_temp=20.0, vtherm_slope=-1.0, @@ -468,7 +468,7 @@ def test_shadow_disturbance_bias_decays_during_defrost() -> None: is_defrost_active=True, minutes_since_change=25.0, ) - bias_after = shadow._disturbance_bias # pylint: disable=protected-access + bias_after = mpc.disturbance_bias # Bias should have decayed, not grown from the -1.0 slope residual assert abs(bias_after) <= abs(bias_before) @@ -477,7 +477,7 @@ def test_shadow_disturbance_bias_decays_during_defrost() -> None: def test_production_mode_property_defaults_to_false() -> None: """Production mode should default to False (shadow only).""" controller = _build_controller() - shadow = MPCController( + mpc = MPCController( learning=controller.learning, deadband=0.3, min_interval=10, @@ -485,13 +485,13 @@ def test_production_mode_property_defaults_to_false() -> None: enabled=True, ) - assert shadow.production_mode is False + assert mpc.production_mode is False def test_production_mode_can_be_toggled() -> None: """Production mode can be enabled and disabled.""" controller = _build_controller() - shadow = MPCController( + mpc = MPCController( learning=controller.learning, deadband=0.3, min_interval=10, @@ -499,18 +499,18 @@ def test_production_mode_can_be_toggled() -> None: enabled=True, ) - shadow.production_mode = True - assert shadow.production_mode is True + mpc.production_mode = True + assert mpc.production_mode is True - shadow.production_mode = False - assert shadow.production_mode is False + mpc.production_mode = False + assert mpc.production_mode is False def test_shadow_always_evaluates_regardless_of_production_mode() -> None: """Shadow evaluates and produces results whether production mode is on or off.""" controller = _build_controller() _prime_learning_profiles(controller) - shadow = MPCController( + mpc = MPCController( learning=controller.learning, deadband=0.3, min_interval=10, @@ -519,8 +519,8 @@ def test_shadow_always_evaluates_regardless_of_production_mode() -> None: ) for prod_mode in (False, True): - shadow.production_mode = prod_mode - result = shadow.evaluate( + mpc.production_mode = prod_mode + result = mpc.evaluate( current_temp=19.0, target_temp=20.0, vtherm_slope=0.25, @@ -532,3 +532,71 @@ def test_shadow_always_evaluates_regardless_of_production_mode() -> None: ) assert result["mpc_status"] != "Disabled" assert result["mpc_fan_mode"] is not 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.""" + controller = _build_controller(fan_modes=["silent", "low", "med", "high", "superhigh"]) + + # Create inverted profiles: silent > med (the real-world bug) + controller.learning.set_mode_effective_slope("silent", "heat", 0.53) + controller.learning.set_mode_effective_slope("low", "heat", 0.0) + controller.learning.set_mode_effective_slope("med", "heat", 0.45) + controller.learning.set_mode_effective_slope("high", "heat", 0.96) + controller.learning.set_mode_effective_slope("superhigh", "heat", 1.35) + + mpc = MPCController( + learning=controller.learning, + deadband=0.3, + min_interval=10, + fan_modes=["silent", "low", "med", "high", "superhigh"], + enabled=True, + ) + + monotone = mpc.build_monotone_slopes(["silent", "low", "med", "high", "superhigh"], "heat") + assert monotone is not None + + # 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"] + + +def test_monotone_constraint_returns_none_for_partial_profiles() -> None: + """On a fresh install with incomplete profiles, monotone should return None.""" + controller = _build_controller() + mpc = MPCController( + learning=controller.learning, + deadband=0.3, + min_interval=10, + fan_modes=FAN_MODES, + enabled=True, + ) + # No profiles learned yet + result = mpc.build_monotone_slopes(FAN_MODES, "heat") + assert result is None + + +def test_monotone_constraint_noop_when_already_ordered() -> None: + """When profiles are already monotone, the constraint should not change values.""" + controller = _build_controller() + + controller.learning.set_mode_effective_slope("low", "heat", 0.15) + controller.learning.set_mode_effective_slope("medium", "heat", 0.5) + controller.learning.set_mode_effective_slope("high", "heat", 1.0) + + mpc = MPCController( + learning=controller.learning, + deadband=0.3, + min_interval=10, + fan_modes=FAN_MODES, + enabled=True, + ) + + monotone = mpc.build_monotone_slopes(FAN_MODES, "heat") + assert monotone is not None + assert monotone["low"] == pytest.approx(0.15, abs=0.001) + assert monotone["medium"] == pytest.approx(0.5, abs=0.001) + assert monotone["high"] == pytest.approx(1.0, abs=0.001) diff --git a/tests/test_phase_detection.py b/tests/test_phase_detection.py index c7d6d2d..fd3025c 100644 --- a/tests/test_phase_detection.py +++ b/tests/test_phase_detection.py @@ -1,5 +1,7 @@ """Tests for phase detection, adaptive timeout, descent path, and window filtering.""" +# pylint: disable=redefined-outer-name import pytest +import time from unittest.mock import patch from custom_components.smart_fan_controller.controller import SmartFanController @@ -28,6 +30,7 @@ @pytest.fixture def controller(): + """Build a SmartFanController with default test configuration.""" return SmartFanController(fan_modes=FAN_MODES, **DEFAULT_CONFIG) @@ -168,7 +171,7 @@ def test_zone_c_boosts_in_transient_without_waiting_for_effective_timeout(self): start_time = 1000.0 controller.last_change_time = start_time controller.previous_slope = 0.2 - controller._slope_at_last_change = 1.5 + controller.slope_at_last_change = 1.5 # 12 minutes -> TRANSIENT with default timings: # - dead time has ended (10 min) @@ -318,7 +321,7 @@ def test_zone_c_boosts_in_transient_without_waiting_for_effective_timeout_cool(s start_time = 1000.0 controller.last_change_time = start_time controller.previous_slope = -0.2 - controller._slope_at_last_change = 1.5 + controller.slope_at_last_change = 1.5 with patch("time.time", return_value=start_time + 12 * 60): result = controller.calculate_decision( @@ -337,7 +340,7 @@ class TestWindowOpenFiltering: def test_learning_skips_samples_when_window_open(self, controller): """With is_window_open=True, no learning samples should be collected.""" - result = controller.calculate_decision( + controller.calculate_decision( current_temp=22.0, target_temp=21.0, vtherm_slope=0.5, @@ -349,7 +352,9 @@ def test_learning_skips_samples_when_window_open(self, controller): def test_learning_collects_when_window_closed(self, controller): """With is_window_open=False (default), samples are collected normally.""" - result = controller.calculate_decision( + controller.last_change_time = time.time() - 1800 # 30 min ago + + controller.calculate_decision( current_temp=22.0, target_temp=21.0, vtherm_slope=0.5, @@ -466,7 +471,6 @@ class TestBackwardCompatibility: def test_from_dict_old_format(self): """Old data with 3-tuples should be upgraded to 4-tuples with 'unknown' hvac_mode.""" - import time ts = time.time() - 3600 # 1 hour ago old_data = { "slope_samples": [(ts, "medium", 0.5), (ts, "high", 0.8)], @@ -486,7 +490,6 @@ def test_from_dict_old_format(self): def test_from_dict_new_format(self): """New data with 4-tuples round-trips correctly.""" - import time ts = time.time() - 3600 new_data = { "slope_samples": [(ts, "medium", 0.5, "heat"), (ts, "high", -0.8, "cool")], diff --git a/tests/test_services.py b/tests/test_services.py index 4cd3c2f..40003cb 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1,13 +1,12 @@ """Tests for Smart Fan Controller HA services (apply_learned_settings, reset_learning).""" import asyncio import pytest -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock from custom_components.smart_fan_controller import _apply_optimal_parameters from custom_components.smart_fan_controller.controller import SmartFanController from custom_components.smart_fan_controller.thermal_learning import ThermalLearning from custom_components.smart_fan_controller.const import ( - DOMAIN, CONF_DEADBAND, CONF_SOFT_ERROR, CONF_HARD_ERROR, @@ -55,7 +54,7 @@ async def test_apply_learned_settings_calls_helper_when_ready(self): applied_data = {} - async def fake_apply(hass, entry, optimal): + async def fake_apply(hass, entry, optimal): # pylint: disable=unused-argument applied_data.update(optimal) hass = MagicMock() diff --git a/tests/test_system.py b/tests/test_system.py index e25fbb6..66b00e7 100644 --- a/tests/test_system.py +++ b/tests/test_system.py @@ -1,4 +1,5 @@ """Tests for SmartFanController system constraints and edge cases.""" +# pylint: disable=redefined-outer-name import pytest from unittest.mock import patch From 53295324e042e826b6e19867a61f0b72c2ee01c7 Mon Sep 17 00:00:00 2001 From: Gamso Date: Mon, 13 Apr 2026 22:40:13 +0200 Subject: [PATCH 03/11] refactor: replace heuristic controller with always-on MPC engine Remove the rule-based heuristic controller (controller.py) and make MPCController the sole, always-active decision engine. Configuration cleanup: - Remove soft_error, hard_error, learning_enabled, mpc_production_enabled parameters (config flow, translations, options flow) - Remove production_mode and enabled toggles from MPCController - Remove step-down limit (_apply_step_down_limit); step-down hold remains - Empty the switch platform (no more user-facing switches) Sensor/data cleanup: - Remove MPC Match, Learned Soft Error, Learned Hard Error sensors - Remove mpc_match column from CSV data collection - Remove learning_enabled attribute from ThermalLearning; always on - Rename data_collection shadow parameter to mpc_decision Migration cleanup: - Remove LEGACY_OBJECT_KEY_MAP and extract_object_key_from_unique_id - Remove _async_migrate_entity_registry and entity registry import - Users upgrading from a legacy install start fresh Tests: - Delete test_entity_migration.py (migration removed) - Delete heuristic controller tests; replace with MPC-focused test suite - Rename all test_shadow_* functions to test_mpc_* - Remove production_mode, enabled, live_decision_fan from all test calls Documentation: - Clean README, docs/mpc_mode.md, copilot-instructions, and skill files of any reference to removed features or prior architecture --- .github/copilot-instructions.md | 65 ++- .github/skills/add-control-zone/SKILL.md | 88 --- .github/skills/analyze-control-data/SKILL.md | 48 +- .../SKILL.md | 54 +- README.md | 259 ++++----- .../smart_fan_controller/__init__.py | 348 +++++------ .../smart_fan_controller/config_flow.py | 57 +- .../smart_fan_controller/const.py | 72 --- .../smart_fan_controller/controller.py | 538 ------------------ .../smart_fan_controller/data_collection.py | 32 +- .../smart_fan_controller/mpc_controller.py | 152 ++--- .../smart_fan_controller/sensor.py | 75 +-- .../smart_fan_controller/switch.py | 121 +--- .../smart_fan_controller/thermal_learning.py | 4 - .../smart_fan_controller/translations/en.json | 22 +- .../smart_fan_controller/translations/fr.json | 24 +- docs/mpc_mode.md | 128 ++--- requirements_test.txt | 2 +- tests/test_cool.py | 201 ------- tests/test_data_collection.py | 3 +- tests/test_defrost.py | 168 ------ tests/test_entity_migration.py | 68 --- tests/test_heat.py | 201 ------- tests/test_hvac_idle.py | 320 ----------- tests/test_learning.py | 192 +------ tests/test_learning_enabled.py | 132 ----- tests/test_learning_persistence.py | 6 +- tests/test_mpc_controller.py | 314 ++++------ tests/test_phase_detection.py | 519 ----------------- tests/test_services.py | 51 +- tests/test_system.py | 506 ---------------- 31 files changed, 620 insertions(+), 4150 deletions(-) delete mode 100644 .github/skills/add-control-zone/SKILL.md rename .github/skills/{modify-mpc-shadow => modify-mpc}/SKILL.md (78%) delete mode 100644 custom_components/smart_fan_controller/controller.py delete mode 100644 tests/test_cool.py delete mode 100644 tests/test_defrost.py delete mode 100644 tests/test_entity_migration.py delete mode 100644 tests/test_heat.py delete mode 100644 tests/test_hvac_idle.py delete mode 100644 tests/test_learning_enabled.py delete mode 100644 tests/test_phase_detection.py delete mode 100644 tests/test_system.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4ae83ab..9617d92 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -6,49 +6,59 @@ The integration lives under `custom_components/smart_fan_controller/`. Key modul | File | Role | |------|------| -| `controller.py` | `SmartFanController` — rule-based decision engine (zones A–F) | -| `mpc_controller.py` | `MPCController` — MPC-lite with observation & production modes, monotone constraint on slopes | +| `mpc_controller.py` | `MPCController` — MPC-lite: learned thermal model, cost-based fan selection, hysteresis guards | | `thermal_learning.py` | `ThermalLearning` — slope samples, response-time events, profile calibration | -| `__init__.py` | HA integration entry point: config, control loop, services | +| `__init__.py` | HA integration entry point: control loop, learning collection, services | | `config_flow.py` | Config and options UI flows | -| `sensor.py` / `switch.py` | HA entity platforms | +| `sensor.py` | HA sensor entity platform | | `data_collection.py` | CSV logger for offline analysis | ## Domain Vocabulary - **error**: always positive when the system needs more heating/cooling (`target - current` in heat, `current - target` in cool) -- **effective_slope**: `vtherm_slope` (°C/h, EMA-smoothed by Versatile Thermostat) -- **dead_time**: thermal lag between a fan change and first observable slope response -- **defrost_active**: heat-pump defrost cycle (auto-detected or via external entity); blocks step-down decisions and learning samples -- **hvac_idle**: heat-pump compressor is off (detected via operating entity or power consumption); blocks step-up decisions (zones C, D) and learning samples — no cooldown unlike defrost -- **Zones A–F**: priority-ordered decision rules; zone A = emergency/setpoint-drop, F = comfort hold - -## Decision Engine Zones (in priority order) - -| Zone | Condition | Action needed to modify | -|--------|-----------------------------------------------------|-------------------------------| -| A | `error ≥ hard_error` OR `error < TARGET_DROP (-1°C)` | `controller.py:_decide_zone_a` | -| B | Projected overshoot > deadband AND slope changed | `controller.py:_decide_zone_b` | -| C | `error > soft_error` | `controller.py:_decide_zone_c` | -| D | `0 < error < soft_error` | `controller.py:_decide_zone_d` | -| E | `error < -deadband` | `controller.py:_decide_zone_e` | -| F | `-deadband ≤ error ≤ 0` | `controller.py:_decide_zone_f` | +- **effective_slope**: learned slope per fan mode (°C/h, from `ThermalLearning`); raw slope comes from VTherm EMA +- **dead_time**: thermal lag between a fan change and first observable slope response (learned via response events) +- **defrost_active**: heat-pump defrost cycle (via external entity); pauses MPC decisions and learning +- **hvac_idle**: heat-pump compressor is off (via operating entity); pauses MPC decisions and learning +- **ctrl_state**: closure dict in `__init__.py` tracking `last_change_time`, `previous_slope`, `defrost` state, `last_setpoint_drop_time` + +## Control Flow (MPC-only) + +Each cycle (`__init__.py → run_control_loop`): + +1. Read climate entity state (temp, target, slope, fan, window) +2. Detect disturbances (defrost, HVAC idle) +3. Call `mpc_controller.evaluate()` → returns `mpc_decision` dict +4. If MPC status is actionable → apply MPC fan choice; otherwise → hold current fan +5. Collect learning data (slope samples, response events) with gating for phase, defrost, idle, window, setpoint-drop cooldown +6. Update sensors via `sensor.update_from_mpc(mpc_decision)` +7. Log to CSV if data collection enabled + +## MPC Decision Engine + +The MPC controller (`mpc_controller.py`) evaluates all candidate fan modes over a 30-min horizon: + +- **Cost function**: comfort error + overshoot penalty + floor violation + mode-change cost + min-interval penalty +- **Hysteresis**: requires minimum cost improvement before switching (margin scales with proximity to target) +- **Step-down guards**: blocks downward moves when under target and not established or predicted shortfall +- **Disturbance bias**: EMA tracker for unmodeled effects (solar, occupancy); decays during paused periods +- **Monotone constraint**: when all profiles learned, enforces slope(mode_i) ≤ slope(mode_i+1) +- **Pause conditions**: window open, defrost, HVAC idle → returns "Disturbed" status ## Test Conventions - Framework: **pytest** in `tests/`; run with `python -m pytest tests/ -q` - Never use `time.sleep`; mock `time.time` via `unittest.mock.patch` -- Protected-member access (`controller._state`) is normal in tests — add `# pylint: disable=protected-access` at module top +- Protected-member access (`mpc._state`) is normal in tests — add `# pylint: disable=protected-access` at module top - Fixtures that shadow outer names need `# pylint: disable=redefined-outer-name` -- Every test function and helper needs a docstring -- HA switch classes only implement `async_turn_on`/`async_turn_off` — add `# pylint: disable=abstract-method` on the class +- Every test function and helper needs a docstring\n- Test helpers: `_build_learning()` returns `ThermalLearning()`, `_build_mpc(learning)` returns configured `MPCController` ## Home Assistant Patterns -- Switches inherit `SwitchEntity` and only override async variants; sync `turn_on`/`turn_off` are intentionally omitted -- `async_write_ha_state()` is called after any state mutation in switch/sensor entities +- `async_write_ha_state()` is called after any state mutation in sensor entities - Config flow uses `vol.Schema` with selectors from `homeassistant.helpers.selector` - Entity IDs are built with `build_entity_id()` / `build_unique_id()` from `const.py` +- Data dict: `hass.data[DOMAIN][entry_id]` contains `"learning"`, `"mpc_controller"`, `"climate_entity"`, `"sensors"`, `"store"` ## Build & Test @@ -62,7 +72,7 @@ python -m pytest tests/test_X.py -q # run one file ## Key Constants (const.py) - `THRESHOLD_TARGET_DROP = -1.0` — setpoint-drop trigger (°C) -- `DEFAULT_DEADBAND`, `DEFAULT_SOFT_ERROR`, `DEFAULT_HARD_ERROR` — all tunable via options flow +- `DEFAULT_DEADBAND` — tunable via options flow - `CONF_DEFROST_ENTITY` — optional entity for external defrost signal - `CONF_OPERATING_ENTITY` — optional entity for heat-pump compressor running state - `SETPOINT_DROP_LEARNING_COOLDOWN = 30.0` — minutes to suppress learning after a large setpoint drop @@ -72,6 +82,5 @@ python -m pytest tests/test_X.py -q # run one file - **Avoid over-engineering**: only add code that directly addresses the requirement - **No second-order slope terms**: VTherm slope is already EMA-smoothed; parabolic projection amplifies noise -- **Step-limited fan changes**: braking is limited to -1 step; recovery is usually +1 step, but Zone C may step up by +2 when error exceeds `0.75*hard_error` - **Learning data integrity**: exclude window-open, defrost, HVAC idle, setpoint-drop cooldown (30 min), and insufficiently-stable periods (< 2× dead_time) from slope samples; effective slope uses **median** (not mean) for outlier robustness -- **Monotone constraint (MPC)**: when all fan-mode profiles are learned, MPC enforces slope(mode_i) ≤ slope(mode_i+1) via isotonic forward pass; partial profiles skip the constraint +- **Monotone constraint**: when all fan-mode profiles are learned, MPC enforces slope(mode_i) ≤ slope(mode_i+1) via isotonic forward pass; partial profiles skip the constraint diff --git a/.github/skills/add-control-zone/SKILL.md b/.github/skills/add-control-zone/SKILL.md deleted file mode 100644 index 75b8881..0000000 --- a/.github/skills/add-control-zone/SKILL.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -name: add-control-zone -description: "Add, modify, or remove a decision zone (A–F) in the SmartFanController decision engine. Use when: adding a new zone or sub-rule to controller.py; modifying zone conditions or actions; renaming or reordering zones; ensuring tests, README, and copilot-instructions.md stay in sync with zone changes." -argument-hint: "Zone letter and description of the new behavior (e.g. 'Zone D sub-rule: step down if slope favorable for 3+ cycles')" ---- - -# Add or Modify a Control Zone - -## Decision Engine Overview - -Zones are evaluated in strict priority order in `controller.py → calculate_decision()`. -Each zone has a dedicated `_decide_zone_X()` method. The first zone whose condition matches returns the decision. - -Current order: **A → B → C → D → E → F** - -## Procedure - -### 1. Understand the Impact -- Read the existing zone method and its condition -- Check if the new condition overlaps with adjacent zones -- Confirm the action respects the ±1 step limit - -### 2. Modify `controller.py` - -**Zone condition change**: Edit `calculate_decision()` where the zone's `if` block lives. - -**Zone action change**: Edit `_decide_zone_X()`. - -**New sub-rule inside a zone**: Add a nested `if/elif` inside `_decide_zone_X()`. - -**New zone**: Insert a new `if` block in `calculate_decision()` at the correct priority position and create `_decide_zone_X()`. - -Constraints: -- Fan changes must be `±1` step — use `min(current_index + 1, max_index)` / `max(current_index - 1, 0)` -- `force=True` bypasses `min_interval`; use only for emergency/setpoint-drop -- Return `{"fan_mode": ..., "reason": ..., "force": ..., "zone": ...}` from the zone method -- Defrost protection zones (B, D step-down paths): verify defrost guard is still respected - -### 3. Update `const.py` (if needed) -New threshold constants go in `const.py` with a `DEFAULT_` prefix and exposed in `config_flow.py` if user-tunable. - -### 4. Write Tests -Create or extend `tests/test_heat.py` / `tests/test_cool.py` / a dedicated file. - -Test template: -```python -# pylint: disable=protected-access,redefined-outer-name -"""Tests for Zone X: .""" - -import pytest -from unittest.mock import patch -from custom_components.smart_fan_controller.controller import SmartFanController - -@pytest.fixture -def controller(): - """Create a SmartFanController for zone tests.""" - return SmartFanController( - fan_modes=["silent", "low", "med", "high", "superhigh"], - deadband=0.2, min_interval=10, soft_error=0.3, hard_error=0.6, - ) - -def test_zone_x_(controller): - """: .""" - with patch("time.time", return_value=3600.0): - result = controller.calculate_decision( - current_temp=..., target_temp=..., - vtherm_slope=..., hvac_mode="heat", current_fan="med", - ) - assert result["fan_mode"] == ... - assert "" in result["reason"] -``` - -Run after editing: `python -m pytest tests/ -q` - -### 5. Update Documentation - -**`README.md`** — Decision Priority table in the Control Logic section. - -**`.github/copilot-instructions.md`** — Decision Engine Zones table. - -**`services.yaml`** (if the zone interacts with services). - -### 6. Validate - -```bash -python -m pytest tests/ -q # all tests pass -./container hassfest # manifest / translations valid -``` diff --git a/.github/skills/analyze-control-data/SKILL.md b/.github/skills/analyze-control-data/SKILL.md index 30b7fef..7c97a93 100644 --- a/.github/skills/analyze-control-data/SKILL.md +++ b/.github/skills/analyze-control-data/SKILL.md @@ -1,6 +1,6 @@ --- name: analyze-control-data -description: "Analyze Smart Fan Controller CSV data files to diagnose algorithm behavior, identify missed MPC opportunities, detect defrost artifacts, and propose improvements. Use when: the user provides a CSV data file; asking to analyze overnight or morning behavior; asking why the fan did or did not change; evaluating MPC shadow accuracy; diagnosing defrost or setpoint-drop events." +description: "Analyze Smart Fan Controller CSV data files to diagnose algorithm behavior, identify missed MPC opportunities, detect defrost artifacts, and propose improvements. Use when: the user provides a CSV data file; asking to analyze overnight or morning behavior; asking why the fan did or did not change; evaluating MPC decision quality; diagnosing defrost or setpoint-drop events." argument-hint: "CSV file path or description of the behavior to diagnose" --- @@ -10,7 +10,7 @@ argument-hint: "CSV file path or description of the behavior to diagnose" - User provides a `smart_fan_controller_data_*.csv` file (from `data/`) - User describes unexpected behavior ("fan stayed on med all night", "slow morning rise") -- User wants to compare MPC shadow decisions vs live decisions +- User wants to evaluate MPC decision quality - Diagnosing defrost artifacts, setpoint drops, or window-open disturbances ## Column Reference @@ -25,18 +25,18 @@ argument-hint: "CSV file path or description of the behavior to diagnose" | `error` | Signed error (positive = needs action) | | `current_fan` | Fan mode at start of cycle | | `fan_mode` | Fan mode selected by controller | -| `reason` | Zone decision reason string | +| `reason` | MPC decision reason string | | `phase` | `DEAD_TIME` / `TRANSIENT` / `ESTABLISHED` | | `effective_slope` | Slope actually used for decisions | | `is_window_open` | `True`/`False` | | `force` | `True` if timer was bypassed | -| `mpc_shadow_fan_mode` | Fan mode the shadow would choose | -| `mpc_shadow_status` | `Ready` / `Disabled` / `Setpoint drop` / `Disturbed` | -| `mpc_shadow_matches_live` | `yes` / `no` / `disabled` | -| `mpc_shadow_would_change` | `yes` / `no` | -| `mpc_shadow_cost` | MPC optimization cost | -| `mpc_shadow_confidence` | Profile coverage % | -| `mpc_shadow_reason` | Shadow decision explanation | +| `mpc_status` | `Ready` / `Not ready` / `Setpoint drop` / `Disturbed` / `Idle` / `Low confidence` | +| `mpc_fan` | Fan mode chosen by MPC | +| `mpc_would_change` | `yes` / `no` | +| `mpc_cost` | MPC optimization cost | +| `mpc_confidence` | Profile coverage % | +| `mpc_temp_10m` | MPC 10-minute temperature prediction | +| `mpc_temp_30m` | MPC 30-minute temperature prediction | | `defrost_active` | `True` when defrost protection is active | | `learning_ready` | `True` when ≥10 samples per profile | @@ -52,35 +52,35 @@ argument-hint: "CSV file path or description of the behavior to diagnose" **Setpoint drop events (`reason` contains "Setpoint drop")** - Check `target_temp` drop magnitude - Verify `fan_mode` went to lowest mode -- Check MPC shadow also reported "Setpoint drop" +- Check MPC also reported "Setpoint drop" **Defrost events (`defrost_active == True`)** - Find the slope drop that triggered detection: look for `vtherm_slope` crossing sharply negative -- Verify zones B/D were blocked (reason should contain "Defrost hold") -- Check duration: 20-min cooldown from detection to next step-down +- Verify MPC paused (status should be "Disturbed") +- Check duration: 20-min cooldown from detection -**MPC divergence periods (`mpc_shadow_matches_live == "no"`)** -- Identify what shadow chose vs live -- Check `mpc_shadow_confidence` — low confidence = not enough learning data -- Check `mpc_shadow_known_profiles` — shadow needs ≥10 samples per mode +**MPC disturbed periods (`mpc_status == "Disturbed"`)** +- Identify what triggered the disturbance: defrost, window open, HVAC idle +- Check `mpc_confidence` — low confidence = not enough learning data +- Check `mpc_known_profiles` — MPC needs ≥10 samples per mode **Slow temperature recovery** - Plot `current_temp` vs `target_temp` over time -- Look for unnecessary step-downs (reason "Maintenance: favorable slope" or "Braking") +- Look for unnecessary step-downs (reason contains "Hysteresis" or step-down hold) - Cross-check with `defrost_active` — was defrost correctly detected? **Night setpoint drop mismatch (MPC on "med" instead of "silent")** - Filter to period where `target_temp` dropped significantly -- Check `mpc_shadow_status` — should be "Setpoint drop" with lowest fan -- If not, the snapshot was taken before the setpoint-drop fix was deployed +- Check `mpc_status` — should be "Setpoint drop" with lowest fan +- If not, check whether defrost or idle paused the MPC ### 3. Report Format Provide a narrative structured as: 1. **Period summary**: time range, hvac mode, temperature trajectory 2. **Key events**: defrost cycles, setpoint drops, fan changes — with timestamps -3. **Algorithm assessment**: zones triggered correctly? any missed steps? -4. **MPC shadow comparison**: agreement rate, key divergences and their cause +3. **Algorithm assessment**: MPC decisions triggered correctly? any missed steps? +4. **MPC quality**: confidence levels, key disturbance periods and their cause 5. **Recommendations**: concrete parameter changes or code fixes if warranted ## Code Hints @@ -88,5 +88,5 @@ Provide a narrative structured as: - Load CSV: `pd.read_csv(path, parse_dates=["timestamp"])` - Filter defrost: `df[df["defrost_active"] == True]` - Find mode changes: `df[df["current_fan"] != df["fan_mode"]]` -- MPC divergence: `df[df["mpc_shadow_matches_live"] == "no"]` -- Zone summary: `df["reason"].value_counts()` +- MPC disturbance: `df[df["mpc_status"] == "Disturbed"]` +- Reason summary: `df["reason"].value_counts()` diff --git a/.github/skills/modify-mpc-shadow/SKILL.md b/.github/skills/modify-mpc/SKILL.md similarity index 78% rename from .github/skills/modify-mpc-shadow/SKILL.md rename to .github/skills/modify-mpc/SKILL.md index 54f2ea6..863cef9 100644 --- a/.github/skills/modify-mpc-shadow/SKILL.md +++ b/.github/skills/modify-mpc/SKILL.md @@ -1,6 +1,6 @@ --- -name: modify-mpc-shadow -description: "Modify the MPC controller: cost function tuning, hysteresis thresholds, simulation model, disturbance tracking, monotone constraint, or adding new pausing conditions. Use when: changing how the MPC evaluates or recommends fan modes; adjusting cost weights or switch-gain margins; adding new disturbance sources; modifying the step-down hold logic; changing how confidence is computed; toggling production mode." +name: modify-mpc +description: "Modify the MPC controller: cost function tuning, hysteresis thresholds, simulation model, disturbance tracking, monotone constraint, or adding new pausing conditions. Use when: changing how the MPC evaluates or recommends fan modes; adjusting cost weights or switch-gain margins; adding new disturbance sources; modifying the step-down hold logic; changing how confidence is computed." argument-hint: "Description of the MPC behavior to change (e.g. 'reduce overshoot penalty weight')" --- @@ -26,16 +26,14 @@ Unlike classic MPC, the model parameters (slopes) are **learned online** by `The ## Architecture -The MPC controller lives in `mpc_controller.py` as `MPCController`. It runs every control cycle (~2 min) and produces a recommendation dict that feeds sensors and CSV logs. +The MPC controller lives in `mpc_controller.py` as `MPCController`. It runs every control cycle (~2 min) and produces a `mpc_decision` dict that feeds sensors, CSV logs, and controls the fan directly. -**Operating modes**: -- **Observation mode** (`production_mode=False`): runs silently alongside the rule-based controller; its recommendation is logged but never applied -- **Production mode** (`production_mode=True`): the integration reads `mpc_fan_mode` from `evaluate()` and applies it instead of the rule-based decision +When MPC status is actionable (`Ready`, `Setpoint drop`, `Low confidence`), the integration applies the fan recommendation. When paused (`Disturbed`, `Idle`, `Not ready`), the current fan is held. ### Key Flow in `evaluate()` ``` -1. Early exits: disabled, idle HVAC mode +1. Early exits: idle HVAC mode, no fan modes 2. Resolve fan modes and active fan 3. Compute effective slope, dead time, phase 4. Update disturbance bias (EMA tracking) @@ -44,7 +42,7 @@ The MPC controller lives in `mpc_controller.py` as `MPCController`. It runs ever 7. Build monotone slope map (if all profiles learned) 8. Simulate ALL fan modes over the horizon (30 min default) 9. Select best by lowest cost -10. Apply guards: min-interval hold, hysteresis, step-down hold, step-down limit +10. Apply guards: min-interval hold, hysteresis, step-down hold 11. Build and return payload ``` @@ -76,10 +74,9 @@ The MPC requires a minimum cost improvement before switching: Plus bonuses for non-established phase (+0.10) and step distance (+0.05/step). -### Step-Down Guards +### Step-Down Hold (`_step_down_hold_note`) -- `_step_down_hold_note()`: blocks downward moves when still under target and either not established or predicted shortfall at 10 min -- `_apply_step_down_limit()`: enforces ±1 step per cycle for downward moves (upward is unrestricted) +Blocks downward fan moves when still under target and either not yet in ESTABLISHED phase or predicted shortfall at 10 min exceeds the reserve threshold. ### Disturbance Bias (`_update_disturbance_bias`) @@ -100,15 +97,28 @@ The MPC pauses (returns "Disturbed") during: All three conditions decay, not update, the disturbance bias. +## Integration Control Loop (`__init__.py`) + +The control loop in `run_control_loop`: +1. Reads climate entity state +2. Calls `mpc_controller.evaluate()` → `mpc_decision` +3. Determines `effective_fan`: MPC choice when status is actionable, else holds current fan +4. Collects learning data (slope samples, response events) with gating +5. Updates sensors via `sensor.update_from_mpc(mpc_decision)` +6. Applies fan change if needed via `_async_apply_fan_change()` + +State tracking uses `ctrl_state` closure dict: `last_change_time`, `previous_slope`, `defrost`, `last_setpoint_drop_time`, `last_hvac_mode`. + ## Public API Surface | Member | Type | Purpose | |--------|------|---------| -| `enabled` | property (r/w) | Enable/disable MPC cycles | -| `production_mode` | property (r/w) | Switch between shadow and live control | | `fan_modes` | property (r/w) | Update available fan modes | +| `learning` | property (read-only) | Access `ThermalLearning` instance | +| `limit_timeout` | property (read-only) | Configured limit timeout | | `disturbance_bias` | property (read-only) | Current external disturbance estimate | | `evaluate(...)` | method | Run one MPC cycle; returns result dict | +| `get_effective_timeout()` | method | Runtime timeout (learned or configured) | | `build_monotone_slopes(fan_modes, hvac_mode)` | method | Isotonic slope map or None | ## Procedure @@ -119,12 +129,13 @@ All three conditions decay, not update, the disturbance bias. |---|---| | Cost weights | module-level constants in `mpc_controller.py` | | Hysteresis margins | module-level constants + `_required_switch_gain()` | -| Step-down guard | `_step_down_hold_note()` | +| Step-down hold | `_step_down_hold_note()` | | New pause condition | `evaluate()` after disturbance update, before setpoint-drop check | | Disturbance tracking | `_update_disturbance_bias()` | | Simulation model | `_simulate_mode()` inner loop | | Confidence | `_compute_confidence()` | | Monotone constraint | `build_monotone_slopes()` | +| Learning collection | `__init__.py` → `run_control_loop` (slope samples, response events) | ### 2. Make the Change @@ -132,7 +143,6 @@ Module-level constants (e.g., `FLOOR_VIOLATION_LINEAR_WEIGHT = 12.0`) control al Key constraints: - **Never call HA services** from the MPC controller -- **Never modify rule-based controller state** — observation mode is read-only - **Respect the payload format** — result keys use `mpc_` prefix (e.g. `mpc_fan_mode`) - **Log at DEBUG level** — MPC logs are verbose by design - **disturbance_bias and build_monotone_slopes are public** — do not prefix with `_` @@ -144,20 +154,14 @@ Tests go in `tests/test_mpc_controller.py`. Use the existing helpers: ```python def test_mpc_() -> None: """.""" - controller = _build_controller() - _prime_learning_profiles(controller) - mpc = MPCController( - learning=controller.learning, - deadband=0.3, - min_interval=10, - fan_modes=FAN_MODES, - enabled=True, - ) + learning = _build_learning() + _prime_learning_profiles(learning) + mpc = _build_mpc(learning) result = mpc.evaluate( current_temp=..., target_temp=..., vtherm_slope=..., hvac_mode="heat", - current_fan="medium", live_decision_fan="medium", + current_fan="medium", is_window_open=False, minutes_since_change=20.0, ) diff --git a/README.md b/README.md index 7427896..a9c3631 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,11 @@ Predictive fan speed control for HVAC systems, designed to work with Versatile T - [Requirements](#requirements) - [Quick Setup](#quick-setup) - [Configuration Parameters](#configuration-parameters) - - [Control Logic](#control-logic) - - [Decision Priority (Zones A–F)](#decision-priority-zones-af) - - [Temperature Projection](#temperature-projection) + - [MPC Controller](#mpc-controller) + - [Cost Function](#cost-function) + - [Hysteresis and Guards](#hysteresis-and-guards) - [Phase Detection](#phase-detection) - - [Safety Constraints](#safety-constraints) + - [Disturbance Handling](#disturbance-handling) - [Defrost Detection](#defrost-detection) - [HVAC Idle Detection](#hvac-idle-detection) - [Learning System](#learning-system) @@ -33,13 +33,13 @@ Predictive fan speed control for HVAC systems, designed to work with Versatile T - [HVAC Idle Learning Exclusion](#hvac-idle-learning-exclusion) - [Sensors \& Entities](#sensors--entities) - [Main Entities](#main-entities) - - [Diagnostic Sensors](#diagnostic-sensors) - [MPC Sensors](#mpc-sensors) + - [Learning Sensors](#learning-sensors) - [Learning Profile Sensors](#learning-profile-sensors) - - [Defrost Diagnostic](#defrost-diagnostic) - [Services](#services) - [`smart_fan_controller.apply_learned_settings`](#smart_fan_controllerapply_learned_settings) - [`smart_fan_controller.reset_learning`](#smart_fan_controllerreset_learning) + - [`smart_fan_controller.set_effective_slope`](#smart_fan_controllerset_effective_slope) - [Troubleshooting](#troubleshooting) - [License](#license) @@ -70,21 +70,18 @@ Alternatively, click the button below to open this repository directly in HACS: ## Overview -Smart Fan Controller is a custom Home Assistant integration that **adjusts HVAC fan speed** based on how the temperature is evolving, not just the current reading. It uses a rule-based decision engine with six priority zones, phase-aware timing, and an automatic learning system. +Smart Fan Controller is a custom Home Assistant integration that **adjusts HVAC fan speed** using a predictive MPC (Model Predictive Control) engine. It learns the thermal behavior of your room and selects the optimal fan mode to reach and maintain the target temperature. ### How It Works 1. Every 2 minutes, reads the current temperature, target, and temperature slope from Versatile Thermostat -2. Projects temperature **10 minutes ahead** using a clamped linear model -3. Classifies the thermal phase (dead time / transient / established) relative to the last fan change -4. Evaluates six priority zones (A–F) and selects the first matching action -5. Applies safety guards (step-down limit, min interval) and changes fan speed if needed -6. Collects learning data to automatically calibrate parameters over time -7. Runs an MPC controller in the background; optionally activates MPC production mode for direct fan control +2. Simulates every available fan mode over a **30-minute prediction horizon** using learned thermal profiles +3. Selects the fan mode that **minimizes a cost function** balancing comfort, overshoot, and energy use +4. Applies safety guards (hysteresis, min interval) before changing the fan +5. Continuously **learns** the thermal response of each fan mode to improve predictions over time -> **MPC Controller** always runs a learned temperature-state model and MPC-lite in observation mode. When **MPC Production Mode** is enabled, the MPC directly controls the fan instead of the rule-based algorithm. It owns its own runtime parameters (`deadband`, `min_interval`, fan modes), pauses itself during disturbed periods such as an open window, and keeps those periods out of dead-time learning. See [docs/mpc_mode.md](docs/mpc_mode.md). -> It supports both `heat` and `cool`, and includes a hysteresis guard so tiny cost differences do not create fan yo-yo around the setpoint. -> When all fan-mode profiles are learned, a **monotone constraint** is enforced: higher fan modes are guaranteed to have slopes ≥ lower modes. This prevents contaminated profiles from causing the MPC to rank a weaker mode above a stronger one. +> The MPC controller supports both `heat` and `cool` modes. It includes a hysteresis guard so tiny cost differences do not create fan oscillations near the setpoint. +> When all fan-mode profiles are learned, a **monotone constraint** guarantees higher fan modes have steeper slopes than lower ones. --- @@ -108,64 +105,45 @@ Smart Fan Controller is a custom Home Assistant integration that **adjusts HVAC All parameters can be changed at any time via **Settings → Devices & Services → Smart Fan Controller → Configure**. -| Parameter | Default | Range | Description | -| -------------------- | -------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **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. | -| **Soft Error** | `0.3°C` | `0.0` – `10.0°C` | Error threshold that triggers recovery mode. Should be larger than deadband. | -| **Hard Error** | `0.6°C` | `0.0` – `10.0°C` | Error threshold that triggers emergency mode (max fan, bypasses min interval). | -| **Limit Timeout** | `15 min` | `10` – `120 min` | Maximum time before forcing a re-evaluation, even without significant slope change. | -| **Learning Enabled** | `true` | — | Enables the automatic learning system. Disable for fully manual tuning. | -| **MPC Production Mode** | `false` | — | When enabled, the MPC controller directly controls the fan mode instead of the rule-based algorithm. Shadow observation always runs. | -| **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. When active, defrost protection is applied. 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. Used to block fan increases and exclude learning data while the compressor is off. See [HVAC Idle Detection](#hvac-idle-detection). | - -> **Tip — recommended ratios**: `deadband < soft_error < hard_error`, e.g. `0.2 / 0.3 / 0.6`. +| Parameter | Default | Range | Description | +| ------------------ | -------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **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). | --- -## Control Logic +## MPC Controller -Each cycle the controller computes the temperature error (always positive when the system needs more action) and applies the first matching rule from six priority zones: +The MPC (Model Predictive Control) engine is the sole decision-maker for fan speed. Each cycle, it evaluates every available fan mode by simulating temperature evolution over a 30-minute horizon and selecting the mode with the lowest cost. -### Decision Priority (Zones A–F) +See [docs/mpc_mode.md](docs/mpc_mode.md) for the full technical design. -| Zone | Condition | Action | -| --------- | -------------------------------------------------- | ---------------------------------------------------------------------------- | -| **A** | `error ≥ hard_error` | **Emergency** — max fan, bypasses timer | -| **A-bis** | Target dropped by more than 1°C | **Setpoint drop** — min fan, bypasses timer | -| **B** | Projected overshoot `> deadband` AND slope changed | **Braking** — decrease fan proactively | -| **C** | `error > soft_error` | **Recovery** — increase fan (or patience during dead time / improving slope) | -| **D** | `0 < error < soft_error` | **Drift / Descent** — adjust towards target (phase-gated) | -| **E** | `error < -deadband` | **Over-target** — decrease fan | -| **F** | `-deadband ≤ error ≤ 0` | **Comfort zone** — hold (react to slow drift) | +### Cost Function -> **Note**: In heat mode, error = `target - current`. In cool mode, error = `current - target`. A positive value always means the system needs more action. +Each candidate fan mode is scored with: -**Zone C details**: During the dead-time phase, the controller reports "Patience: Waiting for thermal response" instead of boosting, to avoid over-reacting before the sensor can reflect the last change. After dead time, if the slope hasn't improved, it increases fan speed. +| Component | Purpose | +| --------------------------- | ---------------------------------------------------------------------------- | +| **Comfort error × urgency** | Penalizes being outside the deadband, amplified when far from target | +| **Overshoot²** | Strongly penalizes going past the target temperature | +| **Floor violation** | Penalizes predicted temperature dropping below setpoint (linear + quadratic) | +| **Mode-change cost** | Penalizes unnecessary fan jumps (proportional to step distance) | +| **Mode-rank cost** | Slight preference for lower fan speeds (energy savings) | +| **Min-interval penalty** | Blocks changes before the minimum interval has elapsed | -**Zone D details**: Four sub-rules gated by control phase: -- **Descent**: Strong favorable slope in ESTABLISHED phase, already within deadband, and projection at/past target → reduce fan -- **Hold**: Strong favorable slope but still too far from target → keep current fan -- **Drift**: Slow drift away from target → increase fan -- **Proactive**: Stable but away from target in ESTABLISHED phase → increase fan to reach setpoint +### Hysteresis and Guards -### Temperature Projection - -The controller projects temperature 10 minutes ahead using a **clamped linear model**: - -``` -projected_temperature = current_temp + (vtherm_slope × 10/60) -``` - -The projection is clamped to ±1.0°C from the current temperature to prevent extreme predictions from noisy slope readings. This value was calibrated against real sensor data showing maximum overshoot of ~0.6°C. - -> The VTherm slope is already an EMA-smoothed signal over ~15–30 minutes, so a second-order (parabolic) term would amplify noise from the double derivative of an already-filtered signal. The clamped linear model is simpler and more robust. +- **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, or the configured limit timeout). ### Phase Detection -After each fan speed change, the controller classifies the elapsed time into three phases: +After each fan speed change, the controller classifies elapsed time into three phases: | Phase | Condition | Meaning | | --------------- | ------------------------------------- | --------------------------------- | @@ -173,37 +151,35 @@ After each fan speed change, the controller classifies the elapsed time into thr | **TRANSIENT** | `dead_time ≤ elapsed < dead_time×1.5` | Sensor starting to respond | | **ESTABLISHED** | `elapsed ≥ dead_time × 1.5` | Slope reflects current fan regime | -The default dead time is 10 minutes. When the learning system is ready, it is replaced by the learned median response time (typically 3–5 minutes for well-placed sensors). +The default dead time is 10 minutes. When the learning system is ready, it is replaced by the learned median response time. -### Safety Constraints +### Disturbance Handling -- **Step-down protection**: Fan speed can only decrease by one step at a time (e.g., `turbo → high`), preventing abrupt pressure changes and protecting the motor. -- **Min interval**: Non-emergency changes respect the effective timeout. Emergency (Zone A) and setpoint drop (Zone A-bis) override it. +The MPC tracks a **disturbance bias** — an EMA estimate of unmodeled thermal effects (solar gains, occupancy). This correction is added to learned slopes during simulation. The bias only updates during ESTABLISHED phase with a known profile and decays during disturbed periods. + +When a disturbance is detected, the MPC pauses and returns "Disturbed" status — the current fan mode is held. ### Defrost Detection -When a heat pump defrosts its outdoor coil, the heat output drops sharply — the HVAC is working to melt ice, not heat the room. Without defrost awareness, the controller would misinterpret the falling temperature slope as insufficient fan speed and try to increase it, or worse, would reduce the fan thinking the slope is already favorable when in fact the defrost is about to end. +When a heat pump defrosts its outdoor coil, the heat output drops sharply. Without defrost awareness, the controller would misinterpret the falling slope. -**External entity (optional)**: Configure a `binary_sensor`, `sensor`, or `input_boolean` from your PAC integration that reports defrost state. When this entity is `on`/`true`/`1`, defrost protection is activated and the 20-minute cooldown timer is refreshed every control cycle — protection stays active *as long as the entity reports defrost*, then the cooldown applies after it clears. +**External entity (optional)**: Configure a `binary_sensor`, `sensor`, or `input_boolean` from your PAC integration that reports defrost state. When this entity is `on`/`true`/`1`, defrost protection is activated with a 20-minute cooldown. **During defrost protection**: -- **Zones B and D are blocked**: no step-down decisions (braking or favorable-slope reduction) -- The decision reason appears as `"Defrost hold: …"` -- **Learning samples are excluded**: slope data during defrost is not added to per-mode profiles, preventing corrupted calibration +- The MPC pauses and returns "Disturbed" status +- Learning samples are excluded (slope data during defrost corrupts profiles) ### HVAC Idle Detection -When the heat pump compressor is off (setpoint reached, system coasting) the HVAC is not actively heating or cooling and the fan should not be increased in response to a small temperature drift. +When the heat pump compressor is off (setpoint reached, system coasting), the HVAC is not actively heating or cooling. -**Operating entity (optional)**: Configure a `binary_sensor`, `sensor`, or `input_boolean` from your PAC integration that reports whether the compressor is running. When this entity is `off`/`false`/`0`, the compressor is considered idle. +**Operating entity (optional)**: Configure a `binary_sensor`, `sensor`, or `input_boolean` that reports compressor state. When `off`/`false`/`0`, the compressor is considered idle. > When no operating entity is configured, HVAC idle detection is disabled. **During HVAC idle**: -- **Zones C and D are held**: no step-up decisions while the compressor is off -- The decision reason appears as `"HVAC idle: compressor off, holding current speed"` -- **Zones A, A-bis, B, and E are unaffected**: emergency, setpoint-drop, braking, and step-down decisions still operate normally -- **Learning samples are excluded**: slope and response-time data while idle is not added to profiles (see [HVAC Idle Learning Exclusion](#hvac-idle-learning-exclusion)) +- The MPC pauses and returns "Disturbed" status +- Learning samples are excluded --- @@ -221,8 +197,6 @@ The integration includes an **automatic learning system** that collects data dur | Parameter | Formula | | --------------- | ------------------------------------------------- | | `deadband` | `0.15 + (volatility_factor × 0.2)` | -| `soft_error` | `0.25 + (volatility_factor × 0.3)` | -| `hard_error` | `0.5 + (volatility_factor × 0.4)` | | `limit_timeout` | rounded median of measured thermal response times | Where `volatility_factor = min(slope_stdev / slope_mean, 3.0)`. @@ -231,8 +205,6 @@ Where `volatility_factor = min(slope_stdev / slope_mean, 3.0)`. 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`. -**Control**: Enable or disable learning at any time via `switch.smart_fan_controller_learning_enabled`. Existing data is preserved when disabled. - ### Per-Mode Fan Profiles The learning system tracks the **effective slope per fan mode and HVAC mode** (e.g., "medium in heat" vs "high in cool"). This data provides visibility into which fan speeds are most effective for each mode. @@ -254,16 +226,15 @@ Response events are only recorded when the delay is between 2 and 60 minutes (fi ### Window-Open Filtering -When Versatile Thermostat reports a window as open (via the `window_manager.window_state` attribute), the controller: -- **Continues making live heuristic fan decisions** normally (the HVAC system is still running) -- **Stops collecting learning data**, including both per-mode slope samples and response-time events used to learn `dead_time` -- **Pauses the MPC model**, marking it as disturbed instead of trusting predictions during the perturbation +When Versatile Thermostat reports a window as open (via the `window_manager.window_state` attribute): +- **The MPC pauses** and returns "Disturbed" status — the current fan mode is held +- **Learning data collection stops**, including both per-mode slope samples and response-time events used to learn `dead_time` This prevents window-open periods from corrupting the learned profiles. ### Defrost Learning Exclusion -Slope samples and response-time events collected during an active defrost period (auto-detected or via external entity, including the 20-minute cooldown) are **not added to learned profiles**. Defrost distorts the effective slope per fan mode and would bias the learning system toward lower heating capacity estimates. +Slope samples and response-time events collected during an active defrost period (via external entity, including the 20-minute cooldown) are **not added to learned profiles**. Defrost distorts the effective slope per fan mode and would bias the learning system toward lower heating capacity estimates. ### HVAC Idle Learning Exclusion @@ -275,73 +246,51 @@ Slope samples and response-time events collected while the compressor is detecte ### Main Entities -| Entity | Type | Description | -| ---------------------------------------------- | ------ | ------------------------------------------------- | -| `sensor.smart_fan_controller_fan_mode` | Sensor | Current fan mode selected by the controller | -| `sensor.smart_fan_controller_status` | Sensor | Current control zone and decision reason | -| `switch.smart_fan_controller_learning_enabled` | Switch | Enable / disable the learning system | -| `switch.smart_fan_controller_mpc_production_mode` | Switch | Enable / disable MPC production mode (MPC controls the fan) | - -### Diagnostic Sensors - -| Entity | Unit | Description | -| --------------------------------------------------------------------- | ----- | -------------------------------------------------------------------------- | -| `sensor.smart_fan_controller_temperature_error` | °C | Current temperature error (positive = needs action) | -| `sensor.smart_fan_controller_temperature_projected_10_min` | °C | Predicted temperature 10 minutes ahead | -| `sensor.smart_fan_controller_temperature_projected_error_10_min` | °C | Predicted error 10 minutes ahead | -| `sensor.smart_fan_controller_fan_mode_last_change` | min | Time elapsed since last fan mode change | -| `sensor.smart_fan_controller_learning_progress` | % | Learning completion (100% = ≥240 samples) | -| `sensor.smart_fan_controller_learning_status` | — | `"Learning (45%)"` or `"Ready"` | -| `sensor.smart_fan_controller_learning_samples` | count | Number of slope samples collected | -| `sensor.smart_fan_controller_learning_response_events` | count | Number of thermal response time measurements | -| `sensor.smart_fan_controller_learned_dead_time` | min | Median learned thermal response delay (`dead_time`) | -| `sensor.smart_fan_controller_effective_timeout` | min | Actual non-emergency timeout currently used | -| `sensor.smart_fan_controller_learned_deadband` | °C | Learned optimal deadband | -| `sensor.smart_fan_controller_learned_soft_error` | °C | Learned optimal soft error threshold | -| `sensor.smart_fan_controller_learned_hard_error` | °C | Learned optimal hard error threshold | -| `sensor.smart_fan_controller_learned_limit_timeout` | min | Learned base timeout stored in config | - ### MPC Sensors -| Entity | Unit | Description | -| --------------------------------------------------------------------- | ----- | -------------------------------------------------------------------------- | -| `sensor.smart_fan_controller_mpc_status` | — | Shadow controller state (`Disabled`, `Ready`, `Disturbed`, etc.) | -| `sensor.smart_fan_controller_mpc_reason` | — | Explanation of the current MPC recommendation | -| `sensor.smart_fan_controller_mpc_fan_mode` | — | Fan mode the MPC would choose | -| `sensor.smart_fan_controller_mpc_match` | — | Whether the MPC recommendation matches the live heuristic | -| `sensor.smart_fan_controller_mpc_would_change_now` | — | Whether the MPC would actively change the fan right now | -| `sensor.smart_fan_controller_mpc_cost` | — | Lowest simulation cost returned by the MPC optimizer | -| `sensor.smart_fan_controller_mpc_confidence` | % | Confidence derived from learned profile coverage | -| `sensor.smart_fan_controller_mpc_predicted_temperature_10_min` | °C | Predicted temperature after 10 minutes with the recommended mode | -| `sensor.smart_fan_controller_mpc_predicted_temperature_30_min` | °C | Predicted temperature after 30 minutes with the recommended mode | -| `sensor.smart_fan_controller_mpc_dead_time` | min | Dead time currently used by the MPC simulator | -| `sensor.smart_fan_controller_mpc_known_profiles` | count | Number of reliable learned fan-mode profiles used by the MPC controller | -| `sensor.smart_fan_controller_mpc_disturbance_bias` | °C/h | Learned disturbance correction currently applied by the MPC model | - -See [docs/mpc_mode.md](docs/mpc_mode.md) for the full technical design of the learned model and MPC-lite observation mode. +| Entity | Unit | Description | +| -------------------------------------------------------------- | ----- | ----------------------------------------------------------------- | +| `sensor.smart_fan_controller_mpc_status` | — | MPC state (`Not ready`, `Ready`, `Disturbed`, `Idle`, etc.) | +| `sensor.smart_fan_controller_mpc_reason` | — | Explanation of the current MPC recommendation | +| `sensor.smart_fan_controller_mpc_fan_mode` | — | Fan mode chosen by the MPC | +| `sensor.smart_fan_controller_mpc_would_change_now` | — | Whether the MPC would actively change the fan right now | +| `sensor.smart_fan_controller_mpc_cost` | — | Lowest simulation cost returned by the MPC optimizer | +| `sensor.smart_fan_controller_mpc_confidence` | % | Confidence derived from learned profile coverage | +| `sensor.smart_fan_controller_mpc_predicted_temperature_10_min` | °C | Predicted temperature after 10 minutes with the recommended mode | +| `sensor.smart_fan_controller_mpc_predicted_temperature_30_min` | °C | Predicted temperature after 30 minutes with the recommended mode | +| `sensor.smart_fan_controller_mpc_dead_time` | min | Dead time currently used by the MPC simulator | +| `sensor.smart_fan_controller_mpc_known_profiles` | count | Number of reliable learned fan-mode profiles | +| `sensor.smart_fan_controller_mpc_disturbance_bias` | °C/h | Learned disturbance correction currently applied by the MPC model | + +### Learning Sensors + +| Entity | Unit | Description | +| --------------------------------------------------------- | ----- | --------------------------------------------------- | +| `sensor.smart_fan_controller_learning_progress` | % | Learning completion (100% = ≥240 samples) | +| `sensor.smart_fan_controller_learning_status` | — | `"Learning (45%)"` or `"Ready"` | +| `sensor.smart_fan_controller_learning_samples` | count | Number of slope samples collected | +| `sensor.smart_fan_controller_learning_response_events` | count | Number of thermal response time measurements | +| `sensor.smart_fan_controller_learned_dead_time` | min | Median learned thermal response delay (`dead_time`) | +| `sensor.smart_fan_controller_effective_timeout` | min | Actual non-emergency timeout currently used | +| `sensor.smart_fan_controller_learned_deadband` | °C | Learned optimal deadband | +| `sensor.smart_fan_controller_learned_limit_timeout` | min | Learned base timeout stored in config | ### Learning Profile Sensors Once fan modes are detected, the integration creates per-HVAC-mode profile summary sensors and one effective slope sensor per fan mode: -| Entity (example with `low`/`medium`/`high` fan modes) | Unit | Description | -| -------------------------------------------------------------------------- | ----- | ------------------------------------------------------- | -| `sensor.smart_fan_controller_mpc_heat_profiles` | — | JSON summary of learned heat profiles per fan mode | -| `sensor.smart_fan_controller_mpc_cool_profiles` | — | JSON summary of learned cool profiles per fan mode | -| `sensor.smart_fan_controller_heat_low_effective_slope` | °C/h | Effective slope learned for `low` in heat mode | -| `sensor.smart_fan_controller_heat_medium_effective_slope` | °C/h | Effective slope learned for `medium` in heat mode | -| `sensor.smart_fan_controller_heat_high_effective_slope` | °C/h | Effective slope learned for `high` in heat mode | -| `sensor.smart_fan_controller_cool_low_effective_slope` | °C/h | Effective slope learned for `low` in cool mode | -| … (one per fan mode × HVAC mode combination) | … | … | +| Entity (example with `low`/`medium`/`high` fan modes) | Unit | Description | +| --------------------------------------------------------- | ---- | -------------------------------------------------- | +| `sensor.smart_fan_controller_mpc_heat_profiles` | — | JSON summary of learned heat profiles per fan mode | +| `sensor.smart_fan_controller_mpc_cool_profiles` | — | JSON summary of learned cool profiles per fan mode | +| `sensor.smart_fan_controller_heat_low_effective_slope` | °C/h | Effective slope learned for `low` in heat mode | +| `sensor.smart_fan_controller_heat_medium_effective_slope` | °C/h | Effective slope learned for `medium` in heat mode | +| `sensor.smart_fan_controller_heat_high_effective_slope` | °C/h | Effective slope learned for `high` in heat mode | +| `sensor.smart_fan_controller_cool_low_effective_slope` | °C/h | Effective slope learned for `low` in cool mode | +| … (one per fan mode × HVAC mode combination) | … | … | These sensors appear automatically when the climate entity's fan modes become known and require at least 10 samples per mode to show reliable data. -### Defrost Diagnostic - -| Entity | Type | Description | -| ------------------------------------ | ------ | ----------------------------------------------------------- | -| `sensor.smart_fan_controller_status` | Sensor | Shows `"Defrost hold: …"` when defrost protection is active | - --- ## Services @@ -362,10 +311,10 @@ Manually set the effective slope for a specific fan mode / HVAC mode profile wit **Parameters**: -| Parameter | Required | Example | Description | -| ----------------- | -------- | -------- | -------------------------------------------------------- | -| `hvac_mode` | Yes | `heat` | The HVAC mode (`heat` or `cool`) | -| `fan_mode` | Yes | `silent` | The fan mode name | +| Parameter | Required | Example | Description | +| ----------------- | -------- | -------- | ---------------------------------------------------------- | +| `hvac_mode` | Yes | `heat` | The HVAC mode (`heat` or `cool`) | +| `fan_mode` | Yes | `silent` | The fan mode name | | `effective_slope` | Yes | `0.15` | Target effective slope in °C/h (positive = towards target) | **Example** (Developer Tools → Services): @@ -381,15 +330,15 @@ data: ## Troubleshooting -| Symptom | What to check / do | -| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Fan not changing** | Check `sensor.smart_fan_controller_status`. Check `sensor.smart_fan_controller_fan_mode_last_change` — min interval or dead-time patience may be active. | -| **Too many fan changes** | Increase `deadband` or `min_interval`. Enable learning to auto-optimize. | -| **Temperature overshoots** | Decrease `deadband`. Verify Versatile Thermostat is providing an accurate slope. | -| **Learning not progressing** | Verify `switch.smart_fan_controller_learning_enabled` is on. Check HVAC is running and windows are closed. | -| **Auto-apply not working** | Verify `sensor.smart_fan_controller_learning_status` is `"Ready"` and learning is on. Auto-apply fires once — use `apply_learned_settings` to re-apply. | -| **Defrost triggering too often** | If auto-detection fires spuriously (noisy slope), configure a physical defrost entity from your PAC integration. This replaces heuristic guesses with the actual defrost signal. | -| **Step-down blocked during defrost** | Normal behavior — zones B and D are intentionally suspended during the 20 min defrost cooldown to avoid fan reduction while the PAC recovers. | +| Symptom | What to check / do | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Fan not changing** | Check `sensor.smart_fan_controller_mpc_status` and `mpc_reason`. The MPC may be paused (disturbed) or the min interval hasn't elapsed. | +| **MPC status: Not ready** | Learning hasn't collected enough profiles. Check `sensor.smart_fan_controller_mpc_known_profiles` and `learning_progress`. | +| **MPC status: Disturbed** | Defrost, HVAC idle, or window open detected. Normal — MPC holds current fan until the disturbance clears. | +| **Too many fan changes** | Increase `deadband` or `min_interval`. Enable learning to auto-optimize. | +| **Temperature overshoots** | Decrease `deadband`. Verify Versatile Thermostat is providing an accurate slope. | +| **Learning not progressing** | Verify HVAC is running and windows are closed. | +| **Auto-apply not working** | Verify `sensor.smart_fan_controller_learning_status` is `"Ready"`. Auto-apply fires once — use `apply_learned_settings` to re-apply. | --- diff --git a/custom_components/smart_fan_controller/__init__.py b/custom_components/smart_fan_controller/__init__.py index 17f4a5f..a533d9d 100644 --- a/custom_components/smart_fan_controller/__init__.py +++ b/custom_components/smart_fan_controller/__init__.py @@ -2,12 +2,12 @@ from __future__ import annotations import logging +import time from datetime import timedelta from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.event import async_track_state_change_event, async_track_time_interval from homeassistant.helpers.storage import Store @@ -16,32 +16,30 @@ CONF_DATA_COLLECTION, CONF_DEADBAND, CONF_DEFROST_ENTITY, - CONF_HARD_ERROR, - CONF_LEARNING_ENABLED, CONF_LIMIT_TIMEOUT, CONF_MIN_INTERVAL, - CONF_MPC_PRODUCTION_ENABLED, CONF_OPERATING_ENTITY, - CONF_SOFT_ERROR, + DEAD_TIME_SAFETY_FACTOR, DEFAULT_DATA_COLLECTION, + DEFAULT_DEAD_TIME, DEFAULT_DEADBAND, - DEFAULT_HARD_ERROR, - DEFAULT_LEARNING_ENABLED, DEFAULT_LIMIT_TIMEOUT, DEFAULT_MIN_INTERVAL, - DEFAULT_MPC_PRODUCTION_ENABLED, - DEFAULT_SOFT_ERROR, DELTA_TIME_CONTROL_LOOP, DOMAIN, LEARNING_DATA_SAVE_INTERVAL, + MIN_ESTABLISHED_RATIO, + PHASE_ESTABLISHED, + SETPOINT_DROP_LEARNING_COOLDOWN, STORAGE_KEY, STORAGE_VERSION, + THRESHOLD_SLOPE, + THRESHOLD_TARGET_DROP, build_unique_id, - extract_object_key_from_unique_id, ) -from .controller import SmartFanController from .data_collection import DataCollector from .mpc_controller import MPCController +from .thermal_learning import ThermalLearning _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR, Platform.SWITCH] @@ -61,85 +59,6 @@ def _extract_supported_fan_modes(state) -> list[str]: return _filter_supported_fan_modes(state.attributes.get("fan_modes")) -async def _async_migrate_entity_registry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Migrate Smart Fan Controller entity IDs and unique IDs to the canonical naming.""" - try: - entity_registry = er.async_get(hass) - except KeyError: - _LOGGER.debug( - "Skipping entity registry migration for %s because the registry is not initialized yet", - entry.entry_id, - ) - return - - if not hasattr(entity_registry, "entities"): - try: - await entity_registry.async_load() - except Exception as exc: # pragma: no cover - defensive guard for partial test harnesses - _LOGGER.debug( - "Skipping entity registry migration for %s because the registry is not ready: %s", - entry.entry_id, - exc, - ) - return - - entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) - if not entries: - return - - migrated = 0 - reserved_entity_ids: set[str] = set() - - for registry_entry in entries: - updates: dict[str, object] = {} - object_key = extract_object_key_from_unique_id(registry_entry.unique_id, entry.entry_id) - - if object_key is None: - reserved_entity_ids.add(registry_entry.entity_id) - _LOGGER.warning( - "Skipping entity registry migration for %s because its unique_id is not recognized: %s", - registry_entry.entity_id, - registry_entry.unique_id, - ) - continue - - desired_unique_id = build_unique_id(object_key, entry.entry_id) - if registry_entry.unique_id != desired_unique_id: - updates["new_unique_id"] = desired_unique_id - - desired_entity_id = entity_registry.async_get_available_entity_id( - registry_entry.domain, - f"{DOMAIN}_{object_key}", - current_entity_id=registry_entry.entity_id, - reserved_entity_ids=reserved_entity_ids, - ) - reserved_entity_ids.add(desired_entity_id) - - if registry_entry.entity_id != desired_entity_id: - updates["new_entity_id"] = desired_entity_id - - if not registry_entry.has_entity_name: - updates["has_entity_name"] = True - - if not updates: - continue - - old_entity_id = registry_entry.entity_id - old_unique_id = registry_entry.unique_id - updated_entry = entity_registry.async_update_entity(registry_entry.entity_id, **updates) - migrated += 1 - _LOGGER.info( - "Migrated entity %s -> %s (unique_id %s -> %s)", - old_entity_id, - updated_entry.entity_id, - old_unique_id, - updated_entry.unique_id, - ) - - if migrated: - _LOGGER.info("Entity registry migration completed for %s: %d entities updated", entry.entry_id, migrated) - - async def _apply_optimal_parameters( hass: HomeAssistant, entry: ConfigEntry, @@ -149,16 +68,12 @@ async def _apply_optimal_parameters( new_data = {**entry.data} new_data["learning_auto_applied"] = True new_data[CONF_DEADBAND] = optimal["deadband"] - new_data[CONF_SOFT_ERROR] = optimal["soft_error"] - new_data[CONF_HARD_ERROR] = optimal["hard_error"] new_data[CONF_LIMIT_TIMEOUT] = optimal["limit_timeout"] _LOGGER.info( - "Applying learned settings for %s: deadband=%.2f soft_error=%.2f hard_error=%.2f limit_timeout=%d", + "Applying learned settings for %s: deadband=%.2f limit_timeout=%d", entry.entry_id, optimal["deadband"], - optimal["soft_error"], - optimal["hard_error"], optimal["limit_timeout"], ) @@ -166,9 +81,9 @@ async def _apply_optimal_parameters( await hass.config_entries.async_reload(entry.entry_id) -# MPC statuses that mean the controller is paused; in production mode the -# rule-based heuristic is used as fallback when the MPC is in one of these states. -_MPC_PAUSED_STATUSES = frozenset({"Disabled", "Idle", "Disturbed", "Not ready"}) +# MPC statuses that mean the controller is paused; the current fan mode is +# held when the MPC is in one of these states. +_MPC_PAUSED_STATUSES = frozenset({"Idle", "Disturbed", "Not ready"}) def _read_climate_cycle_data( @@ -211,40 +126,38 @@ def _read_climate_cycle_data( ) -def _detect_disturbances(hass, conf: dict, controller: SmartFanController) -> bool: - """Check external entities and update the controller defrost state. +def _detect_disturbances(hass, conf: dict, defrost_state: dict) -> tuple[bool, bool]: + """Check external entities and update defrost state dict. - Returns True when the HVAC compressor is reported as idle. + Returns (is_defrost_active, is_hvac_idle). + defrost_state dict keys: 'active', 'start_time'. """ + now_ts = time.time() defrost_entity_id = conf.get(CONF_DEFROST_ENTITY) if defrost_entity_id: - defrost_state = hass.states.get(defrost_entity_id) - if defrost_state and defrost_state.state in ("on", "true", "True", "1"): - if not controller.is_defrost_active: + entity_state = hass.states.get(defrost_entity_id) + if entity_state and entity_state.state in ("on", "true", "True", "1"): + if not defrost_state["active"]: _LOGGER.info("External defrost entity %s reports active defrost", defrost_entity_id) - controller.activate_defrost() - + defrost_state["active"] = True + defrost_state["start_time"] = now_ts + + is_defrost = defrost_state["active"] + if is_defrost: + elapsed = (now_ts - defrost_state["start_time"]) / 60.0 + if elapsed > 20.0: + defrost_state["active"] = False + _LOGGER.debug("Defrost cooldown expired after %.1f min", elapsed) + is_defrost = False + + is_hvac_idle = False operating_entity_id = conf.get(CONF_OPERATING_ENTITY) if operating_entity_id: operating_state = hass.states.get(operating_entity_id) if operating_state and operating_state.state in ("off", "false", "False", "0"): - return True - return False - - -def _pick_effective_fan( - mpc_controller: MPCController, - decision: dict, - shadow_decision: dict, -) -> tuple[str, str]: - """Return (fan_mode, reason) for the current control cycle. + is_hvac_idle = True - When MPC production mode is active and the MPC is not paused, the MPC - decision takes precedence; otherwise the rule-based heuristic is used. - """ - if mpc_controller.production_mode and shadow_decision.get("mpc_status") not in _MPC_PAUSED_STATUSES and shadow_decision.get("mpc_fan_mode"): - return shadow_decision["mpc_fan_mode"], f"MPC: {shadow_decision.get('mpc_reason', 'MPC')}" - return decision["fan_mode"], decision["reason"] + return is_defrost, is_hvac_idle async def _async_apply_fan_change( @@ -253,7 +166,7 @@ async def _async_apply_fan_change( effective_fan: str, current_fan: str | None, reason: str, - controller: SmartFanController, + on_confirmed, ) -> None: """Send a fan-mode command to the climate entity and confirm on success.""" _LOGGER.info( @@ -278,7 +191,7 @@ async def _async_apply_fan_change( effective_fan, ) else: - controller.confirm_fan_change() + on_confirmed() async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -289,10 +202,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: initial_fan_modes = _extract_supported_fan_modes(current_state) _LOGGER.info( - "Setting up Smart Fan Controller for %s (learning=%s, mpc_production=%s, data_collection=%s)", + "Setting up Smart Fan Controller for %s (data_collection=%s)", climate_id, - conf.get(CONF_LEARNING_ENABLED, DEFAULT_LEARNING_ENABLED), - conf.get(CONF_MPC_PRODUCTION_ENABLED, DEFAULT_MPC_PRODUCTION_ENABLED), conf.get(CONF_DATA_COLLECTION, DEFAULT_DATA_COLLECTION), ) @@ -316,25 +227,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: len(learning_data.get("response_events", [])), ) - controller = SmartFanController( - fan_modes=initial_fan_modes or None, - deadband=conf.get(CONF_DEADBAND, DEFAULT_DEADBAND), - min_interval=conf.get(CONF_MIN_INTERVAL, DEFAULT_MIN_INTERVAL), - soft_error=conf.get(CONF_SOFT_ERROR, DEFAULT_SOFT_ERROR), - hard_error=conf.get(CONF_HARD_ERROR, DEFAULT_HARD_ERROR), - limit_timeout=conf.get(CONF_LIMIT_TIMEOUT, DEFAULT_LIMIT_TIMEOUT), - learning_data=learning_data, - learning_enabled=conf.get(CONF_LEARNING_ENABLED, DEFAULT_LEARNING_ENABLED), - ) + learning = ThermalLearning.from_dict(learning_data) if learning_data else ThermalLearning() mpc_controller = MPCController( - learning=controller.learning, + 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, - enabled=True, ) - mpc_controller.production_mode = conf.get(CONF_MPC_PRODUCTION_ENABLED, DEFAULT_MPC_PRODUCTION_ENABLED) collector: DataCollector | None = DataCollector(hass, hass.config.config_dir, entry.entry_id) if conf.get(CONF_DATA_COLLECTION, DEFAULT_DATA_COLLECTION) else None if collector: @@ -342,7 +243,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.info("DataCollector enabled for %s, writing to %s", entry.entry_id, collector.path) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - "controller": controller, + "learning": learning, "mpc_controller": mpc_controller, "climate_entity": climate_id, "sensors": [], @@ -350,7 +251,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "store": store, } - await _async_migrate_entity_registry(hass, entry) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) def _ensure_profile_sensors() -> None: @@ -359,6 +259,15 @@ def _ensure_profile_sensors() -> None: if callable(add_profile_entities): add_profile_entities() + # 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), + "last_setpoint_drop_time": 0.0, + "previous_slope": None, + "last_hvac_mode": None, + "defrost": {"active": False, "start_time": 0.0}, + } + async def run_control_loop(_): """Main control loop executed every 2 minutes.""" current_state = hass.states.get(climate_id) @@ -366,10 +275,10 @@ async def run_control_loop(_): _LOGGER.warning("Climate entity %s not found; skipping control cycle", climate_id) return - if not controller.fan_modes: + if not mpc_controller.fan_modes: detected_modes = _extract_supported_fan_modes(current_state) if detected_modes: - controller.fan_modes = mpc_controller.fan_modes = detected_modes + mpc_controller.fan_modes = detected_modes _LOGGER.info("Detected fan modes for %s during runtime: %s", climate_id, detected_modes) _ensure_profile_sensors() else: @@ -379,7 +288,27 @@ async def run_control_loop(_): if cycle_data is None: return vtherm_slope, current_temp, target_temp, hvac_mode, current_fan, is_window_open = cycle_data - is_hvac_idle = _detect_disturbances(hass, conf, controller) + is_defrost_active, is_hvac_idle = _detect_disturbances(hass, conf, ctrl_state["defrost"]) + + now = time.time() + minutes_since_change = (now - ctrl_state["last_change_time"]) / 60.0 + + # Reset slope memory on HVAC mode switch + if ctrl_state["last_hvac_mode"] is not None and ctrl_state["last_hvac_mode"] != hvac_mode: + _LOGGER.info("HVAC mode changed %s -> %s: resetting slope memory", ctrl_state["last_hvac_mode"], hvac_mode) + ctrl_state["previous_slope"] = None + ctrl_state["last_hvac_mode"] = hvac_mode + + if ctrl_state["previous_slope"] is None: + ctrl_state["previous_slope"] = vtherm_slope + slope_change = abs(vtherm_slope - ctrl_state["previous_slope"]) > THRESHOLD_SLOPE + + # Signed comfort error (positive = need more heating/cooling) + current_error = (current_temp - target_temp) if hvac_mode == "cool" else (target_temp - current_temp) + + # Track setpoint-drop events for learning cooldown + if current_error < THRESHOLD_TARGET_DROP: + ctrl_state["last_setpoint_drop_time"] = now _LOGGER.debug( "Cycle start for %s: temp=%.2f target=%.2f slope=%.3f fan=%s hvac=%s window_open=%s", @@ -392,37 +321,64 @@ async def run_control_loop(_): is_window_open, ) - decision = controller.calculate_decision( - current_temp, - target_temp, - vtherm_slope, - hvac_mode, - current_fan, - is_window_open, - is_hvac_idle, - ) - shadow_decision = mpc_controller.evaluate( + mpc_decision = mpc_controller.evaluate( current_temp=current_temp, target_temp=target_temp, vtherm_slope=vtherm_slope, hvac_mode=hvac_mode, current_fan=current_fan, - live_decision_fan=decision.get("fan_mode"), is_window_open=is_window_open, - is_defrost_active=controller.is_defrost_active, + is_defrost_active=is_defrost_active, is_hvac_idle=is_hvac_idle, - minutes_since_change=decision.get("minutes_since_last_change", 0.0), + minutes_since_change=minutes_since_change, ) _LOGGER.debug( - "Cycle result for %s: live=%s mpc=%s mpc_status=%s mpc_confidence=%s%%", + "Cycle result for %s: mpc=%s mpc_status=%s mpc_confidence=%s%%", climate_id, - decision.get("fan_mode"), - shadow_decision.get("mpc_fan_mode"), - shadow_decision.get("mpc_status"), - shadow_decision.get("mpc_confidence"), + mpc_decision.get("mpc_fan_mode"), + mpc_decision.get("mpc_status"), + mpc_decision.get("mpc_confidence"), ) + # Determine effective fan: use MPC when status is actionable, else hold current + if mpc_decision.get("mpc_status") not in _MPC_PAUSED_STATUSES and mpc_decision.get("mpc_fan_mode"): + effective_fan = mpc_decision["mpc_fan_mode"] + effective_reason = f"MPC: {mpc_decision.get('mpc_reason', 'MPC')}" + else: + effective_fan = current_fan + effective_reason = f"MPC paused: {mpc_decision.get('mpc_status', 'unknown')}" + + # Phase classification (for learning gating and data collection) + learned_dead_time = learning.get_dead_time() if learning.is_ready() else DEFAULT_DEAD_TIME + if minutes_since_change < learned_dead_time: + phase = "DEAD_TIME" + elif minutes_since_change < learned_dead_time * DEAD_TIME_SAFETY_FACTOR: + phase = "TRANSIENT" + else: + phase = PHASE_ESTABLISHED + + # Slope sample collection + if current_fan is not None and not is_defrost_active and not is_hvac_idle: + min_stable_minutes = learned_dead_time * MIN_ESTABLISHED_RATIO + minutes_since_setpoint_drop = (now - ctrl_state["last_setpoint_drop_time"]) / 60.0 + if ( + phase == PHASE_ESTABLISHED + and minutes_since_change >= min_stable_minutes + and (ctrl_state["last_setpoint_drop_time"] == 0 or minutes_since_setpoint_drop >= SETPOINT_DROP_LEARNING_COOLDOWN) + ): + learning.add_slope_sample(current_fan, vtherm_slope, current_error, hvac_mode, is_window_open) + + # Response event collection + if slope_change and ctrl_state["last_change_time"] > 0: + response_time = minutes_since_change + if 2.0 <= response_time <= 60.0 and not is_window_open and not is_defrost_active and not is_hvac_idle: + learning.add_response_event(response_time) + _LOGGER.debug("Recorded thermal response event: %.1f min after last fan change", response_time) + + if slope_change: + ctrl_state["previous_slope"] = vtherm_slope + if collector: await collector.async_record( hvac_mode=hvac_mode, @@ -430,36 +386,36 @@ async def run_control_loop(_): target_temp=target_temp, vtherm_slope=vtherm_slope, is_window_open=is_window_open, - decision={**decision, "current_fan": current_fan}, - phase=controller.detect_phase(decision.get("minutes_since_last_change", 0.0)), + decision={"fan_mode": effective_fan, "reason": effective_reason, "current_fan": current_fan}, + phase=phase, effective_slope=-float(vtherm_slope) if hvac_mode == "cool" else float(vtherm_slope), - effective_timeout=controller.get_effective_timeout(), - force=decision.get("reason", "").startswith(("Emergency", "Setpoint drop")), - learning_ready=controller.learning_enabled and controller.learning.is_ready(), - dead_time=controller.learning.get_dead_time() if controller.learning_enabled else 0.0, - shadow=shadow_decision, - defrost_active=controller.is_defrost_active, + effective_timeout=mpc_controller.get_effective_timeout(), + force=False, + learning_ready=learning.is_ready(), + dead_time=learning.get_dead_time(), + mpc_decision=mpc_decision, + defrost_active=is_defrost_active, is_hvac_idle=is_hvac_idle, ) for sensor in hass.data[DOMAIN][entry.entry_id].get("sensors") or []: - if hasattr(sensor, "update_from_controller"): - sensor.update_from_controller({**decision, **shadow_decision}) + if hasattr(sensor, "update_from_mpc"): + sensor.update_from_mpc(mpc_decision) sensor.async_write_ha_state() - if ( - controller.learning_enabled - and controller.learning.is_ready() - and not entry.data.get("learning_auto_applied", False) - ): - learned_params = controller.learning.compute_optimal_parameters() + if learning.is_ready() and not entry.data.get("learning_auto_applied", False): + learned_params = learning.compute_optimal_parameters() if learned_params: _LOGGER.info("Learning is ready for %s, scheduling auto-apply of learned parameters", climate_id) hass.async_create_task(_apply_optimal_parameters(hass, entry, learned_params)) - effective_fan, effective_reason = _pick_effective_fan(mpc_controller, decision, shadow_decision) if effective_fan != current_fan: - await _async_apply_fan_change(hass, climate_id, effective_fan, current_fan, effective_reason, controller) + + def _confirm(): + ctrl_state["last_change_time"] = time.time() + _LOGGER.debug("Confirmed fan change for %s at %.3f", climate_id, ctrl_state["last_change_time"]) + + await _async_apply_fan_change(hass, climate_id, effective_fan, current_fan, effective_reason, _confirm) else: _LOGGER.debug("No fan mode change required for %s (%s)", climate_id, effective_reason) @@ -477,13 +433,13 @@ async def _handle_manual_change(event): return _LOGGER.info("Manual fan mode change detected for %s: %s -> %s", climate_id, old_fan, new_fan) - - manual_data = controller.record_manual_override(new_fan) + ctrl_state["last_change_time"] = time.time() + manual_data = {"fan_mode": new_fan, "minutes_since_last_change": 0.0, "reason": "Manual Override"} sensors = hass.data[DOMAIN][entry.entry_id].get("sensors", []) for sensor in sensors: - if hasattr(sensor, "update_from_controller"): - sensor.update_from_controller(manual_data) + if hasattr(sensor, "update_from_mpc"): + sensor.update_from_mpc(manual_data) sensor.async_write_ha_state() entry.async_on_unload(async_track_time_interval(hass, run_control_loop, timedelta(minutes=DELTA_TIME_CONTROL_LOOP))) @@ -493,7 +449,7 @@ async def _handle_manual_change(event): async def periodic_save_learning(_): """Periodically save learning data to persistent storage.""" - learning_data_to_save = controller.learning.to_dict() + learning_data_to_save = learning.to_dict() await store.async_save(learning_data_to_save) _LOGGER.debug( "Persisted learning data for %s (%d slope samples, %d response events)", @@ -506,15 +462,15 @@ async def periodic_save_learning(_): async def apply_learned_settings(_): """Service to apply optimal parameters from learning.""" - if not controller.learning.is_ready(): + if not learning.is_ready(): _LOGGER.warning( "Learning not complete yet for %s (%.1f%%), cannot apply settings", climate_id, - controller.learning.get_progress(), + learning.get_progress(), ) return - optimal = controller.learning.compute_optimal_parameters() + optimal = learning.compute_optimal_parameters() if not optimal: _LOGGER.error("Failed to compute optimal parameters for %s", climate_id) return @@ -525,9 +481,9 @@ async def apply_learned_settings(_): async def reset_learning(_): """Service to clear all learning data and restart learning.""" - controller.learning.reset() + learning.reset() - learning_data_to_save = controller.learning.to_dict() + learning_data_to_save = learning.to_dict() new_data = {**entry.data, "learning_data": learning_data_to_save} hass.config_entries.async_update_entry(entry, data=new_data) await store.async_save(learning_data_to_save) @@ -546,10 +502,10 @@ async def set_effective_slope(call): fan_mode = call.data["fan_mode"] effective_slope = float(call.data["effective_slope"]) - controller.learning.set_mode_effective_slope(fan_mode, hvac_mode, effective_slope) + learning.set_mode_effective_slope(fan_mode, hvac_mode, effective_slope) # Persist immediately - learning_data_to_save = controller.learning.to_dict() + learning_data_to_save = learning.to_dict() await store.async_save(learning_data_to_save) # Refresh sensors @@ -570,10 +526,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry when it's being removed.""" entry_data = hass.data[DOMAIN].get(entry.entry_id) if entry_data: - controller = entry_data.get("controller") + learning = entry_data.get("learning") store = entry_data.get("store") - if controller and hasattr(controller, "learning"): - learning_data = controller.learning.to_dict() + if learning is not None: + learning_data = learning.to_dict() new_data = {**entry.data, "learning_data": learning_data} hass.config_entries.async_update_entry(entry, data=new_data) if store: diff --git a/custom_components/smart_fan_controller/config_flow.py b/custom_components/smart_fan_controller/config_flow.py index 3c6f4de..79aa50e 100644 --- a/custom_components/smart_fan_controller/config_flow.py +++ b/custom_components/smart_fan_controller/config_flow.py @@ -12,22 +12,14 @@ CONF_CLIMATE_ENTITY, CONF_DEADBAND, CONF_MIN_INTERVAL, - CONF_SOFT_ERROR, - CONF_HARD_ERROR, CONF_LIMIT_TIMEOUT, - CONF_LEARNING_ENABLED, CONF_DATA_COLLECTION, - CONF_MPC_PRODUCTION_ENABLED, CONF_DEFROST_ENTITY, CONF_OPERATING_ENTITY, DEFAULT_DEADBAND, DEFAULT_MIN_INTERVAL, - DEFAULT_SOFT_ERROR, - DEFAULT_HARD_ERROR, DEFAULT_LIMIT_TIMEOUT, - DEFAULT_LEARNING_ENABLED, DEFAULT_DATA_COLLECTION, - DEFAULT_MPC_PRODUCTION_ENABLED, ) @@ -49,20 +41,6 @@ def _validate_climate_state(state) -> str | None: return None -def _validate_thresholds(input_data: dict) -> str | None: - """Return an error key if deadband/soft_error/hard_error are inconsistent, else None. - - The three values must satisfy: deadband < soft_error < hard_error. - Accepting inconsistent values would make the controller algorithm unreliable. - """ - deadband = input_data.get(CONF_DEADBAND, DEFAULT_DEADBAND) - soft_error = input_data.get(CONF_SOFT_ERROR, DEFAULT_SOFT_ERROR) - hard_error = input_data.get(CONF_HARD_ERROR, DEFAULT_HARD_ERROR) - if not deadband < soft_error < hard_error: - return "invalid_thresholds" - return None - - class SmartFanControllerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for for Smart Fan Controller.""" @@ -90,11 +68,7 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con if climate_error: errors[CONF_CLIMATE_ENTITY] = climate_error else: - threshold_error = _validate_thresholds(user_input) - if threshold_error: - errors["base"] = threshold_error - else: - return self.async_create_entry(title=user_input[CONF_CLIMATE_ENTITY], data=user_input) + return self.async_create_entry(title=user_input[CONF_CLIMATE_ENTITY], data=user_input) # Build selector config without include_entities when none are available selector_config_kwargs: dict[str, Any] = {"domain": CLIMATE_DOMAIN} @@ -110,18 +84,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_SOFT_ERROR, default=DEFAULT_SOFT_ERROR): selector.NumberSelector( - selector.NumberSelectorConfig(min=0.0, max=10.0, step=0.05, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="°C") - ), - vol.Optional(CONF_HARD_ERROR, default=DEFAULT_HARD_ERROR): selector.NumberSelector( - selector.NumberSelectorConfig(min=0.0, max=10.0, step=0.05, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="°C") - ), 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_LEARNING_ENABLED, default=DEFAULT_LEARNING_ENABLED): selector.BooleanSelector(), vol.Optional(CONF_DATA_COLLECTION, default=DEFAULT_DATA_COLLECTION): selector.BooleanSelector(), - vol.Optional(CONF_MPC_PRODUCTION_ENABLED, default=DEFAULT_MPC_PRODUCTION_ENABLED): 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"])), } @@ -154,11 +120,6 @@ async def async_step_init(self, user_input: dict[str, Any] | None = None) -> Con if climate_error: errors[CONF_CLIMATE_ENTITY] = climate_error - if not errors: - threshold_error = _validate_thresholds(user_input) - if threshold_error: - errors["base"] = threshold_error - if not errors: return self.async_create_entry(title="", data=user_input) @@ -187,20 +148,16 @@ 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_SOFT_ERROR, default=current_data.get(CONF_SOFT_ERROR, DEFAULT_SOFT_ERROR)): selector.NumberSelector( - selector.NumberSelectorConfig(min=0.0, max=10.0, step=0.05, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="°C") - ), - vol.Optional(CONF_HARD_ERROR, default=current_data.get(CONF_HARD_ERROR, DEFAULT_HARD_ERROR)): selector.NumberSelector( - selector.NumberSelectorConfig(min=0.0, max=10.0, step=0.05, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="°C") - ), 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_LEARNING_ENABLED, default=current_data.get(CONF_LEARNING_ENABLED, DEFAULT_LEARNING_ENABLED)): selector.BooleanSelector(), vol.Optional(CONF_DATA_COLLECTION, default=current_data.get(CONF_DATA_COLLECTION, DEFAULT_DATA_COLLECTION)): selector.BooleanSelector(), - vol.Optional(CONF_MPC_PRODUCTION_ENABLED, default=current_data.get(CONF_MPC_PRODUCTION_ENABLED, DEFAULT_MPC_PRODUCTION_ENABLED)): 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"])), - 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_DEFROST_ENTITY, default=current_data.get(CONF_DEFROST_ENTITY, vol.UNDEFINED)): selector.EntitySelector( + selector.EntitySelectorConfig(domain=["binary_sensor", "sensor", "input_boolean"]) + ), + vol.Optional(CONF_OPERATING_ENTITY, default=current_data.get(CONF_OPERATING_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 79d6b24..0a80d10 100644 --- a/custom_components/smart_fan_controller/const.py +++ b/custom_components/smart_fan_controller/const.py @@ -3,7 +3,6 @@ DOMAIN = "smart_fan_controller" DEVICE_NAME = "Smart Fan Controller" -LEGACY_UNIQUE_ID_PREFIX = "smart_fan" ENTITY_UNIQUE_ID_PREFIX = DOMAIN EFFECTIVE_SLOPE_UNIT = "°C/h" PROFILE_HVAC_MODES = ("heat", "cool") @@ -11,24 +10,16 @@ CONF_CLIMATE_ENTITY = "climate_entity" CONF_DEADBAND = "deadband" CONF_MIN_INTERVAL = "min_interval" -CONF_SOFT_ERROR = "soft_error" -CONF_HARD_ERROR = "hard_error" CONF_LIMIT_TIMEOUT = "limit_timeout" -CONF_LEARNING_ENABLED = "learning_enabled" CONF_DATA_COLLECTION = "data_collection" -CONF_MPC_PRODUCTION_ENABLED = "mpc_production_enabled" CONF_DEFROST_ENTITY = "defrost_entity" CONF_OPERATING_ENTITY = "operating_entity" # Default values DEFAULT_DEADBAND = 0.2 DEFAULT_MIN_INTERVAL = 10 -DEFAULT_SOFT_ERROR = 0.3 -DEFAULT_HARD_ERROR = 0.6 DEFAULT_LIMIT_TIMEOUT = 15 -DEFAULT_LEARNING_ENABLED = True DEFAULT_DATA_COLLECTION = True -DEFAULT_MPC_PRODUCTION_ENABLED = False DELTA_TIME_CONTROL_LOOP = 2 # minutes between each control loop execution @@ -56,54 +47,6 @@ SETPOINT_DROP_LEARNING_COOLDOWN = 30.0 # Minutes to block learning after a setpoint drop MIN_ESTABLISHED_RATIO = 2.0 # Minimum factor × dead_time the fan mode must be active before learning -LEGACY_OBJECT_KEY_MAP = { - "reason": "status", - "fan_mode": "fan_mode", - "minutes_since_last_change": "fan_mode_last_change", - "projected_temperature": "temperature_projected_10_min", - "projected_temperature_error": "temperature_projected_error_10_min", - "temperature_error": "temperature_error", - # MPC sensors — old object_keys (mpc_shadow_*) → new canonical (mpc_*) - "mpc_shadow_status": "mpc_status", - "mpc_shadow_reason": "mpc_reason", - "mpc_shadow_fan_mode": "mpc_fan_mode", - "mpc_shadow_matches_live": "mpc_match", - "mpc_shadow_would_change_now": "mpc_would_change_now", - "mpc_shadow_cost": "mpc_cost", - "mpc_shadow_confidence": "mpc_confidence", - "mpc_shadow_predicted_temperature_10m": "mpc_predicted_temperature_10_min", - "mpc_shadow_predicted_temperature_30m": "mpc_predicted_temperature_30_min", - "mpc_shadow_dead_time": "mpc_dead_time", - "mpc_shadow_known_profiles": "mpc_known_profiles", - "mpc_shadow_disturbance_bias": "mpc_disturbance_bias", - # Current object_keys (identity mapping — required for extract_object_key_from_unique_id) - "mpc_status": "mpc_status", - "mpc_reason": "mpc_reason", - "mpc_fan_mode": "mpc_fan_mode", - "mpc_match": "mpc_match", - "mpc_would_change_now": "mpc_would_change_now", - "mpc_cost": "mpc_cost", - "mpc_confidence": "mpc_confidence", - "mpc_predicted_temperature_10_min": "mpc_predicted_temperature_10_min", - "mpc_predicted_temperature_30_min": "mpc_predicted_temperature_30_min", - "mpc_dead_time": "mpc_dead_time", - "mpc_known_profiles": "mpc_known_profiles", - "mpc_disturbance_bias": "mpc_disturbance_bias", - "learning_progress": "learning_progress", - "learning_status": "learning_status", - "learning_samples": "learning_samples", - "learning_response_events": "learning_response_events", - "learned_dead_time": "learned_dead_time", - "effective_timeout": "effective_timeout", - "mpc_profiles_heat": "mpc_heat_profiles", - "mpc_profiles_cool": "mpc_cool_profiles", - "deadband": "learned_deadband", - "soft_error": "learned_soft_error", - "hard_error": "learned_hard_error", - "limit_timeout": "learned_limit_timeout", - "learning_enabled": "learning_enabled", -} - def build_unique_id(object_key: str, entry_id: str) -> str: """Build the canonical unique_id for an entity.""" @@ -113,18 +56,3 @@ def build_unique_id(object_key: str, entry_id: str) -> str: def build_entity_id(platform_domain: str, object_key: str) -> str: """Build the canonical entity_id suggestion for an entity.""" return f"{platform_domain}.{DOMAIN}_{object_key}" - - -def extract_object_key_from_unique_id(unique_id: str, entry_id: str) -> str | None: - """Extract the logical object key from either a legacy or canonical unique_id.""" - suffix = f"_{entry_id}" - if not unique_id.endswith(suffix): - return None - - stem = unique_id[: -len(suffix)] - for prefix in (f"{ENTITY_UNIQUE_ID_PREFIX}_", f"{LEGACY_UNIQUE_ID_PREFIX}_"): - if stem.startswith(prefix): - raw_key = stem[len(prefix):] - return LEGACY_OBJECT_KEY_MAP.get(raw_key, raw_key) - - return None diff --git a/custom_components/smart_fan_controller/controller.py b/custom_components/smart_fan_controller/controller.py deleted file mode 100644 index e198e5c..0000000 --- a/custom_components/smart_fan_controller/controller.py +++ /dev/null @@ -1,538 +0,0 @@ -import logging -import time -from typing import Callable - -from .const import ( - THRESHOLD_SLOPE, - THRESHOLD_TARGET_DROP, - MAX_PROJECTION_DELTA, - DEFAULT_DEAD_TIME, - DEAD_TIME_SAFETY_FACTOR, - PHASE_DEAD_TIME, - PHASE_TRANSIENT, - PHASE_ESTABLISHED, - SETPOINT_DROP_LEARNING_COOLDOWN, - MIN_ESTABLISHED_RATIO, -) -from .thermal_learning import ThermalLearning - -_LOGGER = logging.getLogger(__name__) - - -class SmartFanController: - """Decision engine for selecting fan mode based on thermal signals.""" - - def __init__( - self, - fan_modes: list | None, - deadband: float, - min_interval: int, - soft_error: float, - hard_error: float, - limit_timeout: int = 15, - learning_data: dict | None = None, - learning_enabled: bool = True, - clock: Callable[[], float] = time.time, - ): - # Initialize the attribute even if it is None initially - self._fan_modes: list | None = fan_modes - - # Config Flow values - self._deadband = deadband - self._min_interval = min_interval - self._soft_error = soft_error - self._hard_error = hard_error - self._limit_timeout = limit_timeout - - # Injectable clock – can override for isolated unit tests. - # Note: calculate_decision still calls time.time() directly so that - # unittest.mock.patch('time.time', ...) continues to work in tests. - self._clock = clock - - # State variables - self._previous_slope: float | None = None - self._slope_at_last_change: float = 0.0 - self._now: float = time.time() - self._last_change_time: float = self._now - (self._limit_timeout * 60) - self._last_slope_significant_change: float = self._now - self._last_hvac_mode: str | None = None - self._last_setpoint_drop_time: float = 0.0 # timestamp of last setpoint-drop event (init: far in the past) - - # Defrost state (set externally via CONF_DEFROST_ENTITY; 20-min cooldown applied by __init__.py) - self._defrost_active: bool = False - self._defrost_start_time: float = 0.0 - self._defrost_cooldown_minutes: float = 20.0 - - # Learning system - self.learning_enabled = learning_enabled - if learning_data: - self.learning = ThermalLearning.from_dict(learning_data) - else: - self.learning = ThermalLearning() - - @property - def fan_modes(self) -> list | None: - """Return available fan modes, if initialized.""" - return self._fan_modes - - @fan_modes.setter - def fan_modes(self, modes: list | None) -> None: - """Set available fan modes.""" - self._fan_modes = modes - - @property - def previous_slope(self) -> float | None: - """Last recorded slope snapshot (set before each fan-speed change).""" - return self._previous_slope - - @previous_slope.setter - def previous_slope(self, value: float | None) -> None: - self._previous_slope = value - - @property - def slope_at_last_change(self) -> float: - """Slope snapshot taken at the moment of the last confirmed fan-speed change.""" - return self._slope_at_last_change - - @slope_at_last_change.setter - def slope_at_last_change(self, value: float) -> None: - self._slope_at_last_change = value - - @property - def last_change_time(self) -> float: - """Timestamp of the last confirmed fan-speed change.""" - return self._last_change_time - - @last_change_time.setter - def last_change_time(self, value: float) -> None: - self._last_change_time = value - - @property - def now(self) -> float: - """Clock snapshot captured at the start of the last calculate_decision cycle.""" - return self._now - - @now.setter - def now(self, value: float) -> None: - self._now = value - - @property - def last_hvac_mode(self) -> str | None: - """HVAC mode observed during the previous cycle.""" - return self._last_hvac_mode - - @property - def limit_timeout(self) -> int: - """Configured static limit timeout (minutes).""" - return self._limit_timeout - - @property - def is_defrost_active(self) -> bool: - """Return whether defrost (or its cooldown) is currently active.""" - if not self._defrost_active: - return False - elapsed = (self._now - self._defrost_start_time) / 60.0 - if elapsed > self._defrost_cooldown_minutes: - self._defrost_active = False - _LOGGER.debug("Defrost cooldown expired after %.1f min", elapsed) - return False - return True - - def activate_defrost(self) -> None: - """Activate defrost protection from an external entity trigger.""" - self._defrost_active = True - self._defrost_start_time = self._now - - @property - def _projected_error_threshold(self) -> float: - """Calculate projected error threshold as midpoint between soft and hard error.""" - return (self._soft_error + self._hard_error) / 2 - - def compute_temperature_projection(self, current_temp: float, vtherm_slope: float) -> float: - """Estimate temperature projection in 10 min using a linear model. - - The VTherm slope is already smoothed over ~15-30 min (EMA). A second-order - (parabolic) term added negligible accuracy but amplified noise through a - double derivative of an already-filtered signal. A clamped linear projection - is simpler and more robust. - """ - window_time = 10 / 60 # hours - temp_proj = current_temp + (vtherm_slope * window_time) - - # Clamp to physically reasonable range - temp_proj = max(current_temp - MAX_PROJECTION_DELTA, min(current_temp + MAX_PROJECTION_DELTA, temp_proj)) - return temp_proj - - def get_effective_timeout(self) -> float: - """Return the adaptive timeout (minutes) based on learned dead time. - - Uses the learned median response time × safety factor so the system - waits long enough for a fan change to materialize at the sensor. - Falls back to the configured limit_timeout when learning is not ready. - """ - if self.learning_enabled and self.learning.is_ready(): - learned_dead_time = self.learning.get_dead_time() - return max(self._min_interval, learned_dead_time * DEAD_TIME_SAFETY_FACTOR) - return self._limit_timeout - - def detect_phase(self, minutes_since_change: float) -> str: - """Classify the current control phase relative to the last fan change. - - - DEAD_TIME: Too early for the sensor to see the effect of the change. - - TRANSIENT: The sensor is starting to react but hasn't stabilized. - - ESTABLISHED: The slope now reflects the current fan regime. - """ - learned_dead_time = self.learning.get_dead_time() if self.learning_enabled and self.learning.is_ready() else DEFAULT_DEAD_TIME - if minutes_since_change < learned_dead_time: - return PHASE_DEAD_TIME - if minutes_since_change < learned_dead_time * DEAD_TIME_SAFETY_FACTOR: - return PHASE_TRANSIENT - return PHASE_ESTABLISHED - - def _apply_step_limit(self, current_index: int, new_index: int, current_error: float = 0.0) -> int: - """ - Ensure the fan speed decreases by no more than one step at a time - to maintain system stability. - Allow +2 step-up when error exceeds soft_error for faster recovery. - """ - if (new_index - current_index) < -1: - return current_index - 1 - if (new_index - current_index) > 2: - if current_error > self._soft_error: - return current_index + 2 - return current_index + 1 - if (new_index - current_index) == 2 and current_error <= self._soft_error: - return current_index + 1 - return new_index - - def determine_final_index(self, current_index: int, new_index: int, minutes_since_change: float, force: bool, current_error: float = 0.0) -> int: - """Limit fan speed changes with safety guards.""" - if force: - # Emergency and setpoint-drop bypass all guards (timer + step limit). - # If temperature then drops too fast, Emergency (Zone A) will react. - return new_index - - # Enforce the minimum time between two changes - if minutes_since_change < self._min_interval: - return current_index - - return self._apply_step_limit(current_index, new_index, current_error) - - def record_manual_override(self, new_fan: str) -> dict: - """Persist last change timestamp and return manual override payload.""" - self._last_change_time = time.time() - _LOGGER.debug("Manual override recorded: fan=%s at %.3f", new_fan, self._last_change_time) - - return { - "fan_mode": new_fan, - "minutes_since_last_change": 0.0, - "reason": "Manual Override" - } - - def confirm_fan_change(self) -> None: - """Update the last-change timestamp once HA confirms the fan speed change. - - Uses self._now (the clock snapshot taken at the start of the last - calculate_decision cycle) rather than calling the clock a second time. - This keeps the cooldown timer aligned with the decision cycle and makes - the method compatible with tests that set self._now directly. - """ - self._last_change_time = self._now - _LOGGER.debug("Confirmed fan change at %.3f", self._last_change_time) - - def save_states( - self, - target_fan: str, - current_fan: str | None, - vtherm_slope: float, - effective_slope: float, - slope_change: bool, - is_window_open: bool = False, - is_defrost_active: bool = False, - is_hvac_idle: bool = False, - ): - """Update controller state and learn response times when conditions are clean.""" - if target_fan != current_fan: - # Record the slope snapshot at the moment of the decision. - # Note: _last_change_time is updated by confirm_fan_change() AFTER the - # HA service call succeeds, to avoid advancing the cooldown on failed calls. - self._slope_at_last_change = effective_slope - _LOGGER.debug( - "Pending fan change snapshot saved: %s -> %s with effective_slope=%.3f", - current_fan, - target_fan, - effective_slope, - ) - - if target_fan != current_fan or slope_change: - self._previous_slope = vtherm_slope - - # Track significant slope changes for response time calculation - # Response time = time from last fan change to this slope change (thermal response) - if slope_change and self._last_change_time > 0: - response_time = (self._now - self._last_change_time) / 60 - # Only record reasonable response times (between 2 and 60 minutes) - # Very short times might be noise, very long times might be system off or other issues - if 2.0 <= response_time <= 60.0 and self.learning_enabled and not is_window_open and not is_defrost_active and not is_hvac_idle: - self.learning.add_response_event(response_time) - _LOGGER.debug( - "Recorded thermal response event: %.1f min after last fan change", - response_time, - ) - else: - _LOGGER.debug( - "Ignored thermal response event: response_time=%.1f learning_enabled=%s window_open=%s defrost=%s hvac_idle=%s", - response_time, - self.learning_enabled, - is_window_open, - is_defrost_active, - is_hvac_idle, - ) - # Track when the last slope change occurred (for reference, not used in calculation) - self._last_slope_significant_change = self._now - - def calculate_decision( - self, current_temp: float, target_temp: float, vtherm_slope: float, hvac_mode: str, current_fan: str | None, is_window_open: bool = False, is_hvac_idle: bool = False - ) -> dict: - """Compute new fan speed.""" - self._now = time.time() - - # Early exit: unsupported or inactive HVAC modes - if hvac_mode in ("off", "dry", "fan_only"): - _LOGGER.debug("Skipping active control because HVAC mode is %s", hvac_mode) - return { - "fan_mode": current_fan, - "projected_temperature": round(current_temp, 2), - "projected_temperature_error": 0.0, - "temperature_error": 0.0, - "minutes_since_last_change": round((self._now - self._last_change_time) / 60, 1), - "reason": f"HVAC mode '{hvac_mode}': holding current speed", - } - - # Reset thermal memory when HVAC mode switches (heat ↔ cool) - if self._last_hvac_mode is not None and self._last_hvac_mode != hvac_mode: - _LOGGER.info("HVAC mode changed from %s to %s: resetting thermal state", self._last_hvac_mode, hvac_mode) - self._previous_slope = vtherm_slope - self._last_hvac_mode = hvac_mode - - # Init slope states - if self._previous_slope is None: - self._previous_slope = vtherm_slope - self._slope_at_last_change = vtherm_slope - - # Time since last fan change - minutes_since_change = (self._now - self._last_change_time) / 60 - - # -----------------------# - # --- Error analysis ---# - # -----------------------# - # Effective slope: positive if moving towards target - effective_slope = -vtherm_slope if hvac_mode == 'cool' else vtherm_slope - projected_temperature = self.compute_temperature_projection(current_temp, vtherm_slope) - # Current error (positive = need more heat/cool) - current_temperature_error = (current_temp - target_temp) if hvac_mode == 'cool' else (target_temp - current_temp) - # Projected error in 10 min (positive = will miss target) - projected_temperature_error = (projected_temperature - target_temp) if hvac_mode == 'cool' else (target_temp - projected_temperature) - above_soft_error = current_temperature_error > (self._soft_error + 1e-9) - - # Return early if fan modes not initialized, but include all sensor data - if not self._fan_modes: - _LOGGER.warning("Fan modes are not initialized; holding current mode %s", current_fan) - return { - "fan_mode": current_fan, - "projected_temperature": round(projected_temperature, 2), - "projected_temperature_error": round(projected_temperature_error, 2), - "temperature_error": round(current_temperature_error, 2), - "minutes_since_last_change": round(minutes_since_change, 1), - "reason": "No fan modes defined" - } - - # -------------------------# - # --- Logic indicators ---# - # -------------------------# - effective_timeout = self.get_effective_timeout() - interval_expired = minutes_since_change >= effective_timeout - min_interval_expired = minutes_since_change >= self._min_interval - slope_change = abs(vtherm_slope - self._previous_slope) > THRESHOLD_SLOPE - is_slope_improving = effective_slope > (self._slope_at_last_change + THRESHOLD_SLOPE) - can_reduce_on_favorable_slope = current_temperature_error <= (self._deadband + 1e-9) and projected_temperature_error <= 0 - phase = self.detect_phase(minutes_since_change) - - if current_fan is None: - current_index = 0 - else: - try: - current_index = self._fan_modes.index(current_fan) - except ValueError: - _LOGGER.debug("Current fan mode %s not in declared modes %s; defaulting to index 0", current_fan, self._fan_modes) - current_index = 0 - - max_index = len(self._fan_modes) - 1 - new_index = current_index - force = False - reason = "Unknown" - - defrost_protection = self.is_defrost_active - - # A. EMERGENCY (High real-time error) => highest fan speed immediatly - if current_temperature_error >= self._hard_error: - new_index = max_index - force = True - reason = f"Emergency: High error ({round(current_temperature_error, 2)}°C)" - - # A-bis. SETPOINT DROP (Target lowered significantly) => lowest fan speed immediately - # Night mode: when target drops ≥1°C below current (heat) or rises ≥1°C above current (cool) - elif current_temperature_error < THRESHOLD_TARGET_DROP: - new_index = 0 - force = True - self._last_setpoint_drop_time = self._now - reason = f"Setpoint drop: Target moved away ({round(current_temperature_error, 2)}°C)" - - # B. BRAKING ANTICIPATION (Overshoot predicted) - elif projected_temperature_error < -self._deadband and slope_change: - if defrost_protection and current_temperature_error > 0: - reason = "Defrost hold: blocking braking during defrost recovery" - else: - new_index = max(0, current_index - 1) - reason = f"Braking: Target overshoot predicted ({round(projected_temperature, 2)}°C)" - - # C. RECOVERY ANTICIPATION (Under-target predicted) - elif above_soft_error: - if is_hvac_idle: - reason = "HVAC idle: compressor off, holding current speed" - elif phase == PHASE_DEAD_TIME: - reason = "Patience: Waiting for thermal response" - elif is_slope_improving and projected_temperature_error <= 0: - reason = "Patience: Trend is improving" - elif min_interval_expired: - new_index = min(max_index, current_index + 2) if current_temperature_error > self._hard_error * 0.75 else min(max_index, current_index + 1) - intensity = "Strong" if projected_temperature_error > self._projected_error_threshold else "Soft" - reason = f"{intensity} recovery: Drop predicted to {round(projected_temperature, 2)}°C" - else: - reason = f"Waiting: Min interval active ({round(minutes_since_change)} min)" - - # D. DRIFT IN COMFORT ZONE - elif current_temperature_error > 0: - if is_hvac_idle: - reason = "HVAC idle: compressor off, holding current speed" - # Descent: strong favorable slope in established phase → reduce only when close enough to target - elif effective_slope > THRESHOLD_SLOPE * 2 and phase == PHASE_ESTABLISHED and interval_expired: - if defrost_protection: - reason = "Defrost hold: blocking speed reduction during defrost recovery" - elif can_reduce_on_favorable_slope: - new_index = max(0, current_index - 1) - reason = "Maintenance: Strong favorable slope, reducing" - else: - reason = "Maintenance: Favorable slope, holding" - elif (effective_slope < -THRESHOLD_SLOPE or projected_temperature_error > self._projected_error_threshold) and (slope_change or interval_expired): - new_index = min(max_index, current_index + 1) - reason = "Maintenance: Slow drift detected" - elif interval_expired and phase == PHASE_ESTABLISHED: - new_index = min(max_index, current_index + 1) - reason = "Maintenance: Stable away from target, reaching setpoint" - else: - reason = "Low Active: Observing inertia" - - # E. OVERHEATING / COOLING (ERROR < -DEADBAND) - elif current_temperature_error < -self._deadband: - if slope_change or interval_expired: - new_index = max(0, current_index - 1) - reason = "Over-target: Reducing speed" - else: - reason = "Over-target: Observing inertia" - - # F. COMFORT ZONE (STABLE) - else: - if slope_change and effective_slope < -THRESHOLD_SLOPE: - new_index = min(max_index, current_index + 1) - reason = "Comfort: Slow drift detected" - else: - reason = "Comfort: Stable" - - # FINAL GUARDS & STEP-DOWN - final_index = self.determine_final_index(current_index, new_index, minutes_since_change, force, current_temperature_error) - target_fan = self._fan_modes[final_index] - - # Update memory - self.save_states( - target_fan, - current_fan, - vtherm_slope, - effective_slope, - slope_change, - is_window_open, - defrost_protection, - is_hvac_idle, - ) - - # Collect learning data using the fan mode CURRENTLY active (not the decided one), - # so the slope observation is correctly attributed to the mode that produced it. - # Skip during defrost and HVAC idle: the slope is distorted and would corrupt learned profiles. - # Only learn from ESTABLISHED phase to avoid inheriting residual inertia from previous modes. - # Also require the mode to be active for at least MIN_ESTABLISHED_RATIO × dead_time to ensure - # the EMA slope has fully decayed from the previous mode. - # Block learning for SETPOINT_DROP_LEARNING_COOLDOWN minutes after a setpoint drop event, - # because the VTherm EMA slope retains inertia from the pre-drop high-speed mode. - if self.learning_enabled and current_fan is not None and current_fan in self._fan_modes and not defrost_protection and not is_hvac_idle: - learned_dead_time = self.learning.get_dead_time() if self.learning.is_ready() else DEFAULT_DEAD_TIME - min_stable_minutes = learned_dead_time * MIN_ESTABLISHED_RATIO - minutes_since_setpoint_drop = (self._now - self._last_setpoint_drop_time) / 60.0 - - if phase != PHASE_ESTABLISHED: - pass # Too early: sensor hasn't stabilized yet - elif minutes_since_change < min_stable_minutes: - _LOGGER.debug( - "Learning: Skipped sample (mode only active %.1f min, need %.1f min)", - minutes_since_change, min_stable_minutes, - ) - elif self._last_setpoint_drop_time > 0 and minutes_since_setpoint_drop < SETPOINT_DROP_LEARNING_COOLDOWN: - _LOGGER.debug( - "Learning: Skipped sample (setpoint drop cooldown, %.1f min ago)", - minutes_since_setpoint_drop, - ) - else: - self.learning.add_slope_sample(current_fan, vtherm_slope, current_temperature_error, hvac_mode, is_window_open) - - _LOGGER.debug( - ( - "Decision: hvac=%s current=%.2f target=%.2f err=%.2f proj=%.2f proj_err=%.2f " - "slope=%.3f eff_slope=%.3f phase=%s minutes=%.1f timeout=%.1f " - "slope_change=%s improving=%s interval_expired=%s min_interval_expired=%s " - "window_open=%s defrost=%s hvac_idle=%s idx=%d->%d->%d force=%s -> %s (%s)" - ), - hvac_mode, - current_temp, - target_temp, - current_temperature_error, - projected_temperature, - projected_temperature_error, - vtherm_slope, - effective_slope, - phase, - minutes_since_change, - effective_timeout, - slope_change, - is_slope_improving, - interval_expired, - min_interval_expired, - is_window_open, - defrost_protection, - is_hvac_idle, - current_index, - new_index, - final_index, - force, - target_fan, - reason, - ) - - return { - "fan_mode": target_fan, - "projected_temperature": round(projected_temperature, 2), - "projected_temperature_error": round(projected_temperature_error, 2), - "temperature_error": round(current_temperature_error, 2), - "minutes_since_last_change": round(minutes_since_change, 1), - "reason": reason, - "defrost_active": defrost_protection, - "hvac_idle": is_hvac_idle, - } diff --git a/custom_components/smart_fan_controller/data_collection.py b/custom_components/smart_fan_controller/data_collection.py index b1ed0a2..0fc134c 100644 --- a/custom_components/smart_fan_controller/data_collection.py +++ b/custom_components/smart_fan_controller/data_collection.py @@ -8,7 +8,7 @@ vtherm_slope, effective_slope, projected_temp, projected_error, phase, minutes_since_change, effective_timeout, current_fan, decided_fan, force, reason, learning_ready, dead_time, is_window_open, - mpc_status, mpc_fan, mpc_match, + mpc_status, mpc_fan, mpc_would_change, mpc_cost, mpc_confidence, mpc_temp_10m, mpc_temp_30m, mpc_known_profiles, mpc_disturbance, defrost_active, hvac_idle @@ -47,7 +47,6 @@ "is_window_open", "mpc_status", "mpc_fan", - "mpc_match", "mpc_would_change", "mpc_cost", "mpc_confidence", @@ -92,12 +91,12 @@ async def async_record( force: bool, learning_ready: bool, dead_time: float, - shadow: dict | None = None, + mpc_decision: dict | None = None, defrost_active: bool = False, is_hvac_idle: bool = False, ) -> None: """Append one row to the CSV file outside the event loop.""" - shadow = shadow or {} + mpc = mpc_decision or {} row = [ datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), hvac_mode, @@ -118,21 +117,20 @@ async def async_record( int(learning_ready), round(dead_time, 2), int(is_window_open), - shadow.get("mpc_status", "Disabled"), - shadow.get("mpc_fan_mode", ""), - shadow.get("mpc_matches_live", "n/a"), - shadow.get("mpc_would_change_now", "no"), - round(shadow.get("mpc_cost", 0.0), 3) if shadow.get("mpc_cost") is not None else "", - round(shadow.get("mpc_confidence", 0.0), 1) if shadow.get("mpc_confidence") is not None else "", - round(shadow.get("mpc_predicted_temperature_10m", 0.0), 3) - if shadow.get("mpc_predicted_temperature_10m") is not None + mpc.get("mpc_status", "Not ready"), + mpc.get("mpc_fan_mode", ""), + mpc.get("mpc_would_change_now", "no"), + round(mpc.get("mpc_cost", 0.0), 3) if mpc.get("mpc_cost") is not None else "", + round(mpc.get("mpc_confidence", 0.0), 1) if mpc.get("mpc_confidence") is not None else "", + round(mpc.get("mpc_predicted_temperature_10m", 0.0), 3) + if mpc.get("mpc_predicted_temperature_10m") is not None else "", - round(shadow.get("mpc_predicted_temperature_30m", 0.0), 3) - if shadow.get("mpc_predicted_temperature_30m") is not None + round(mpc.get("mpc_predicted_temperature_30m", 0.0), 3) + if mpc.get("mpc_predicted_temperature_30m") is not None else "", - shadow.get("mpc_known_profiles", 0), - round(shadow.get("mpc_disturbance_bias", 0.0), 3) - if shadow.get("mpc_disturbance_bias") is not None + mpc.get("mpc_known_profiles", 0), + round(mpc.get("mpc_disturbance_bias", 0.0), 3) + if mpc.get("mpc_disturbance_bias") is not None else "", int(defrost_active), int(is_hvac_idle), diff --git a/custom_components/smart_fan_controller/mpc_controller.py b/custom_components/smart_fan_controller/mpc_controller.py index 1ff07e8..104e046 100644 --- a/custom_components/smart_fan_controller/mpc_controller.py +++ b/custom_components/smart_fan_controller/mpc_controller.py @@ -1,4 +1,4 @@ -"""MPC controller: observation mode with optional production fan control.""" +"""MPC controller: learned thermal model with cost-based fan selection.""" from __future__ import annotations from dataclasses import dataclass @@ -53,41 +53,20 @@ def __init__( learning: ThermalLearning, deadband: float, min_interval: int, + limit_timeout: int = 15, fan_modes: list[str] | None = None, - enabled: bool = False, horizon_minutes: int = 30, cycle_minutes: int = DELTA_TIME_CONTROL_LOOP, ) -> None: self._learning = learning self._deadband = deadband self._min_interval = min_interval + self._limit_timeout = limit_timeout self._fan_modes = fan_modes - self._enabled = enabled - self._production_mode = False self._horizon_minutes = horizon_minutes self._cycle_minutes = cycle_minutes self._disturbance_bias = 0.0 - @property - def enabled(self) -> bool: - """Return whether MPC observation mode is enabled.""" - return self._enabled - - @enabled.setter - def enabled(self, value: bool) -> None: - """Enable or disable MPC observation mode.""" - self._enabled = value - - @property - def production_mode(self) -> bool: - """Return whether production mode is active (MPC controls the fan).""" - return self._production_mode - - @production_mode.setter - def production_mode(self, value: bool) -> None: - """Enable or disable MPC production mode.""" - self._production_mode = value - @property def fan_modes(self) -> list[str] | None: """Return the currently known fan modes.""" @@ -98,6 +77,23 @@ def fan_modes(self, modes: list[str] | None) -> None: """Update the available fan modes.""" self._fan_modes = modes + @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) -> float: + """Return the adaptive timeout based on learned dead time, or static fallback.""" + if self._learning.is_ready(): + learned_dead_time = self._learning.get_dead_time() + return max(self._min_interval, learned_dead_time * DEAD_TIME_SAFETY_FACTOR) + return self._limit_timeout + @property def disturbance_bias(self) -> float: """Return the current disturbance bias estimate (°C/h).""" @@ -111,41 +107,28 @@ def evaluate( vtherm_slope: float, hvac_mode: str, current_fan: str | None, - live_decision_fan: str | None, is_window_open: bool = False, is_defrost_active: bool = False, is_hvac_idle: bool = False, minutes_since_change: float = 0.0, ) -> dict: - """Evaluate the best shadow fan mode for the current cycle.""" + """Evaluate the best fan mode for the current cycle.""" _LOGGER.debug( - "Shadow evaluate: enabled=%s hvac=%s current_temp=%.2f target=%.2f slope=%.3f current_fan=%s live_decision=%s minutes_since_change=%.1f window_open=%s", - self._enabled, + "MPC evaluate: hvac=%s current_temp=%.2f target=%.2f slope=%.3f current_fan=%s minutes_since_change=%.1f window_open=%s", hvac_mode, current_temp, target_temp, vtherm_slope, current_fan, - live_decision_fan, minutes_since_change, is_window_open, ) - if not self._enabled: - return self._payload( - status="Disabled", - fan_mode=current_fan, - reason="Shadow mode disabled", - matches_live="disabled", - would_change_now="no", - ) - if hvac_mode in ("off", "dry", "fan_only"): return self._payload( status="Idle", fan_mode=current_fan, reason=f"HVAC mode '{hvac_mode}' is not simulated", - matches_live="n/a", would_change_now="no", ) @@ -155,7 +138,6 @@ def evaluate( status="Unavailable", fan_mode=current_fan, reason="No fan modes available yet", - matches_live="n/a", would_change_now="no", ) @@ -185,8 +167,7 @@ def evaluate( return self._payload( status="Disturbed", fan_mode=active_fan, - reason="Window open detected: shadow model paused", - matches_live="n/a", + reason="Window open detected: MPC paused", would_change_now="no", dead_time=dead_time, disturbance_bias=self._disturbance_bias, @@ -196,8 +177,7 @@ def evaluate( return self._payload( status="Disturbed", fan_mode=active_fan, - reason="Defrost active: shadow model paused", - matches_live="n/a", + reason="Defrost active: MPC paused", would_change_now="no", dead_time=dead_time, disturbance_bias=self._disturbance_bias, @@ -207,26 +187,23 @@ def evaluate( return self._payload( status="Disturbed", fan_mode=active_fan, - reason="HVAC idle: compressor off, shadow model paused", - matches_live="n/a", + reason="HVAC idle: compressor off, MPC paused", would_change_now="no", dead_time=dead_time, disturbance_bias=self._disturbance_bias, ) - # Setpoint drop: mirror the live controller's immediate minimum-speed rule. - # When the target moves far away (e.g. night setpoint), there is no point - # running the full MPC cost optimisation — the answer is always the lowest mode. + # Setpoint drop: when the target moves far away (e.g. night setpoint), + # there is no point running the full MPC cost optimisation — the answer + # is always the lowest mode. current_error = self._temperature_error(current_temp, target_temp, hvac_mode) if current_error < THRESHOLD_TARGET_DROP: lowest_fan = fan_modes[0] - matches_live = "n/a" if live_decision_fan is None else ("yes" if live_decision_fan == lowest_fan else "no") would_change = "yes" if active_fan != lowest_fan else "no" return self._payload( status="Setpoint drop", fan_mode=lowest_fan, reason=f"Setpoint drop: target moved away ({current_error:.1f}°C), minimum speed", - matches_live=matches_live, would_change_now=would_change, dead_time=dead_time, disturbance_bias=self._disturbance_bias, @@ -305,25 +282,11 @@ def evaluate( selection_note = hold_note best = current_simulation - if change_allowed and best.fan_mode != active_fan: - limited_best = self._apply_step_down_limit( - best=best, - active_fan=active_fan, - simulations=simulations, - fan_modes=fan_modes, - ) - if limited_best.fan_mode != best.fan_mode: - if selection_note: - selection_note += " | " - selection_note += f"Step-down limited to {limited_best.fan_mode}" - best = limited_best - confidence = self._compute_confidence(known_profiles, len(fan_modes), phase) - matches_live = "n/a" if live_decision_fan is None else ("yes" if live_decision_fan == best.fan_mode else "no") would_change_now = "yes" if change_allowed and best.fan_mode != active_fan else "no" status = "Ready" if confidence >= 0.5 else "Low confidence" _LOGGER.debug( - "Shadow candidates: %s", + "MPC candidates: %s", [ ( sim.fan_mode, @@ -336,7 +299,7 @@ def evaluate( ], ) reason = ( - f"Shadow recommends {best.fan_mode}: cost={best.total_cost:.2f}, " + 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 selection_note: @@ -350,7 +313,7 @@ def evaluate( ) _LOGGER.debug( - "Shadow result: active=%s best=%s status=%s confidence=%.2f known_profiles=%d/%d bias=%.3f note=%s", + "MPC result: active=%s best=%s status=%s confidence=%.2f known_profiles=%d/%d bias=%.3f note=%s", active_fan, best.fan_mode, status, @@ -369,7 +332,6 @@ def evaluate( predicted_30m=best.predicted_temp_30m, cost=best.total_cost, confidence=confidence * 100.0, - matches_live=matches_live, would_change_now=would_change_now, dead_time=dead_time, known_profiles=known_profiles, @@ -392,7 +354,7 @@ def _get_mode_slope( learned = self._learning.get_mode_effective_slope(fan_mode, hvac_mode) if learned is not None: _LOGGER.debug( - "Shadow slope model: using learned profile for %s/%s = %.3f", + "MPC slope model: using learned profile for %s/%s = %.3f", hvac_mode, fan_mode, learned, @@ -404,7 +366,7 @@ def _get_mode_slope( candidate_rank = fan_modes.index(fan_mode) + 1 scaled = baseline_slope * (candidate_rank / max(current_rank, 1)) _LOGGER.debug( - "Shadow slope model: using fallback for %s/%s = %.3f (baseline=%.3f current_fan=%s)", + "MPC slope model: using fallback for %s/%s = %.3f (baseline=%.3f current_fan=%s)", hvac_mode, fan_mode, scaled, @@ -479,7 +441,7 @@ def _update_disturbance_bias( else: decay_reason = "HVAC compressor is idle" _LOGGER.debug( - "Shadow disturbance bias decayed to %.3f because %s", + "MPC disturbance bias decayed to %.3f because %s", self._disturbance_bias, decay_reason, ) @@ -488,7 +450,7 @@ def _update_disturbance_bias( if not known_profile or phase != PHASE_ESTABLISHED: self._disturbance_bias *= DISTURBANCE_DECAY _LOGGER.debug( - "Shadow disturbance bias decayed to %.3f because known_profile=%s phase=%s", + "MPC disturbance bias decayed to %.3f because known_profile=%s phase=%s", self._disturbance_bias, known_profile, phase, @@ -499,7 +461,7 @@ def _update_disturbance_bias( updated = ((1 - DISTURBANCE_EMA_ALPHA) * self._disturbance_bias) + (DISTURBANCE_EMA_ALPHA * residual) self._disturbance_bias = max(-MAX_DISTURBANCE_BIAS, min(MAX_DISTURBANCE_BIAS, updated)) _LOGGER.debug( - "Shadow disturbance bias updated to %.3f (observed=%.3f expected=%.3f residual=%.3f)", + "MPC disturbance bias updated to %.3f (observed=%.3f expected=%.3f residual=%.3f)", self._disturbance_bias, observed_effective_slope, expected_effective_slope, @@ -526,7 +488,7 @@ def _simulate_mode( steps = max(1, int(self._horizon_minutes / self._cycle_minutes)) step_hours = self._cycle_minutes / 60.0 blend = 0.45 - shadow_temp = current_temp + sim_temp = current_temp predicted_10m = None predicted_30m = None cost = 0.0 @@ -546,17 +508,17 @@ def _simulate_mode( thermal_power += blend * (target_effective_slope - thermal_power) raw_slope = -thermal_power if hvac_mode == "cool" else thermal_power - shadow_temp += step_hours * raw_slope + sim_temp += step_hours * raw_slope if elapsed >= 10 and predicted_10m is None: - predicted_10m = shadow_temp + predicted_10m = sim_temp if elapsed >= 30 and predicted_30m is None: - predicted_30m = shadow_temp + predicted_30m = sim_temp - error = self._temperature_error(shadow_temp, target_temp, hvac_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) - floor_violation = max(target_temp - shadow_temp, 0.0) if hvac_mode == "heat" else max(shadow_temp - target_temp, 0.0) + floor_violation = max(target_temp - sim_temp, 0.0) if hvac_mode == "heat" else max(sim_temp - target_temp, 0.0) cost += comfort_error * urgency_weight cost += 3.0 * overshoot * overshoot cost += FLOOR_VIOLATION_LINEAR_WEIGHT * floor_violation * urgency_weight @@ -633,32 +595,6 @@ def _step_down_hold_note( return None - @staticmethod - def _apply_step_down_limit( - *, - best: ModeSimulation, - active_fan: str, - simulations: list[ModeSimulation], - fan_modes: list[str], - ) -> ModeSimulation: - """Allow at most one downward fan step per cycle, like the live controller. - - Upward moves are unrestricted so the MPC can ramp up aggressively. - """ - current_index = fan_modes.index(active_fan) - best_index = fan_modes.index(best.fan_mode) - - # Upward moves: no limit - if best_index >= current_index: - return best - - # Downward moves: limit to one step - if best_index >= current_index - 1: - return best - - limited_fan = fan_modes[current_index - 1] - return next(sim for sim in simulations if sim.fan_mode == limited_fan) - def _compute_confidence(self, known_profiles: int, total_profiles: int, phase: str) -> float: """Return a coarse confidence score for the current recommendation.""" coverage = known_profiles / max(total_profiles, 1) @@ -677,13 +613,12 @@ def _payload( predicted_30m: float | None = None, cost: float | None = None, confidence: float | None = None, - matches_live: str = "n/a", would_change_now: str = "no", dead_time: float | None = None, known_profiles: int = 0, disturbance_bias: float | None = None, ) -> dict: - """Build the shadow payload injected into sensors and CSV logs.""" + """Build the MPC payload injected into sensors and CSV logs.""" return { "mpc_status": status, "mpc_fan_mode": fan_mode, @@ -692,7 +627,6 @@ def _payload( "mpc_predicted_temperature_30m": round(predicted_30m, 2) if predicted_30m is not None else None, "mpc_cost": round(cost, 3) if cost is not None else None, "mpc_confidence": round(confidence, 1) if confidence is not None else None, - "mpc_matches_live": matches_live, "mpc_would_change_now": would_change_now, "mpc_dead_time": round(dead_time, 2) if dead_time is not None else None, "mpc_known_profiles": known_profiles, diff --git a/custom_components/smart_fan_controller/sensor.py b/custom_components/smart_fan_controller/sensor.py index 7d26b79..469bd91 100644 --- a/custom_components/smart_fan_controller/sensor.py +++ b/custom_components/smart_fan_controller/sensor.py @@ -63,19 +63,12 @@ def _build_profile_effective_slope_entities(entry_id: str, controller, known_key async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None: """Set up the sensor platform from a config entry.""" data = hass.data[DOMAIN][entry.entry_id] - controller = data["controller"] + mpc = data["mpc_controller"] sensor_definitions = [ - ("Status", "status", "reason", None, None, "mdi:information-outline", EntityCategory.DIAGNOSTIC), - ("Fan Mode", "fan_mode", "fan_mode", None, SensorDeviceClass.ENUM, "mdi:fan", None), - ("Fan Mode Last Change", "fan_mode_last_change", "minutes_since_last_change", UnitOfTime.MINUTES, SensorDeviceClass.DURATION, "mdi:clock-outline", EntityCategory.DIAGNOSTIC), - ("Temperature Projected 10 Min", "temperature_projected_10_min", "projected_temperature", UnitOfTemperature.CELSIUS, SensorDeviceClass.TEMPERATURE, "mdi:chart-bell-curve", EntityCategory.DIAGNOSTIC), - ("Temperature Projected Error 10 Min", "temperature_projected_error_10_min", "projected_temperature_error", UnitOfTemperature.CELSIUS, SensorDeviceClass.TEMPERATURE, "mdi:chart-bell-curve", EntityCategory.DIAGNOSTIC), - ("Temperature Error", "temperature_error", "temperature_error", UnitOfTemperature.CELSIUS, SensorDeviceClass.TEMPERATURE, "mdi:thermometer-lines", EntityCategory.DIAGNOSTIC), ("MPC Status", "mpc_status", "mpc_status", None, None, "mdi:robot-outline", EntityCategory.DIAGNOSTIC), ("MPC Reason", "mpc_reason", "mpc_reason", None, None, "mdi:text-box-search-outline", EntityCategory.DIAGNOSTIC), ("MPC Fan Mode", "mpc_fan_mode", "mpc_fan_mode", None, None, "mdi:fan-chevron-up", EntityCategory.DIAGNOSTIC), - ("MPC Match", "mpc_match", "mpc_matches_live", None, None, "mdi:compare", EntityCategory.DIAGNOSTIC), ("MPC Would Change Now", "mpc_would_change_now", "mpc_would_change_now", None, None, "mdi:swap-horizontal", EntityCategory.DIAGNOSTIC), ("MPC Cost", "mpc_cost", "mpc_cost", None, None, "mdi:calculator", EntityCategory.DIAGNOSTIC), ("MPC Confidence", "mpc_confidence", "mpc_confidence", PERCENTAGE, None, "mdi:chart-line", EntityCategory.DIAGNOSTIC), @@ -93,23 +86,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e entities.extend( [ - SmartFanLearningSensor(entry.entry_id, controller), - SmartFanLearningStatusSensor(entry.entry_id, controller), - SmartFanLearningSamplesSensor(entry.entry_id, controller), - SmartFanLearningResponseSensor(entry.entry_id, controller), - SmartFanLearnedDeadTimeSensor(entry.entry_id, controller), - SmartFanEffectiveTimeoutSensor(entry.entry_id, controller), - SmartFanMpcProfilesSensor(entry.entry_id, controller, "heat"), - SmartFanMpcProfilesSensor(entry.entry_id, controller, "cool"), - SmartFanLearnedDeadbandSensor(entry.entry_id, controller), - SmartFanLearnedSoftErrorSensor(entry.entry_id, controller), - SmartFanLearnedHardErrorSensor(entry.entry_id, controller), - SmartFanLearnedLimitTimeoutSensor(entry.entry_id, controller), + SmartFanLearningSensor(entry.entry_id, mpc), + SmartFanLearningStatusSensor(entry.entry_id, mpc), + SmartFanLearningSamplesSensor(entry.entry_id, mpc), + SmartFanLearningResponseSensor(entry.entry_id, mpc), + SmartFanLearnedDeadTimeSensor(entry.entry_id, mpc), + SmartFanEffectiveTimeoutSensor(entry.entry_id, mpc), + SmartFanMpcProfilesSensor(entry.entry_id, mpc, "heat"), + SmartFanMpcProfilesSensor(entry.entry_id, mpc, "cool"), + SmartFanLearnedDeadbandSensor(entry.entry_id, mpc), + SmartFanLearnedLimitTimeoutSensor(entry.entry_id, mpc), ] ) profile_sensor_keys: set[tuple[str, str]] = set() - profile_entities = _build_profile_effective_slope_entities(entry.entry_id, controller, profile_sensor_keys) + profile_entities = _build_profile_effective_slope_entities(entry.entry_id, mpc, profile_sensor_keys) entities.extend(profile_entities) if profile_entities: @@ -127,7 +118,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e def ensure_profile_sensors() -> None: """Add late-discovered profile slope sensors once fan modes are known.""" - new_entities = _build_profile_effective_slope_entities(entry.entry_id, controller, profile_sensor_keys) + new_entities = _build_profile_effective_slope_entities(entry.entry_id, mpc, profile_sensor_keys) if not new_entities: return @@ -170,7 +161,7 @@ def __init__( self._attr_entity_category = entity_category self.entity_id = build_entity_id("sensor", object_key) - def update_from_controller(self, data: dict) -> None: + def update_from_mpc(self, data: dict) -> None: """Update the sensor value with data from the controller.""" if self._data_key in data: self._attr_native_value = data.get(self._data_key) @@ -208,8 +199,6 @@ def extra_state_attributes(self) -> dict: optimal = self._controller.learning.compute_optimal_parameters() if optimal: attrs["learned_deadband"] = optimal.get("deadband") - attrs["learned_soft_error"] = optimal.get("soft_error") - attrs["learned_hard_error"] = optimal.get("hard_error") attrs["learned_limit_timeout"] = optimal.get("limit_timeout") attrs["learned_samples_count"] = optimal.get("samples_count") attrs["learned_response_samples"] = optimal.get("response_samples") @@ -364,7 +353,7 @@ def extra_state_attributes(self) -> dict: class SmartFanMpcProfilesSensor(_SmartFanEntity): - """Sensor exposing the learned per-mode profiles used by the shadow MPC.""" + """Sensor exposing the learned per-mode profiles used by the MPC.""" def __init__(self, entry_id: str, controller, hvac_mode: str) -> None: self._entry_id = entry_id @@ -506,40 +495,6 @@ def __init__(self, entry_id: str, controller) -> None: ) -class SmartFanLearnedSoftErrorSensor(_BaseLearnedParameterSensor): - """Learned soft_error parameter.""" - - def __init__(self, entry_id: str, controller) -> None: - super().__init__( - entry_id, - controller, - name="Learned Soft Error", - object_key="learned_soft_error", - unit=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - learning_key="soft_error", - icon="mdi:speedometer-slow", - current_attr="soft_error", - ) - - -class SmartFanLearnedHardErrorSensor(_BaseLearnedParameterSensor): - """Learned hard_error parameter.""" - - def __init__(self, entry_id: str, controller) -> None: - super().__init__( - entry_id, - controller, - name="Learned Hard Error", - object_key="learned_hard_error", - unit=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - learning_key="hard_error", - icon="mdi:speedometer", - current_attr="hard_error", - ) - - class SmartFanLearnedLimitTimeoutSensor(_BaseLearnedParameterSensor): """Learned limit_timeout parameter.""" diff --git a/custom_components/smart_fan_controller/switch.py b/custom_components/smart_fan_controller/switch.py index f151423..ff93e10 100644 --- a/custom_components/smart_fan_controller/switch.py +++ b/custom_components/smart_fan_controller/switch.py @@ -3,131 +3,14 @@ import logging -from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - CONF_LEARNING_ENABLED, - CONF_MPC_PRODUCTION_ENABLED, - DEVICE_NAME, - DOMAIN, - build_entity_id, - build_unique_id, -) +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -class _SmartFanSwitchEntity(SwitchEntity): - """Base switch wired to the Smart Fan Controller device.""" - - _entry_id: str - _attr_has_entity_name = True - - @property - def device_info(self) -> DeviceInfo: - """Link the entity to the Smart Fan Controller device.""" - return DeviceInfo( - identifiers={(DOMAIN, self._entry_id)}, - name=DEVICE_NAME, - ) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None: - """Set up the switch platform from a config entry.""" - data = hass.data[DOMAIN][entry.entry_id] - controller = data["controller"] - mpc_controller = data["mpc_controller"] - - entities = [ - SmartFanLearningSwitch(entry.entry_id, controller, entry, hass), - SmartFanMpcProductionSwitch(entry.entry_id, mpc_controller, entry, hass), - ] - - async_add_entities(entities) - - -class SmartFanLearningSwitch(_SmartFanSwitchEntity): # pylint: disable=abstract-method - """Switch to enable or disable learning mode.""" - - def __init__(self, entry_id: str, controller, entry: ConfigEntry, hass: HomeAssistant) -> None: - self._entry_id = entry_id - self._controller = controller - self._entry = entry - self._hass = hass - - self._attr_name = "Learning Enabled" - self._attr_unique_id = build_unique_id("learning_enabled", entry_id) - self._attr_icon = "mdi:brain" - self._attr_entity_category = EntityCategory.CONFIG - self.entity_id = build_entity_id("switch", "learning_enabled") - - @property - def is_on(self) -> bool: - """Return true if learning is enabled.""" - return self._controller.learning_enabled - - async def async_turn_on(self, **kwargs) -> None: - """Turn on learning mode.""" - self._controller.learning_enabled = True - self._hass.config_entries.async_update_entry( - self._entry, - options={**self._entry.options, CONF_LEARNING_ENABLED: True}, - ) - _LOGGER.info("Learning enabled for %s", self._entry.entry_id) - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs) -> None: - """Turn off learning mode.""" - self._controller.learning_enabled = False - self._hass.config_entries.async_update_entry( - self._entry, - options={**self._entry.options, CONF_LEARNING_ENABLED: False}, - ) - _LOGGER.info("Learning disabled for %s", self._entry.entry_id) - self.async_write_ha_state() - - -class SmartFanMpcProductionSwitch(_SmartFanSwitchEntity): # pylint: disable=abstract-method - """Switch to enable MPC production mode (MPC controls the fan).""" - - def __init__(self, entry_id: str, shadow_controller, entry: ConfigEntry, hass: HomeAssistant) -> None: - self._entry_id = entry_id - self._shadow_controller = shadow_controller - self._entry = entry - self._hass = hass - - self._attr_name = "MPC Production Mode" - self._attr_unique_id = build_unique_id("mpc_production_mode", entry_id) - self._attr_icon = "mdi:robot-outline" - self._attr_entity_category = EntityCategory.CONFIG - self.entity_id = build_entity_id("switch", "mpc_production_mode") - - @property - def is_on(self) -> bool: - """Return true if MPC production mode is enabled.""" - return self._shadow_controller.production_mode - - async def async_turn_on(self, **kwargs) -> None: - """Enable MPC production mode (MPC controls the fan).""" - self._shadow_controller.production_mode = True - self._hass.config_entries.async_update_entry( - self._entry, - options={**self._entry.options, CONF_MPC_PRODUCTION_ENABLED: True}, - ) - _LOGGER.info("MPC production mode enabled for %s", self._entry.entry_id) - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs) -> None: - """Disable MPC production mode (shadow only).""" - self._shadow_controller.production_mode = False - self._hass.config_entries.async_update_entry( - self._entry, - options={**self._entry.options, CONF_MPC_PRODUCTION_ENABLED: False}, - ) - _LOGGER.info("MPC production mode disabled for %s", self._entry.entry_id) - self.async_write_ha_state() + """Set up the switch platform from a config entry (no switches currently).""" diff --git a/custom_components/smart_fan_controller/thermal_learning.py b/custom_components/smart_fan_controller/thermal_learning.py index fe9774e..db5211c 100644 --- a/custom_components/smart_fan_controller/thermal_learning.py +++ b/custom_components/smart_fan_controller/thermal_learning.py @@ -330,8 +330,6 @@ def compute_optimal_parameters(self) -> dict: volatility_factor = min(slope_stdev / max(self._slope_mean, 0.1), 3.0) optimal_deadband = 0.15 + (volatility_factor * 0.2) - optimal_soft_error = 0.25 + (volatility_factor * 0.3) - optimal_hard_error = 0.5 + (volatility_factor * 0.4) _LOGGER.info( "Auto-calibration complete: avg_slope=%.2f std=%.2f max=%.2f | avg_response=%.1fmin | limit_timeout=%d", @@ -344,8 +342,6 @@ def compute_optimal_parameters(self) -> dict: result = { "deadband": round(optimal_deadband, 2), - "soft_error": round(optimal_soft_error, 2), - "hard_error": round(optimal_hard_error, 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 bd358f4..f5a2529 100644 --- a/custom_components/smart_fan_controller/translations/en.json +++ b/custom_components/smart_fan_controller/translations/en.json @@ -8,12 +8,8 @@ "climate_entity": " ", "deadband": "Deadband (°C)", "min_interval": "Min Interval (minutes)", - "soft_error": "Soft Error Threshold (°C)", - "hard_error": "Hard Error Threshold (°C)", "limit_timeout": "Interval Before Forcing Change (minutes)", - "learning_enabled": "Enable Automatic Learning", "data_collection": "Enable Data Collection (beta)", - "mpc_production_enabled": "Enable MPC Production Mode", "defrost_entity": "Defrost Entity (optional)", "operating_entity": "Heat Pump Operating Entity (optional)" }, @@ -21,19 +17,14 @@ "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.", - "soft_error": "Range: 0.0-10.0 °C. Default: 0.3 °C.", - "hard_error": "Range: 0.0-10.0 °C. Default: 0.6 °C.", "limit_timeout": "Range: 10-120 min. Default: 15 min.", - "learning_enabled": "Enable automatic learning of optimal parameters. When ready, settings will be applied automatically.", "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.", - "mpc_production_enabled": "When enabled, the MPC controller directly controls the fan mode instead of the rule-based algorithm. The MPC shadow observation always runs regardless of this setting.", "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." }, "error": { "invalid_climate_entity": "This entity must come from Versatile Thermostat.", - "no_versatile_thermostat": "No Versatile Thermostat entity found.", - "invalid_thresholds": "Deadband must be less than Soft Error, which must be less than Hard Error." + "no_versatile_thermostat": "No Versatile Thermostat entity found." } } } @@ -46,12 +37,8 @@ "climate_entity": " ", "deadband": "Deadband (°C)", "min_interval": "Min Interval (minutes)", - "soft_error": "Soft Error Threshold (°C)", - "hard_error": "Hard Error Threshold (°C)", "limit_timeout": "Interval Before Forcing Change (minutes)", - "learning_enabled": "Enable Automatic Learning", "data_collection": "Enable Data Collection (beta)", - "mpc_production_enabled": "Enable MPC Production Mode", "defrost_entity": "Defrost Entity (optional)", "operating_entity": "Heat Pump Operating Entity (optional)" }, @@ -59,19 +46,14 @@ "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.", - "soft_error": "Range: 0.0-10.0 °C. Default: 0.3 °C.", - "hard_error": "Range: 0.0-10.0 °C. Default: 0.6 °C.", "limit_timeout": "Range: 10-120 min. Default: 15 min.", - "learning_enabled": "Enable automatic learning of optimal parameters. When ready, settings will be applied automatically.", "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.", - "mpc_production_enabled": "When enabled, the MPC controller directly controls the fan mode instead of the rule-based algorithm. The MPC shadow observation always runs regardless of this setting.", "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." }, "error": { "invalid_climate_entity": "This entity must come from Versatile Thermostat.", - "no_versatile_thermostat": "No Versatile Thermostat entity found.", - "invalid_thresholds": "Deadband must be less than Soft Error, which must be less than Hard Error." + "no_versatile_thermostat": "No Versatile Thermostat entity found." } } } diff --git a/custom_components/smart_fan_controller/translations/fr.json b/custom_components/smart_fan_controller/translations/fr.json index 2912a08..d92183a 100644 --- a/custom_components/smart_fan_controller/translations/fr.json +++ b/custom_components/smart_fan_controller/translations/fr.json @@ -8,12 +8,8 @@ "climate_entity": " ", "deadband": "Bande morte (°C)", "min_interval": "Intervalle minimum (minutes)", - "soft_error": "Seuil d'erreur faible (°C)", - "hard_error": "Seuil d'erreur élevée (°C)", "limit_timeout": "Intervalle avant de forcer un changement (minutes)", - "learning_enabled": "Activer l'apprentissage automatique", "data_collection": "Activer la collecte de données (bêta)", - "mpc_production_enabled": "Activer le mode production MPC", "defrost_entity": "Entité dégivrage (optionnel)", "operating_entity": "Entité fonctionnement PAC (optionnel)" }, @@ -21,19 +17,14 @@ "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.", - "soft_error": "Plage : 0.0-10.0 °C. Défaut : 0.3 °C.", - "hard_error": "Plage : 0.0-10.0 °C. Défaut : 0.6 °C.", "limit_timeout": "Plage : 10-120 min. Défaut : 15 min.", - "learning_enabled": "Activer l'apprentissage automatique des paramètres optimaux. Quand prêt, les paramètres seront appliqués automatiquement.", "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.", - "mpc_production_enabled": "Lorsqu'activé, le contrôleur MPC pilote directement le mode ventilateur au lieu de l'algorithme à règles. L'observation MPC shadow fonctionne toujours, quel que soit ce réglage.", "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." }, "error": { "invalid_climate_entity": "Cette entité doit provenir de Versatile Thermostat.", - "no_versatile_thermostat": "Aucune entité Versatile Thermostat trouvée.", - "invalid_thresholds": "La bande morte doit être inférieure au seuil faible, lui-même inférieur au seuil élevé." + "no_versatile_thermostat": "Aucune entité Versatile Thermostat trouvée." } } } @@ -46,12 +37,8 @@ "climate_entity": " ", "deadband": "Bande morte (°C)", "min_interval": "Intervalle minimum (minutes)", - "soft_error": "Seuil d'erreur faible (°C)", - "hard_error": "Seuil d'erreur élevée (°C)", "limit_timeout": "Intervalle avant de forcer un changement (minutes)", - "learning_enabled": "Activer l'apprentissage automatique", "data_collection": "Activer la collecte de données (bêta)", - "mpc_production_enabled": "Activer le mode production MPC", "defrost_entity": "Entité dégivrage (optionnel)", "operating_entity": "Entité fonctionnement PAC (optionnel)" }, @@ -59,21 +46,16 @@ "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.", - "soft_error": "Plage : 0.0-10.0 °C. Défaut : 0.3 °C.", - "hard_error": "Plage : 0.0-10.0 °C. Défaut : 0.6 °C.", "limit_timeout": "Plage : 10-120 min. Défaut : 15 min.", - "learning_enabled": "Activer l'apprentissage automatique des paramètres optimaux. Quand prêt, les paramètres seront appliqués automatiquement.", "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.", - "mpc_production_enabled": "Lorsqu'activé, le contrôleur MPC pilote directement le mode ventilateur au lieu de l'algorithme à règles. L'observation MPC shadow fonctionne toujours, quel que soit ce réglage.", "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." }, "error": { "invalid_climate_entity": "Cette entité doit provenir de Versatile Thermostat.", - "no_versatile_thermostat": "Aucune entité Versatile Thermostat trouvée.", - "invalid_thresholds": "La bande morte doit être inférieure au seuil faible, lui-même inférieur au seuil élevé." + "no_versatile_thermostat": "Aucune entité Versatile Thermostat trouvée." } } } } -} \ No newline at end of file +} diff --git a/docs/mpc_mode.md b/docs/mpc_mode.md index 45bb045..630ea6c 100644 --- a/docs/mpc_mode.md +++ b/docs/mpc_mode.md @@ -1,26 +1,15 @@ -# MPC Controller — Shadow Observation and Production Mode +# MPC Controller — Technical Design ## Purpose -The MPC controller runs alongside the rule-based heuristic every cycle. -It maintains a learned thermal model, scores every candidate fan mode, and produces a recommendation. - -The integration offers two operating modes: - -- **MPC observation mode** (default): the MPC recommendation is logged and exposed as diagnostics only. - The rule-based heuristic remains solely responsible for sending `set_fan_mode` commands. - This is the low-risk path to validate the predictive controller before activating it. - -- **Production mode**: when enabled via `switch.smart_fan_controller_mpc_production_mode`, the MPC - recommendation overrides the heuristic fan decision for every cycle where the MPC controller - produces a confident, actionable result. The heuristic decision is still computed and logged for - comparison, but the real `set_fan_mode` command uses the MPC-selected mode. +The MPC controller is the sole decision engine for fan speed. +It maintains a learned thermal model, scores every candidate fan mode over a 30-minute horizon, and selects the mode with the lowest cost. +When MPC status is actionable (`Ready`, `Setpoint drop`, `Low confidence`), the integration applies the fan recommendation. When paused (`Disturbed`, `Idle`, `Not ready`), the current fan mode is held. ## Goals - Learn the thermal behavior of a specific room with minimal manual tuning. - Reuse the data already collected by the integration. -- Compare heuristic decisions and predictive decisions side by side. - Keep the controller explainable and easy to debug from Home Assistant diagnostics. ## Non-Goals @@ -34,38 +23,37 @@ The integration offers two operating modes: Each control cycle follows this flow: 1. Read VTherm temperature, target, slope, HVAC mode, and current fan mode. -2. Run the existing heuristic controller and record its decision. -3. Run `MPCController.evaluate(...)` with the same inputs. -4. Merge the MPC diagnostics into the sensor payload. -5. Append both heuristic and MPC information to the CSV log. -6. **MPC observation mode**: apply the heuristic fan change, if any. - **Production mode**: apply the MPC fan recommendation when the MPC status is `Ready` or - `Setpoint drop`; fall back to the heuristic otherwise (e.g. status `Low confidence`, - `Disturbed`, `Disabled`, `Idle`). +2. Detect disturbances (defrost, HVAC idle, window open). +3. Compute phase (DEAD_TIME / TRANSIENT / ESTABLISHED). +4. Run `MPCController.evaluate(...)` → `mpc_decision` dict. +5. Push `mpc_decision` to all sensors via `sensor.update_from_mpc()`. +6. Collect learning data (slope samples, response events) with gating. +7. Append MPC information to the CSV log. +8. Apply the MPC fan recommendation when the MPC status is actionable (`Ready`, `Setpoint drop`, + `Low confidence`); hold the current fan otherwise (e.g. status `Not ready`, + `Disturbed`, `Idle`). ### HA Entities -| Platform | Entity ID | Purpose | -|----------|-----------|---------| -| `switch` | `switch.smart_fan_controller_mpc_production_mode` | Enable/disable MPC production override | -| `sensor` | `sensor.smart_fan_controller_mpc_status` | Current MPC recommendation status | -| `sensor` | `sensor.smart_fan_controller_mpc_reason` | Human-readable reason for the recommendation | -| `sensor` | `sensor.smart_fan_controller_mpc_fan_mode` | Recommended fan mode | -| `sensor` | `sensor.smart_fan_controller_mpc_match` | Whether MPC and heuristic agree | -| `sensor` | `sensor.smart_fan_controller_mpc_would_change_now` | Whether MPC would change the fan this cycle | -| `sensor` | `sensor.smart_fan_controller_mpc_cost` | Best cost score for the recommended mode | -| `sensor` | `sensor.smart_fan_controller_mpc_confidence` | Confidence percentage | -| `sensor` | `sensor.smart_fan_controller_mpc_predicted_temperature_10_min` | 10-minute temperature forecast | -| `sensor` | `sensor.smart_fan_controller_mpc_predicted_temperature_30_min` | 30-minute temperature forecast | -| `sensor` | `sensor.smart_fan_controller_mpc_dead_time` | Dead time used by the simulator (min) | -| `sensor` | `sensor.smart_fan_controller_mpc_known_profiles` | Number of reliable learned profiles | -| `sensor` | `sensor.smart_fan_controller_mpc_disturbance_bias` | Slow disturbance correction term | -| `sensor` | `sensor.smart_fan_controller_mpc_heat_profiles` | Per-mode learned profiles for heat | -| `sensor` | `sensor.smart_fan_controller_mpc_cool_profiles` | Per-mode learned profiles for cool | +| Platform | Entity ID | Purpose | +| -------- | -------------------------------------------------------------- | -------------------------------------------------- | +| `sensor` | `sensor.smart_fan_controller_mpc_status` | Current MPC recommendation status | +| `sensor` | `sensor.smart_fan_controller_mpc_reason` | Human-readable reason for the recommendation | +| `sensor` | `sensor.smart_fan_controller_mpc_fan_mode` | Recommended fan mode | +| `sensor` | `sensor.smart_fan_controller_mpc_would_change_now` | Whether MPC would change the fan this cycle | +| `sensor` | `sensor.smart_fan_controller_mpc_cost` | Best cost score for the recommended mode | +| `sensor` | `sensor.smart_fan_controller_mpc_confidence` | Confidence percentage | +| `sensor` | `sensor.smart_fan_controller_mpc_predicted_temperature_10_min` | 10-minute temperature forecast | +| `sensor` | `sensor.smart_fan_controller_mpc_predicted_temperature_30_min` | 30-minute temperature forecast | +| `sensor` | `sensor.smart_fan_controller_mpc_dead_time` | Dead time used by the simulator (min) | +| `sensor` | `sensor.smart_fan_controller_mpc_known_profiles` | Number of reliable learned profiles | +| `sensor` | `sensor.smart_fan_controller_mpc_disturbance_bias` | Slow disturbance correction term | +| `sensor` | `sensor.smart_fan_controller_mpc_heat_profiles` | Per-mode learned profiles for heat | +| `sensor` | `sensor.smart_fan_controller_mpc_cool_profiles` | Per-mode learned profiles for cool | CSV log fields are prefixed with `mpc_*`. -Even while it shares the same learning backend today, the MPC controller owns its own runtime parameters (`deadband`, `min_interval`, `fan_modes`) and no longer depends on protected members of the live heuristic controller. +The MPC controller owns its runtime parameters (`deadband`, `min_interval`, `fan_modes`) and consumes learned profiles from `ThermalLearning`. ## Learned Thermal Model @@ -130,34 +118,35 @@ Each candidate mode gets a scalar cost: ```text J(mode) = - sum(comfort_error) - + 4 * sum(overshoot^2) - + 0.4 * fan_step_distance - + 0.15 * fan_energy_rank + sum(comfort_error × urgency) + + 3.0 * sum(overshoot²) + + 12.0 * urgency * sum(floor_violation) + 30.0 * sum(floor_violation²) + + 0.15 * fan_step_distance + + 0.05 * fan_energy_rank + min_interval_penalty ``` Where: -- `comfort_error = max(abs(error) - deadband, 0)` -- `overshoot = max(-error, 0)` +- `comfort_error = max(abs(error) - deadband, 0)`, amplified by urgency (`1 + excess × 2`) +- `overshoot = max(-error, 0)` — going past target +- `floor_violation = max(target - predicted, 0)` — dropping below setpoint - `fan_step_distance` penalizes unnecessary fan jumps - `fan_energy_rank` lightly discourages staying on the highest modes all the time -- `min_interval_penalty` keeps the MPC recommendation aligned with the same actuator guardrail as the live controller +- `min_interval_penalty` prevents changes before the effective timeout has elapsed In addition, the current implementation applies a mode-independent floor penalty when the predicted room temperature drops below the setpoint. This reflects a conservative comfort rule: if the target is `20°C`, predictions below `20°C` are considered increasingly unacceptable in both `heat` and `cool`. The selected mode is the one with the lowest total cost. To avoid fan yo-yo near the setpoint, a recommendation that changes the fan must also beat the current mode by a minimum gain. If the gain is only marginal, the MPC controller keeps the current fan and reports that hysteresis blocked the switch. -## Shadow Diagnostics +## Diagnostics -The current implementation exposes: +The MPC exposes: - recommendation status - recommendation reason - recommended fan mode -- whether it matches the live heuristic decision - whether it would actually change the fan now - 10-minute and 30-minute predicted temperatures - confidence percentage @@ -167,40 +156,17 @@ The current implementation exposes: The CSV log also stores the MPC recommendation so we can replay and compare decisions offline. -## Validation Strategy - -The MPC controller should stay in observation mode until it meets clear quality gates. -Recommended promotion criteria: - -- stable behavior over at least 7 days -- lower or equal overshoot than the heuristic controller -- no increase in fan oscillations -- acceptable prediction quality on live data -- confidence high enough on the modes frequently used by the room - -Suggested metrics to track: - -- MAE at 10 minutes -- MAE at 30 minutes -- percentage of cycles where MPC and heuristic agree -- number of hypothetical fan changes per day -- overshoot duration above target - ## Implementation Map Current files involved: -- `custom_components/smart_fan_controller/mpc_controller.py`: MPC thermal model and MPC-lite scorer -- `custom_components/smart_fan_controller/controller.py`: filters disturbed response-time learning at the source -- `custom_components/smart_fan_controller/__init__.py`: runtime coexistence and sensor payload merge -- `custom_components/smart_fan_controller/sensor.py`: MPC diagnostics exposed in Home Assistant -- `custom_components/smart_fan_controller/switch.py`: switch to enable or disable MPC production mode (`switch.smart_fan_controller_mpc_production_mode`) -- `custom_components/smart_fan_controller/data_collection.py`: MPC fields appended to the CSV log +- `custom_components/smart_fan_controller/mpc_controller.py`: MPC thermal model and cost-based scorer +- `custom_components/smart_fan_controller/thermal_learning.py`: slope samples, response events, profile calibration +- `custom_components/smart_fan_controller/__init__.py`: control loop, learning collection, services +- `custom_components/smart_fan_controller/sensor.py`: MPC and learning sensors exposed in Home Assistant +- `custom_components/smart_fan_controller/data_collection.py`: CSV logger for offline analysis ## Next Steps -After enough MPC validation and before enabling production mode: - -1. improve the thermal model with residual correction and better disturbance handling -2. add offline replay tooling against recorded CSV traces -3. verify MPC metrics are consistently good before enabling `switch.smart_fan_controller_mpc_production_mode` +1. Improve the thermal model with residual correction and better disturbance handling. +2. Add offline replay tooling against recorded CSV traces. diff --git a/requirements_test.txt b/requirements_test.txt index 40a62dd..1b0ff4b 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,4 @@ # Development and Testing Requirements pytest pytest-asyncio -pytest-homeassistant-custom-component==0.13.316 +pytest-homeassistant-custom-component diff --git a/tests/test_cool.py b/tests/test_cool.py deleted file mode 100644 index 4975435..0000000 --- a/tests/test_cool.py +++ /dev/null @@ -1,201 +0,0 @@ -"""Tests for SmartFanController logic - COOL mode.""" -# pylint: disable=redefined-outer-name -import pytest - -from custom_components.smart_fan_controller.controller import SmartFanController -from custom_components.smart_fan_controller.const import ( - DEFAULT_DEADBAND, - DEFAULT_MIN_INTERVAL, - DEFAULT_SOFT_ERROR, - DEFAULT_HARD_ERROR, -) - -# Standard configuration for tests -FAN_MODES = ["low", "medium", "high", "turbo"] -DEFAULT_CONFIG = { - "deadband": DEFAULT_DEADBAND, - "min_interval": DEFAULT_MIN_INTERVAL, - "soft_error": DEFAULT_SOFT_ERROR, - "hard_error": DEFAULT_HARD_ERROR, -} - -@pytest.fixture -def controller(): - """Fixture to provide a clean controller instance.""" - return SmartFanController(fan_modes=FAN_MODES, **DEFAULT_CONFIG) - -class TestSmartFanControllerCool: - """Exhaustive test suite for COOL mode decision logic.""" - - def test_emergency(self, controller): - """Test Scenario A: Emergency trigger when room is too hot.""" - result = controller.calculate_decision( - current_temp=21.0, # +1.0 error - target_temp=20.0, - vtherm_slope=0.0, - hvac_mode="cool", - current_fan="low" - ) - assert result["fan_mode"] == "turbo" - assert "Emergency: High error" in result["reason"] - - def test_braking_anticipation(self, controller): - """Test Scenario B: Reducing speed before over-cooling (overshoot). - - With linear projection, braking requires the projected temperature to - already overshoot target by more than deadband. Use a strong slope and - a current_temp that is already very close to (or past) target. - """ - controller.previous_slope = -0.3 - - result = controller.calculate_decision( - current_temp=20.05, target_temp=20.0, vtherm_slope=-3.0, hvac_mode="cool", current_fan="high" # Very strong cooling → proj = 20.05 - 0.5 = 19.55 - ) - assert result["fan_mode"] == "medium" - assert "Braking: Target overshoot predicted" in result["reason"] - - def test_recovery_relance(self, controller): - """Test Scenario C: Error persists (too warm) and cooling is stagnant.""" - # Setup: Last slope was 0.0, current is 0.0 (no progress) - controller.previous_slope = 0.0 - - result = controller.calculate_decision( - current_temp=20.4, # Error 0.4 > soft_error - target_temp=20.0, - vtherm_slope=0.0, - hvac_mode="cool", - current_fan="low" - ) - assert result["fan_mode"] == "medium" - assert "Soft recovery: Drop predicted" in result["reason"] - - def test_comfort_drift(self, controller): - """Test Scenario D: Small error but temperature starts rising (drift).""" - result = controller.calculate_decision( - current_temp=20.1, # Tiny error - target_temp=20.0, - vtherm_slope=0.3, # But rising - hvac_mode="cool", - current_fan="medium" - ) - assert result["fan_mode"] == "high" - assert "Maintenance: Slow drift detected" in result["reason"] - - def test_over_target_reduction(self, controller): - """Test Scenario E: Reducing fan when it's already colder than target.""" - result = controller.calculate_decision( - current_temp=19.5, - target_temp=20.0, - vtherm_slope=-0.5, - hvac_mode="cool", - current_fan="medium" - ) - assert result["fan_mode"] == "low" - assert "Over-target: Reducing speed" in result["reason"] - - def test_snapshot_stability(self, controller): - """Test the snapshot mechanism in cool mode.""" - # 1. First change sets the snapshot - result = controller.calculate_decision(21.0, 20.0, -0.5, "cool", "low") - first_snapshot = controller.previous_slope - assert first_snapshot == -0.5 - - # 2. Minor change (0.05) should NOT update snapshot - controller.calculate_decision(20.9, 20.0, -0.55, "cool", result.get("fan_mode")) - assert controller.previous_slope == first_snapshot - - # 3. Significant change (0.2) SHOULD update snapshot - controller.calculate_decision(20.8, 20.0, -0.75, "cool", result.get("fan_mode")) - assert controller.previous_slope == -0.75 - - def test_stable_above_target_with_custom_deadband(self): - """Test issue: stable temperature above target should still cool to reach setpoint. - - Similar to heat mode scenario: - - Setpoint changed from 20.5°C to 20°C - - Current temp is 20.2°C (error = 0.2°C, within custom deadband of 0.4°C) - - Slope is near zero (stable) - - System should increase cooling to reach target, not just maintain - """ - controller = SmartFanController( - fan_modes=FAN_MODES, - deadband=0.4, # Custom deadband - min_interval=DEFAULT_MIN_INTERVAL, - soft_error=DEFAULT_SOFT_ERROR, - hard_error=DEFAULT_HARD_ERROR, - ) - - # Simulate stable temperature above target - controller.previous_slope = 0.01 - controller.last_change_time = controller.now - (20 * 60) # 20 minutes ago - - result = controller.calculate_decision( - current_temp=20.2, - target_temp=20.0, - vtherm_slope=0.0, # Stable (not drifting) - hvac_mode="cool", - current_fan="low" - ) - - # System should increase fan to reach target - assert result["fan_mode"] == "medium", f"Expected fan increase, got {result['fan_mode']} with reason: {result['reason']}" - - def test_setpoint_drop_night_mode(self, controller): - """Test Scenario A-bis: Target rises significantly → immediate min fan. - - Simulates night mode where cooling setpoint rises from 22°C to 25°C while - room is at 23°C. Error = 23 - 25 = -2.0 < THRESHOLD_TARGET_DROP (-1.0). - Forced changes bypass step-down protection. If temperature then rises - too fast, Emergency (Zone A) will react immediately. - """ - result = controller.calculate_decision(current_temp=23.0, target_temp=25.0, vtherm_slope=-0.5, hvac_mode="cool", current_fan="turbo") - assert result["fan_mode"] == "low" - assert "Setpoint drop" in result["reason"] - - def test_comfort_stable_no_action(self, controller): - """Test Scenario F: Temperature at target, slope stable → no change.""" - controller.previous_slope = -0.05 - - result = controller.calculate_decision(current_temp=20.0, target_temp=20.0, vtherm_slope=-0.05, hvac_mode="cool", current_fan="medium") - assert result["fan_mode"] == "medium" - assert "Comfort: Stable" in result["reason"] - - def test_stable_away_reaching_setpoint(self): - """Test Zone D: Stable but away from target in ESTABLISHED phase → increase fan.""" - controller = SmartFanController(fan_modes=FAN_MODES, **DEFAULT_CONFIG) - # Established phase: 20 min since last change - controller.last_change_time = controller.now - (20 * 60) - controller.previous_slope = 0.0 - - result = controller.calculate_decision( - current_temp=20.1, # error = 0.1 (in zone D: 0 < 0.1 < soft_error) - target_temp=20.0, - vtherm_slope=0.0, # no drift, no favorable slope - hvac_mode="cool", - current_fan="low", - ) - assert result["fan_mode"] == "medium" - assert "Stable away from target" in result["reason"] - - def test_favorable_slope_holds_when_still_far_from_target(self): - """Strong cooling trend should not trigger a step-down while still above deadband.""" - controller = SmartFanController( - fan_modes=FAN_MODES, - deadband=0.3, - min_interval=DEFAULT_MIN_INTERVAL, - soft_error=0.5, - hard_error=DEFAULT_HARD_ERROR, - ) - controller.last_change_time = controller.now - (30 * 60) - controller.previous_slope = -3.66 - - result = controller.calculate_decision( - current_temp=20.4, - target_temp=20.0, - vtherm_slope=-3.66, - hvac_mode="cool", - current_fan="high", - ) - - assert result["fan_mode"] == "high" - assert "Favorable slope, holding" in result["reason"] diff --git a/tests/test_data_collection.py b/tests/test_data_collection.py index c88b264..4d993cb 100644 --- a/tests/test_data_collection.py +++ b/tests/test_data_collection.py @@ -92,9 +92,8 @@ async def test_async_record_appends_row(tmp_path: Path) -> None: "0", "10.99", "0", - "Disabled", + "Not ready", "", - "n/a", "no", "", "", diff --git a/tests/test_defrost.py b/tests/test_defrost.py deleted file mode 100644 index 5d057b1..0000000 --- a/tests/test_defrost.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Tests for defrost protection in the controller.""" -# pylint: disable=redefined-outer-name,protected-access -from unittest.mock import patch - -import pytest - -from custom_components.smart_fan_controller.controller import SmartFanController -from custom_components.smart_fan_controller.const import ( - DEFAULT_DEADBAND, - DEFAULT_MIN_INTERVAL, - DEFAULT_SOFT_ERROR, - DEFAULT_HARD_ERROR, -) - -FAN_MODES = ["silent", "low", "med", "high", "superhigh"] -DEFAULT_CONFIG = { - "deadband": DEFAULT_DEADBAND, - "min_interval": DEFAULT_MIN_INTERVAL, - "soft_error": DEFAULT_SOFT_ERROR, - "hard_error": DEFAULT_HARD_ERROR, -} - - -@pytest.fixture -def controller(): - """Create a SmartFanController instance for testing.""" - return SmartFanController(fan_modes=FAN_MODES, **DEFAULT_CONFIG) - - -class TestDefrostProtection: - """Tests that defrost protection blocks step-down decisions.""" - - def test_defrost_blocks_braking(self, controller): - """During defrost, braking (step-down on overshoot) should be blocked when under target.""" - # Set up controller state: strong positive slope that triggers B (braking) - # but we're still under target, so defrost should protect - controller._previous_slope = 0.0 - controller._now = 0.0 - controller._last_change_time = 0.0 - controller._defrost_active = True - controller._defrost_start_time = 0.0 - controller._last_change_time = 0.0 - - with patch("time.time", return_value=1200.0): # 20 min later - result = controller.calculate_decision( - current_temp=19.9, - target_temp=20.0, - vtherm_slope=2.0, # Very strong slope that would cause braking - hvac_mode="heat", - current_fan="high", - ) - - assert "Defrost hold" in result["reason"] - assert result["fan_mode"] == "high" # Should NOT step down - - def test_defrost_blocks_favorable_slope_reduction(self, controller): - """During defrost, maintenance favorable slope reduction should be blocked.""" - controller._previous_slope = 0.0 - controller._slope_at_last_change = 0.0 - controller._now = 0.0 - controller._last_change_time = 0.0 - controller._defrost_active = True - controller._defrost_start_time = 0.0 - controller._last_change_time = 0.0 - - with patch("time.time", return_value=1200.0): - result = controller.calculate_decision( - current_temp=19.85, - target_temp=20.0, - vtherm_slope=0.6, - hvac_mode="heat", - current_fan="superhigh", - ) - - assert "Defrost hold" in result["reason"] - assert result["fan_mode"] == "superhigh" - - def test_defrost_allows_emergency(self, controller): - """Emergency should still work during defrost.""" - controller._defrost_active = True - controller._defrost_start_time = 1000.0 - - with patch("time.time", return_value=1200.0): - result = controller.calculate_decision( - current_temp=18.0, - target_temp=20.0, - vtherm_slope=0.0, - hvac_mode="heat", - current_fan="low", - ) - - assert "Emergency" in result["reason"] - assert result["fan_mode"] == "superhigh" - - def test_defrost_output_includes_flag(self, controller): - """The decision output should include defrost_active flag.""" - with patch("time.time", return_value=1000.0): - result = controller.calculate_decision( - current_temp=20.0, - target_temp=20.0, - vtherm_slope=0.0, - hvac_mode="heat", - current_fan="med", - ) - - assert "defrost_active" in result - assert result["defrost_active"] is False - - -class TestDefrostLearningProtection: - """Tests that learning samples are excluded during defrost.""" - - def test_learning_skipped_during_defrost(self, controller): - """Learning should NOT collect slope samples during active defrost.""" - controller._defrost_active = True - controller._defrost_start_time = 1000.0 - initial_count = controller.learning.slope_sample_count() - - with patch("time.time", return_value=1200.0): - controller.calculate_decision( - current_temp=19.5, - target_temp=20.0, - vtherm_slope=0.8, - hvac_mode="heat", - current_fan="high", - ) - - assert controller.learning.slope_sample_count() == initial_count - - def test_response_events_excluded_during_defrost(self, controller): - """Response time events should NOT be recorded during active defrost.""" - controller._defrost_active = True - controller._defrost_start_time = 1000.0 - controller.previous_slope = 0.0 - controller._last_change_time = 1000.0 - (5 * 60) # 5 min ago - initial_events = controller.learning.response_event_count() - - # slope change from 0.0 → 0.8 would normally trigger a response event - with patch("time.time", return_value=1000.0): - controller.calculate_decision( - current_temp=19.5, - target_temp=20.0, - vtherm_slope=0.8, - hvac_mode="heat", - current_fan="high", - ) - - assert controller.learning.response_event_count() == initial_events - - def test_learning_resumes_after_defrost_expires(self, controller): - """Learning should resume after defrost cooldown expires.""" - controller._defrost_active = True - controller._defrost_start_time = 0.0 - controller._last_change_time = 0.0 - initial_count = controller.learning.slope_sample_count() - - # 25 minutes after defrost start → cooldown expired - with patch("time.time", return_value=1500.0): - controller.calculate_decision( - current_temp=19.5, - target_temp=20.0, - vtherm_slope=0.8, - hvac_mode="heat", - current_fan="high", - ) - - # Learning should have collected a sample (slope 0.8 > 0.15 threshold) - assert controller.learning.slope_sample_count() > initial_count diff --git a/tests/test_entity_migration.py b/tests/test_entity_migration.py deleted file mode 100644 index d90b2d7..0000000 --- a/tests/test_entity_migration.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Tests for entity naming migration.""" - -import pytest -from homeassistant.helpers import entity_registry as er -from pytest_homeassistant_custom_component.common import MockConfigEntry - -from custom_components.smart_fan_controller import _async_migrate_entity_registry -from custom_components.smart_fan_controller.const import DOMAIN - - -@pytest.mark.asyncio -async def test_entity_registry_migration_renames_legacy_entities(hass) -> None: - """Legacy smart_fan_* entities should be migrated to smart_fan_controller_*.""" - registry = er.async_get(hass) - await registry.async_load() - - entry = MockConfigEntry(domain=DOMAIN, entry_id="entry123") - entry.add_to_hass(hass) - - registry.async_get_or_create( - "sensor", - DOMAIN, - "smart_fan_reason_entry123", - config_entry=entry, - suggested_object_id="Status", - original_name="Status", - has_entity_name=False, - ) - registry.async_get_or_create( - "sensor", - DOMAIN, - "smart_fan_learned_dead_time_entry123", - config_entry=entry, - suggested_object_id="Learned Dead Time", - original_name="Learned Dead Time", - has_entity_name=False, - ) - registry.async_get_or_create( - "switch", - DOMAIN, - "smart_fan_learning_enabled_entry123", - config_entry=entry, - suggested_object_id="Learning Enabled", - original_name="Learning Enabled", - has_entity_name=False, - ) - - await _async_migrate_entity_registry(hass, entry) - - status = registry.async_get("sensor.smart_fan_controller_status") - learned_dead_time = registry.async_get("sensor.smart_fan_controller_learned_dead_time") - learning_enabled = registry.async_get("switch.smart_fan_controller_learning_enabled") - - assert status is not None - assert status.unique_id == "smart_fan_controller_status_entry123" - assert status.has_entity_name is True - - assert learned_dead_time is not None - assert learned_dead_time.unique_id == "smart_fan_controller_learned_dead_time_entry123" - assert learned_dead_time.has_entity_name is True - - assert learning_enabled is not None - assert learning_enabled.unique_id == "smart_fan_controller_learning_enabled_entry123" - assert learning_enabled.has_entity_name is True - - assert registry.async_get("sensor.status") is None - assert registry.async_get("sensor.learned_dead_time") is None - assert registry.async_get("switch.learning_enabled") is None diff --git a/tests/test_heat.py b/tests/test_heat.py deleted file mode 100644 index 9c8d918..0000000 --- a/tests/test_heat.py +++ /dev/null @@ -1,201 +0,0 @@ -"""Tests for SmartFanController logic - HEAT mode.""" -# pylint: disable=redefined-outer-name -import pytest - -from custom_components.smart_fan_controller.controller import SmartFanController -from custom_components.smart_fan_controller.const import ( - DEFAULT_DEADBAND, - DEFAULT_MIN_INTERVAL, - DEFAULT_SOFT_ERROR, - DEFAULT_HARD_ERROR, -) - -# Standard configuration for tests -FAN_MODES = ["low", "medium", "high", "turbo"] -DEFAULT_CONFIG = { - "deadband": DEFAULT_DEADBAND, - "min_interval": DEFAULT_MIN_INTERVAL, - "soft_error": DEFAULT_SOFT_ERROR, - "hard_error": DEFAULT_HARD_ERROR, -} - -@pytest.fixture -def controller(): - """Fixture to provide a clean controller instance.""" - return SmartFanController(fan_modes=FAN_MODES, **DEFAULT_CONFIG) - -class TestSmartFanControllerHeat: - """Exhaustive test suite for HEAT decision logic.""" - - def test_emergency(self, controller): - """Test Scenario A: Emergency trigger when error is very high.""" - result = controller.calculate_decision( - current_temp=19.0, # -1.0 error - target_temp=20.0, - vtherm_slope=0.0, - hvac_mode="heat", - current_fan="low" - ) - assert result["fan_mode"] == "turbo" - assert "Emergency: High error" in result["reason"] - - def test_braking_anticipation(self, controller): - """Test Scenario B: Reducing speed before over-heating (overshoot). - - With linear projection, braking requires the projected temperature to - overshoot target by more than deadband. Use a strong slope and a - current_temp very close to target. - """ - controller.previous_slope = 0.3 - - result = controller.calculate_decision( - current_temp=19.95, target_temp=20.0, vtherm_slope=3.0, hvac_mode="heat", current_fan="high" # Very strong heating → proj = 19.95 + 0.5 = 20.45 - ) - assert result["fan_mode"] == "medium" - assert "Braking: Target overshoot predicted" in result["reason"] - - def test_recovery_relance(self, controller): - """Test Scenario C: Error persists and slope is not improving.""" - # Setup: Last slope was 0.0, current is 0.0 (no progress) - controller.previous_slope = 0.0 - - result = controller.calculate_decision( - current_temp=19.6, # Error 0.4 > soft_error - target_temp=20.0, - vtherm_slope=0.0, - hvac_mode="heat", - current_fan="low" - ) - assert result["fan_mode"] == "medium" - assert "Soft recovery: Drop predicted" in result["reason"] - - def test_comfort_drift(self, controller): - """Test Scenario D: Small error but temperature starts drifting away.""" - result = controller.calculate_decision( - current_temp=19.9, # Tiny error - target_temp=20.0, - vtherm_slope=-0.3, # But dropping - hvac_mode="heat", - current_fan="medium" - ) - assert result["fan_mode"] == "high" - assert "Maintenance: Slow drift detected" in result["reason"] - - def test_over_target_reduction(self, controller): - """Test Scenario E: Reducing fan when target is exceeded.""" - result = controller.calculate_decision( - current_temp=20.5, - target_temp=20.0, - vtherm_slope=-0.5, - hvac_mode="heat", - current_fan="medium" - ) - assert result["fan_mode"] == "low" - assert "Over-target: Reducing speed" in result["reason"] - - def test_snapshot_stability(self, controller): - """Test the snapshot mechanism: stability with minor slope noise.""" - # 1. First change sets the snapshot - result = controller.calculate_decision(19.0, 20.0, 0.5, "heat", "low") - first_snapshot = controller.previous_slope - assert first_snapshot == 0.5 - - # 2. Minor change (0.05) should NOT update snapshot - controller.calculate_decision(19.1, 20.0, 0.55, "heat", result.get("fan_mode")) - assert controller.previous_slope == first_snapshot - - # 3. Significant change (0.2) SHOULD update snapshot - controller.calculate_decision(19.2, 20.0, 0.75, "heat", result.get("fan_mode")) - assert controller.previous_slope == 0.75 - - def test_stable_below_target_with_custom_deadband(self): - """Test issue: stable temperature below target should still heat to reach setpoint. - - Reproduces user scenario: - - Setpoint changed from 17.5°C to 18°C - - Current temp is 17.8°C (error = 0.2°C, within custom deadband of 0.4°C) - - Slope is near zero (stable) - - System should increase heating to reach target, not just maintain - """ - controller = SmartFanController( - fan_modes=FAN_MODES, - deadband=0.4, # User's custom deadband - min_interval=DEFAULT_MIN_INTERVAL, - soft_error=DEFAULT_SOFT_ERROR, - hard_error=DEFAULT_HARD_ERROR, - ) - - # Simulate stable temperature below target - controller.previous_slope = -0.01 - controller.last_change_time = controller.now - (20 * 60) # 20 minutes ago - - result = controller.calculate_decision( - current_temp=17.8, - target_temp=18.0, - vtherm_slope=-0.0, # Stable (not drifting) - hvac_mode="heat", - current_fan="low" - ) - - # System should increase fan to reach target - assert result["fan_mode"] == "medium", f"Expected fan increase, got {result['fan_mode']} with reason: {result['reason']}" - - def test_setpoint_drop_night_mode(self, controller): - """Test Scenario A-bis: Target drops significantly → immediate min fan. - - Simulates night mode where setpoint drops from 21°C to 18°C while room - is at 20°C. Error = 18 - 20 = -2.0 < THRESHOLD_TARGET_DROP (-1.0). - Forced changes bypass step-down protection. If temperature then drops - too fast, Emergency (Zone A) will react immediately. - """ - result = controller.calculate_decision(current_temp=20.0, target_temp=18.0, vtherm_slope=0.5, hvac_mode="heat", current_fan="turbo") - assert result["fan_mode"] == "low" - assert "Setpoint drop" in result["reason"] - - def test_comfort_stable_no_action(self, controller): - """Test Scenario F: Temperature at target, slope stable → no change.""" - controller.previous_slope = 0.05 - - result = controller.calculate_decision(current_temp=20.0, target_temp=20.0, vtherm_slope=0.05, hvac_mode="heat", current_fan="medium") - assert result["fan_mode"] == "medium" - assert "Comfort: Stable" in result["reason"] - - def test_stable_away_reaching_setpoint(self): - """Test Zone D: Stable but away from target in ESTABLISHED phase → increase fan.""" - controller = SmartFanController(fan_modes=FAN_MODES, **DEFAULT_CONFIG) - # Established phase: 20 min since last change - controller.last_change_time = controller.now - (20 * 60) - controller.previous_slope = 0.0 - - result = controller.calculate_decision( - current_temp=19.9, # error = 0.1 (in zone D: 0 < 0.1 < soft_error) - target_temp=20.0, - vtherm_slope=0.0, # no drift, no favorable slope - hvac_mode="heat", - current_fan="low", - ) - assert result["fan_mode"] == "medium" - assert "Stable away from target" in result["reason"] - - def test_favorable_slope_holds_when_still_far_from_target(self): - """Strong heating trend should not trigger a step-down while still above deadband.""" - controller = SmartFanController( - fan_modes=FAN_MODES, - deadband=0.3, - min_interval=DEFAULT_MIN_INTERVAL, - soft_error=0.5, - hard_error=DEFAULT_HARD_ERROR, - ) - controller.last_change_time = controller.now - (30 * 60) - controller.previous_slope = 3.66 - - result = controller.calculate_decision( - current_temp=19.6, - target_temp=20.0, - vtherm_slope=3.66, - hvac_mode="heat", - current_fan="high", - ) - - assert result["fan_mode"] == "high" - assert "Favorable slope, holding" in result["reason"] diff --git a/tests/test_hvac_idle.py b/tests/test_hvac_idle.py deleted file mode 100644 index 2a693e1..0000000 --- a/tests/test_hvac_idle.py +++ /dev/null @@ -1,320 +0,0 @@ -"""Tests for HVAC idle (compressor off) detection and protection.""" -# pylint: disable=redefined-outer-name,protected-access -from unittest.mock import patch - -import pytest - -from custom_components.smart_fan_controller.controller import SmartFanController -from custom_components.smart_fan_controller.mpc_controller import MPCController -from custom_components.smart_fan_controller.const import ( - DEFAULT_DEADBAND, - DEFAULT_MIN_INTERVAL, - DEFAULT_SOFT_ERROR, - DEFAULT_HARD_ERROR, -) - -FAN_MODES = ["silent", "low", "med", "high", "superhigh"] -DEFAULT_CONFIG = { - "deadband": DEFAULT_DEADBAND, - "min_interval": DEFAULT_MIN_INTERVAL, - "soft_error": DEFAULT_SOFT_ERROR, - "hard_error": DEFAULT_HARD_ERROR, -} - - -@pytest.fixture -def controller(): - """Create a SmartFanController instance for testing.""" - return SmartFanController(fan_modes=FAN_MODES, **DEFAULT_CONFIG) - - -def _prime_learning_profiles(ctrl: SmartFanController) -> None: - """Feed enough slope samples for all profiles to become ready.""" - for _ in range(60): - ctrl.learning.add_slope_sample("silent", 0.15, 0.8, "heat") - ctrl.learning.add_slope_sample("low", 0.25, 0.8, "heat") - ctrl.learning.add_slope_sample("med", 0.9, 0.8, "heat") - ctrl.learning.add_slope_sample("high", 1.5, 0.8, "heat") - ctrl.learning.add_slope_sample("superhigh", 2.0, 0.8, "heat") - ctrl.learning.add_response_event(8.0) - ctrl.learning.add_response_event(10.0) - ctrl.learning.add_response_event(12.0) - - -class TestHvacIdleControllerHeat: - """Controller blocks step-up decisions when HVAC compressor is idle.""" - - def test_zone_c_blocked_during_idle(self, controller): - """Zone C (recovery): step-up should be blocked when compressor is off.""" - with patch("time.time", return_value=3600.0): - result = controller.calculate_decision( - current_temp=19.6, - target_temp=20.0, - vtherm_slope=0.0, - hvac_mode="heat", - current_fan="low", - is_hvac_idle=True, - ) - assert result["fan_mode"] == "low" - assert "HVAC idle" in result["reason"] - assert result["hvac_idle"] is True - - def test_zone_c_allowed_when_not_idle(self, controller): - """Zone C (recovery): step-up should work normally when compressor is running.""" - controller._last_change_time = 3600.0 - (DEFAULT_MIN_INTERVAL * 60 + 60) - with patch("time.time", return_value=3600.0): - result = controller.calculate_decision( - current_temp=19.6, - target_temp=20.0, - vtherm_slope=0.0, - hvac_mode="heat", - current_fan="low", - is_hvac_idle=False, - ) - assert result["fan_mode"] == "med" - assert "recovery" in result["reason"] - - def test_zone_d_blocked_during_idle(self, controller): - """Zone D (drift): step-up should be blocked when compressor is off.""" - with patch("time.time", return_value=3600.0): - result = controller.calculate_decision( - current_temp=19.9, - target_temp=20.0, - vtherm_slope=-0.3, - hvac_mode="heat", - current_fan="med", - is_hvac_idle=True, - ) - assert result["fan_mode"] == "med" - assert "HVAC idle" in result["reason"] - - def test_zone_a_emergency_not_blocked_by_idle(self, controller): - """Zone A: emergency should override idle protection.""" - with patch("time.time", return_value=3600.0): - result = controller.calculate_decision( - current_temp=19.0, - target_temp=20.0, - vtherm_slope=0.0, - hvac_mode="heat", - current_fan="low", - is_hvac_idle=True, - ) - assert result["fan_mode"] == "superhigh" - assert "Emergency" in result["reason"] - - def test_zone_a_setpoint_drop_not_blocked_by_idle(self, controller): - """Zone A-bis: setpoint drop should work even during idle.""" - with patch("time.time", return_value=3600.0): - result = controller.calculate_decision( - current_temp=22.5, - target_temp=20.0, - vtherm_slope=0.0, - hvac_mode="heat", - current_fan="high", - is_hvac_idle=True, - ) - assert result["fan_mode"] == "silent" - assert "Setpoint drop" in result["reason"] - - def test_zone_b_braking_allowed_during_idle(self, controller): - """Zone B: braking step-down should work during idle.""" - controller.previous_slope = 0.3 - controller._last_change_time = 3600.0 - (DEFAULT_MIN_INTERVAL * 60 + 60) - with patch("time.time", return_value=3600.0): - result = controller.calculate_decision( - current_temp=19.95, - target_temp=20.0, - vtherm_slope=3.0, - hvac_mode="heat", - current_fan="high", - is_hvac_idle=True, - ) - assert result["fan_mode"] == "med" - assert "Braking" in result["reason"] - - def test_zone_e_step_down_allowed_during_idle(self, controller): - """Zone E: over-target step-down should work during idle.""" - controller.previous_slope = 0.3 - controller._last_change_time = 3600.0 - (16 * 60) - with patch("time.time", return_value=3600.0): - result = controller.calculate_decision( - current_temp=20.5, - target_temp=20.0, - vtherm_slope=0.3, - hvac_mode="heat", - current_fan="high", - is_hvac_idle=True, - ) - assert result["fan_mode"] == "med" - assert "Over-target" in result["reason"] - - -class TestHvacIdleLearningExclusion: - """Learning data should be excluded during HVAC idle periods.""" - - def test_slope_samples_excluded_during_idle(self, controller): - """Slope samples should NOT be recorded when compressor is off.""" - initial_samples = controller.learning.slope_sample_count() - controller._last_change_time = 0.0 - with patch("time.time", return_value=3600.0): - controller.calculate_decision( - current_temp=19.8, - target_temp=20.0, - vtherm_slope=0.5, - hvac_mode="heat", - current_fan="med", - is_hvac_idle=True, - ) - assert controller.learning.slope_sample_count() == initial_samples - - def test_slope_samples_collected_when_not_idle(self, controller): - """Slope samples should be recorded when compressor is running.""" - initial_samples = controller.learning.slope_sample_count() - controller._last_change_time = 0.0 - with patch("time.time", return_value=3600.0): - controller.calculate_decision( - current_temp=19.8, - target_temp=20.0, - vtherm_slope=0.5, - hvac_mode="heat", - current_fan="med", - is_hvac_idle=False, - ) - assert controller.learning.slope_sample_count() > initial_samples - - def test_response_events_excluded_during_idle(self, controller): - """Response time events should NOT be recorded during idle.""" - controller.previous_slope = 0.0 - controller._last_change_time = 3600.0 - (5 * 60) # 5 min ago - initial_events = controller.learning.response_event_count() - with patch("time.time", return_value=3600.0): - controller.calculate_decision( - current_temp=19.8, - target_temp=20.0, - vtherm_slope=0.5, - hvac_mode="heat", - current_fan="med", - is_hvac_idle=True, - ) - assert controller.learning.response_event_count() == initial_events - - -class TestHvacIdleMpc: - """MPC shadow should pause during HVAC idle.""" - - def test_shadow_pauses_during_hvac_idle(self): - """Shadow should return Disturbed when compressor is off.""" - ctrl = SmartFanController(fan_modes=FAN_MODES, **DEFAULT_CONFIG) - _prime_learning_profiles(ctrl) - shadow = MPCController( - learning=ctrl.learning, - deadband=0.3, - min_interval=10, - fan_modes=FAN_MODES, - enabled=True, - ) - - result = shadow.evaluate( - current_temp=19.5, - target_temp=20.0, - vtherm_slope=0.2, - hvac_mode="heat", - current_fan="high", - live_decision_fan="high", - is_window_open=False, - is_defrost_active=False, - is_hvac_idle=True, - minutes_since_change=12.0, - ) - - assert result["mpc_status"] == "Disturbed" - assert result["mpc_fan_mode"] == "high" - assert result["mpc_would_change_now"] == "no" - assert "HVAC idle" in result["mpc_reason"] - - def test_shadow_active_when_not_idle(self): - """Shadow should produce a normal recommendation when compressor is running.""" - ctrl = SmartFanController(fan_modes=FAN_MODES, **DEFAULT_CONFIG) - _prime_learning_profiles(ctrl) - shadow = MPCController( - learning=ctrl.learning, - deadband=0.3, - min_interval=10, - fan_modes=FAN_MODES, - enabled=True, - ) - - result = shadow.evaluate( - current_temp=19.5, - target_temp=20.0, - vtherm_slope=0.2, - hvac_mode="heat", - current_fan="high", - live_decision_fan="high", - is_window_open=False, - is_defrost_active=False, - is_hvac_idle=False, - minutes_since_change=12.0, - ) - - assert result["mpc_status"] != "Disturbed" - - def test_shadow_disturbance_bias_decays_during_idle(self): - """Disturbance bias should decay, not update, during HVAC idle.""" - ctrl = SmartFanController(fan_modes=FAN_MODES, **DEFAULT_CONFIG) - _prime_learning_profiles(ctrl) - shadow = MPCController( - learning=ctrl.learning, - deadband=0.3, - min_interval=10, - fan_modes=FAN_MODES, - enabled=True, - ) - - # Prime the disturbance bias with a normal cycle - shadow.evaluate( - current_temp=19.5, - target_temp=20.0, - vtherm_slope=0.5, - hvac_mode="heat", - current_fan="med", - live_decision_fan="med", - is_window_open=False, - minutes_since_change=20.0, - ) - bias_before = shadow._disturbance_bias - - # HVAC idle cycle — should NOT poison the bias - shadow.evaluate( - current_temp=19.5, - target_temp=20.0, - vtherm_slope=-0.5, - hvac_mode="heat", - current_fan="med", - live_decision_fan="med", - is_window_open=False, - is_hvac_idle=True, - minutes_since_change=25.0, - ) - bias_after = shadow._disturbance_bias - - # Bias should have decayed, not grown from the negative slope residual - assert abs(bias_after) <= abs(bias_before) - - -class TestHvacIdleCoolMode: - """HVAC idle also works in cooling mode.""" - - def test_zone_c_blocked_during_idle_cool(self, controller): - """Zone C (recovery in cool): step-up blocked when compressor is off.""" - with patch("time.time", return_value=3600.0): - result = controller.calculate_decision( - current_temp=20.5, - target_temp=20.0, - vtherm_slope=0.0, - hvac_mode="cool", - current_fan="low", - is_hvac_idle=True, - ) - assert result["fan_mode"] == "low" - assert "HVAC idle" in result["reason"] diff --git a/tests/test_learning.py b/tests/test_learning.py index 7ae74d3..0a54d14 100644 --- a/tests/test_learning.py +++ b/tests/test_learning.py @@ -1,11 +1,23 @@ """Tests for ThermalLearning auto-calibration.""" import pytest -import time as time_mod -from unittest.mock import patch -from custom_components.smart_fan_controller.controller import SmartFanController, ThermalLearning +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"] + + +def _build_mpc(learning: ThermalLearning) -> MPCController: + """Build a minimal MPCController for sensor tests.""" + return MPCController( + fan_modes=FAN_MODES, + learning=learning, + deadband=0.2, + min_interval=10, + limit_timeout=15, + ) + class TestThermalLearning: """Test auto-calibration and optimal parameter computation.""" @@ -88,96 +100,27 @@ def test_optimal_limit_timeout_typical_case(self): # With median≈13, optimal should be 13 (direct value, no multiplier) assert optimal["limit_timeout"] == 13 - def test_response_time_validation_rejects_unreasonable_values(self): - """Test that very short (<2 min) or very long (>60 min) response times are filtered.""" - controller = SmartFanController( - fan_modes=["low", "medium", "high"], - deadband=0.2, - min_interval=10, - soft_error=0.3, - hard_error=0.6, - limit_timeout=15 - ) - - # Add enough slope samples to make learning ready - for _ in range(250): - controller.learning.add_slope_sample("medium", 0.3, 0.1) - - base_time = 1000000.0 - - # Test 1: Response time of 1 minute (too short, should be filtered) - with patch('time.time', return_value=base_time): - controller.now = base_time - controller.save_states("medium", "low", 0.5, 0.5, False) # Fan changes - controller.confirm_fan_change() # Confirm: sets _last_change_time = base_time - - events_before = len(controller.learning.response_events) - with patch('time.time', return_value=base_time + 60): # +1 minute - controller.now = base_time + 60 - controller.save_states("medium", "medium", 0.3, 0.3, True) # Slope changes - events_after = len(controller.learning.response_events) - assert events_after == events_before, "1-minute response should be filtered out" - - # Test 2: Response time of 10 minutes (valid, should be recorded) - with patch('time.time', return_value=base_time + 1000): - controller.now = base_time + 1000 - controller.save_states("high", "medium", 0.6, 0.6, False) # Fan changes - controller.confirm_fan_change() # Confirm: sets _last_change_time = base_time + 1000 - - events_before = len(controller.learning.response_events) - with patch('time.time', return_value=base_time + 1000 + 600): # +10 minutes - controller.now = base_time + 1000 + 600 - controller.save_states("high", "high", 0.4, 0.4, True) # Slope changes - events_after = len(controller.learning.response_events) - assert events_after == events_before + 1, "10-minute response should be recorded" - assert controller.learning.response_events[-1][1] == 10.0 - - # Test 3: Response time of 65 minutes (too long, should be filtered) - with patch('time.time', return_value=base_time + 5000): - controller.now = base_time + 5000 - controller.save_states("low", "high", 0.7, 0.7, False) # Fan changes - controller.confirm_fan_change() # Confirm: sets _last_change_time = base_time + 5000 - - events_before = len(controller.learning.response_events) - with patch('time.time', return_value=base_time + 5000 + 3900): # +65 minutes - controller.now = base_time + 5000 + 3900 - controller.save_states("low", "low", 0.2, 0.2, True) # Slope changes - events_after = len(controller.learning.response_events) - assert events_after == events_before, "65-minute response should be filtered out" - def test_learned_dead_time_sensor_reports_median_response(self): """The diagnostic dead-time sensor should expose the median response delay.""" - controller = SmartFanController( - fan_modes=["low", "medium", "high"], - deadband=0.2, - min_interval=10, - soft_error=0.3, - hard_error=0.6, - limit_timeout=15, - ) + learning = ThermalLearning() for response_time in [6.0, 8.0, 8.0, 10.0]: - controller.learning.add_response_event(response_time) + learning.add_response_event(response_time) + mpc = _build_mpc(learning) - sensor = SmartFanLearnedDeadTimeSensor("entry", controller) + sensor = SmartFanLearnedDeadTimeSensor("entry", mpc) assert sensor.native_value == 8.0 def test_effective_timeout_sensor_shows_runtime_timeout(self): """The effective-timeout sensor should expose dead_time × 1.5 once learning is ready.""" - controller = SmartFanController( - fan_modes=["low", "medium", "high"], - deadband=0.2, - min_interval=10, - soft_error=0.3, - hard_error=0.6, - limit_timeout=15, - ) + learning = ThermalLearning() for _ in range(250): - controller.learning.add_slope_sample("medium", 0.3, 0.1) + learning.add_slope_sample("medium", 0.3, 0.1) for response_time in [8.0, 8.0, 9.0]: - controller.learning.add_response_event(response_time) + learning.add_response_event(response_time) + mpc = _build_mpc(learning) - sensor = SmartFanEffectiveTimeoutSensor("entry", controller) + sensor = SmartFanEffectiveTimeoutSensor("entry", mpc) assert sensor.native_value == 12.0 @@ -234,90 +177,3 @@ def test_median_resists_outliers(self): # Median of [0.15]*12 + [1.29]*3 = 0.15 (most values are 0.15) assert slope is not None assert slope == pytest.approx(0.15, abs=0.01) - - def test_setpoint_drop_cooldown_blocks_learning(self): - """Samples should be blocked for 30 min after a setpoint-drop event.""" - controller = SmartFanController( - fan_modes=["low", "medium", "high"], - deadband=0.2, - min_interval=10, - soft_error=0.3, - hard_error=0.6, - limit_timeout=15, - ) - - now = time_mod.time() - - # Trigger a setpoint drop (error < -1°C: target moved away from current) - # In heat mode: error = target - current = 18.0 - 20.5 = -2.5 - with patch("time.time", return_value=now): - controller.last_change_time = now - 1800 - decision = controller.calculate_decision( - current_temp=20.5, - target_temp=18.0, - vtherm_slope=0.5, - hvac_mode="heat", - current_fan="medium", - ) - assert "Setpoint drop" in decision.get("reason", "") - - # 15 min later (within cooldown), ESTABLISHED slope should NOT be learned - with patch("time.time", return_value=now + 900): - controller.last_change_time = now - 1800 # ensure ESTABLISHED + min stable - controller.calculate_decision( - current_temp=19.5, - target_temp=20.0, - vtherm_slope=0.5, - hvac_mode="heat", - current_fan="medium", - ) - assert controller.learning.slope_sample_count() == 0 - - # 35 min later (after cooldown), learning should resume - with patch("time.time", return_value=now + 2100): - controller.last_change_time = now # ~35 min ago, ESTABLISHED + min stable - controller.calculate_decision( - current_temp=19.5, - target_temp=20.0, - vtherm_slope=0.5, - hvac_mode="heat", - current_fan="medium", - ) - assert controller.learning.slope_sample_count() > 0 - - def test_min_stable_duration_blocks_early_samples(self): - """Samples should be blocked when fan mode hasn't been active long enough.""" - controller = SmartFanController( - fan_modes=["low", "medium", "high"], - deadband=0.2, - min_interval=10, - soft_error=0.3, - hard_error=0.6, - limit_timeout=15, - ) - - now = time_mod.time() - - # Set last change to only 16 min ago (ESTABLISHED but < 2 × 10 min dead_time = 20 min) - with patch("time.time", return_value=now): - controller.last_change_time = now - 960 # 16 min ago - controller.calculate_decision( - current_temp=22.0, - target_temp=21.0, - vtherm_slope=0.5, - hvac_mode="heat", - current_fan="medium", - ) - assert controller.learning.slope_sample_count() == 0 - - # Set last change to 25 min ago (> 20 min), should learn - with patch("time.time", return_value=now + 600): - controller.last_change_time = now - 900 # 25 min ago - controller.calculate_decision( - current_temp=22.0, - target_temp=21.0, - vtherm_slope=0.5, - hvac_mode="heat", - current_fan="medium", - ) - assert controller.learning.slope_sample_count() > 0 diff --git a/tests/test_learning_enabled.py b/tests/test_learning_enabled.py deleted file mode 100644 index 7a05444..0000000 --- a/tests/test_learning_enabled.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Test for learning enabled switch functionality.""" -import pytest -import time -from custom_components.smart_fan_controller.controller import SmartFanController - - -class TestLearningEnabledFeature: - """Test the learning_enabled flag functionality.""" - - def test_learning_enabled_default_true(self): - """Test that learning is enabled by default.""" - controller = SmartFanController( - fan_modes=["low", "medium", "high"], - deadband=0.2, - min_interval=10, - soft_error=0.3, - hard_error=0.6, - limit_timeout=15, - ) - assert controller.learning_enabled is True - - def test_learning_can_be_disabled(self): - """Test that learning can be disabled during initialization.""" - controller = SmartFanController( - fan_modes=["low", "medium", "high"], - deadband=0.2, - min_interval=10, - soft_error=0.3, - hard_error=0.6, - limit_timeout=15, - learning_enabled=False, - ) - assert controller.learning_enabled is False - - def test_learning_enabled_prevents_slope_collection(self): - """Test that disabling learning prevents slope sample collection.""" - controller = SmartFanController( - fan_modes=["low", "medium", "high"], - deadband=0.2, - min_interval=10, - soft_error=0.3, - hard_error=0.6, - limit_timeout=15, - learning_enabled=False, - ) - - # Make a decision that would normally collect learning data - decision = controller.calculate_decision( - current_temp=22.0, - target_temp=21.0, - vtherm_slope=0.5, - hvac_mode="heat", - current_fan="medium" - ) - - # Verify no samples were collected - assert controller.learning.slope_sample_count() == 0 - - def test_learning_enabled_allows_slope_collection(self): - """Test that enabling learning allows slope sample collection.""" - controller = SmartFanController( - fan_modes=["low", "medium", "high"], - deadband=0.2, - min_interval=10, - soft_error=0.3, - hard_error=0.6, - limit_timeout=15, - learning_enabled=True, - ) - - # Ensure the fan mode has been active long enough for the stable-duration filter - controller._last_change_time = time.time() - 1800 # 30 min ago - - # Make a decision that would collect learning data - decision = controller.calculate_decision( - current_temp=22.0, - target_temp=21.0, - vtherm_slope=0.5, - hvac_mode="heat", - current_fan="medium" - ) - - # Verify samples were collected (temperature_error = 1.0, which is > 0) - assert controller.learning.slope_sample_count() > 0 - - def test_learning_can_be_toggled_at_runtime(self): - """Test that learning can be toggled on/off at runtime.""" - controller = SmartFanController( - fan_modes=["low", "medium", "high"], - deadband=0.2, - min_interval=10, - soft_error=0.3, - hard_error=0.6, - limit_timeout=15, - learning_enabled=True, - ) - - # Ensure the fan mode has been active long enough for the stable-duration filter - controller._last_change_time = time.time() - 1800 # 30 min ago - - # Collect some samples with learning enabled (positive error so it's not skipped) - controller.calculate_decision( - current_temp=20.0, - target_temp=21.0, - vtherm_slope=0.5, - hvac_mode="heat", - current_fan="medium" - ) - initial_count = controller.learning.slope_sample_count() - assert initial_count > 0 - - # Disable learning and verify no new samples are collected - controller.learning_enabled = False - controller.calculate_decision( - current_temp=20.5, - target_temp=21.0, - vtherm_slope=0.4, - hvac_mode="heat", - current_fan="medium" - ) - assert controller.learning.slope_sample_count() == initial_count - - # Re-enable learning and verify samples are collected again - controller.learning_enabled = True - controller.calculate_decision( - current_temp=20.2, - target_temp=21.0, - vtherm_slope=0.3, - hvac_mode="heat", - current_fan="medium" - ) - assert controller.learning.slope_sample_count() > initial_count diff --git a/tests/test_learning_persistence.py b/tests/test_learning_persistence.py index 98532d8..eb72612 100644 --- a/tests/test_learning_persistence.py +++ b/tests/test_learning_persistence.py @@ -186,7 +186,7 @@ async def test_integration_storage_persistence(self, hass: HomeAssistant): mock_store_instance.async_load.assert_called_once() # Verify controller has restored learning data - controller = hass.data[DOMAIN][entry.entry_id]["controller"] + learning = hass.data[DOMAIN][entry.entry_id]["learning"] # After window cleanup, data should still be there (timestamps are recent) - assert len(controller.learning.slope_samples) > 0 - assert len(controller.learning.response_events) > 0 + assert len(learning.slope_samples) > 0 + assert len(learning.response_events) > 0 diff --git a/tests/test_mpc_controller.py b/tests/test_mpc_controller.py index 0d2981b..4c201d2 100644 --- a/tests/test_mpc_controller.py +++ b/tests/test_mpc_controller.py @@ -1,4 +1,4 @@ -"""Tests for the MPC shadow diagnostics and guardrails.""" +"""Tests for the MPC diagnostics and guardrails.""" import csv from pathlib import Path @@ -6,7 +6,6 @@ import pytest -from custom_components.smart_fan_controller.controller import SmartFanController 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 ( @@ -14,31 +13,36 @@ SmartFanProfileEffectiveSlopeSensor, SmartFanSensor, ) +from custom_components.smart_fan_controller.thermal_learning import ThermalLearning FAN_MODES = ["low", "medium", "high"] -def _build_controller(*, fan_modes=None, min_interval: int = 10) -> SmartFanController: - """Build a SmartFanController with default test parameters.""" - return SmartFanController( - fan_modes=fan_modes or FAN_MODES, +def _build_learning(*, fan_modes=None) -> ThermalLearning: # pylint: disable=unused-argument + """Build a ThermalLearning instance for test use.""" + return ThermalLearning() + + +def _build_mpc(learning: ThermalLearning, *, fan_modes=None, min_interval: int = 10) -> MPCController: + """Build an MPCController with default test parameters.""" + return MPCController( + learning=learning, deadband=0.3, min_interval=min_interval, - soft_error=0.5, - hard_error=0.9, limit_timeout=18, + fan_modes=fan_modes or FAN_MODES, ) -def _prime_learning_profiles(controller: SmartFanController) -> None: +def _prime_learning_profiles(learning: ThermalLearning) -> None: """Feed enough slope samples for all profiles to become ready.""" for _ in range(60): - controller.learning.add_slope_sample("low", 0.25, 0.8, "heat") - controller.learning.add_slope_sample("medium", 0.9, 0.8, "heat") - controller.learning.add_slope_sample("high", 1.5, 0.8, "heat") - controller.learning.add_response_event(8.0) - controller.learning.add_response_event(10.0) - controller.learning.add_response_event(12.0) + learning.add_slope_sample("low", 0.25, 0.8, "heat") + learning.add_slope_sample("medium", 0.9, 0.8, "heat") + learning.add_slope_sample("high", 1.5, 0.8, "heat") + learning.add_response_event(8.0) + learning.add_response_event(10.0) + learning.add_response_event(12.0) def _make_executor_hass() -> MagicMock: @@ -53,43 +57,39 @@ async def run_in_executor(target, *args): return hass -def test_shadow_disabled_reports_disabled_status() -> None: - """Shadow reports disabled status when not enabled.""" - controller = _build_controller() +def test_mpc_idle_for_unsimulated_hvac_modes() -> None: + """MPC reports idle status for unsimulated HVAC modes.""" + learning = ThermalLearning() mpc = MPCController( - learning=controller.learning, + learning=learning, deadband=0.3, min_interval=10, fan_modes=FAN_MODES, - enabled=False, ) result = mpc.evaluate( current_temp=19.2, target_temp=20.0, vtherm_slope=0.4, - hvac_mode="heat", + hvac_mode="off", current_fan="medium", - live_decision_fan="high", is_window_open=False, ) - assert result["mpc_status"] == "Disabled" + assert result["mpc_status"] == "Idle" assert result["mpc_fan_mode"] == "medium" - assert result["mpc_matches_live"] == "disabled" assert result["mpc_would_change_now"] == "no" -def test_shadow_prefers_stronger_fan_when_profiles_support_it() -> None: - """Shadow picks a stronger fan mode when learned profiles support it.""" - controller = _build_controller() - _prime_learning_profiles(controller) +def test_mpc_prefers_stronger_fan_when_profiles_support_it() -> None: + """MPC picks a stronger fan mode when learned profiles support it.""" + learning = ThermalLearning() + _prime_learning_profiles(learning) mpc = MPCController( - learning=controller.learning, + learning=learning, deadband=0.3, min_interval=10, fan_modes=FAN_MODES, - enabled=True, ) result = mpc.evaluate( @@ -98,34 +98,31 @@ def test_shadow_prefers_stronger_fan_when_profiles_support_it() -> None: vtherm_slope=0.25, hvac_mode="heat", current_fan="low", - live_decision_fan="medium", is_window_open=False, minutes_since_change=20.0, ) assert result["mpc_fan_mode"] == "high" - assert result["mpc_matches_live"] == "no" assert result["mpc_would_change_now"] == "yes" assert result["mpc_known_profiles"] == 3 -def test_shadow_holds_superhigh_while_still_below_target() -> None: - """Shadow holds superhigh when temperature is still below target.""" +def test_mpc_holds_superhigh_while_still_below_target() -> None: + """MPC holds superhigh when temperature is still below target.""" fan_modes = ["low", "medium", "high", "superhigh"] - controller = _build_controller(fan_modes=fan_modes) + learning = ThermalLearning() for _ in range(60): - controller.learning.add_slope_sample("low", 0.2, 0.4, "heat") - controller.learning.add_slope_sample("medium", 0.5, 0.4, "heat") - controller.learning.add_slope_sample("high", 0.8, 0.4, "heat") - controller.learning.add_slope_sample("superhigh", 1.0, 0.4, "heat") - controller.learning.add_response_event(30.0) + learning.add_slope_sample("low", 0.2, 0.4, "heat") + learning.add_slope_sample("medium", 0.5, 0.4, "heat") + learning.add_slope_sample("high", 0.8, 0.4, "heat") + learning.add_slope_sample("superhigh", 1.0, 0.4, "heat") + learning.add_response_event(30.0) mpc = MPCController( - learning=controller.learning, + learning=learning, deadband=0.2, min_interval=10, fan_modes=fan_modes, - enabled=True, ) result = mpc.evaluate( @@ -134,7 +131,6 @@ def test_shadow_holds_superhigh_while_still_below_target() -> None: vtherm_slope=0.2, hvac_mode="heat", current_fan="superhigh", - live_decision_fan="superhigh", is_window_open=False, minutes_since_change=40.0, ) @@ -144,16 +140,15 @@ def test_shadow_holds_superhigh_while_still_below_target() -> None: assert "Below target: holding superhigh" in result["mpc_reason"] -def test_shadow_pauses_when_window_is_open() -> None: - """Shadow pauses evaluation when a window is open.""" - controller = _build_controller() - _prime_learning_profiles(controller) +def test_mpc_pauses_when_window_is_open() -> None: + """MPC pauses evaluation when a window is open.""" + learning = ThermalLearning() + _prime_learning_profiles(learning) mpc = MPCController( - learning=controller.learning, + learning=learning, deadband=0.3, min_interval=10, fan_modes=FAN_MODES, - enabled=True, ) result = mpc.evaluate( @@ -162,7 +157,6 @@ def test_shadow_pauses_when_window_is_open() -> None: vtherm_slope=0.2, hvac_mode="heat", current_fan="medium", - live_decision_fan="high", is_window_open=True, minutes_since_change=12.0, ) @@ -173,11 +167,11 @@ def test_shadow_pauses_when_window_is_open() -> None: assert "paused" in result["mpc_reason"] -def test_shadow_sensor_can_clear_to_none() -> None: - """Shadow sensor value can be cleared to None.""" +def test_mpc_sensor_can_clear_to_none() -> None: + """MPC sensor value can be cleared to None.""" sensor = SmartFanSensor( "entry-1", - "MPC Shadow Cost", + "MPC Cost", "mpc_cost", "mpc_cost", None, @@ -185,25 +179,25 @@ def test_shadow_sensor_can_clear_to_none() -> None: "mdi:calculator", ) - sensor.update_from_controller({"mpc_cost": 3.2}) + sensor.update_from_mpc({"mpc_cost": 3.2}) assert sensor.native_value == 3.2 - sensor.update_from_controller({"mpc_cost": None}) + sensor.update_from_mpc({"mpc_cost": None}) assert sensor.native_value is None def test_mpc_profiles_sensor_exposes_per_mode_values() -> None: """MPC profiles sensor exposes per-mode effective slope values.""" - controller = _build_controller() - controller.fan_modes = FAN_MODES + learning = ThermalLearning() + mpc = _build_mpc(learning) for _ in range(15): - controller.learning.add_slope_sample("medium", 0.5, 0.3, "heat") + learning.add_slope_sample("medium", 0.5, 0.3, "heat") for _ in range(8): - controller.learning.add_slope_sample("high", 0.9, 0.3, "heat") + learning.add_slope_sample("high", 0.9, 0.3, "heat") for _ in range(12): - controller.learning.add_slope_sample("low", -0.4, 0.3, "cool") + learning.add_slope_sample("low", -0.4, 0.3, "cool") - sensor = SmartFanMpcProfilesSensor("entry-1", controller, "heat") + sensor = SmartFanMpcProfilesSensor("entry-1", mpc, "heat") attrs = sensor.extra_state_attributes assert sensor.native_value == 1 @@ -218,11 +212,12 @@ def test_mpc_profiles_sensor_exposes_per_mode_values() -> None: def test_profile_effective_slope_sensor_exposes_historizable_state() -> None: """Profile effective slope sensor is historizable with correct attributes.""" - controller = _build_controller() + learning = ThermalLearning() + mpc = _build_mpc(learning) for _ in range(12): - controller.learning.add_slope_sample("high", 0.9, 0.3, "heat") + learning.add_slope_sample("high", 0.9, 0.3, "heat") - sensor = SmartFanProfileEffectiveSlopeSensor("entry-1", controller, "heat", "high") + sensor = SmartFanProfileEffectiveSlopeSensor("entry-1", mpc, "heat", "high") assert sensor.entity_id == "sensor.smart_fan_controller_heat_high_effective_slope" assert sensor.native_value == 0.9 @@ -230,44 +225,9 @@ def test_profile_effective_slope_sensor_exposes_historizable_state() -> None: assert sensor.extra_state_attributes["ready"] is True -def test_live_controller_holds_favorable_slope_until_close_to_target() -> None: - """Live controller holds current fan while slope is favorable near target.""" - controller = _build_controller(fan_modes=["low", "high"]) - controller.previous_slope = 0.0 - controller.now = 0.0 - controller.last_change_time = 0.0 - - with patch("time.time", return_value=3600.0): - result = controller.calculate_decision(19.6, 20.0, 0.5, "heat", "high") - - assert result["fan_mode"] == "high" - assert result["reason"] == "Maintenance: Favorable slope, holding" - - -def test_response_time_learning_skips_window_open_disturbances() -> None: - """Response-time learning skips events disturbed by open window.""" - controller = _build_controller() - for _ in range(250): - controller.learning.add_slope_sample("medium", 0.3, 0.1) - - base_time = 1_000_000.0 - - with patch("time.time", return_value=base_time): - controller.now = base_time - controller.save_states("high", "low", 0.5, 0.5, False, is_window_open=False) - controller.confirm_fan_change() - - events_before = len(controller.learning.response_events) - with patch("time.time", return_value=base_time + 600): - controller.now = base_time + 600 - controller.save_states("high", "high", 0.2, 0.2, True, is_window_open=True) - - assert len(controller.learning.response_events) == events_before - - @pytest.mark.asyncio -async def test_data_collector_records_shadow_columns(tmp_path: Path) -> None: - """Data collector CSV includes shadow-specific columns.""" +async def test_data_collector_records_mpc_columns(tmp_path: Path) -> None: + """Data collector CSV includes MPC-specific columns.""" hass = _make_executor_hass() collector = DataCollector(hass, str(tmp_path), "entry123456") @@ -293,10 +253,9 @@ async def test_data_collector_records_shadow_columns(tmp_path: Path) -> None: force=True, learning_ready=True, dead_time=10.0, - shadow={ + mpc_decision={ "mpc_status": "Ready", "mpc_fan_mode": "high", - "mpc_matches_live": "yes", "mpc_would_change_now": "yes", "mpc_cost": 4.321, "mpc_confidence": 75.0, @@ -321,16 +280,15 @@ async def test_data_collector_records_shadow_columns(tmp_path: Path) -> None: assert row[header.index("mpc_disturbance")] == "-0.25" -def test_shadow_setpoint_drop_forces_lowest_mode() -> None: - """When target drops significantly, shadow should go to the lowest fan mode.""" - controller = _build_controller() - _prime_learning_profiles(controller) +def test_mpc_setpoint_drop_forces_lowest_mode() -> None: + """When target drops significantly, MPC should go to the lowest fan mode.""" + learning = ThermalLearning() + _prime_learning_profiles(learning) mpc = MPCController( - learning=controller.learning, + learning=learning, deadband=0.3, min_interval=10, fan_modes=FAN_MODES, - enabled=True, ) result = mpc.evaluate( @@ -339,7 +297,6 @@ def test_shadow_setpoint_drop_forces_lowest_mode() -> None: vtherm_slope=0.0, hvac_mode="heat", current_fan="high", - live_decision_fan="low", is_window_open=False, minutes_since_change=5.0, ) @@ -349,15 +306,14 @@ def test_shadow_setpoint_drop_forces_lowest_mode() -> None: assert "Setpoint drop" in result["mpc_reason"] -def test_shadow_setpoint_drop_matches_live() -> None: - """Setpoint drop should report match with live when both choose lowest.""" - controller = _build_controller() +def test_mpc_setpoint_drop_reports_would_change() -> None: + """Setpoint drop should report would_change correctly.""" + learning = ThermalLearning() mpc = MPCController( - learning=controller.learning, + learning=learning, deadband=0.3, min_interval=10, fan_modes=FAN_MODES, - enabled=True, ) result = mpc.evaluate( @@ -366,25 +322,23 @@ def test_shadow_setpoint_drop_matches_live() -> None: vtherm_slope=-0.2, hvac_mode="heat", current_fan="medium", - live_decision_fan="low", is_window_open=False, minutes_since_change=15.0, ) assert result["mpc_fan_mode"] == "low" - assert result["mpc_matches_live"] == "yes" + assert result["mpc_would_change_now"] == "yes" -def test_shadow_no_setpoint_drop_when_error_above_threshold() -> None: +def test_mpc_no_setpoint_drop_when_error_above_threshold() -> None: """Normal over-target should NOT trigger setpoint drop.""" - controller = _build_controller() - _prime_learning_profiles(controller) + learning = ThermalLearning() + _prime_learning_profiles(learning) mpc = MPCController( - learning=controller.learning, + learning=learning, deadband=0.3, min_interval=10, fan_modes=FAN_MODES, - enabled=True, ) result = mpc.evaluate( @@ -393,7 +347,6 @@ def test_shadow_no_setpoint_drop_when_error_above_threshold() -> None: vtherm_slope=0.0, hvac_mode="heat", current_fan="high", - live_decision_fan="medium", is_window_open=False, minutes_since_change=15.0, ) @@ -401,16 +354,15 @@ def test_shadow_no_setpoint_drop_when_error_above_threshold() -> None: assert result["mpc_status"] != "Setpoint drop" -def test_shadow_pauses_during_defrost() -> None: - """Shadow should pause when defrost is active, like window-open.""" - controller = _build_controller() - _prime_learning_profiles(controller) +def test_mpc_pauses_during_defrost() -> None: + """MPC should pause when defrost is active, like window-open.""" + learning = ThermalLearning() + _prime_learning_profiles(learning) mpc = MPCController( - learning=controller.learning, + learning=learning, deadband=0.3, min_interval=10, fan_modes=FAN_MODES, - enabled=True, ) result = mpc.evaluate( @@ -419,7 +371,6 @@ def test_shadow_pauses_during_defrost() -> None: vtherm_slope=0.2, hvac_mode="heat", current_fan="high", - live_decision_fan="high", is_window_open=False, is_defrost_active=True, minutes_since_change=12.0, @@ -431,16 +382,15 @@ def test_shadow_pauses_during_defrost() -> None: assert "Defrost" in result["mpc_reason"] -def test_shadow_disturbance_bias_decays_during_defrost() -> None: +def test_mpc_disturbance_bias_decays_during_defrost() -> None: """Disturbance bias should decay, not update, during defrost.""" - controller = _build_controller() - _prime_learning_profiles(controller) + learning = ThermalLearning() + _prime_learning_profiles(learning) mpc = MPCController( - learning=controller.learning, + learning=learning, deadband=0.3, min_interval=10, fan_modes=FAN_MODES, - enabled=True, ) # Prime the disturbance bias with a normal cycle @@ -450,7 +400,6 @@ def test_shadow_disturbance_bias_decays_during_defrost() -> None: vtherm_slope=0.5, hvac_mode="heat", current_fan="medium", - live_decision_fan="medium", is_window_open=False, minutes_since_change=20.0, ) @@ -463,7 +412,6 @@ def test_shadow_disturbance_bias_decays_during_defrost() -> None: vtherm_slope=-1.0, hvac_mode="heat", current_fan="high", - live_decision_fan="high", is_window_open=False, is_defrost_active=True, minutes_since_change=25.0, @@ -474,83 +422,23 @@ def test_shadow_disturbance_bias_decays_during_defrost() -> None: assert abs(bias_after) <= abs(bias_before) -def test_production_mode_property_defaults_to_false() -> None: - """Production mode should default to False (shadow only).""" - controller = _build_controller() - mpc = MPCController( - learning=controller.learning, - deadband=0.3, - min_interval=10, - fan_modes=FAN_MODES, - enabled=True, - ) - - assert mpc.production_mode is False - - -def test_production_mode_can_be_toggled() -> None: - """Production mode can be enabled and disabled.""" - controller = _build_controller() - mpc = MPCController( - learning=controller.learning, - deadband=0.3, - min_interval=10, - fan_modes=FAN_MODES, - enabled=True, - ) - - mpc.production_mode = True - assert mpc.production_mode is True - - mpc.production_mode = False - assert mpc.production_mode is False - - -def test_shadow_always_evaluates_regardless_of_production_mode() -> None: - """Shadow evaluates and produces results whether production mode is on or off.""" - controller = _build_controller() - _prime_learning_profiles(controller) - mpc = MPCController( - learning=controller.learning, - deadband=0.3, - min_interval=10, - fan_modes=FAN_MODES, - enabled=True, - ) - - for prod_mode in (False, True): - mpc.production_mode = prod_mode - result = mpc.evaluate( - current_temp=19.0, - target_temp=20.0, - vtherm_slope=0.25, - hvac_mode="heat", - current_fan="low", - live_decision_fan="low", - is_window_open=False, - minutes_since_change=20.0, - ) - assert result["mpc_status"] != "Disabled" - assert result["mpc_fan_mode"] is not 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.""" - controller = _build_controller(fan_modes=["silent", "low", "med", "high", "superhigh"]) + learning = ThermalLearning() + fan_modes = ["silent", "low", "med", "high", "superhigh"] # Create inverted profiles: silent > med (the real-world bug) - controller.learning.set_mode_effective_slope("silent", "heat", 0.53) - controller.learning.set_mode_effective_slope("low", "heat", 0.0) - controller.learning.set_mode_effective_slope("med", "heat", 0.45) - controller.learning.set_mode_effective_slope("high", "heat", 0.96) - controller.learning.set_mode_effective_slope("superhigh", "heat", 1.35) + 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) + learning.set_mode_effective_slope("high", "heat", 0.96) + learning.set_mode_effective_slope("superhigh", "heat", 1.35) mpc = MPCController( - learning=controller.learning, + learning=learning, deadband=0.3, min_interval=10, fan_modes=["silent", "low", "med", "high", "superhigh"], - enabled=True, ) monotone = mpc.build_monotone_slopes(["silent", "low", "med", "high", "superhigh"], "heat") @@ -566,13 +454,12 @@ def test_monotone_constraint_enforces_ordering() -> None: def test_monotone_constraint_returns_none_for_partial_profiles() -> None: """On a fresh install with incomplete profiles, monotone should return None.""" - controller = _build_controller() + learning = ThermalLearning() mpc = MPCController( - learning=controller.learning, + learning=learning, deadband=0.3, min_interval=10, fan_modes=FAN_MODES, - enabled=True, ) # No profiles learned yet result = mpc.build_monotone_slopes(FAN_MODES, "heat") @@ -581,18 +468,17 @@ def test_monotone_constraint_returns_none_for_partial_profiles() -> None: def test_monotone_constraint_noop_when_already_ordered() -> None: """When profiles are already monotone, the constraint should not change values.""" - controller = _build_controller() + learning = ThermalLearning() - controller.learning.set_mode_effective_slope("low", "heat", 0.15) - controller.learning.set_mode_effective_slope("medium", "heat", 0.5) - controller.learning.set_mode_effective_slope("high", "heat", 1.0) + learning.set_mode_effective_slope("low", "heat", 0.15) + learning.set_mode_effective_slope("medium", "heat", 0.5) + learning.set_mode_effective_slope("high", "heat", 1.0) mpc = MPCController( - learning=controller.learning, + learning=learning, deadband=0.3, min_interval=10, fan_modes=FAN_MODES, - enabled=True, ) monotone = mpc.build_monotone_slopes(FAN_MODES, "heat") diff --git a/tests/test_phase_detection.py b/tests/test_phase_detection.py deleted file mode 100644 index fd3025c..0000000 --- a/tests/test_phase_detection.py +++ /dev/null @@ -1,519 +0,0 @@ -"""Tests for phase detection, adaptive timeout, descent path, and window filtering.""" -# pylint: disable=redefined-outer-name -import pytest -import time -from unittest.mock import patch - -from custom_components.smart_fan_controller.controller import SmartFanController -from custom_components.smart_fan_controller.thermal_learning import ThermalLearning -from custom_components.smart_fan_controller.const import ( - DEFAULT_DEADBAND, - DEFAULT_MIN_INTERVAL, - DEFAULT_SOFT_ERROR, - DEFAULT_HARD_ERROR, - PHASE_DEAD_TIME, - PHASE_TRANSIENT, - PHASE_ESTABLISHED, - DEFAULT_DEAD_TIME, - DEAD_TIME_SAFETY_FACTOR, - MIN_SAMPLES_LEARNING, -) - -FAN_MODES = ["low", "medium", "high", "turbo"] -DEFAULT_CONFIG = { - "deadband": DEFAULT_DEADBAND, - "min_interval": DEFAULT_MIN_INTERVAL, - "soft_error": DEFAULT_SOFT_ERROR, - "hard_error": DEFAULT_HARD_ERROR, -} - - -@pytest.fixture -def controller(): - """Build a SmartFanController with default test configuration.""" - return SmartFanController(fan_modes=FAN_MODES, **DEFAULT_CONFIG) - - -class TestPhaseDetection: - """Tests for _detect_phase with default and learned dead times.""" - - def test_dead_time_phase_default(self, controller): - """Within default dead time (10 min), phase should be DEAD_TIME.""" - assert controller.detect_phase(5.0) == PHASE_DEAD_TIME - - def test_transient_phase_default(self, controller): - """Between dead time and dead_time * 1.5, phase should be TRANSIENT.""" - assert controller.detect_phase(12.0) == PHASE_TRANSIENT - - def test_established_phase_default(self, controller): - """After dead_time * 1.5, phase should be ESTABLISHED.""" - assert controller.detect_phase(16.0) == PHASE_ESTABLISHED - - def test_phase_boundary_dead_to_transient(self, controller): - """At exactly the dead time boundary, phase transitions to TRANSIENT.""" - # DEFAULT_DEAD_TIME = 10.0; at 10.0 it's no longer < 10 → TRANSIENT - assert controller.detect_phase(DEFAULT_DEAD_TIME) == PHASE_TRANSIENT - - def test_phase_boundary_transient_to_established(self, controller): - """At exactly dead_time * 1.5, phase transitions to ESTABLISHED.""" - threshold = DEFAULT_DEAD_TIME * DEAD_TIME_SAFETY_FACTOR - assert controller.detect_phase(threshold) == PHASE_ESTABLISHED - - def test_phase_with_learned_dead_time(self): - """When learning provides a dead time, phases use that value.""" - controller = SmartFanController(fan_modes=FAN_MODES, **DEFAULT_CONFIG) - # Populate learning with enough data and response events - for _ in range(MIN_SAMPLES_LEARNING): - controller.learning.add_slope_sample("medium", 0.4, 0.2) - # Dead time of 6 minutes (all events at 6 min) - for _ in range(5): - controller.learning.add_response_event(6.0) - assert controller.learning.is_ready() - - # 4 min < 6 → DEAD_TIME - assert controller.detect_phase(4.0) == PHASE_DEAD_TIME - # 7 min → between 6 and 9 → TRANSIENT - assert controller.detect_phase(7.0) == PHASE_TRANSIENT - # 10 min → > 9 → ESTABLISHED - assert controller.detect_phase(10.0) == PHASE_ESTABLISHED - - -class TestAdaptiveTimeout: - """Tests for _get_effective_timeout.""" - - def test_default_timeout_without_learning(self, controller): - """Without learning data, timeout falls back to _limit_timeout.""" - assert controller.get_effective_timeout() == controller.limit_timeout - - def test_adaptive_timeout_with_learning(self): - """With learning data, timeout = max(min_interval, dead_time * 1.5).""" - controller = SmartFanController(fan_modes=FAN_MODES, **DEFAULT_CONFIG) - for _ in range(MIN_SAMPLES_LEARNING): - controller.learning.add_slope_sample("medium", 0.4, 0.2) - for _ in range(5): - controller.learning.add_response_event(8.0) # dead time = 8 min - assert controller.learning.is_ready() - - expected = max(DEFAULT_MIN_INTERVAL, 8.0 * DEAD_TIME_SAFETY_FACTOR) # max(10, 12) = 12 - assert controller.get_effective_timeout() == expected - - def test_adaptive_timeout_floors_to_min_interval(self): - """When learned dead_time * 1.5 < min_interval, min_interval wins.""" - controller = SmartFanController(fan_modes=FAN_MODES, **DEFAULT_CONFIG) - for _ in range(MIN_SAMPLES_LEARNING): - controller.learning.add_slope_sample("medium", 0.4, 0.2) - for _ in range(5): - controller.learning.add_response_event(4.0) # dead time = 4 min; 4*1.5=6 < 10 - assert controller.learning.is_ready() - - assert controller.get_effective_timeout() == DEFAULT_MIN_INTERVAL - - def test_adaptive_timeout_disabled_when_learning_off(self): - """When learning is disabled, always use static limit_timeout.""" - controller = SmartFanController( - fan_modes=FAN_MODES, learning_enabled=False, **DEFAULT_CONFIG - ) - assert controller.get_effective_timeout() == controller.limit_timeout - - -class TestDeadTimePatienceInZoneC: - """Zone C must not boost during the dead-time phase.""" - - def test_zone_c_patience_during_dead_time(self): - """During DEAD_TIME, zone C should report patience, not boost.""" - controller = SmartFanController(fan_modes=FAN_MODES, **DEFAULT_CONFIG) - start_time = 1000.0 - controller.last_change_time = start_time - controller.previous_slope = 0.0 - - # 5 minutes after change → DEAD_TIME (< DEFAULT_DEAD_TIME=10) - with patch("time.time", return_value=start_time + 5 * 60): - result = controller.calculate_decision( - current_temp=19.5, # error = 0.5 > soft_error (0.3) - target_temp=20.0, - vtherm_slope=0.0, # slope unchanged - hvac_mode="heat", - current_fan="low", - ) - assert result["fan_mode"] == "low" - assert "Patience: Waiting for thermal response" in result["reason"] - - def test_zone_c_boosts_after_dead_time(self): - """After dead time expires and slope hasn't improved, zone C should boost. - - With error=0.5 > 0.75*hard_error, the controller now requests +2 steps - for faster recovery (low → high). - """ - controller = SmartFanController(fan_modes=FAN_MODES, **DEFAULT_CONFIG) - start_time = 1000.0 - controller.last_change_time = start_time - controller.previous_slope = 0.0 - - # 16 minutes → ESTABLISHED phase, interval_expired - with patch("time.time", return_value=start_time + 16 * 60): - result = controller.calculate_decision( - current_temp=19.5, # error 0.5 > soft_error - target_temp=20.0, - vtherm_slope=0.0, - hvac_mode="heat", - current_fan="low", - ) - assert result["fan_mode"] == "high" - assert "recovery" in result["reason"].lower() - - def test_zone_c_boosts_in_transient_without_waiting_for_effective_timeout(self): - """Once DEAD_TIME ends, zone C should boost even if no fresh slope_change arrives. - - With error=0.5 > 0.75*hard_error, the controller now requests +2 steps - for faster recovery (low → high). - """ - controller = SmartFanController(fan_modes=FAN_MODES, **DEFAULT_CONFIG) - start_time = 1000.0 - controller.last_change_time = start_time - controller.previous_slope = 0.2 - controller.slope_at_last_change = 1.5 - - # 12 minutes -> TRANSIENT with default timings: - # - dead time has ended (10 min) - # - effective timeout has NOT expired yet (15 min) - # - slope hasn't improved vs the last fan-change snapshot - with patch("time.time", return_value=start_time + 12 * 60): - result = controller.calculate_decision( - current_temp=19.5, - target_temp=20.0, - vtherm_slope=0.2, - hvac_mode="heat", - current_fan="low", - ) - assert result["fan_mode"] == "high" - assert "recovery" in result["reason"].lower() - - -class TestDescentPathZoneD: - """Zone D should allow descent when slope is strongly favorable.""" - - def test_descent_with_favorable_slope(self, controller): - """In zone D, strong favorable slope in established phase → reduce.""" - start_time = 1000.0 - controller.last_change_time = start_time - 20 * 60 # 20 min ago - controller.previous_slope = 0.5 - - with patch("time.time", return_value=start_time): - result = controller.calculate_decision( - current_temp=19.9, # error 0.1 in heat (0 < 0.1 < 0.3) - target_temp=20.0, - vtherm_slope=0.8, # effective_slope = 0.8 > 0.2 threshold - hvac_mode="heat", - current_fan="high", - ) - assert result["fan_mode"] == "medium" - assert "Strong favorable slope" in result["reason"] - - def test_no_descent_during_dead_time(self): - """Zone D descent must not fire during dead time, even with favorable slope.""" - controller = SmartFanController(fan_modes=FAN_MODES, **DEFAULT_CONFIG) - start_time = 1000.0 - controller.last_change_time = start_time # just changed - controller.previous_slope = 0.5 - - # 3 min → DEAD_TIME - with patch("time.time", return_value=start_time + 3 * 60): - result = controller.calculate_decision( - current_temp=19.9, - target_temp=20.0, - vtherm_slope=0.8, - hvac_mode="heat", - current_fan="high", - ) - # Should still be high; observing inertia (phase is dead time, interval not expired) - assert result["fan_mode"] == "high" - assert "Observing inertia" in result["reason"] - - def test_zone_d_proactive_increase_only_in_established(self): - """Zone D proactive increase ('Stable away from target') requires ESTABLISHED phase.""" - controller = SmartFanController(fan_modes=FAN_MODES, **DEFAULT_CONFIG) - start_time = 1000.0 - controller.last_change_time = start_time - controller.previous_slope = -0.01 - - # 12 minutes → TRANSIENT (between 10 and 15), effective_timeout = 15 - with patch("time.time", return_value=start_time + 12 * 60): - result = controller.calculate_decision( - current_temp=19.9, - target_temp=20.0, - vtherm_slope=-0.0, # stable, no drift - hvac_mode="heat", - current_fan="low", - ) - # TRANSIENT phase + interval not expired → observing inertia - assert result["fan_mode"] == "low" - assert "Observing inertia" in result["reason"] - - -class TestCoolModePhaseDetection: - """Cool-mode equivalents for phase-gated zone behavior.""" - - def test_zone_c_patience_during_dead_time_cool(self): - """In cool mode, zone C should report patience during DEAD_TIME.""" - controller = SmartFanController(fan_modes=FAN_MODES, **DEFAULT_CONFIG) - start_time = 1000.0 - controller.last_change_time = start_time - controller.previous_slope = 0.0 - - # 5 minutes → DEAD_TIME, error = 0.4 > soft_error (cool: 20.4 - 20.0 = 0.4) - with patch("time.time", return_value=start_time + 5 * 60): - result = controller.calculate_decision( - current_temp=20.4, - target_temp=20.0, - vtherm_slope=0.0, - hvac_mode="cool", - current_fan="low", - ) - assert result["fan_mode"] == "low" - assert "Patience: Waiting for thermal response" in result["reason"] - - def test_zone_d_descent_cool(self): - """In cool mode, strong negative slope (favorable) in established phase → reduce.""" - controller = SmartFanController(fan_modes=FAN_MODES, **DEFAULT_CONFIG) - controller.last_change_time = controller.now - 20 * 60 # 20 min ago - controller.previous_slope = -0.5 - - result = controller.calculate_decision( - current_temp=20.1, # error 0.1 in cool (zone D) - target_temp=20.0, - vtherm_slope=-0.8, # effective_slope = 0.8 (negated for cool) > 0.2 - hvac_mode="cool", - current_fan="high", - ) - assert result["fan_mode"] == "medium" - assert "Strong favorable slope" in result["reason"] - - def test_zone_c_boosts_after_dead_time_cool(self): - """In cool mode, zone C boosts after dead time with no improvement. - - With error=0.5 > 0.75*hard_error, the controller now requests +2 steps - for faster recovery (low → high). - """ - controller = SmartFanController(fan_modes=FAN_MODES, **DEFAULT_CONFIG) - start_time = 1000.0 - controller.last_change_time = start_time - controller.previous_slope = 0.0 - - # 16 minutes → ESTABLISHED phase, interval_expired - with patch("time.time", return_value=start_time + 16 * 60): - result = controller.calculate_decision( - current_temp=20.5, # error 0.5 > soft_error - target_temp=20.0, - vtherm_slope=0.0, - hvac_mode="cool", - current_fan="low", - ) - assert result["fan_mode"] == "high" - assert "recovery" in result["reason"].lower() - - def test_zone_c_boosts_in_transient_without_waiting_for_effective_timeout_cool(self): - """Cool mode should also relaunch once dead time ends, even during TRANSIENT. - - With error=0.5 > 0.75*hard_error, the controller now requests +2 steps - for faster recovery (low → high). - """ - controller = SmartFanController(fan_modes=FAN_MODES, **DEFAULT_CONFIG) - start_time = 1000.0 - controller.last_change_time = start_time - controller.previous_slope = -0.2 - controller.slope_at_last_change = 1.5 - - with patch("time.time", return_value=start_time + 12 * 60): - result = controller.calculate_decision( - current_temp=20.5, - target_temp=20.0, - vtherm_slope=-0.2, - hvac_mode="cool", - current_fan="low", - ) - assert result["fan_mode"] == "high" - assert "recovery" in result["reason"].lower() - - -class TestWindowOpenFiltering: - """Window-open state should prevent learning data collection.""" - - def test_learning_skips_samples_when_window_open(self, controller): - """With is_window_open=True, no learning samples should be collected.""" - controller.calculate_decision( - current_temp=22.0, - target_temp=21.0, - vtherm_slope=0.5, - hvac_mode="heat", - current_fan="medium", - is_window_open=True, - ) - assert controller.learning.slope_sample_count() == 0 - - def test_learning_collects_when_window_closed(self, controller): - """With is_window_open=False (default), samples are collected normally.""" - controller.last_change_time = time.time() - 1800 # 30 min ago - - controller.calculate_decision( - current_temp=22.0, - target_temp=21.0, - vtherm_slope=0.5, - hvac_mode="heat", - current_fan="medium", - is_window_open=False, - ) - assert controller.learning.slope_sample_count() > 0 - - def test_window_open_does_not_affect_decision(self, controller): - """Window-open only affects learning, not the decision itself.""" - result = controller.calculate_decision( - current_temp=19.0, - target_temp=20.0, - vtherm_slope=0.0, - hvac_mode="heat", - current_fan="low", - is_window_open=True, - ) - # Emergency still fires even with window open - assert result["fan_mode"] == "turbo" - assert "Emergency" in result["reason"] - - -class TestLinearProjectionClamp: - """Tests for the clamped linear temperature projection.""" - - def test_linear_projection_positive_slope(self, controller): - """Positive slope projects temperature increase.""" - controller.previous_slope = 0.0 - proj = controller.compute_temperature_projection(20.0, 1.2) - # 20.0 + 1.2 * (10/60) = 20.0 + 0.2 = 20.2 - assert proj == pytest.approx(20.2, abs=0.01) - - def test_linear_projection_negative_slope(self, controller): - """Negative slope projects temperature decrease.""" - controller.previous_slope = 0.0 - proj = controller.compute_temperature_projection(20.0, -1.8) - # 20.0 + (-1.8) * (10/60) = 20.0 - 0.3 = 19.7 - assert proj == pytest.approx(19.7, abs=0.01) - - def test_projection_clamp_maximum(self, controller): - """Extreme positive slope is clamped to +1°C.""" - proj = controller.compute_temperature_projection(20.0, 30.0) - # Raw: 20.0 + 30 * 0.1667 = 25.0 → clamped to 21.0 - assert proj == 21.0 - - def test_projection_clamp_minimum(self, controller): - """Extreme negative slope is clamped to -1°C.""" - proj = controller.compute_temperature_projection(20.0, -30.0) - # Raw: 20.0 - 5.0 = 15.0 → clamped to 19.0 - assert proj == 19.0 - - -class TestPerModeLearningProfile: - """Tests for per-fan-mode effective slope profiles.""" - - def test_mode_slope_heating(self): - """In heating, effective slope = raw slope (positive = towards target).""" - learning = ThermalLearning() - for _ in range(15): - learning.add_slope_sample("medium", 0.5, 0.3, hvac_mode="heat") - result = learning.get_mode_effective_slope("medium", "heat") - assert result is not None - assert result == pytest.approx(0.5, abs=0.01) - - def test_mode_slope_cooling(self): - """In cooling, effective slope = -raw slope (negative raw → positive effective).""" - learning = ThermalLearning() - for _ in range(15): - learning.add_slope_sample("high", -0.8, 0.3, hvac_mode="cool") - result = learning.get_mode_effective_slope("high", "cool") - assert result is not None - assert result == pytest.approx(0.8, abs=0.01) - - def test_mode_slope_insufficient_samples(self): - """Returns None when fewer than MIN_MODE_PROFILE_SAMPLES.""" - learning = ThermalLearning() - for _ in range(5): - learning.add_slope_sample("low", 0.3, 0.2, hvac_mode="heat") - assert learning.get_mode_effective_slope("low", "heat") is None - - def test_mode_slope_filters_by_hvac(self): - """Profiles for 'heat' and 'cool' are independent.""" - learning = ThermalLearning() - for _ in range(15): - learning.add_slope_sample("medium", 0.5, 0.3, hvac_mode="heat") - for _ in range(15): - learning.add_slope_sample("medium", -0.6, 0.3, hvac_mode="cool") - - heat_slope = learning.get_mode_effective_slope("medium", "heat") - cool_slope = learning.get_mode_effective_slope("medium", "cool") - - assert heat_slope == pytest.approx(0.5, abs=0.01) - assert cool_slope == pytest.approx(0.6, abs=0.01) - - def test_mode_slope_filters_window_open(self): - """Samples with is_window_open=True are not included in profiles.""" - learning = ThermalLearning() - for _ in range(15): - learning.add_slope_sample("medium", 0.5, 0.3, hvac_mode="heat", is_window_open=True) - assert learning.get_mode_effective_slope("medium", "heat") is None - - def test_mode_slope_filters_setpoint_drop(self): - """Night mode samples (error < -1°C) are not included in profiles.""" - learning = ThermalLearning() - for _ in range(15): - learning.add_slope_sample("medium", -0.5, -2.0, hvac_mode="heat") - assert learning.get_mode_effective_slope("medium", "heat") is None - - -class TestBackwardCompatibility: - """Test that old 3-tuple slope samples are migrated correctly.""" - - def test_from_dict_old_format(self): - """Old data with 3-tuples should be upgraded to 4-tuples with 'unknown' hvac_mode.""" - ts = time.time() - 3600 # 1 hour ago - old_data = { - "slope_samples": [(ts, "medium", 0.5), (ts, "high", 0.8)], - "response_events": [(ts, 12.5)], - "slope_count": 2, - "slope_mean": 0.65, - "slope_M2": 0.045, - "slope_max": 0.8, - } - restored = ThermalLearning.from_dict(old_data) - - assert len(restored.slope_samples) == 2 - # Each sample should now be a 4-tuple - for sample in restored.slope_samples: - assert len(sample) == 4 - assert sample[3] == "unknown" - - def test_from_dict_new_format(self): - """New data with 4-tuples round-trips correctly.""" - ts = time.time() - 3600 - new_data = { - "slope_samples": [(ts, "medium", 0.5, "heat"), (ts, "high", -0.8, "cool")], - "response_events": [(ts, 12.5)], - "slope_count": 2, - "slope_mean": 0.65, - "slope_M2": 0.045, - "slope_max": 0.8, - } - restored = ThermalLearning.from_dict(new_data) - - assert len(restored.slope_samples) == 2 - assert restored.slope_samples[0][3] == "heat" - assert restored.slope_samples[1][3] == "cool" - - def test_round_trip_preserves_hvac_mode(self): - """to_dict → from_dict preserves the 4-tuple format.""" - learning = ThermalLearning() - learning.add_slope_sample("medium", 0.5, 0.3, hvac_mode="heat") - learning.add_slope_sample("high", -0.8, 0.3, hvac_mode="cool") - - data = learning.to_dict() - restored = ThermalLearning.from_dict(data) - - for original, restored_s in zip(learning.slope_samples, restored.slope_samples): - assert len(restored_s) == 4 - assert original[3] == restored_s[3] # hvac_mode preserved diff --git a/tests/test_services.py b/tests/test_services.py index 40003cb..b5e48ce 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -4,12 +4,9 @@ from unittest.mock import AsyncMock, MagicMock from custom_components.smart_fan_controller import _apply_optimal_parameters -from custom_components.smart_fan_controller.controller import SmartFanController from custom_components.smart_fan_controller.thermal_learning import ThermalLearning from custom_components.smart_fan_controller.const import ( CONF_DEADBAND, - CONF_SOFT_ERROR, - CONF_HARD_ERROR, CONF_LIMIT_TIMEOUT, MIN_SAMPLES_LEARNING, ) @@ -26,20 +23,6 @@ def _make_ready_learning() -> ThermalLearning: return learning -def _make_controller(learning: ThermalLearning | None = None) -> SmartFanController: - controller = SmartFanController( - fan_modes=["low", "medium", "high"], - deadband=0.2, - min_interval=10, - soft_error=0.3, - hard_error=0.6, - limit_timeout=15, - ) - if learning is not None: - controller.learning = learning - return controller - - class TestApplyLearnedSettings: """Unit-level tests for the apply_learned_settings service logic.""" @@ -47,10 +30,9 @@ class TestApplyLearnedSettings: async def test_apply_learned_settings_calls_helper_when_ready(self): """When learning is ready, apply_learned_settings must call _apply_optimal_parameters.""" learning = _make_ready_learning() - controller = _make_controller(learning) entry = MagicMock() - entry.data = {CONF_DEADBAND: 0.2, CONF_SOFT_ERROR: 0.3, CONF_HARD_ERROR: 0.6, CONF_LIMIT_TIMEOUT: 15} + entry.data = {CONF_DEADBAND: 0.2, CONF_LIMIT_TIMEOUT: 15} applied_data = {} @@ -59,38 +41,29 @@ async def fake_apply(hass, entry, optimal): # pylint: disable=unused-argument hass = MagicMock() - # Inline the service logic (mirrors __init__.py apply_learned_settings). - # Call fake_apply directly: the real _apply_optimal_parameters is tested separately. - if controller.learning.is_ready(): - optimal = controller.learning.compute_optimal_parameters() + if learning.is_ready(): + optimal = learning.compute_optimal_parameters() if optimal: await fake_apply(hass, entry, optimal) assert "deadband" in applied_data - assert "soft_error" in applied_data - assert "hard_error" 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.""" - controller = _make_controller() - assert not controller.learning.is_ready() + learning = ThermalLearning() + assert not learning.is_ready() - # Guard that mirrors what the service does before calling the helper - should_apply = controller.learning.is_ready() + should_apply = learning.is_ready() assert should_apply is False def test_optimal_parameters_satisfy_threshold_constraints(self): - """Learned deadband < soft_error < hard_error must always hold.""" + """Learned deadband must be positive and reasonable.""" learning = _make_ready_learning() optimal = learning.compute_optimal_parameters() - assert optimal["deadband"] < optimal["soft_error"], ( - f"deadband ({optimal['deadband']}) must be < soft_error ({optimal['soft_error']})" - ) - assert optimal["soft_error"] < optimal["hard_error"], ( - f"soft_error ({optimal['soft_error']}) must be < hard_error ({optimal['hard_error']})" - ) + 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.""" @@ -100,9 +73,9 @@ 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_SOFT_ERROR: 0.3, CONF_HARD_ERROR: 0.6, CONF_LIMIT_TIMEOUT: 15} + entry.data = {CONF_DEADBAND: 0.2, CONF_LIMIT_TIMEOUT: 15} - optimal = {"deadband": 0.25, "soft_error": 0.4, "hard_error": 0.7, "limit_timeout": 12} + optimal = {"deadband": 0.25, "limit_timeout": 12} asyncio.get_event_loop().run_until_complete(_apply_optimal_parameters(hass, entry, optimal)) @@ -111,8 +84,6 @@ def test_apply_optimal_sets_learning_auto_applied_flag(self): updated_data = call_args[1]["data"] assert updated_data.get("learning_auto_applied") is True assert updated_data[CONF_DEADBAND] == 0.25 - assert updated_data[CONF_SOFT_ERROR] == 0.4 - assert updated_data[CONF_HARD_ERROR] == 0.7 assert updated_data[CONF_LIMIT_TIMEOUT] == 12 diff --git a/tests/test_system.py b/tests/test_system.py deleted file mode 100644 index 66b00e7..0000000 --- a/tests/test_system.py +++ /dev/null @@ -1,506 +0,0 @@ -"""Tests for SmartFanController system constraints and edge cases.""" -# pylint: disable=redefined-outer-name -import pytest - -from unittest.mock import patch -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from homeassistant.helpers.event import async_track_state_change_event - -from custom_components.smart_fan_controller.const import DOMAIN -from custom_components.smart_fan_controller.controller import SmartFanController -from custom_components.smart_fan_controller.thermal_learning import ThermalLearning -from custom_components.smart_fan_controller.const import ( - DEFAULT_DEADBAND, - DEFAULT_MIN_INTERVAL, - DEFAULT_SOFT_ERROR, - DEFAULT_HARD_ERROR, -) - -# Standard configuration for tests -FAN_MODES = ["low", "medium", "high", "turbo"] -DEFAULT_CONFIG = { - "deadband": DEFAULT_DEADBAND, - "min_interval": DEFAULT_MIN_INTERVAL, - "soft_error": DEFAULT_SOFT_ERROR, - "hard_error": DEFAULT_HARD_ERROR, -} - -@pytest.fixture -def controller(): - """Fixture to provide a clean controller instance.""" - return SmartFanController(fan_modes=FAN_MODES, **DEFAULT_CONFIG) - -class TestSmartFanControllerSystem: - """System-level tests: timing, boundaries, and mode switching.""" - - def test_min_interval_protection(self, controller): - """Test that speed doesn't change before min_interval unless forced.""" - start_time = 60 - controller.last_change_time = 60 - - # 1. First change at T=0 - with patch('time.time', return_value=start_time): - result = controller.calculate_decision(20.0, 20.0, 0.0, "heat", "low") - assert result["fan_mode"] == "low" - - # 2. Try to change at T + 2 minutes (too early) - # Even with a significant error, it should stay 'low' - with patch('time.time', return_value=start_time + 2*60): - result = controller.calculate_decision(19.5, 20.0, -0.2, "heat", "low") - assert result["fan_mode"] == "low" - - # 3. Try to change at T + 10 minutes - # Fan speed should be allowed to change now - # With error=0.5 > 0.75*hard_error, controller steps up +2 (low → high) - with patch('time.time', return_value=start_time + 10*60): - result = controller.calculate_decision(19.5, 20.0, -0.5, "heat", "low") - assert result["fan_mode"] == "high" - - def test_emergency_overrides_interval(self, controller): - """Test that Emergency bypasses the min_interval timer.""" - start_time = 60 - controller.last_change_time = 60 - - # 1. First change at T=0 - with patch('time.time', return_value=start_time): - controller.calculate_decision(19.9, 20.0, 0.0, "heat", "low") - - # 2. Major error at T + 1 minute - with patch('time.time', return_value=start_time + 60): - result = controller.calculate_decision(18.0, 20.0, 0.0, "heat", "low") - - # Should bypass timer because it's an Emergency - assert result["fan_mode"] == "turbo" - assert "Emergency" in result["reason"] - - def test_index_boundaries_low(self, controller): - """Ensure no crash when trying to go below the first fan mode.""" - # Case: Overheating in heat mode while already at 'low' - result = controller.calculate_decision(21.0, 20.0, 0.1, "heat", "low") - - assert result["fan_mode"] == "low" - assert "Over-target" in result["reason"] - - def test_index_boundaries_high(self, controller): - """Ensure no crash when trying to go above the last fan mode.""" - # Case: Drifting away while already at 'turbo' - result = controller.calculate_decision(19.9, 20.0, -0.5, "heat", "turbo") - - assert result["fan_mode"] == "turbo" - assert "Maintenance" in result["reason"] - - def test_hvac_mode_switch_mid_operation(self, controller): - """Test switching from Heat to Cool mode maintains logic integrity.""" - # 1. Operating in Heat: error=0.5 > soft_error, interval expired → Strong recovery - # With error=0.5 > 0.75*hard_error, controller steps up +2 (low → high) - result = controller.calculate_decision(19.5, 20.0, 0.2, "heat", "low") - assert controller.last_hvac_mode == "heat" - assert "Strong recovery: Drop predicted " in result["reason"] - assert result["fan_mode"] == "high" - - # 2. Instant switch to Cool: hvac_mode change resets _previous_slope and - # _thermal_acceleration so no spurious slope_change is triggered. - # With interval still expired and error=-0.5 < -deadband, Zone E fires. - result = controller.calculate_decision(19.5, 20.0, 0.2, "cool", "medium") - assert controller.last_hvac_mode == "cool" - # _thermal_acceleration was reset; slope_change=False; interval_expired=True - # Zone E: current_temperature_error=-0.5 < -deadband → reduce speed - assert "Over-target: Reducing speed" in result["reason"] - assert result["fan_mode"] == "low" - - def test_hvac_mode_off_holds_fan(self, controller): - """Test that hvac_mode='off' returns immediately without changing fan speed.""" - result = controller.calculate_decision(19.0, 20.0, -0.5, "off", "high") - assert result["fan_mode"] == "high" - assert "off" in result["reason"] - assert result["temperature_error"] == 0.0 - - def test_hvac_mode_dry_holds_fan(self, controller): - """Test that hvac_mode='dry' returns immediately without changing fan speed.""" - result = controller.calculate_decision(22.0, 20.0, 0.3, "dry", "medium") - assert result["fan_mode"] == "medium" - assert "dry" in result["reason"] - - def test_hvac_mode_fan_only_holds_fan(self, controller): - """Test that hvac_mode='fan_only' returns immediately without changing fan speed.""" - result = controller.calculate_decision(20.0, 20.0, 0.0, "fan_only", "low") - assert result["fan_mode"] == "low" - assert "fan_only" in result["reason"] - - def test_hvac_mode_switch_resets_slope(self, controller): - """Test that _previous_slope is reset when hvac_mode changes.""" - controller.previous_slope = 0.1 - controller.calculate_decision(19.5, 20.0, 0.3, "heat", "low") - # Switch to cool: previous_slope must be reset to the current slope - controller.calculate_decision(19.5, 20.0, 0.3, "cool", "low") - assert controller.previous_slope == 0.3 - - def test_learning_records_current_fan_not_decided_fan(self, controller): - """Verify that the slope sample is attributed to the ACTIVE fan mode, not the decided one.""" - controller.last_change_time = 0 # interval expired immediately - # current_fan='low', error=0.5 (soft zone), slope=-0.5 (falling, not improving) - # With error=0.5 > 0.75*hard_error, controller steps up +2 (low → high) - result = controller.calculate_decision(19.5, 20.0, -0.5, "heat", "low") - assert result["fan_mode"] == "high" # decision changed from low to high - # But the learning sample must carry 'low' (the mode that produced the measured slope) - assert len(controller.learning.slope_samples) > 0 - sample = controller.learning.slope_samples[-1] - assert sample[1] == "low", f"Expected sample fan_mode='low' but got '{sample[1]}'" - - def test_auto_apply_flag_in_entry_data_prevents_second_apply(self): - """Verify that the learning_auto_applied flag logic blocks a second auto-apply. - - This is a unit-level guard against the infinite reload loop: - if entry.data already has learning_auto_applied=True, the condition must - evaluate to False regardless of learning readiness. - """ - - # Simulate: learning is ready, but the flag is already set in entry.data - class _FakeEntry: - data = {"learning_auto_applied": True} - - entry = _FakeEntry() - # Condition mirrors what __init__.py checks: - should_apply = True and True and not entry.data.get("learning_auto_applied", False) # learning_enabled # learning.is_ready() - assert should_apply is False, "Auto-apply should be blocked when flag is already set" - - # Verify that a fresh entry (no flag) would pass - entry_fresh = _FakeEntry() - entry_fresh.data = {} - should_apply_fresh = True and True and not entry_fresh.data.get("learning_auto_applied", False) - assert should_apply_fresh is True - - def test_step_down_protection(self, controller): - """ - Scenario: Normal (non-forced) change wants to drop from Turbo to Low. - Goal: Validate step-down limit caps descent to one level at a time. - """ - # Non-forced: step-down protection limits Turbo (3) -> High (2) - final_idx = controller.determine_final_index(current_index=3, new_index=0, minutes_since_change=35, force=False) - assert FAN_MODES[final_idx] == "high" - - def test_forced_change_bypasses_step_down(self, controller): - """ - Scenario: Forced change (emergency / setpoint drop) from Turbo to Low. - Goal: Forced changes bypass step-down protection entirely. - The emergency system (Zone A) handles subsequent rapid temperature changes. - """ - final_idx = controller.determine_final_index(current_index=3, new_index=0, minutes_since_change=35, force=True) - assert FAN_MODES[final_idx] == "low" - - def test_startup_with_invalid_fan_mode(self, controller): - """ - Scenario: Integration starts with an unknown fan state (None or Unknown). - Goal: Ensure the 'try/except' block handles the ValueError and defaults to index 0. - """ - # Should not crash and should return a valid mode from your list - result = controller.calculate_decision(19.0, 20.0, 0.5, "heat", "unknown_mode") - # With 1.0°C error, it should have moved from 0 (low) to 1 (medium) - assert result["fan_mode"] in FAN_MODES # Ne doit pas crasher - - def test_projection_math(self, controller): - """ - Scenario: Linear projection with clamp. - Goal: Verify the linear math: temp_proj = current + (v * 10/60), clamped. - """ - controller.previous_slope = 0.0 # Started stable - current_slope = 0.6 # Now rising at 0.6°C/h - proj = controller.compute_temperature_projection(20.0, current_slope) - # 20.0 + 0.6 * (10/60) = 20.0 + 0.1 = 20.1 - assert proj == 20.1 - - def _run_sequence_test(self, controller, sequence, initial_time=0.0, initial_slope=0.0, last_change_ago=None): - controller.last_change_time = initial_time - controller.previous_slope = initial_slope - - if last_change_ago is not None: - controller.last_change_time = last_change_ago - - current_fan = sequence[0][4] - - for elapsed_min, current, target, slope, _, expected_fan in sequence: - current_time = initial_time + (elapsed_min * 60) - with patch('time.time', return_value=current_time): - result = controller.calculate_decision(current, target, slope, "heat", current_fan) - actual_fan = result["fan_mode"] - - # Simulate HA confirming the fan change (mirrors __init__.py behaviour) - if actual_fan != current_fan: - controller.confirm_fan_change() - - error_msg = ( - f"Failed at T+{elapsed_min}min: " - f"Expected {expected_fan}, got {actual_fan} " - f"(Input was {current_fan}, Reason: {result['reason']})" - ) - - assert result["fan_mode"] == expected_fan, error_msg - current_fan = actual_fan - - def test_sequence_recovery_inertia(self, controller): - """ - Scenario: System recovering from a large error with thermal lag. - Validates that the controller remains patient during the 'dead time' - after a speed change, avoiding rapid oscillations. - """ - sequence = [ - # elapsed_min, current, target, slope, current_fan, expected_fan - (0, 20.2, 20.0, 1.68, "turbo", "turbo"), # Init - (10, 20.4, 20.0, 1.84, "turbo", "high"), # Slope change triggers drop - (20, 20.4, 20.0, 1.84, "high", "high"), # Inertia: stay at high - (30, 20.4, 20.0, 0.37, "high", "medium"),# Slope change triggers 2nd drop - ] - - self._run_sequence_test(controller, sequence, initial_slope=1.68) - - def test_sequence_dynamic_braking(self, controller): - """ - Scenario: Rapidly rising temperature (e.g., external heat gain). - Validates the 'Braking' effect: slope spike overrides timer. - """ - sequence = [ - # elapsed_min, current, target, slope, current_fan, expected_fan - (0, 20.0, 20.0, -0.40, "high", "high"), - (10, 20.4, 20.0, 1.20, "high", "medium",), # Brake! - (20, 20.4, 20.0, 1.20, "medium", "medium"), # Timer: 10m - (30, 20.4, 20.0, 1.20, "medium", "low"), # Timer: 20m -> Drop - ] - - self._run_sequence_test(controller, sequence, initial_slope=-0.42) - - def test_sequence_stress_recovery(self, controller): - """ - Scenario: Rapid temperature drop followed by a forced recovery (Booster). - Validates: Drop Detection -> Emergency Booster -> Smooth Brake. - """ - sequence = [ - # elapsed_min, current, target, slope, current_fan, expected_fan - (0, 19.8, 20.0, -0.10, "high", "high"), # Slight drop - (10, 19.4, 20.0, -1.80, "high", "turbo"), # Emergency detected - (20, 19.6, 20.0, -0.20, "turbo", "turbo"), # Trend is improving - (30, 20.0, 20.0, 1.50, "turbo", "high"), # Braking: Target overshoot predicted - (40, 20.0, 20.0, 0.10, "high", "high"), # Stabilized - ] - - self._run_sequence_test(controller, sequence, initial_slope=0.08) - - def test_sequence_overshoot_recovery(self, controller): - """ - Scenario: Room is significantly over-target (Overshoot). - Validates that the fan stays at minimum ('low') and doesn't - restart until the temperature is back near the target. - """ - sequence = [ - # elapsed_min, current, target, slope, current_fan, expected_fan - (0, 21.0, 20.0, -0.10, "low", "low"), # Way over target, cooling slowly - (10, 20.8, 20.0, -0.20, "low", "low"), # Still over - (20, 20.4, 20.0, -0.40, "low", "low"), # Getting closer - (30, 20.1, 20.0, -0.10, "low", "low"), # Almost there, stability logic should hold 'low' - ] - - self._run_sequence_test(controller, sequence, initial_slope=-0.10) - - def test_sequence_soft_landing(self, controller): - """ - Scenario: Approaching target from below with a steady slope. - Validates that the controller maintains the speed once the target - is reached if the situation is stable. - """ - sequence = [ - # T+0: Below target with a strong slope. Stay in High. - (0, 19.5, 20.0, 1.40, "high", "high"), - - # T+10: Temp is 19.95 (Still below target). - # Simple Projection: 19.95 + (1.55 * 10/60) = 20.208 - # Projected Error: 20.0 - 20.208 = -0.208 - # Since -0.208 < -deadband (-0.2), Block B triggers and reduces speed. - (10, 19.95, 20.0, 1.55, "high", "medium"), - - # T+20: Reached target (20.0). - # Error is 0.0 -> Enters Block F (Comfort Zone/Stable). Maintain Medium. - (20, 20.0, 20.0, 0.20, "medium", "medium"), - - # T+30: Stable on target with minimal slope. - (30, 20.0, 20.0, 0.05, "medium", "medium"), - ] - - self._run_sequence_test(controller, sequence, initial_slope=-0.10) - - def test_sequence_noisy_sensor(self, controller): - """ - Scenario: Sensor noise (slight jitter in temperature/slope). - Validates that the inertia timer (30min) prevents the fan - from switching back and forth (Anti-short cycle). - """ - sequence = [ - # elapsed_min, current, target, slope, current_fan, expected_fan - (0, 19.7, 20.0, 0.0, "low", "medium"), # Change triggered - (5, 19.5, 20.0, -0.05, "medium", "medium"), # Small drop (noise) -> Should ignore - (10, 19.7, 20.0, 0.05, "medium", "medium"), # Small jump (noise) -> Should ignore - (15, 20.0, 20.0, 0.05, "medium", "medium"), # Back to normal - ] - - self._run_sequence_test(controller, sequence, initial_time=3600, initial_slope=-0.10, last_change_ago=0) - - async def test_manual_fan_change_integration(self, hass, controller): - """ - Test that a state change event on the climate entity - actually updates the controller logic. - """ - climate_id = "climate.salon" - entry_id = "entry_id_123" - - # 1. Setup the initial fake state - hass.states.async_set(climate_id, "heat", { - "fan_mode": "low", - "fan_modes": ["low", "medium", "high", "turbo"] - }) - - # 2. Setup storage (mirroring what's in your __init__.py) - hass.data[DOMAIN] = { - entry_id: { - "controller": controller, - "sensors": [] - } - } - - # 3. Define the listener logic LOCALLY in the test - # This matches the logic in your __init__.py - async def mock_handle_manual_change(event): - new_state = event.data.get("new_state") - old_state = event.data.get("old_state") - if not new_state or not old_state: - return - - new_fan = new_state.attributes.get("fan_mode") - old_fan = old_state.attributes.get("fan_mode") - - if new_fan != old_fan and new_fan is not None: - # This is the call we want to verify - controller.record_manual_override(new_fan) - - # Register our local mock listener - async_track_state_change_event(hass, [climate_id], mock_handle_manual_change) - - # 4. Trigger a manual change by updating the state - # We simulate this happening at T=2000 - test_time = 2000.0 - with patch('time.time', return_value=test_time): - hass.states.async_set(climate_id, "heat", { - "fan_mode": "high", # The change - "fan_modes": ["low", "medium", "high", "turbo"] - }) - # Wait for Home Assistant's event bus to process mock_handle_manual_change - await hass.async_block_till_done() - - # 5. Verification - # If the listener worked, last_change_time should match our patched time - assert controller.last_change_time == test_time - - def test_uninitialized_fan_modes_returns_sensor_data(self): - """ - Test that when fan_modes is None (not initialized), the controller - still returns all sensor data (temperature_error, projected_temperature, etc.) - instead of just fan_mode and reason. This prevents sensors from staying 'unknown'. - """ - # Create controller without fan modes - controller_no_modes = SmartFanController(fan_modes=None, **DEFAULT_CONFIG) - - # Call calculate_decision with valid temperature data - result = controller_no_modes.calculate_decision( - current_temp=19.5, - target_temp=20.0, - vtherm_slope=0.2, - hvac_mode="heat", - current_fan="medium" - ) - - # Verify all expected keys are present - assert "fan_mode" in result - assert "projected_temperature" in result - assert "projected_temperature_error" in result - assert "temperature_error" in result - assert "minutes_since_last_change" in result - assert "reason" in result - - # Verify fan_mode is preserved (not changed when modes not initialized) - assert result["fan_mode"] == "medium" - assert result["reason"] == "No fan modes defined" - - # Verify temperature calculations are performed - assert result["temperature_error"] == 0.5 # target - current for heat mode - assert isinstance(result["projected_temperature"], float) - assert isinstance(result["projected_temperature_error"], float) - assert isinstance(result["minutes_since_last_change"], float) - - def test_empty_fan_modes_list_retries(self): - """ - Test that when fan_modes is set to empty list (race condition scenario), - the controller continues to accept fan_modes updates on subsequent attempts. - This simulates the startup race condition where VTherm hasn't initialized yet. - """ - # Create controller without fan modes - controller_no_modes = SmartFanController(fan_modes=None, **DEFAULT_CONFIG) - - # Verify initial state is None - assert controller_no_modes.fan_modes is None - - # Simulate race condition: set to empty list (what happens when VTherm not ready) - controller_no_modes.fan_modes = [] - - # Verify it's empty - assert controller_no_modes.fan_modes == [] - assert not controller_no_modes.fan_modes # Should be falsy - - # Now simulate VTherm becoming available with proper modes - controller_no_modes.fan_modes = ["low", "medium", "high"] - - # Verify modes are now set - assert controller_no_modes.fan_modes == ["low", "medium", "high"] - - # Verify controller now works properly - result = controller_no_modes.calculate_decision( - current_temp=19.5, - target_temp=20.0, - vtherm_slope=0.2, - hvac_mode="heat", - current_fan="low" - ) - - # Should now be able to make decisions - assert result["fan_mode"] in ["low", "medium", "high"] - assert "No fan modes defined" not in result["reason"] - - def test_confirm_fan_change_updates_timestamp(self, controller): - """confirm_fan_change should set _last_change_time to the cycle's _now.""" - controller.now = 5000.0 - controller.confirm_fan_change() - assert controller.last_change_time == 5000.0 - - def test_record_manual_override_payload(self, controller): - """record_manual_override should return correct structure and update timestamp.""" - result = controller.record_manual_override("high") - assert result["fan_mode"] == "high" - assert result["minutes_since_last_change"] == 0.0 - assert "Manual Override" in result["reason"] - # Timestamp should be updated - assert controller.last_change_time > 0 - - def test_projection_zero_slope(self, controller): - """Zero slope should project same temperature.""" - proj = controller.compute_temperature_projection(20.0, 0.0) - assert proj == 20.0 - - def test_learning_progress_tracks_sample_count(self): - """Learning progress should reflect sample count vs MIN_SAMPLES_LEARNING.""" - learning = ThermalLearning() - assert learning.get_progress() == 0.0 - - for _ in range(120): - learning.add_slope_sample("medium", 0.3, 0.2) - assert learning.get_progress() == pytest.approx(50.0, abs=1.0) - - for _ in range(120): - learning.add_slope_sample("medium", 0.3, 0.2) - assert learning.get_progress() == 100.0 From 9246864d6a57e92adfa839ad2c386c455783e095 Mon Sep 17 00:00:00 2001 From: Gamso Date: Tue, 14 Apr 2026 19:18:24 +0200 Subject: [PATCH 04/11] Make Smart Fan Controller multi-entry safe and migrate entity IDs - route services to the correct config entry via climate_entity - prevent duplicate climate entries in config flows - apply learned settings to runtime options and reset auto-apply state on learning reset - scope sensor entity IDs by climate entity and migrate legacy IDs automatically - enrich CSV audit rows with live control-loop metrics - update tests, translations, and README for the new behavior --- .github/agents/mpc-specialist.agent.md | 35 +++ .github/copilot-instructions.md | 6 + README.md | 119 +++---- .../smart_fan_controller/__init__.py | 295 ++++++++++++++---- .../smart_fan_controller/config_flow.py | 32 +- .../smart_fan_controller/const.py | 18 ++ .../smart_fan_controller/sensor.py | 138 +++++--- .../smart_fan_controller/services.yaml | 30 +- .../smart_fan_controller/translations/en.json | 2 + .../smart_fan_controller/translations/fr.json | 2 + tests/test_config_flow.py | 48 +++ tests/test_data_collection.py | 45 ++- tests/test_learning.py | 4 +- tests/test_mpc_controller.py | 15 +- tests/test_services.py | 94 +++++- 15 files changed, 712 insertions(+), 171 deletions(-) create mode 100644 .github/agents/mpc-specialist.agent.md create mode 100644 tests/test_config_flow.py diff --git a/.github/agents/mpc-specialist.agent.md b/.github/agents/mpc-specialist.agent.md new file mode 100644 index 0000000..a772c50 --- /dev/null +++ b/.github/agents/mpc-specialist.agent.md @@ -0,0 +1,35 @@ +--- +name: "MPC Specialist" +description: "Use when tuning or debugging the Smart Fan Controller MPC, thermal learning, hysteresis, dead time, disturbance bias, step-down guards, fan-mode profiles, or for French requests like agent MPC, analyse MPC, optimiser le MPC." +tools: [read, search, edit, execute, todo] +argument-hint: "Describe the MPC behavior, fan decision, thermal-learning issue, or change to implement." +user-invocable: true +--- +You are the Smart Fan Controller MPC specialist. Your job is to analyze, tune, and validate the predictive fan controller for this repository. + +## Scope +- Work first in `custom_components/smart_fan_controller/mpc_controller.py`. +- Then inspect `custom_components/smart_fan_controller/thermal_learning.py`. +- Use `custom_components/smart_fan_controller/__init__.py` for control-loop and disturbance gating. +- Update `tests/test_mpc_controller.py`, `tests/test_learning.py`, and nearby focused tests when behavior changes. + +## Constraints +- Preserve the project vocabulary: error is positive when the system needs more heating or cooling. +- Do not add second-order slope or parabolic prediction terms. +- Keep learning-data integrity guards for window-open, defrost, HVAC idle, setpoint-drop cooldown, and insufficiently established periods. +- Keep per-mode effective slope based on the median of accepted samples. +- Apply monotone slope enforcement only when all fan-mode profiles are learned. +- Prefer small, behavior-scoped edits with targeted pytest validation. + +## Approach +1. Start from the deciding code path or failing test nearest to the MPC behavior. +2. Form one falsifiable local hypothesis before editing. +3. Make the smallest change that tests the hypothesis. +4. Run the narrowest relevant pytest file or test. +5. Report the behavioral impact, validation, and residual risks. + +## Output Format +- Findings or change summary +- Files inspected or changed +- Validation run +- Remaining risks or missing data \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9617d92..6f886ff 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -69,6 +69,12 @@ python -m pytest tests/test_X.py -q # run one file ./container hassfest # validate manifest / translations ``` +## Dev Container Workflow + +- When working inside this repository's provided devcontainer / Docker environment, do **not** spend time creating or configuring a separate Python environment by default. +- Treat the container runtime and the already available project tooling as the authoritative execution environment. +- Prefer the existing shell environment and direct test commands such as `pytest tests/ -q` unless the user explicitly asks for Python environment debugging. + ## Key Constants (const.py) - `THRESHOLD_TARGET_DROP = -1.0` — setpoint-drop trigger (°C) diff --git a/README.md b/README.md index a9c3631..6106375 100644 --- a/README.md +++ b/README.md @@ -105,14 +105,14 @@ Smart Fan Controller is a custom Home Assistant integration that **adjusts HVAC All parameters can be changed at any time via **Settings → Devices & Services → Smart Fan Controller → Configure**. -| Parameter | Default | Range | Description | -| ------------------ | -------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **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). | +| Parameter | Default | Range | Description | +| -------------------- | -------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **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). | --- @@ -248,48 +248,51 @@ Slope samples and response-time events collected while the compressor is detecte ### MPC Sensors -| Entity | Unit | Description | -| -------------------------------------------------------------- | ----- | ----------------------------------------------------------------- | -| `sensor.smart_fan_controller_mpc_status` | — | MPC state (`Not ready`, `Ready`, `Disturbed`, `Idle`, etc.) | -| `sensor.smart_fan_controller_mpc_reason` | — | Explanation of the current MPC recommendation | -| `sensor.smart_fan_controller_mpc_fan_mode` | — | Fan mode chosen by the MPC | -| `sensor.smart_fan_controller_mpc_would_change_now` | — | Whether the MPC would actively change the fan right now | -| `sensor.smart_fan_controller_mpc_cost` | — | Lowest simulation cost returned by the MPC optimizer | -| `sensor.smart_fan_controller_mpc_confidence` | % | Confidence derived from learned profile coverage | -| `sensor.smart_fan_controller_mpc_predicted_temperature_10_min` | °C | Predicted temperature after 10 minutes with the recommended mode | -| `sensor.smart_fan_controller_mpc_predicted_temperature_30_min` | °C | Predicted temperature after 30 minutes with the recommended mode | -| `sensor.smart_fan_controller_mpc_dead_time` | min | Dead time currently used by the MPC simulator | -| `sensor.smart_fan_controller_mpc_known_profiles` | count | Number of reliable learned fan-mode profiles | -| `sensor.smart_fan_controller_mpc_disturbance_bias` | °C/h | Learned disturbance correction currently applied by the MPC model | +Entity IDs are scoped by the configured climate entity. For example, a controller attached to `climate.living_room` exposes `sensor.smart_fan_controller_living_room_mpc_status`. + +| Entity | Unit | Description | +| -------------------------------------------------------------------------- | ----- | ----------------------------------------------------------------- | +| `sensor.smart_fan_controller_living_room_mpc_status` | — | MPC state (`Not ready`, `Ready`, `Disturbed`, `Idle`, etc.) | +| `sensor.smart_fan_controller_living_room_mpc_reason` | — | Explanation of the current MPC recommendation | +| `sensor.smart_fan_controller_living_room_mpc_fan_mode` | — | Fan mode chosen by the MPC | +| `sensor.smart_fan_controller_living_room_mpc_would_change_now` | — | Whether the MPC would actively change the fan right now | +| `sensor.smart_fan_controller_living_room_mpc_cost` | — | Lowest simulation cost returned by the MPC optimizer | +| `sensor.smart_fan_controller_living_room_mpc_confidence` | % | Confidence derived from learned profile coverage | +| `sensor.smart_fan_controller_living_room_mpc_predicted_temperature_10_min` | °C | Predicted temperature after 10 minutes with the recommended mode | +| `sensor.smart_fan_controller_living_room_mpc_predicted_temperature_30_min` | °C | Predicted temperature after 30 minutes with the recommended mode | +| `sensor.smart_fan_controller_living_room_mpc_dead_time` | min | Dead time currently used by the MPC simulator | +| `sensor.smart_fan_controller_living_room_mpc_known_profiles` | count | Number of reliable learned fan-mode profiles | +| `sensor.smart_fan_controller_living_room_mpc_disturbance_bias` | °C/h | Learned disturbance correction currently applied by the MPC model | ### Learning Sensors -| Entity | Unit | Description | -| --------------------------------------------------------- | ----- | --------------------------------------------------- | -| `sensor.smart_fan_controller_learning_progress` | % | Learning completion (100% = ≥240 samples) | -| `sensor.smart_fan_controller_learning_status` | — | `"Learning (45%)"` or `"Ready"` | -| `sensor.smart_fan_controller_learning_samples` | count | Number of slope samples collected | -| `sensor.smart_fan_controller_learning_response_events` | count | Number of thermal response time measurements | -| `sensor.smart_fan_controller_learned_dead_time` | min | Median learned thermal response delay (`dead_time`) | -| `sensor.smart_fan_controller_effective_timeout` | min | Actual non-emergency timeout currently used | -| `sensor.smart_fan_controller_learned_deadband` | °C | Learned optimal deadband | -| `sensor.smart_fan_controller_learned_limit_timeout` | min | Learned base timeout stored in config | +| Entity | Unit | Description | +| ------------------------------------------------------------------ | ----- | --------------------------------------------------- | +| `sensor.smart_fan_controller_living_room_learning_progress` | % | Learning completion (100% = ≥240 samples) | +| `sensor.smart_fan_controller_living_room_learning_status` | — | `"Learning (45%)"` or `"Ready"` | +| `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_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 Once fan modes are detected, the integration creates per-HVAC-mode profile summary sensors and one effective slope sensor per fan mode: -| Entity (example with `low`/`medium`/`high` fan modes) | Unit | Description | -| --------------------------------------------------------- | ---- | -------------------------------------------------- | -| `sensor.smart_fan_controller_mpc_heat_profiles` | — | JSON summary of learned heat profiles per fan mode | -| `sensor.smart_fan_controller_mpc_cool_profiles` | — | JSON summary of learned cool profiles per fan mode | -| `sensor.smart_fan_controller_heat_low_effective_slope` | °C/h | Effective slope learned for `low` in heat mode | -| `sensor.smart_fan_controller_heat_medium_effective_slope` | °C/h | Effective slope learned for `medium` in heat mode | -| `sensor.smart_fan_controller_heat_high_effective_slope` | °C/h | Effective slope learned for `high` in heat mode | -| `sensor.smart_fan_controller_cool_low_effective_slope` | °C/h | Effective slope learned for `low` in cool mode | -| … (one per fan mode × HVAC mode combination) | … | … | +| Entity (example with `low`/`medium`/`high` fan modes) | Unit | Description | +| --------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| `sensor.smart_fan_controller_living_room_mpc_heat_profiles` | — | JSON summary of learned heat profiles per fan mode | +| `sensor.smart_fan_controller_living_room_mpc_cool_profiles` | — | JSON summary of learned cool profiles per fan mode | +| `sensor.smart_fan_controller_living_room_heat_low_effective_slope` | °C/h | Effective slope learned for `low` in heat mode | +| `sensor.smart_fan_controller_living_room_heat_medium_effective_slope` | °C/h | Effective slope learned for `medium` in heat mode | +| `sensor.smart_fan_controller_living_room_heat_high_effective_slope` | °C/h | Effective slope learned for `high` in heat mode | +| `sensor.smart_fan_controller_living_room_cool_low_effective_slope` | °C/h | Effective slope learned for `low` in cool mode | +| … (one per fan mode × HVAC mode combination) | … | … | These sensors appear automatically when the climate entity's fan modes become known and require at least 10 samples per mode to show reliable data. +Existing installations are migrated automatically from the legacy non-scoped entity IDs to the new climate-scoped names. --- @@ -299,28 +302,34 @@ These sensors appear automatically when the climate entity's fan modes become kn Manually apply the parameters computed by the learning system. Useful when auto-apply is disabled or to re-apply after a manual change. -**Requirement**: `sensor.smart_fan_controller_learning_status` must be `"Ready"`. +When several Smart Fan Controller entries are configured, pass `climate_entity` to target the correct controller. + +**Requirement**: `sensor.smart_fan_controller_living_room_learning_status` must be `"Ready"`. ### `smart_fan_controller.reset_learning` Clear all learning data and start fresh. Use after HVAC maintenance or a significant system change. +When several Smart Fan Controller entries are configured, pass `climate_entity` to target the correct controller. + ### `smart_fan_controller.set_effective_slope` Manually set the effective slope for a specific fan mode / HVAC mode profile without resetting all learning data. Replaces existing samples for that profile with synthetic ones matching the provided slope. **Parameters**: -| Parameter | Required | Example | Description | -| ----------------- | -------- | -------- | ---------------------------------------------------------- | -| `hvac_mode` | Yes | `heat` | The HVAC mode (`heat` or `cool`) | -| `fan_mode` | Yes | `silent` | The fan mode name | -| `effective_slope` | Yes | `0.15` | Target effective slope in °C/h (positive = towards target) | +| Parameter | Required | Example | Description | +| ----------------- | -------- | --------------------- | ----------------------------------------------------------------- | +| `climate_entity` | No* | `climate.living_room` | Required when several Smart Fan Controller entries are configured | +| `hvac_mode` | Yes | `heat` | The HVAC mode (`heat` or `cool`) | +| `fan_mode` | Yes | `silent` | The fan mode name | +| `effective_slope` | Yes | `0.15` | Target effective slope in °C/h (positive = towards target) | **Example** (Developer Tools → Services): ```yaml service: smart_fan_controller.set_effective_slope data: + climate_entity: climate.living_room hvac_mode: heat fan_mode: silent effective_slope: 0.15 @@ -330,15 +339,15 @@ data: ## Troubleshooting -| Symptom | What to check / do | -| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Fan not changing** | Check `sensor.smart_fan_controller_mpc_status` and `mpc_reason`. The MPC may be paused (disturbed) or the min interval hasn't elapsed. | -| **MPC status: Not ready** | Learning hasn't collected enough profiles. Check `sensor.smart_fan_controller_mpc_known_profiles` and `learning_progress`. | -| **MPC status: Disturbed** | Defrost, HVAC idle, or window open detected. Normal — MPC holds current fan until the disturbance clears. | -| **Too many fan changes** | Increase `deadband` or `min_interval`. Enable learning to auto-optimize. | -| **Temperature overshoots** | Decrease `deadband`. Verify Versatile Thermostat is providing an accurate slope. | -| **Learning not progressing** | Verify HVAC is running and windows are closed. | -| **Auto-apply not working** | Verify `sensor.smart_fan_controller_learning_status` is `"Ready"`. Auto-apply fires once — use `apply_learned_settings` to re-apply. | +| Symptom | What to check / do | +| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Fan not changing** | Check `sensor.smart_fan_controller_living_room_mpc_status` and `mpc_reason`. The MPC may be paused (disturbed) or the min interval hasn't elapsed. | +| **MPC status: Not ready** | Learning hasn't collected enough profiles. Check `sensor.smart_fan_controller_living_room_mpc_known_profiles` and `learning_progress`. | +| **MPC status: Disturbed** | Defrost, HVAC idle, or window open detected. Normal — MPC holds current fan until the disturbance clears. | +| **Too many fan changes** | Increase `deadband` or `min_interval`. Enable learning to auto-optimize. | +| **Temperature overshoots** | Decrease `deadband`. Verify Versatile Thermostat is providing an accurate slope. | +| **Learning not progressing** | Verify HVAC is running and windows are closed. | +| **Auto-apply not working** | Verify `sensor.smart_fan_controller_living_room_learning_status` is `"Ready"`. Auto-apply fires once — use `apply_learned_settings` to re-apply. | --- diff --git a/custom_components/smart_fan_controller/__init__.py b/custom_components/smart_fan_controller/__init__.py index a533d9d..2819f22 100644 --- a/custom_components/smart_fan_controller/__init__.py +++ b/custom_components/smart_fan_controller/__init__.py @@ -2,12 +2,15 @@ from __future__ import annotations import logging +import re import time from datetime import timedelta from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.event import async_track_state_change_event, async_track_time_interval from homeassistant.helpers.storage import Store @@ -36,6 +39,9 @@ THRESHOLD_SLOPE, THRESHOLD_TARGET_DROP, build_unique_id, + build_entity_id, + build_scoped_entity_id, + extract_object_key_from_unique_id, ) from .data_collection import DataCollector from .mpc_controller import MPCController @@ -43,6 +49,9 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR, Platform.SWITCH] +SERVICE_APPLY_LEARNED_SETTINGS = "apply_learned_settings" +SERVICE_RESET_LEARNING = "reset_learning" +SERVICE_SET_EFFECTIVE_SLOPE = "set_effective_slope" def _filter_supported_fan_modes(raw_modes: list[str] | None) -> list[str]: @@ -59,6 +68,122 @@ def _extract_supported_fan_modes(state) -> list[str]: return _filter_supported_fan_modes(state.attributes.get("fan_modes")) +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, {}) + return [ + (entry_id, entry_data) + for entry_id, entry_data in domain_data.items() + if not entry_id.startswith("_") + ] + + +def _resolve_controller_entry( + hass: HomeAssistant, + climate_entity: str | None = None, +) -> tuple[str, dict]: + """Resolve one loaded controller entry for a service call.""" + entries = _iter_loaded_entries(hass) + if not entries: + raise HomeAssistantError("No Smart Fan Controller entry is currently loaded") + + if climate_entity is not None: + matches = [ + (entry_id, entry_data) + for entry_id, entry_data in entries + if entry_data.get("climate_entity") == climate_entity + ] + if not matches: + raise HomeAssistantError( + f"No Smart Fan Controller entry is configured for {climate_entity}" + ) + if len(matches) > 1: + raise HomeAssistantError( + f"Multiple Smart Fan Controller entries are configured for {climate_entity}; remove duplicates first" + ) + return matches[0] + + if len(entries) > 1: + raise HomeAssistantError( + "Multiple Smart Fan Controller entries are configured; specify climate_entity in the service call" + ) + + return entries[0] + + +def _build_data_collection_decision( + *, + effective_fan: str | None, + effective_reason: str, + current_fan: str | None, + current_error: float, + minutes_since_change: float, + hvac_mode: str, + target_temp: float, + mpc_decision: dict, +) -> dict: + """Build the audit payload written to the CSV collector.""" + projected_temperature = mpc_decision.get("mpc_predicted_temperature_10m") + projected_error = None + + if projected_temperature is not None: + projected_error = ( + projected_temperature - target_temp + if hvac_mode == "cool" + else target_temp - projected_temperature + ) + + return { + "fan_mode": effective_fan, + "reason": effective_reason, + "current_fan": current_fan, + "temperature_error": current_error, + "projected_temperature": projected_temperature, + "projected_temperature_error": projected_error, + "minutes_since_last_change": minutes_since_change, + } + + +def _is_legacy_generated_entity_id(entity_id: str, platform_domain: str, object_key: str) -> bool: + """Return True when the entity_id matches the old auto-generated naming scheme.""" + legacy_entity_id = build_entity_id(platform_domain, object_key) + return re.fullmatch(rf"{re.escape(legacy_entity_id)}(?:_\d+)?", entity_id) is not None + + +async def _async_migrate_entity_ids(hass: HomeAssistant, entry: ConfigEntry, climate_entity: str) -> None: + """Migrate legacy entity_ids to the climate-scoped naming scheme.""" + entity_registry = er.async_get(hass) + + for entity_entry in er.async_entries_for_config_entry(entity_registry, entry.entry_id): + object_key = extract_object_key_from_unique_id(entity_entry.unique_id, entry.entry_id) + if object_key is None: + continue + + desired_entity_id = build_scoped_entity_id(entity_entry.domain, climate_entity, object_key) + if entity_entry.entity_id == desired_entity_id: + continue + + if not _is_legacy_generated_entity_id(entity_entry.entity_id, entity_entry.domain, object_key): + continue + + suggested_object_id = desired_entity_id.split(".", maxsplit=1)[1] + available_entity_id = entity_registry.async_get_available_entity_id( + entity_entry.domain, + suggested_object_id, + current_entity_id=entity_entry.entity_id, + ) + entity_registry.async_update_entity( + entity_entry.entity_id, + new_entity_id=available_entity_id, + ) + _LOGGER.info( + "Migrated %s entity_id from %s to %s", + entry.entry_id, + entity_entry.entity_id, + available_entity_id, + ) + + async def _apply_optimal_parameters( hass: HomeAssistant, entry: ConfigEntry, @@ -69,6 +194,9 @@ async def _apply_optimal_parameters( 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", @@ -77,10 +205,84 @@ async def _apply_optimal_parameters( optimal["limit_timeout"], ) - hass.config_entries.async_update_entry(entry, data=new_data) + hass.config_entries.async_update_entry(entry, data=new_data, options=new_options) await hass.config_entries.async_reload(entry.entry_id) +def _register_services(hass: HomeAssistant) -> None: + """Register domain services once per Home Assistant instance.""" + + async def apply_learned_settings(call): + """Service to apply optimal parameters from learning.""" + _, entry_data = _resolve_controller_entry(hass, call.data.get(CONF_CLIMATE_ENTITY)) + learning = entry_data["learning"] + entry = entry_data["entry"] + climate_id = entry_data["climate_entity"] + + if not learning.is_ready(): + _LOGGER.warning( + "Learning not complete yet for %s (%.1f%%), cannot apply settings", + climate_id, + learning.get_progress(), + ) + return + + optimal = learning.compute_optimal_parameters() + if not optimal: + _LOGGER.error("Failed to compute optimal parameters for %s", climate_id) + return + + await _apply_optimal_parameters(hass, entry, optimal) + + async def reset_learning(call): + """Service to clear all learning data and restart learning.""" + entry_id, entry_data = _resolve_controller_entry(hass, call.data.get(CONF_CLIMATE_ENTITY)) + learning = entry_data["learning"] + entry = entry_data["entry"] + store = entry_data["store"] + climate_id = entry_data["climate_entity"] + + learning.reset() + + learning_data_to_save = learning.to_dict() + new_data = {**entry.data, "learning_data": learning_data_to_save} + new_data.pop("learning_auto_applied", None) + hass.config_entries.async_update_entry(entry, data=new_data) + await store.async_save(learning_data_to_save) + + sensors = hass.data[DOMAIN][entry_id].get("sensors", []) + for sensor in sensors: + sensor.async_write_ha_state() + + _LOGGER.info("Learning reset for %s: all samples and stats cleared", climate_id) + + async def set_effective_slope(call): + """Service to manually set the effective slope for a fan/HVAC profile.""" + entry_id, entry_data = _resolve_controller_entry(hass, call.data.get(CONF_CLIMATE_ENTITY)) + learning = entry_data["learning"] + store = entry_data["store"] + hvac_mode = call.data["hvac_mode"] + fan_mode = call.data["fan_mode"] + effective_slope = float(call.data["effective_slope"]) + + learning.set_mode_effective_slope(fan_mode, hvac_mode, effective_slope) + + learning_data_to_save = learning.to_dict() + await store.async_save(learning_data_to_save) + + sensors = hass.data[DOMAIN][entry_id].get("sensors", []) + for sensor in sensors: + sensor.async_write_ha_state() + + _LOGGER.info( + "Set effective slope for %s/%s to %.3f", hvac_mode, fan_mode, effective_slope + ) + + hass.services.async_register(DOMAIN, SERVICE_APPLY_LEARNED_SETTINGS, apply_learned_settings) + hass.services.async_register(DOMAIN, SERVICE_RESET_LEARNING, reset_learning) + hass.services.async_register(DOMAIN, SERVICE_SET_EFFECTIVE_SLOPE, set_effective_slope) + + # MPC statuses that mean the controller is paused; the current fan mode is # held when the MPC is in one of these states. _MPC_PAUSED_STATUSES = frozenset({"Idle", "Disturbed", "Not ready"}) @@ -197,6 +399,7 @@ async def _async_apply_fan_change( async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the integration from a config entry.""" conf = {**entry.data, **entry.options} + 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) @@ -242,7 +445,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await collector.async_initialize() _LOGGER.info("DataCollector enabled for %s, writing to %s", entry.entry_id, collector.path) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + domain_data[entry.entry_id] = { + "entry": entry, "learning": learning, "mpc_controller": mpc_controller, "climate_entity": climate_id, @@ -251,6 +455,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "store": store, } + if not domain_data.get("_services_registered"): + _register_services(hass) + domain_data["_services_registered"] = True + + await _async_migrate_entity_ids(hass, entry, climate_id) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) def _ensure_profile_sensors() -> None: @@ -380,13 +590,23 @@ async def run_control_loop(_): ctrl_state["previous_slope"] = vtherm_slope if collector: + collector_decision = _build_data_collection_decision( + effective_fan=effective_fan, + effective_reason=effective_reason, + current_fan=current_fan, + current_error=current_error, + minutes_since_change=minutes_since_change, + hvac_mode=hvac_mode, + target_temp=target_temp, + mpc_decision=mpc_decision, + ) await collector.async_record( hvac_mode=hvac_mode, current_temp=current_temp, target_temp=target_temp, vtherm_slope=vtherm_slope, is_window_open=is_window_open, - decision={"fan_mode": effective_fan, "reason": effective_reason, "current_fan": current_fan}, + decision=collector_decision, phase=phase, effective_slope=-float(vtherm_slope) if hvac_mode == "cool" else float(vtherm_slope), effective_timeout=mpc_controller.get_effective_timeout(), @@ -460,65 +680,6 @@ async def periodic_save_learning(_): entry.async_on_unload(async_track_time_interval(hass, periodic_save_learning, LEARNING_DATA_SAVE_INTERVAL)) - async def apply_learned_settings(_): - """Service to apply optimal parameters from learning.""" - if not learning.is_ready(): - _LOGGER.warning( - "Learning not complete yet for %s (%.1f%%), cannot apply settings", - climate_id, - learning.get_progress(), - ) - return - - optimal = learning.compute_optimal_parameters() - if not optimal: - _LOGGER.error("Failed to compute optimal parameters for %s", climate_id) - return - - await _apply_optimal_parameters(hass, entry, optimal) - - hass.services.async_register(DOMAIN, "apply_learned_settings", apply_learned_settings) - - async def reset_learning(_): - """Service to clear all learning data and restart learning.""" - learning.reset() - - learning_data_to_save = learning.to_dict() - new_data = {**entry.data, "learning_data": learning_data_to_save} - hass.config_entries.async_update_entry(entry, data=new_data) - await store.async_save(learning_data_to_save) - - sensors = hass.data[DOMAIN][entry.entry_id].get("sensors", []) - for sensor in sensors: - sensor.async_write_ha_state() - - _LOGGER.info("Learning reset for %s: all samples and stats cleared", climate_id) - - hass.services.async_register(DOMAIN, "reset_learning", reset_learning) - - async def set_effective_slope(call): - """Service to manually set the effective slope for a fan/HVAC profile.""" - hvac_mode = call.data["hvac_mode"] - fan_mode = call.data["fan_mode"] - effective_slope = float(call.data["effective_slope"]) - - learning.set_mode_effective_slope(fan_mode, hvac_mode, effective_slope) - - # Persist immediately - learning_data_to_save = learning.to_dict() - await store.async_save(learning_data_to_save) - - # Refresh sensors - sensors = hass.data[DOMAIN][entry.entry_id].get("sensors", []) - for sensor in sensors: - sensor.async_write_ha_state() - - _LOGGER.info( - "Set effective slope for %s/%s to %.3f", hvac_mode, fan_mode, effective_slope - ) - - hass.services.async_register(DOMAIN, "set_effective_slope", set_effective_slope) - return True @@ -543,7 +704,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id, None) + domain_data = hass.data[DOMAIN] + domain_data.pop(entry.entry_id, None) + if not _iter_loaded_entries(hass): + hass.services.async_remove(DOMAIN, SERVICE_APPLY_LEARNED_SETTINGS) + hass.services.async_remove(DOMAIN, SERVICE_RESET_LEARNING) + hass.services.async_remove(DOMAIN, SERVICE_SET_EFFECTIVE_SLOPE) + domain_data.pop("_services_registered", None) + if not domain_data: + hass.data.pop(DOMAIN, None) _LOGGER.info("Unloaded Smart Fan Controller entry %s", entry.entry_id) return unload_ok diff --git a/custom_components/smart_fan_controller/config_flow.py b/custom_components/smart_fan_controller/config_flow.py index 79aa50e..aeb71f1 100644 --- a/custom_components/smart_fan_controller/config_flow.py +++ b/custom_components/smart_fan_controller/config_flow.py @@ -41,6 +41,24 @@ def _validate_climate_state(state) -> str | None: return None +def _is_climate_entity_already_configured( + hass, + climate_entity: str, + *, + exclude_entry_id: str | None = None, +) -> bool: + """Return True when another Smart Fan Controller entry already targets the climate.""" + for entry in hass.config_entries.async_entries(DOMAIN): + if exclude_entry_id is not None and entry.entry_id == exclude_entry_id: + continue + + existing_conf = {**entry.data, **entry.options} + if existing_conf.get(CONF_CLIMATE_ENTITY) == climate_entity: + return True + + return False + + class SmartFanControllerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for for Smart Fan Controller.""" @@ -67,6 +85,8 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con climate_error = _validate_climate_state(state) if climate_error: errors[CONF_CLIMATE_ENTITY] = climate_error + elif _is_climate_entity_already_configured(self.hass, climate_id): + errors[CONF_CLIMATE_ENTITY] = "already_configured" else: return self.async_create_entry(title=user_input[CONF_CLIMATE_ENTITY], data=user_input) @@ -108,10 +128,11 @@ async def async_step_init(self, user_input: dict[str, Any] | None = None) -> Con """Manage the options.""" 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} if user_input is not None: climate_id = user_input[CONF_CLIMATE_ENTITY] - current_climate_id = self.config_entry.data.get(CONF_CLIMATE_ENTITY) + current_climate_id = current_data.get(CONF_CLIMATE_ENTITY) # Only validate the climate entity if the user is changing it if climate_id != current_climate_id: @@ -119,13 +140,16 @@ async def async_step_init(self, user_input: dict[str, Any] | None = None) -> Con climate_error = _validate_climate_state(state) if climate_error: errors[CONF_CLIMATE_ENTITY] = climate_error + elif _is_climate_entity_already_configured( + self.hass, + climate_id, + exclude_entry_id=self.config_entry.entry_id, + ): + errors[CONF_CLIMATE_ENTITY] = "already_configured" if not errors: return self.async_create_entry(title="", data=user_input) - # Merge data and options (options take priority) - current_data = {**self.config_entry.data, **self.config_entry.options} - # 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) diff --git a/custom_components/smart_fan_controller/const.py b/custom_components/smart_fan_controller/const.py index 0a80d10..09413e0 100644 --- a/custom_components/smart_fan_controller/const.py +++ b/custom_components/smart_fan_controller/const.py @@ -1,6 +1,8 @@ """Constants for Smart Fan Controller.""" from datetime import timedelta +from homeassistant.util import slugify + DOMAIN = "smart_fan_controller" DEVICE_NAME = "Smart Fan Controller" ENTITY_UNIQUE_ID_PREFIX = DOMAIN @@ -56,3 +58,19 @@ def build_unique_id(object_key: str, entry_id: str) -> str: def build_entity_id(platform_domain: str, object_key: str) -> str: """Build the canonical entity_id suggestion for an entity.""" return f"{platform_domain}.{DOMAIN}_{object_key}" + + +def build_scoped_entity_id(platform_domain: str, climate_entity: str, object_key: str) -> str: + """Build the climate-scoped entity_id suggestion for an entity.""" + climate_object_id = climate_entity.split(".", maxsplit=1)[-1] + return f"{platform_domain}.{DOMAIN}_{slugify(climate_object_id)}_{object_key}" + + +def extract_object_key_from_unique_id(unique_id: str, entry_id: str) -> str | None: + """Extract the object key from the canonical unique_id format.""" + prefix = f"{ENTITY_UNIQUE_ID_PREFIX}_" + suffix = f"_{entry_id}" + if not unique_id.startswith(prefix) or not unique_id.endswith(suffix): + return None + + return unique_id[len(prefix) : -len(suffix)] diff --git a/custom_components/smart_fan_controller/sensor.py b/custom_components/smart_fan_controller/sensor.py index 469bd91..027b0dc 100644 --- a/custom_components/smart_fan_controller/sensor.py +++ b/custom_components/smart_fan_controller/sensor.py @@ -17,7 +17,7 @@ EFFECTIVE_SLOPE_UNIT, MIN_MODE_PROFILE_SAMPLES, PROFILE_HVAC_MODES, - build_entity_id, + build_scoped_entity_id, build_unique_id, ) @@ -28,8 +28,13 @@ class _SmartFanEntity(SensorEntity): """Base sensor wired to the Smart Fan Controller device.""" _entry_id: str + _climate_entity: str _attr_has_entity_name = True + def _set_entity_id(self, object_key: str) -> None: + """Assign the climate-scoped entity_id.""" + self.entity_id = build_scoped_entity_id("sensor", self._climate_entity, object_key) + @property def device_info(self) -> DeviceInfo: """Link the entity to the Smart Fan Controller device.""" @@ -44,7 +49,12 @@ def _profile_effective_slope_object_key(hvac_mode: str, fan_mode: str) -> str: return f"{hvac_mode}_{slugify(fan_mode)}_effective_slope" -def _build_profile_effective_slope_entities(entry_id: str, controller, known_keys: set[tuple[str, str]]) -> list["SmartFanProfileEffectiveSlopeSensor"]: +def _build_profile_effective_slope_entities( + entry_id: str, + climate_entity: str, + controller, + known_keys: set[tuple[str, str]], +) -> list["SmartFanProfileEffectiveSlopeSensor"]: """Create profile slope sensors for fan modes that do not yet have an entity.""" entities: list[SmartFanProfileEffectiveSlopeSensor] = [] @@ -55,7 +65,15 @@ def _build_profile_effective_slope_entities(entry_id: str, controller, known_key continue known_keys.add(key) - entities.append(SmartFanProfileEffectiveSlopeSensor(entry_id, controller, hvac_mode, fan_mode)) + entities.append( + SmartFanProfileEffectiveSlopeSensor( + entry_id, + climate_entity, + controller, + hvac_mode, + fan_mode, + ) + ) return entities @@ -64,6 +82,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e """Set up the sensor platform from a config entry.""" data = hass.data[DOMAIN][entry.entry_id] mpc = data["mpc_controller"] + climate_entity = data["climate_entity"] sensor_definitions = [ ("MPC Status", "mpc_status", "mpc_status", None, None, "mdi:robot-outline", EntityCategory.DIAGNOSTIC), @@ -80,27 +99,42 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e ] entities: list[SensorEntity] = [ - SmartFanSensor(entry.entry_id, name, object_key, controller_key, unit, device_class, icon, entity_category) + SmartFanSensor( + entry.entry_id, + climate_entity, + name, + object_key, + controller_key, + unit, + device_class, + icon, + entity_category, + ) for name, object_key, controller_key, unit, device_class, icon, entity_category in sensor_definitions ] entities.extend( [ - SmartFanLearningSensor(entry.entry_id, mpc), - SmartFanLearningStatusSensor(entry.entry_id, mpc), - SmartFanLearningSamplesSensor(entry.entry_id, mpc), - SmartFanLearningResponseSensor(entry.entry_id, mpc), - SmartFanLearnedDeadTimeSensor(entry.entry_id, mpc), - SmartFanEffectiveTimeoutSensor(entry.entry_id, mpc), - SmartFanMpcProfilesSensor(entry.entry_id, mpc, "heat"), - SmartFanMpcProfilesSensor(entry.entry_id, mpc, "cool"), - SmartFanLearnedDeadbandSensor(entry.entry_id, mpc), - SmartFanLearnedLimitTimeoutSensor(entry.entry_id, mpc), + SmartFanLearningSensor(entry.entry_id, climate_entity, mpc), + SmartFanLearningStatusSensor(entry.entry_id, climate_entity, mpc), + SmartFanLearningSamplesSensor(entry.entry_id, climate_entity, mpc), + SmartFanLearningResponseSensor(entry.entry_id, climate_entity, mpc), + SmartFanLearnedDeadTimeSensor(entry.entry_id, climate_entity, mpc), + SmartFanEffectiveTimeoutSensor(entry.entry_id, climate_entity, mpc), + 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), ] ) profile_sensor_keys: set[tuple[str, str]] = set() - profile_entities = _build_profile_effective_slope_entities(entry.entry_id, mpc, profile_sensor_keys) + profile_entities = _build_profile_effective_slope_entities( + entry.entry_id, + climate_entity, + mpc, + profile_sensor_keys, + ) entities.extend(profile_entities) if profile_entities: @@ -118,7 +152,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e def ensure_profile_sensors() -> None: """Add late-discovered profile slope sensors once fan modes are known.""" - new_entities = _build_profile_effective_slope_entities(entry.entry_id, mpc, profile_sensor_keys) + new_entities = _build_profile_effective_slope_entities( + entry.entry_id, + climate_entity, + mpc, + profile_sensor_keys, + ) if not new_entities: return @@ -142,6 +181,7 @@ class SmartFanSensor(_SmartFanEntity): def __init__( self, entry_id: str, + climate_entity: str, name_suffix: str, object_key: str, data_key: str, @@ -151,6 +191,7 @@ def __init__( entity_category: EntityCategory | None = EntityCategory.DIAGNOSTIC, ) -> None: self._entry_id = entry_id + self._climate_entity = climate_entity self._data_key = data_key self._attr_name = name_suffix self._attr_unique_id = build_unique_id(object_key, entry_id) @@ -159,7 +200,7 @@ def __init__( self._attr_native_value = None self._attr_icon = icon self._attr_entity_category = entity_category - self.entity_id = build_entity_id("sensor", object_key) + self._set_entity_id(object_key) def update_from_mpc(self, data: dict) -> None: """Update the sensor value with data from the controller.""" @@ -170,15 +211,16 @@ def update_from_mpc(self, data: dict) -> None: class SmartFanLearningSensor(_SmartFanEntity): """Sensor showing learning progress and optimal parameters.""" - def __init__(self, entry_id: str, controller) -> None: + def __init__(self, entry_id: str, climate_entity: str, controller) -> None: self._entry_id = entry_id + self._climate_entity = climate_entity self._controller = controller self._attr_name = "Learning Progress" self._attr_unique_id = build_unique_id("learning_progress", entry_id) self._attr_native_unit_of_measurement = PERCENTAGE self._attr_icon = "mdi:school" self._attr_entity_category = EntityCategory.DIAGNOSTIC - self.entity_id = build_entity_id("sensor", "learning_progress") + self._set_entity_id("learning_progress") @property def native_value(self) -> float: @@ -209,14 +251,15 @@ def extra_state_attributes(self) -> dict: class SmartFanLearningStatusSensor(_SmartFanEntity): """Sensor showing learning readiness status.""" - def __init__(self, entry_id: str, controller) -> None: + def __init__(self, entry_id: str, climate_entity: str, controller) -> None: self._entry_id = entry_id + self._climate_entity = climate_entity self._controller = controller self._attr_name = "Learning Status" self._attr_unique_id = build_unique_id("learning_status", entry_id) self._attr_icon = "mdi:school-outline" self._attr_entity_category = EntityCategory.DIAGNOSTIC - self.entity_id = build_entity_id("sensor", "learning_status") + self._set_entity_id("learning_status") @property def native_value(self) -> str: @@ -230,15 +273,16 @@ def native_value(self) -> str: class SmartFanLearningSamplesSensor(_SmartFanEntity): """Sensor showing number of slope samples collected.""" - def __init__(self, entry_id: str, controller) -> None: + def __init__(self, entry_id: str, climate_entity: str, controller) -> None: self._entry_id = entry_id + self._climate_entity = climate_entity self._controller = controller self._attr_name = "Learning Samples" self._attr_unique_id = build_unique_id("learning_samples", entry_id) self._attr_native_unit_of_measurement = "samples" self._attr_icon = "mdi:chart-box-outline" self._attr_entity_category = EntityCategory.DIAGNOSTIC - self.entity_id = build_entity_id("sensor", "learning_samples") + self._set_entity_id("learning_samples") @property def native_value(self) -> int: @@ -263,15 +307,16 @@ def extra_state_attributes(self) -> dict: class SmartFanLearningResponseSensor(_SmartFanEntity): """Sensor showing number of response events recorded.""" - def __init__(self, entry_id: str, controller) -> None: + def __init__(self, entry_id: str, climate_entity: str, controller) -> None: self._entry_id = entry_id + self._climate_entity = climate_entity self._controller = controller self._attr_name = "Learning Response Events" self._attr_unique_id = build_unique_id("learning_response_events", entry_id) self._attr_native_unit_of_measurement = "events" self._attr_icon = "mdi:timer-outline" self._attr_entity_category = EntityCategory.DIAGNOSTIC - self.entity_id = build_entity_id("sensor", "learning_response_events") + self._set_entity_id("learning_response_events") @property def native_value(self) -> int: @@ -298,8 +343,9 @@ def extra_state_attributes(self) -> dict: class SmartFanLearnedDeadTimeSensor(_SmartFanEntity): """Sensor showing the learned thermal dead time.""" - def __init__(self, entry_id: str, controller) -> None: + def __init__(self, entry_id: str, climate_entity: str, controller) -> None: self._entry_id = entry_id + self._climate_entity = climate_entity self._controller = controller self._attr_name = "Learned Dead Time" self._attr_unique_id = build_unique_id("learned_dead_time", entry_id) @@ -307,7 +353,7 @@ def __init__(self, entry_id: str, controller) -> None: self._attr_device_class = SensorDeviceClass.DURATION self._attr_icon = "mdi:timer-sand" self._attr_entity_category = EntityCategory.DIAGNOSTIC - self.entity_id = build_entity_id("sensor", "learned_dead_time") + self._set_entity_id("learned_dead_time") @property def native_value(self) -> float: @@ -326,8 +372,9 @@ def extra_state_attributes(self) -> dict: class SmartFanEffectiveTimeoutSensor(_SmartFanEntity): """Sensor showing the actual non-emergency timeout in use.""" - def __init__(self, entry_id: str, controller) -> None: + def __init__(self, entry_id: str, climate_entity: str, controller) -> None: self._entry_id = entry_id + self._climate_entity = climate_entity self._controller = controller self._attr_name = "Effective Timeout" self._attr_unique_id = build_unique_id("effective_timeout", entry_id) @@ -335,7 +382,7 @@ def __init__(self, entry_id: str, controller) -> None: self._attr_device_class = SensorDeviceClass.DURATION self._attr_icon = "mdi:clock-check-outline" self._attr_entity_category = EntityCategory.DIAGNOSTIC - self.entity_id = build_entity_id("sensor", "effective_timeout") + self._set_entity_id("effective_timeout") @property def native_value(self) -> float: @@ -355,15 +402,16 @@ def extra_state_attributes(self) -> dict: class SmartFanMpcProfilesSensor(_SmartFanEntity): """Sensor exposing the learned per-mode profiles used by the MPC.""" - def __init__(self, entry_id: str, controller, hvac_mode: str) -> None: + def __init__(self, entry_id: str, climate_entity: str, controller, hvac_mode: str) -> None: self._entry_id = entry_id + self._climate_entity = climate_entity self._controller = controller self._hvac_mode = hvac_mode self._attr_name = f"MPC {hvac_mode.title()} Profiles" self._attr_unique_id = build_unique_id(f"mpc_{hvac_mode}_profiles", entry_id) self._attr_icon = "mdi:chart-timeline-variant-shimmer" self._attr_entity_category = EntityCategory.DIAGNOSTIC - self.entity_id = build_entity_id("sensor", f"mpc_{hvac_mode}_profiles") + self._set_entity_id(f"mpc_{hvac_mode}_profiles") @property def native_value(self) -> int: @@ -381,7 +429,11 @@ def extra_state_attributes(self) -> dict: "known_profiles": sum(1 for profile in profiles.values() if profile["ready"]), "min_samples_required_per_profile": MIN_MODE_PROFILE_SAMPLES, "profile_effective_slope_sensors": { - fan_mode: build_entity_id("sensor", _profile_effective_slope_object_key(self._hvac_mode, fan_mode)) + fan_mode: build_scoped_entity_id( + "sensor", + self._climate_entity, + _profile_effective_slope_object_key(self._hvac_mode, fan_mode), + ) for fan_mode in profiles }, "profiles": profiles, @@ -391,8 +443,16 @@ def extra_state_attributes(self) -> dict: class SmartFanProfileEffectiveSlopeSensor(_SmartFanEntity): """Historized per-profile effective slope sensor for one HVAC/fan combination.""" - def __init__(self, entry_id: str, controller, hvac_mode: str, fan_mode: str) -> None: + def __init__( + self, + entry_id: str, + climate_entity: str, + controller, + hvac_mode: str, + fan_mode: str, + ) -> None: self._entry_id = entry_id + self._climate_entity = climate_entity self._controller = controller self._hvac_mode = hvac_mode self._fan_mode = fan_mode @@ -403,7 +463,7 @@ def __init__(self, entry_id: str, controller, hvac_mode: str, fan_mode: str) -> self._attr_native_unit_of_measurement = EFFECTIVE_SLOPE_UNIT self._attr_icon = "mdi:chart-line-variant" self._attr_entity_category = EntityCategory.DIAGNOSTIC - self.entity_id = build_entity_id("sensor", object_key) + self._set_entity_id(object_key) @property def native_value(self) -> float | None: @@ -430,6 +490,7 @@ class _BaseLearnedParameterSensor(_SmartFanEntity): def __init__( self, entry_id: str, + climate_entity: str, controller, *, name: str, @@ -441,6 +502,7 @@ def __init__( current_attr: str | None = None, ) -> None: self._entry_id = entry_id + self._climate_entity = climate_entity self._controller = controller self._learning_key = learning_key self._current_attr = current_attr @@ -451,7 +513,7 @@ def __init__( self._attr_icon = icon self._attr_native_value = None self._attr_entity_category = EntityCategory.DIAGNOSTIC - self.entity_id = build_entity_id("sensor", object_key) + self._set_entity_id(object_key) @property def native_value(self): @@ -481,9 +543,10 @@ def extra_state_attributes(self) -> dict: class SmartFanLearnedDeadbandSensor(_BaseLearnedParameterSensor): """Learned deadband parameter.""" - def __init__(self, entry_id: str, controller) -> None: + def __init__(self, entry_id: str, climate_entity: str, controller) -> None: super().__init__( entry_id, + climate_entity, controller, name="Learned Deadband", object_key="learned_deadband", @@ -498,9 +561,10 @@ def __init__(self, entry_id: str, controller) -> None: class SmartFanLearnedLimitTimeoutSensor(_BaseLearnedParameterSensor): """Learned limit_timeout parameter.""" - def __init__(self, entry_id: str, controller) -> None: + 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", diff --git a/custom_components/smart_fan_controller/services.yaml b/custom_components/smart_fan_controller/services.yaml index 2106995..19503af 100644 --- a/custom_components/smart_fan_controller/services.yaml +++ b/custom_components/smart_fan_controller/services.yaml @@ -1,15 +1,41 @@ apply_learned_settings: name: Apply learned settings - description: Apply optimal parameters computed from the learning algorithm. The integration automatically learns the best settings for deadband, soft_error, hard_error, and limit_timeout based on observed system behavior. This service applies those learned parameters and reloads the integration. + description: Apply optimal parameters computed from the learning algorithm. When several Smart Fan Controller entries are configured, specify the climate entity to target the correct controller. + fields: + climate_entity: + name: Climate entity + description: Optional when only one Smart Fan Controller entry is configured. Required when several climates are managed. + required: false + example: "climate.living_room" + selector: + entity: + domain: climate reset_learning: name: Reset learning - description: Clear all learning data and restart the learning process from scratch. This removes all collected samples and statistics, allowing the integration to begin learning optimal parameters again. + description: Clear all learning data and restart the learning process from scratch. When several Smart Fan Controller entries are configured, specify the climate entity to target the correct controller. + fields: + climate_entity: + name: Climate entity + description: Optional when only one Smart Fan Controller entry is configured. Required when several climates are managed. + required: false + example: "climate.living_room" + selector: + entity: + domain: climate set_effective_slope: name: Set effective slope description: Manually set the effective slope for a specific fan mode / HVAC mode profile. Replaces existing samples for that profile with synthetic ones so the learned value matches the provided slope. fields: + climate_entity: + name: Climate entity + description: Optional when only one Smart Fan Controller entry is configured. Required when several climates are managed. + required: false + example: "climate.living_room" + selector: + entity: + domain: climate hvac_mode: name: HVAC mode description: The HVAC mode (heat or cool). diff --git a/custom_components/smart_fan_controller/translations/en.json b/custom_components/smart_fan_controller/translations/en.json index f5a2529..17dc82e 100644 --- a/custom_components/smart_fan_controller/translations/en.json +++ b/custom_components/smart_fan_controller/translations/en.json @@ -23,6 +23,7 @@ "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." }, "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." } @@ -52,6 +53,7 @@ "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." }, "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." } diff --git a/custom_components/smart_fan_controller/translations/fr.json b/custom_components/smart_fan_controller/translations/fr.json index d92183a..e8e34fe 100644 --- a/custom_components/smart_fan_controller/translations/fr.json +++ b/custom_components/smart_fan_controller/translations/fr.json @@ -23,6 +23,7 @@ "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." }, "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." } @@ -52,6 +53,7 @@ "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." }, "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." } diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..283945a --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,48 @@ +"""Tests for Smart Fan Controller config-flow guards.""" + +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.const import CONF_CLIMATE_ENTITY + + +def _build_entry(entry_id: str, climate_entity: str, *, options: dict | None = None) -> MagicMock: + """Return a mock config entry for config-flow guard tests.""" + entry = MagicMock() + entry.entry_id = entry_id + entry.data = {CONF_CLIMATE_ENTITY: climate_entity} + entry.options = options or {} + return entry + + +def test_duplicate_climate_guard_detects_existing_entry() -> None: + """A climate already present in another entry must be rejected.""" + hass = MagicMock() + hass.config_entries.async_entries = MagicMock( + return_value=[ + _build_entry( + "entry-1", + "climate.old", + options={CONF_CLIMATE_ENTITY: "climate.living_room"}, + ) + ] + ) + + assert _is_climate_entity_already_configured(hass, "climate.living_room") is True + + +def test_duplicate_climate_guard_ignores_current_entry() -> None: + """The current entry must be ignored when editing its options.""" + hass = MagicMock() + hass.config_entries.async_entries = MagicMock( + return_value=[_build_entry("entry-1", "climate.living_room")] + ) + + assert ( + _is_climate_entity_already_configured( + hass, + "climate.living_room", + exclude_entry_id="entry-1", + ) + is False + ) \ No newline at end of file diff --git a/tests/test_data_collection.py b/tests/test_data_collection.py index 4d993cb..1c95796 100644 --- a/tests/test_data_collection.py +++ b/tests/test_data_collection.py @@ -6,7 +6,10 @@ import pytest -from custom_components.smart_fan_controller import async_setup_entry +from custom_components.smart_fan_controller import ( + _build_data_collection_decision, + async_setup_entry, +) from custom_components.smart_fan_controller.const import CONF_CLIMATE_ENTITY, CONF_DATA_COLLECTION from custom_components.smart_fan_controller.data_collection import DataCollector, _HEADER @@ -161,8 +164,46 @@ async def test_async_setup_entry_initializes_data_collector() -> None: with patch("custom_components.smart_fan_controller.DataCollector", return_value=fake_collector) as collector_cls: with patch("custom_components.smart_fan_controller.async_track_time_interval", return_value=MagicMock()): with patch("custom_components.smart_fan_controller.async_track_state_change_event", return_value=MagicMock()): - result = await async_setup_entry(hass, entry) + with patch("custom_components.smart_fan_controller._async_migrate_entity_ids", new=AsyncMock()): + result = await async_setup_entry(hass, entry) assert result is True collector_cls.assert_called_once_with(hass, hass.config.config_dir, entry.entry_id) fake_collector.async_initialize.assert_awaited_once() + + +def test_build_data_collection_decision_uses_live_cycle_metrics() -> None: + """CSV audit payload should include the live error and elapsed minutes from the loop.""" + decision = _build_data_collection_decision( + effective_fan="high", + effective_reason="MPC: Strong recovery", + current_fan="medium", + current_error=0.8, + minutes_since_change=14.5, + hvac_mode="heat", + target_temp=20.0, + mpc_decision={}, + ) + + assert decision["fan_mode"] == "high" + assert decision["temperature_error"] == pytest.approx(0.8) + assert decision["minutes_since_last_change"] == pytest.approx(14.5) + assert decision["projected_temperature"] is None + assert decision["projected_temperature_error"] is None + + +def test_build_data_collection_decision_uses_mpc_projection() -> None: + """CSV audit payload should derive projected error from the MPC 10-minute temperature.""" + decision = _build_data_collection_decision( + effective_fan="low", + effective_reason="MPC paused", + current_fan="low", + current_error=0.2, + minutes_since_change=6.0, + hvac_mode="heat", + target_temp=20.0, + mpc_decision={"mpc_predicted_temperature_10m": 19.6}, + ) + + assert decision["projected_temperature"] == 19.6 + assert decision["projected_temperature_error"] == pytest.approx(0.4) diff --git a/tests/test_learning.py b/tests/test_learning.py index 0a54d14..ade055a 100644 --- a/tests/test_learning.py +++ b/tests/test_learning.py @@ -107,7 +107,7 @@ def test_learned_dead_time_sensor_reports_median_response(self): learning.add_response_event(response_time) mpc = _build_mpc(learning) - sensor = SmartFanLearnedDeadTimeSensor("entry", mpc) + sensor = SmartFanLearnedDeadTimeSensor("entry", "climate.test", mpc) assert sensor.native_value == 8.0 @@ -120,7 +120,7 @@ def test_effective_timeout_sensor_shows_runtime_timeout(self): learning.add_response_event(response_time) mpc = _build_mpc(learning) - sensor = SmartFanEffectiveTimeoutSensor("entry", mpc) + sensor = SmartFanEffectiveTimeoutSensor("entry", "climate.test", mpc) assert sensor.native_value == 12.0 diff --git a/tests/test_mpc_controller.py b/tests/test_mpc_controller.py index 4c201d2..f5a89cd 100644 --- a/tests/test_mpc_controller.py +++ b/tests/test_mpc_controller.py @@ -171,6 +171,7 @@ def test_mpc_sensor_can_clear_to_none() -> None: """MPC sensor value can be cleared to None.""" sensor = SmartFanSensor( "entry-1", + "climate.living_room", "MPC Cost", "mpc_cost", "mpc_cost", @@ -197,7 +198,7 @@ def test_mpc_profiles_sensor_exposes_per_mode_values() -> None: for _ in range(12): learning.add_slope_sample("low", -0.4, 0.3, "cool") - sensor = SmartFanMpcProfilesSensor("entry-1", mpc, "heat") + sensor = SmartFanMpcProfilesSensor("entry-1", "climate.living_room", mpc, "heat") attrs = sensor.extra_state_attributes assert sensor.native_value == 1 @@ -205,7 +206,7 @@ def test_mpc_profiles_sensor_exposes_per_mode_values() -> None: assert attrs["profiles"]["medium"]["effective_slope"] == 0.5 assert attrs["profiles"]["medium"]["samples"] == 15 assert attrs["profiles"]["medium"]["ready"] is True - assert attrs["profile_effective_slope_sensors"]["medium"] == "sensor.smart_fan_controller_heat_medium_effective_slope" + assert attrs["profile_effective_slope_sensors"]["medium"] == "sensor.smart_fan_controller_living_room_heat_medium_effective_slope" assert attrs["profiles"]["high"]["effective_slope"] is None assert attrs["profiles"]["high"]["samples"] == 8 @@ -217,9 +218,15 @@ def test_profile_effective_slope_sensor_exposes_historizable_state() -> None: for _ in range(12): learning.add_slope_sample("high", 0.9, 0.3, "heat") - sensor = SmartFanProfileEffectiveSlopeSensor("entry-1", mpc, "heat", "high") + sensor = SmartFanProfileEffectiveSlopeSensor( + "entry-1", + "climate.living_room", + mpc, + "heat", + "high", + ) - assert sensor.entity_id == "sensor.smart_fan_controller_heat_high_effective_slope" + assert sensor.entity_id == "sensor.smart_fan_controller_living_room_heat_high_effective_slope" assert sensor.native_value == 0.9 assert sensor.extra_state_attributes["samples"] == 12 assert sensor.extra_state_attributes["ready"] is True diff --git a/tests/test_services.py b/tests/test_services.py index b5e48ce..04d5efb 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1,14 +1,24 @@ """Tests for Smart Fan Controller HA services (apply_learned_settings, reset_learning).""" import asyncio -import pytest from unittest.mock import AsyncMock, MagicMock -from custom_components.smart_fan_controller import _apply_optimal_parameters +import pytest +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from custom_components.smart_fan_controller import ( + _apply_optimal_parameters, + _async_migrate_entity_ids, + _resolve_controller_entry, +) from custom_components.smart_fan_controller.thermal_learning import ThermalLearning from custom_components.smart_fan_controller.const import ( + CONF_CLIMATE_ENTITY, CONF_DEADBAND, CONF_LIMIT_TIMEOUT, + DOMAIN, MIN_SAMPLES_LEARNING, + build_unique_id, ) @@ -33,6 +43,7 @@ async def test_apply_learned_settings_calls_helper_when_ready(self): entry = MagicMock() entry.data = {CONF_DEADBAND: 0.2, CONF_LIMIT_TIMEOUT: 15} + entry.options = {} applied_data = {} @@ -74,6 +85,7 @@ def test_apply_optimal_sets_learning_auto_applied_flag(self): 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"} optimal = {"deadband": 0.25, "limit_timeout": 12} @@ -82,9 +94,87 @@ def test_apply_optimal_sets_learning_auto_applied_flag(self): # Verify async_update_entry was called with learning_auto_applied=True call_args = hass.config_entries.async_update_entry.call_args updated_data = call_args[1]["data"] + 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.""" + hass = MagicMock() + hass.data = { + DOMAIN: { + "entry-1": {"climate_entity": "climate.living_room"}, + "entry-2": {"climate_entity": "climate.bedroom"}, + "_services_registered": True, + } + } + + with pytest.raises(HomeAssistantError, match="Multiple Smart Fan Controller entries"): + _resolve_controller_entry(hass) + + def test_resolve_controller_entry_matches_requested_climate(self): + """Service routing must resolve the correct loaded entry from climate_entity.""" + hass = MagicMock() + hass.data = { + DOMAIN: { + "entry-1": {"climate_entity": "climate.living_room"}, + "entry-2": {"climate_entity": "climate.bedroom"}, + "_services_registered": True, + } + } + + entry_id, entry_data = _resolve_controller_entry(hass, "climate.bedroom") + + assert entry_id == "entry-2" + assert entry_data["climate_entity"] == "climate.bedroom" + + +@pytest.mark.asyncio +async def test_entity_id_migration_renames_legacy_registry_entries(hass) -> None: + """Existing legacy entity_ids should migrate to climate-scoped names.""" + entity_registry = er.async_get(hass) + entry = MagicMock() + entry.entry_id = "entry-1" + hass.config_entries.async_get_entry = MagicMock(return_value=entry) + + entity_registry.async_get_or_create( + "sensor", + DOMAIN, + build_unique_id("mpc_status", entry.entry_id), + config_entry=entry, + suggested_object_id="smart_fan_controller_mpc_status", + ) + + await _async_migrate_entity_ids(hass, entry, "climate.living_room") + + migrated = entity_registry.async_get("sensor.smart_fan_controller_living_room_mpc_status") + assert migrated is not None + assert migrated.unique_id == build_unique_id("mpc_status", entry.entry_id) + + +@pytest.mark.asyncio +async def test_entity_id_migration_preserves_custom_registry_ids(hass) -> None: + """Migration should not override a user-customized entity_id.""" + entity_registry = er.async_get(hass) + entry = MagicMock() + entry.entry_id = "entry-1" + hass.config_entries.async_get_entry = MagicMock(return_value=entry) + + entity_registry.async_get_or_create( + "sensor", + DOMAIN, + build_unique_id("mpc_status", entry.entry_id), + config_entry=entry, + suggested_object_id="custom_living_room_mpc_status", + ) + + await _async_migrate_entity_ids(hass, entry, "climate.living_room") + + assert entity_registry.async_get("sensor.custom_living_room_mpc_status") is not None + assert entity_registry.async_get("sensor.smart_fan_controller_living_room_mpc_status") is None class TestResetLearning: From 8dda89c6a379107042262d34af3849ae92797684 Mon Sep 17 00:00:00 2001 From: Gamso Date: Tue, 14 Apr 2026 19:59:58 +0200 Subject: [PATCH 05/11] =?UTF-8?q?fix:=20audit=20cleanup=20=E2=80=94=20name?= =?UTF-8?q?d=20cost=20constants,=20fan=20ordering=20check,=20retry=20guard?= =?UTF-8?q?,=20code=20smells?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract MPC cost function magic numbers into named module-level constants (COMFORT_ERROR_WEIGHT, OVERSHOOT_QUADRATIC_WEIGHT, URGENCY_SENSITIVITY, etc.) - Add fan_modes ordering validation: warn when learned slopes violate monotonicity - Fix retry storm on fan command failure: update last_change_time even on error to prevent rapid-fire retries when the climate API is broken - Replace _services_registered flag with hass.services.has_service() for race-free service registration across concurrent entry setups - Fix data_collection rotated path using os.path.splitext() instead of fragile str.replace('.csv', '_old.csv') - Fix thermal_learning serialization key slope_M2 → slope_m2 for naming consistency (with backward-compatible deserialization) - Extract _should_collect_slope_sample() helper from nested learning gate - Extract _update_sensors() helper to deduplicate sensor update loops - Delete unused switch.py stub and remove Platform.SWITCH from PLATFORMS - Add replay_bench.py to replay csv file. --- .../smart_fan_controller/__init__.py | 78 ++- .../smart_fan_controller/config_flow.py | 1 + .../smart_fan_controller/data_collection.py | 3 +- .../smart_fan_controller/mpc_controller.py | 50 +- .../smart_fan_controller/sensor.py | 10 + .../smart_fan_controller/switch.py | 16 - .../smart_fan_controller/thermal_learning.py | 4 +- scripts/README.md | 110 ++++ scripts/replay_bench.py | 529 ++++++++++++++++++ tests/test_config_flow.py | 2 +- tests/test_learning_persistence.py | 9 +- 11 files changed, 757 insertions(+), 55 deletions(-) delete mode 100644 custom_components/smart_fan_controller/switch.py create mode 100644 scripts/README.md create mode 100644 scripts/replay_bench.py diff --git a/custom_components/smart_fan_controller/__init__.py b/custom_components/smart_fan_controller/__init__.py index 2819f22..b4bff9d 100644 --- a/custom_components/smart_fan_controller/__init__.py +++ b/custom_components/smart_fan_controller/__init__.py @@ -48,7 +48,7 @@ from .thermal_learning import ThermalLearning _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [Platform.SENSOR] SERVICE_APPLY_LEARNED_SETTINGS = "apply_learned_settings" SERVICE_RESET_LEARNING = "reset_learning" SERVICE_SET_EFFECTIVE_SLOPE = "set_effective_slope" @@ -184,6 +184,40 @@ async def _async_migrate_entity_ids(hass: HomeAssistant, entry: ConfigEntry, cli ) +def _should_collect_slope_sample( + *, + current_fan: str | None, + is_defrost_active: bool, + is_hvac_idle: bool, + phase: str, + minutes_since_change: float, + learned_dead_time: float, + ctrl_state: dict, + now: float, +) -> bool: + """Return True when slope learning conditions are met.""" + if current_fan is None or is_defrost_active or is_hvac_idle: + return False + if phase != PHASE_ESTABLISHED: + return False + min_stable_minutes = learned_dead_time * MIN_ESTABLISHED_RATIO + if minutes_since_change < min_stable_minutes: + return False + if ctrl_state["last_setpoint_drop_time"] != 0: + minutes_since_setpoint_drop = (now - ctrl_state["last_setpoint_drop_time"]) / 60.0 + if minutes_since_setpoint_drop < SETPOINT_DROP_LEARNING_COOLDOWN: + return False + return True + + +def _update_sensors(hass: HomeAssistant, entry_id: str, data: dict) -> None: + """Push new data to all sensor entities and trigger a state write.""" + for sensor in hass.data[DOMAIN][entry_id].get("sensors") or []: + if hasattr(sensor, "update_from_mpc"): + sensor.update_from_mpc(data) + sensor.async_write_ha_state() + + async def _apply_optimal_parameters( hass: HomeAssistant, entry: ConfigEntry, @@ -392,6 +426,7 @@ async def _async_apply_fan_change( current_fan, effective_fan, ) + on_confirmed() # update last_change_time to prevent retry storm else: on_confirmed() @@ -455,9 +490,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "store": store, } - if not domain_data.get("_services_registered"): + if not hass.services.has_service(DOMAIN, SERVICE_APPLY_LEARNED_SETTINGS): _register_services(hass) - domain_data["_services_registered"] = True await _async_migrate_entity_ids(hass, entry, climate_id) @@ -569,15 +603,17 @@ async def run_control_loop(_): phase = PHASE_ESTABLISHED # Slope sample collection - if current_fan is not None and not is_defrost_active and not is_hvac_idle: - min_stable_minutes = learned_dead_time * MIN_ESTABLISHED_RATIO - minutes_since_setpoint_drop = (now - ctrl_state["last_setpoint_drop_time"]) / 60.0 - if ( - phase == PHASE_ESTABLISHED - and minutes_since_change >= min_stable_minutes - and (ctrl_state["last_setpoint_drop_time"] == 0 or minutes_since_setpoint_drop >= SETPOINT_DROP_LEARNING_COOLDOWN) - ): - learning.add_slope_sample(current_fan, vtherm_slope, current_error, hvac_mode, is_window_open) + if _should_collect_slope_sample( + current_fan=current_fan, + is_defrost_active=is_defrost_active, + is_hvac_idle=is_hvac_idle, + phase=phase, + minutes_since_change=minutes_since_change, + learned_dead_time=learned_dead_time, + ctrl_state=ctrl_state, + now=now, + ): # current_fan is guaranteed non-None by _should_collect_slope_sample + learning.add_slope_sample(current_fan, vtherm_slope, current_error, hvac_mode, is_window_open) # type: ignore[arg-type] # Response event collection if slope_change and ctrl_state["last_change_time"] > 0: @@ -618,10 +654,11 @@ async def run_control_loop(_): is_hvac_idle=is_hvac_idle, ) - for sensor in hass.data[DOMAIN][entry.entry_id].get("sensors") or []: - if hasattr(sensor, "update_from_mpc"): - sensor.update_from_mpc(mpc_decision) - sensor.async_write_ha_state() + _update_sensors(hass, entry.entry_id, { + **mpc_decision, + "fan_mode": effective_fan, + "minutes_since_last_change": round(minutes_since_change, 2), + }) if learning.is_ready() and not entry.data.get("learning_auto_applied", False): learned_params = learning.compute_optimal_parameters() @@ -629,7 +666,7 @@ async def run_control_loop(_): _LOGGER.info("Learning is ready for %s, scheduling auto-apply of learned parameters", climate_id) hass.async_create_task(_apply_optimal_parameters(hass, entry, learned_params)) - if effective_fan != current_fan: + if effective_fan is not None and effective_fan != current_fan: def _confirm(): ctrl_state["last_change_time"] = time.time() @@ -656,11 +693,7 @@ async def _handle_manual_change(event): ctrl_state["last_change_time"] = time.time() manual_data = {"fan_mode": new_fan, "minutes_since_last_change": 0.0, "reason": "Manual Override"} - sensors = hass.data[DOMAIN][entry.entry_id].get("sensors", []) - for sensor in sensors: - if hasattr(sensor, "update_from_mpc"): - sensor.update_from_mpc(manual_data) - sensor.async_write_ha_state() + _update_sensors(hass, entry.entry_id, manual_data) entry.async_on_unload(async_track_time_interval(hass, run_control_loop, timedelta(minutes=DELTA_TIME_CONTROL_LOOP))) entry.async_on_unload(async_track_state_change_event(hass, [climate_id], _handle_manual_change)) @@ -710,7 +743,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.services.async_remove(DOMAIN, SERVICE_APPLY_LEARNED_SETTINGS) hass.services.async_remove(DOMAIN, SERVICE_RESET_LEARNING) hass.services.async_remove(DOMAIN, SERVICE_SET_EFFECTIVE_SLOPE) - domain_data.pop("_services_registered", None) if not domain_data: hass.data.pop(DOMAIN, None) _LOGGER.info("Unloaded Smart Fan Controller entry %s", entry.entry_id) diff --git a/custom_components/smart_fan_controller/config_flow.py b/custom_components/smart_fan_controller/config_flow.py index aeb71f1..265e098 100644 --- a/custom_components/smart_fan_controller/config_flow.py +++ b/custom_components/smart_fan_controller/config_flow.py @@ -1,3 +1,4 @@ +"""Config and options flow for Smart Fan Controller.""" from typing import Any import voluptuous as vol diff --git a/custom_components/smart_fan_controller/data_collection.py b/custom_components/smart_fan_controller/data_collection.py index 0fc134c..7ca9d45 100644 --- a/custom_components/smart_fan_controller/data_collection.py +++ b/custom_components/smart_fan_controller/data_collection.py @@ -68,7 +68,8 @@ class DataCollector: def __init__(self, hass: HomeAssistant, config_dir: str, entry_id: str) -> None: self._hass = hass self._path = os.path.join(config_dir, f"smart_fan_controller_data_{entry_id[:8]}.csv") - self._rotated_path = self._path.replace(".csv", "_old.csv") + base, ext = os.path.splitext(self._path) + self._rotated_path = f"{base}_old{ext}" self._io_lock = asyncio.Lock() async def async_initialize(self) -> None: diff --git a/custom_components/smart_fan_controller/mpc_controller.py b/custom_components/smart_fan_controller/mpc_controller.py index 104e046..df511ff 100644 --- a/custom_components/smart_fan_controller/mpc_controller.py +++ b/custom_components/smart_fan_controller/mpc_controller.py @@ -17,10 +17,22 @@ _LOGGER = logging.getLogger(__name__) +# Cost function weights (simulation loop in _simulate_mode) +COMFORT_ERROR_WEIGHT = 1.0 +OVERSHOOT_QUADRATIC_WEIGHT = 3.0 +FLOOR_VIOLATION_LINEAR_WEIGHT = 12.0 +FLOOR_VIOLATION_QUADRATIC_WEIGHT = 30.0 +MODE_CHANGE_DISTANCE_COST = 0.15 +MODE_RANK_COST = 0.05 MIN_INTERVAL_CHANGE_PENALTY = 25.0 +URGENCY_SENSITIVITY = 2.0 + +# Disturbance bias tracker DISTURBANCE_EMA_ALPHA = 0.2 DISTURBANCE_DECAY = 0.85 MAX_DISTURBANCE_BIAS = 2.0 + +# Hysteresis margins BASE_SWITCH_GAIN_MARGIN = 0.1 NEAR_TARGET_SWITCH_GAIN_MARGIN = 0.3 APPROACHING_TARGET_SWITCH_GAIN_MARGIN = 0.15 @@ -29,8 +41,6 @@ UNDER_TARGET_STEPDOWN_GAIN_MARGIN = 0.2 UNDER_TARGET_STEPDOWN_GAIN_PER_DEG = 0.5 UNDER_TARGET_SHORTFALL_RESERVE = 0.1 -FLOOR_VIOLATION_LINEAR_WEIGHT = 12.0 -FLOOR_VIOLATION_QUADRATIC_WEIGHT = 30.0 @dataclass(slots=True) @@ -74,8 +84,32 @@ def fan_modes(self) -> list[str] | None: @fan_modes.setter def fan_modes(self, modes: list[str] | None) -> None: - """Update the available fan modes.""" + """Update the available fan modes. + + Fan modes are assumed ordered weakest-to-strongest. A warning is + logged when learned profiles are available and their slopes violate + this ordering, which usually indicates a configuration issue. + """ self._fan_modes = modes + if modes and len(modes) >= 2: + 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 @property def learning(self) -> ThermalLearning: @@ -497,7 +531,7 @@ def _simulate_mode( candidate_effective_slope = candidate_mode_slope + self._disturbance_bias current_error = self._temperature_error(current_temp, target_temp, hvac_mode) - urgency_weight = 1.0 + max(current_error - self._deadband, 0.0) * 2.0 + urgency_weight = 1.0 + max(current_error - self._deadband, 0.0) * URGENCY_SENSITIVITY for step in range(1, steps + 1): elapsed = step * self._cycle_minutes @@ -519,13 +553,13 @@ def _simulate_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) - cost += comfort_error * urgency_weight - cost += 3.0 * overshoot * overshoot + cost += COMFORT_ERROR_WEIGHT * comfort_error * urgency_weight + cost += OVERSHOOT_QUADRATIC_WEIGHT * overshoot * overshoot cost += FLOOR_VIOLATION_LINEAR_WEIGHT * floor_violation * urgency_weight cost += FLOOR_VIOLATION_QUADRATIC_WEIGHT * floor_violation * floor_violation - cost += 0.15 * abs(candidate_index - current_index) - cost += 0.05 * (candidate_index + 1) + cost += MODE_CHANGE_DISTANCE_COST * abs(candidate_index - current_index) + cost += MODE_RANK_COST * (candidate_index + 1) 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 027b0dc..ca3a39c 100644 --- a/custom_components/smart_fan_controller/sensor.py +++ b/custom_components/smart_fan_controller/sensor.py @@ -85,6 +85,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e climate_entity = data["climate_entity"] sensor_definitions = [ + ("Fan Mode", "fan_mode", "fan_mode", None, SensorDeviceClass.ENUM, "mdi:fan", None), + ( + "Fan Mode Last Change", + "fan_mode_last_change", + "minutes_since_last_change", + UnitOfTime.MINUTES, + SensorDeviceClass.DURATION, + "mdi:clock-outline", + EntityCategory.DIAGNOSTIC, + ), ("MPC Status", "mpc_status", "mpc_status", None, None, "mdi:robot-outline", EntityCategory.DIAGNOSTIC), ("MPC Reason", "mpc_reason", "mpc_reason", None, None, "mdi:text-box-search-outline", EntityCategory.DIAGNOSTIC), ("MPC Fan Mode", "mpc_fan_mode", "mpc_fan_mode", None, None, "mdi:fan-chevron-up", EntityCategory.DIAGNOSTIC), diff --git a/custom_components/smart_fan_controller/switch.py b/custom_components/smart_fan_controller/switch.py deleted file mode 100644 index ff93e10..0000000 --- a/custom_components/smart_fan_controller/switch.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Switch platform for Smart Fan Controller.""" -from __future__ import annotations - -import logging - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None: - """Set up the switch platform from a config entry (no switches currently).""" diff --git a/custom_components/smart_fan_controller/thermal_learning.py b/custom_components/smart_fan_controller/thermal_learning.py index db5211c..db12821 100644 --- a/custom_components/smart_fan_controller/thermal_learning.py +++ b/custom_components/smart_fan_controller/thermal_learning.py @@ -361,7 +361,7 @@ def to_dict(self) -> dict: "response_events": self._response_events[-100:], "slope_count": self._slope_count, "slope_mean": self._slope_mean, - "slope_M2": self._slope_m2, + "slope_m2": self._slope_m2, "slope_max": self._slope_max, } @@ -413,7 +413,7 @@ def from_dict(cls, data: dict): # Restore incremental statistics instance._slope_count = data.get("slope_count", 0) instance._slope_mean = data.get("slope_mean", 0.0) - instance._slope_m2 = data.get("slope_M2", 0.0) + instance._slope_m2 = data.get("slope_m2", data.get("slope_M2", 0.0)) instance._slope_max = data.get("slope_max", 0.0) # Apply sliding window cleanup on restore diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..daa1879 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,110 @@ +# Analysis Scripts — Smart Fan Controller + +Offline tools for analysing and testing MPC algorithm behaviour. + +--- + +## `replay_bench.py` — MPC replay on CSV traces + +Replays (open-loop) a CSV file produced by the data collector through `MPCController`, +with support for comparing multiple cost-weight variants simultaneously. + +### Prerequisites + +The CSV file must be in `data_collection.py` format (columns: `timestamp`, `hvac_mode`, +`current_temp`, `target_temp`, `vtherm_slope`, `current_fan`, etc.). + +### Basic usage + +```bash +python scripts/replay_bench.py /config/smart_fan_controller_data_XXXXXXXX.csv +``` + +### Comparing cost variants + +```bash +python scripts/replay_bench.py data.csv \ + --variant baseline \ + --variant high_rank:MODE_RANK_COST=0.3 \ + --variant aggressive:COMFORT_ERROR_WEIGHT=2.0,URGENCY_SENSITIVITY=4.0 +``` + +### Exporting per-row decisions + +```bash +python scripts/replay_bench.py data.csv \ + --variant baseline \ + --variant high_rank:MODE_RANK_COST=0.3 \ + --output /tmp/replay_results.csv +``` + +### Options + +| Option | Default | Description | +| ------------------------------ | ------------- | ------------------------------------------ | +| `--variant NAME[:KEY=VAL,...]` | `baseline` | Variant to replay (repeatable) | +| `--deadband FLOAT` | `0.2` | Deadband in degrees | +| `--min-interval INT` | `10` | Minimum interval between fan changes (min) | +| `--fan-order a,b,c` | auto-detected | Fan modes ordered weakest to strongest | +| `--output FILE` | — | Write per-row decisions to a CSV file | + +### Tunable constants + +| Constant | Default | Role | +| ---------------------------------- | ------- | ------------------------------------------------- | +| `COMFORT_ERROR_WEIGHT` | `1.0` | Weight of the comfort error term | +| `OVERSHOOT_QUADRATIC_WEIGHT` | `3.0` | Quadratic penalty for overshoot | +| `FLOOR_VIOLATION_LINEAR_WEIGHT` | `12.0` | Linear penalty for floor violation | +| `FLOOR_VIOLATION_QUADRATIC_WEIGHT` | `30.0` | Quadratic penalty for floor violation | +| `MODE_CHANGE_DISTANCE_COST` | `0.15` | Distance cost when switching fan mode | +| `MODE_RANK_COST` | `0.05` | Rank cost (nudges towards lower fan modes) | +| `MIN_INTERVAL_CHANGE_PENALTY` | `25.0` | Penalty for switching before the minimum interval | +| `URGENCY_SENSITIVITY` | `2.0` | Urgency scaling when error exceeds deadband | + +### Report output + +- **Learning profiles**: learned effective slope per mode/HVAC mode + sample count +- **Fan changes/hour**: stability comparison across variants +- **Agreement with live (%)**: match with decisions recorded in the CSV +- **Avg MPC cost**: mean weighted cost (relative comparison between variants) +- **Prediction MAE T+10**: absolute prediction error for temperature at T+10 min +- **Fan distribution**: percentage of time spent in each fan mode + +--- + +## `analyze_mpc_viability.py` — Shadow MPC vs production analysis + +Compares decisions from the legacy shadow MPC (beta period, CSV format `mpc_shadow_*`) +against live decisions from the production controller. + +> **Note:** This script expects a CSV file at `data/smart_fan_controller_data_01KK6PQT.csv` +> in the historical format (columns `mpc_shadow_fan`, `mpc_shadow_cost`, etc.). +> It is no longer compatible with the current data collector format. + +### Usage + +```bash +python scripts/analyze_mpc_viability.py +``` + +Produces 12 analysis sections: global statistics, reactivity, fan speed distribution, +live/MPC divergence episodes, aggressiveness evaluation, etc. + +--- + +## `analyze_real_data.py` / `analyze_real_data_v2.py` — Initial calibration + +One-shot scripts used to calibrate algorithm parameters from raw HA data +(`sensor.thermometre_salon_temperature` + VTherm slope). + +Data is embedded directly in the script (no external file needed). + +- `analyze_real_data.py`: dead time, slopes per fan mode, heating/maintenance behaviour +- `analyze_real_data_v2.py`: in-depth analysis of real dead time and maintenance-mode oscillations + +### Usage + +```bash +python scripts/analyze_real_data.py +python scripts/analyze_real_data_v2.py +``` diff --git a/scripts/replay_bench.py b/scripts/replay_bench.py new file mode 100644 index 0000000..f465fad --- /dev/null +++ b/scripts/replay_bench.py @@ -0,0 +1,529 @@ +#!/usr/bin/env python3 +"""Offline replay bench for Smart Fan Controller MPC. + +Replays data-collection CSV traces through the MPC controller with configurable +cost weights, producing comparison metrics across variants. + +NOTE: This is an open-loop replay — real temperature data from the CSV is used +as-is, but the *simulated fan state* is tracked per variant. Because the +thermal trajectory is fixed, results show "what would MPC have decided" rather +than "what would have happened". This is valid for comparing cost-weight +sensitivity on the same trace. + +Usage: + python scripts/replay_bench.py data.csv + python scripts/replay_bench.py data.csv --variant baseline --variant high_rank:MODE_RANK_COST=0.3 + python scripts/replay_bench.py data.csv \\ + --variant baseline \\ + --variant aggressive:COMFORT_ERROR_WEIGHT=2.0,OVERSHOOT_QUADRATIC_WEIGHT=5.0 \\ + --output replay_results.csv +""" + +from __future__ import annotations + +import argparse +import csv +import logging +import sys +from collections import Counter +from dataclasses import dataclass, field +from pathlib import Path + +# --------------------------------------------------------------------------- +# Make the custom component importable +# --------------------------------------------------------------------------- +_REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(_REPO / "custom_components")) + +from smart_fan_controller import mpc_controller as _mpc_mod # noqa: E402 +from smart_fan_controller.mpc_controller import MPCController # noqa: E402 +from smart_fan_controller.thermal_learning import ThermalLearning # noqa: E402 + +# Quiet the debug chatter during bulk replay +logging.getLogger("custom_components.smart_fan_controller").setLevel(logging.WARNING) + +# --------------------------------------------------------------------------- +# Tunable cost constants (module-level in mpc_controller.py) +# --------------------------------------------------------------------------- +TUNABLE = [ + "COMFORT_ERROR_WEIGHT", + "OVERSHOOT_QUADRATIC_WEIGHT", + "FLOOR_VIOLATION_LINEAR_WEIGHT", + "FLOOR_VIOLATION_QUADRATIC_WEIGHT", + "MODE_CHANGE_DISTANCE_COST", + "MODE_RANK_COST", + "MIN_INTERVAL_CHANGE_PENALTY", + "URGENCY_SENSITIVITY", +] +_DEFAULTS = {k: getattr(_mpc_mod, k) for k in TUNABLE} + + +# --------------------------------------------------------------------------- +# Data structures +# --------------------------------------------------------------------------- +@dataclass +class Row: + """One parsed CSV row.""" + + timestamp: str + hvac_mode: str + current_temp: float + target_temp: float + vtherm_slope: float + current_fan: str + decided_fan: str + minutes_since_change: float + is_window_open: bool + defrost_active: bool + hvac_idle: bool + mpc_fan: str + mpc_status: str + phase: str + dead_time: float + + +@dataclass +class Metrics: + """Aggregate metrics for one replay variant.""" + + name: str + overrides: dict[str, float] + total_rows: int = 0 + active_rows: int = 0 + fan_changes: int = 0 + fan_dist: Counter = field(default_factory=Counter) + costs: list[float] = field(default_factory=list) + agree_with_live: int = 0 + prediction_errors_10m: list[float] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# CSV loading +# --------------------------------------------------------------------------- +def load_csv(path: str) -> list[Row]: + """Parse a data_collection CSV into Row objects.""" + rows: list[Row] = [] + with open(path, newline="", encoding="utf-8") as fh: + for r in csv.DictReader(fh): + try: + rows.append( + Row( + timestamp=r["timestamp"], + hvac_mode=r["hvac_mode"], + current_temp=float(r["current_temp"]), + target_temp=float(r["target_temp"]), + vtherm_slope=float(r["vtherm_slope"]), + current_fan=r["current_fan"], + decided_fan=r["decided_fan"], + minutes_since_change=float(r["minutes_since_change"]), + is_window_open=bool(int(r.get("is_window_open", "0"))), + defrost_active=bool(int(r.get("defrost_active", "0"))), + hvac_idle=bool(int(r.get("hvac_idle", "0"))), + mpc_fan=r.get("mpc_fan", ""), + mpc_status=r.get("mpc_status", ""), + phase=r.get("phase", ""), + dead_time=float(r["dead_time"]) if r.get("dead_time") else 10.0, + ) + ) + except (ValueError, KeyError) as exc: + print(f" Warning: skipping row: {exc}", file=sys.stderr) + return rows + + +# --------------------------------------------------------------------------- +# Learning profile builder +# --------------------------------------------------------------------------- +def build_learning(rows: list[Row]) -> ThermalLearning: + """Seed a ThermalLearning from established, undisturbed CSV rows.""" + learning = ThermalLearning() + for row in rows: + if row.phase != "ESTABLISHED": + continue + if row.is_window_open or row.defrost_active or row.hvac_idle: + continue + if row.hvac_mode in ("off", "dry", "fan_only"): + continue + if not row.current_fan: + continue + error = ( + row.current_temp - row.target_temp + if row.hvac_mode == "cool" + else row.target_temp - row.current_temp + ) + learning.add_slope_sample( + fan_mode=row.current_fan, + slope=row.vtherm_slope, + temperature_error=error, + hvac_mode=row.hvac_mode, + ) + return learning + + +def detect_fan_modes(rows: list[Row]) -> list[str]: + """Return fan modes in first-seen order.""" + seen: dict[str, None] = {} + for row in rows: + if row.current_fan and row.current_fan not in seen: + seen[row.current_fan] = None + return list(seen.keys()) + + +# --------------------------------------------------------------------------- +# Variant replay (semi-closed-loop: simulated fan, real temperatures) +# --------------------------------------------------------------------------- +def _apply(overrides: dict[str, float]) -> None: + """Monkey-patch mpc_controller cost constants.""" + for k, v in overrides.items(): + setattr(_mpc_mod, k, v) + + +def _restore() -> None: + """Restore original mpc_controller cost constants.""" + for k, v in _DEFAULTS.items(): + setattr(_mpc_mod, k, v) + + +def replay( + rows: list[Row], + learning: ThermalLearning, + fan_modes: list[str], + overrides: dict[str, float], + deadband: float, + min_interval: int, + cycle_minutes: int = 2, +) -> list[tuple[dict, str]]: + """Replay CSV through MPC, tracking simulated fan state. + + Returns [(mpc_payload, simulated_fan), ...] for each row. + """ + _apply(overrides) + try: + mpc = MPCController( + learning=learning, + deadband=deadband, + min_interval=min_interval, + fan_modes=fan_modes, + ) + results: list[tuple[dict, str]] = [] + sim_fan = rows[0].current_fan if rows else fan_modes[0] + sim_minutes = rows[0].minutes_since_change if rows else 0.0 + + for row in rows: + payload = mpc.evaluate( + current_temp=row.current_temp, + target_temp=row.target_temp, + vtherm_slope=row.vtherm_slope, + hvac_mode=row.hvac_mode, + current_fan=sim_fan, + is_window_open=row.is_window_open, + is_defrost_active=row.defrost_active, + is_hvac_idle=row.hvac_idle, + minutes_since_change=sim_minutes, + ) + rec_fan = payload.get("mpc_fan_mode") or sim_fan + would_change = payload.get("mpc_would_change_now", "no") + + if would_change == "yes" and rec_fan != sim_fan: + sim_fan = rec_fan + sim_minutes = 0.0 + else: + sim_minutes += cycle_minutes + + results.append((payload, sim_fan)) + return results + finally: + _restore() + + +# --------------------------------------------------------------------------- +# Metrics computation +# --------------------------------------------------------------------------- +def compute_metrics( + name: str, + overrides: dict[str, float], + rows: list[Row], + results: list[tuple[dict, str]], + deadband: float, + cycle_minutes: int = 2, +) -> Metrics: + """Compute aggregate metrics from a replay run.""" + m = Metrics(name=name, overrides=overrides, total_rows=len(rows)) + prev_fan: str | None = None + + for i, (row, (payload, sim_fan)) in enumerate(zip(rows, results)): + status = payload.get("mpc_status", "") + + m.fan_dist[sim_fan] += 1 + + if status in ("Disturbed", "Idle", "Unavailable"): + prev_fan = sim_fan + continue + + m.active_rows += 1 + + # Fan change + if prev_fan is not None and sim_fan != prev_fan: + m.fan_changes += 1 + + # Agreement with live system + if sim_fan == row.decided_fan: + m.agree_with_live += 1 + + # Cost + cost = payload.get("mpc_cost") + if cost is not None: + m.costs.append(cost) + + # Prediction error at T+10 (lookahead rows) + pred_10 = payload.get("mpc_predicted_temperature_10m") + lookahead = 10 // cycle_minutes + if pred_10 is not None and (i + lookahead) < len(rows): + actual = rows[i + lookahead].current_temp + m.prediction_errors_10m.append(abs(pred_10 - actual)) + + prev_fan = sim_fan + + return m + + +# --------------------------------------------------------------------------- +# Reporting +# --------------------------------------------------------------------------- +def print_report( + rows: list[Row], + fan_modes: list[str], + learning: ThermalLearning, + metrics_list: list[Metrics], + deadband: float, + cycle_minutes: int = 2, +) -> None: + """Print a formatted comparison report.""" + total_hours = len(rows) * cycle_minutes / 60.0 + + # --- Learning profiles --- + print("\n=== Learning Profiles ===") + hvac_modes_seen = sorted( + {r.hvac_mode for r in rows if r.hvac_mode not in ("off", "dry", "fan_only")} + ) + for hvac in hvac_modes_seen: + profiles = learning.get_mode_profiles(hvac, fan_modes) + print(f"\n {hvac}:") + for mode, info in profiles.items(): + slope = info["effective_slope"] + samples = info["samples"] + status = "+" if info["ready"] else "." + slope_str = f"{slope:+.3f} C/h" if slope is not None else "n/a" + print(f" {status} {mode:>10s}: {slope_str} ({samples} samples)") + + print(f"\n Dead time: {learning.get_dead_time():.1f} min") + print(f" Total slope samples: {learning.slope_sample_count()}") + print(f" Trace duration: {total_hours:.1f} hours ({len(rows)} rows)") + + # --- Variant comparison table --- + print("\n=== Variant Comparison ===\n") + col_w = max(18, max(len(m.name) for m in metrics_list) + 4) + header = f"{'Metric':<32s}" + for m in metrics_list: + header += f"{m.name:>{col_w}s}" + print(header) + print("-" * len(header)) + + def _row(label: str, values: list[str]) -> None: + line = f"{label:<32s}" + for v in values: + line += f"{v:>{col_w}s}" + print(line) + + _row( + "Fan changes/hour", + [f"{m.fan_changes / max(total_hours, 0.01):.2f}" for m in metrics_list], + ) + _row("Total fan changes", [str(m.fan_changes) for m in metrics_list]) + _row( + "Agreement with live (%)", + [f"{100.0 * m.agree_with_live / max(m.active_rows, 1):.1f}" for m in metrics_list], + ) + _row( + "Avg MPC cost", + [f"{sum(m.costs) / max(len(m.costs), 1):.2f}" for m in metrics_list], + ) + _row( + "Prediction MAE T+10 (C)", + [ + f"{sum(m.prediction_errors_10m) / len(m.prediction_errors_10m):.3f}" + if m.prediction_errors_10m + else "n/a" + for m in metrics_list + ], + ) + _row("Active rows", [str(m.active_rows) for m in metrics_list]) + _row("Disturbed/idle rows", [str(m.total_rows - m.active_rows) for m in metrics_list]) + + # Fan distribution + print(f"\n{'--- Fan Distribution (%) ---':<32s}") + for fm in fan_modes: + _row( + f" {fm}", + [ + f"{100.0 * m.fan_dist.get(fm, 0) / max(sum(m.fan_dist.values()), 1):.1f}" + for m in metrics_list + ], + ) + + # Overrides summary + print(f"\n--- Cost Overrides ---") + for m in metrics_list: + if m.overrides: + parts = ", ".join(f"{k}={v}" for k, v in m.overrides.items()) + print(f" {m.name}: {parts}") + else: + print(f" {m.name}: (defaults)") + + +# --------------------------------------------------------------------------- +# CSV output +# --------------------------------------------------------------------------- +def write_output_csv( + path: str, + rows: list[Row], + variants: list[tuple[str, dict[str, float]]], + all_results: dict[str, list[tuple[dict, str]]], +) -> None: + """Write per-row replay decisions for all variants to a CSV.""" + variant_names = [name for name, _ in variants] + header = ["timestamp", "hvac_mode", "current_temp", "target_temp", "vtherm_slope", "live_fan"] + for vn in variant_names: + header.extend([f"{vn}_fan", f"{vn}_cost", f"{vn}_status", f"{vn}_would_change"]) + + with open(path, "w", newline="", encoding="utf-8") as fh: + writer = csv.writer(fh) + writer.writerow(header) + for i, row in enumerate(rows): + out: list[object] = [ + row.timestamp, + row.hvac_mode, + row.current_temp, + row.target_temp, + row.vtherm_slope, + row.decided_fan, + ] + for vn in variant_names: + payload, sim_fan = all_results[vn][i] + cost = payload.get("mpc_cost") + out.extend([ + sim_fan, + f"{cost:.3f}" if cost is not None else "", + payload.get("mpc_status", ""), + payload.get("mpc_would_change_now", "no"), + ]) + writer.writerow(out) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- +def parse_variant_spec(spec: str) -> tuple[str, dict[str, float]]: + """Parse 'name:KEY=val,KEY=val' into (name, {overrides}).""" + if ":" not in spec: + return spec, {} + name, params = spec.split(":", 1) + overrides: dict[str, float] = {} + for part in params.split(","): + key, val = part.split("=", 1) + key = key.strip() + if key not in TUNABLE: + raise argparse.ArgumentTypeError( + f"Unknown tunable: {key}. Valid: {', '.join(TUNABLE)}" + ) + overrides[key] = float(val) + return name, overrides + + +def main() -> None: + """Entry point.""" + parser = argparse.ArgumentParser( + description="Replay CSV traces through the MPC controller with configurable cost weights.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Variant syntax:\n" + " --variant NAME Use default cost weights\n" + " --variant NAME:KEY=VAL,... Override specific constants\n\n" + "Tunable constants (defaults):\n" + + "\n".join(f" {k} = {v}" for k, v in _DEFAULTS.items()) + ), + ) + parser.add_argument("csv_file", help="Path to a data_collection CSV file") + parser.add_argument( + "--variant", + action="append", + default=[], + metavar="SPEC", + help="Variant specification (repeatable). Format: NAME[:KEY=VAL,...]", + ) + parser.add_argument("--deadband", type=float, default=0.2, help="Deadband in degrees (default: 0.2)") + parser.add_argument("--min-interval", type=int, default=10, help="Min interval in minutes (default: 10)") + parser.add_argument( + "--fan-order", + help="Comma-separated fan modes, weakest to strongest (auto-detected if omitted)", + ) + parser.add_argument( + "--output", + help="Write per-row replay decisions to this CSV path", + ) + args = parser.parse_args() + + # Parse variants + variants: list[tuple[str, dict[str, float]]] = [] + if not args.variant: + variants.append(("baseline", {})) + for spec in args.variant: + try: + variants.append(parse_variant_spec(spec)) + except (ValueError, argparse.ArgumentTypeError) as exc: + parser.error(str(exc)) + + # Load data + print(f"Loading {args.csv_file}...") + rows = load_csv(args.csv_file) + if not rows: + print("Error: no valid rows found.", file=sys.stderr) + sys.exit(1) + print(f" {len(rows)} rows loaded ({rows[0].timestamp} -> {rows[-1].timestamp})") + + # Fan modes + if args.fan_order: + fan_modes = [m.strip() for m in args.fan_order.split(",")] + else: + fan_modes = detect_fan_modes(rows) + print(f" Fan modes: {fan_modes}") + + # Build learning + print("Building learning profiles...") + learning = build_learning(rows) + print(f" {learning.slope_sample_count()} slope samples ingested") + + # Run variants + all_metrics: list[Metrics] = [] + all_results: dict[str, list[tuple[dict, str]]] = {} + + for name, overrides in variants: + label = name + if overrides: + label += " (" + ", ".join(f"{k}={v}" for k, v in overrides.items()) + ")" + print(f"Replaying variant '{label}'...") + results = replay(rows, learning, fan_modes, overrides, args.deadband, args.min_interval) + m = compute_metrics(name, overrides, rows, results, args.deadband) + all_metrics.append(m) + all_results[name] = results + + # Report + print_report(rows, fan_modes, learning, all_metrics, args.deadband) + + # Optional CSV output + if args.output: + write_output_csv(args.output, rows, variants, all_results) + print(f"\nPer-row decisions written to {args.output}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 283945a..49e4628 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -45,4 +45,4 @@ def test_duplicate_climate_guard_ignores_current_entry() -> None: exclude_entry_id="entry-1", ) is False - ) \ No newline at end of file + ) diff --git a/tests/test_learning_persistence.py b/tests/test_learning_persistence.py index eb72612..4eb3a60 100644 --- a/tests/test_learning_persistence.py +++ b/tests/test_learning_persistence.py @@ -1,7 +1,8 @@ """Tests for learning data persistence functionality.""" import time -import pytest from unittest.mock import AsyncMock, MagicMock, patch + +import pytest from homeassistant.core import HomeAssistant from homeassistant.config_entries import ConfigEntry @@ -36,7 +37,7 @@ def test_learning_to_dict_serialization(self): assert "response_events" in data assert "slope_count" in data assert "slope_mean" in data - assert "slope_M2" in data + assert "slope_m2" in data assert "slope_max" in data # Verify data is preserved (at least partially for windowing) @@ -121,8 +122,8 @@ def test_learning_sliding_window_cleanup(self): learning = ThermalLearning() # Manually add old samples (simulate data from a week ago) - DAYS_IN_SECONDS = 24 * 3600 - old_timestamp = time.time() - (8 * DAYS_IN_SECONDS) # 8 days ago + days_in_seconds = 24 * 3600 + old_timestamp = time.time() - (8 * days_in_seconds) # 8 days ago learning.slope_samples = [ (old_timestamp, "medium", 0.5), (time.time(), "high", 0.8), From 63ac5f83d9eee609a11739bd0b444dfd610248c1 Mon Sep 17 00:00:00 2001 From: Gamso Date: Wed, 22 Apr 2026 22:00:42 +0200 Subject: [PATCH 06/11] feat: harden MPC profile learning and add snapshot-aware replay analysis - treat idle operating state as HVAC idle during disturbance detection - preserve sparse learned profiles and enforce monotone known slopes during partial learning - make replay bench snapshot-aware with time window filtering for real-trace analysis - add real trace datasets, regression tests, and reference analysis notes --- .../smart_fan_controller/__init__.py | 2 +- .../smart_fan_controller/mpc_controller.py | 45 ++- .../smart_fan_controller/thermal_learning.py | 51 ++- scripts/README.md | 35 ++- scripts/replay_bench.py | 290 +++++++++++++++++- tests/test_learning_persistence.py | 36 ++- tests/test_mpc_controller.py | 43 ++- 7 files changed, 447 insertions(+), 55 deletions(-) diff --git a/custom_components/smart_fan_controller/__init__.py b/custom_components/smart_fan_controller/__init__.py index b4bff9d..3f1b333 100644 --- a/custom_components/smart_fan_controller/__init__.py +++ b/custom_components/smart_fan_controller/__init__.py @@ -390,7 +390,7 @@ def _detect_disturbances(hass, conf: dict, defrost_state: dict) -> tuple[bool, b operating_entity_id = conf.get(CONF_OPERATING_ENTITY) if operating_entity_id: operating_state = hass.states.get(operating_entity_id) - if operating_state and operating_state.state in ("off", "false", "False", "0"): + if operating_state and operating_state.state in ("off", "false", "False", "0", "idle"): is_hvac_idle = True return is_defrost, is_hvac_idle diff --git a/custom_components/smart_fan_controller/mpc_controller.py b/custom_components/smart_fan_controller/mpc_controller.py index df511ff..b518f8a 100644 --- a/custom_components/smart_fan_controller/mpc_controller.py +++ b/custom_components/smart_fan_controller/mpc_controller.py @@ -419,38 +419,37 @@ def _detect_phase(minutes_since_change: float, dead_time: float) -> str: return PHASE_TRANSIENT return PHASE_ESTABLISHED - def build_monotone_slopes(self, fan_modes: list[str], hvac_mode: str) -> dict[str, float] | None: - """Return monotone-enforced slopes keyed by fan mode, or None if not all profiles are ready. - - Fan modes are assumed ordered from weakest to strongest. If every mode - has a learned profile, we walk left-to-right and clamp each value to be - >= the previous one (isotonic regression, pool-adjacent-violators with - a simple forward pass). This prevents a contaminated low-speed profile - from being ranked above a mid-speed one in the MPC cost comparison. - - Returns None when any profile is missing — on a fresh installation not - all modes have data yet and the constraint could mask real measurements. + 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. """ - raw: list[tuple[str, float]] = [] + 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: - return None - raw.append((fm, slope)) - - enforced: dict[str, float] = {} - prev = float("-inf") - for fm, val in raw: - clamped = max(val, prev) + continue # unknown profile — skip, caller uses rank-scaling fallback + clamped = max(slope, prev) + if clamped != slope: + changed = True enforced[fm] = clamped prev = clamped - if enforced != {fm: val for fm, val in raw}: + if changed: _LOGGER.debug( - "Monotone enforcement applied for %s: raw=%s → enforced=%s", + "Monotone enforcement applied for %s: %s", hvac_mode, - {fm: round(val, 3) for fm, val in raw}, - {fm: round(val, 3) for fm, val in enforced.items()}, + {fm: round(v, 3) for fm, v in enforced.items()}, ) return enforced diff --git a/custom_components/smart_fan_controller/thermal_learning.py b/custom_components/smart_fan_controller/thermal_learning.py index db12821..368c758 100644 --- a/custom_components/smart_fan_controller/thermal_learning.py +++ b/custom_components/smart_fan_controller/thermal_learning.py @@ -94,10 +94,12 @@ def add_slope_sample(self, fan_mode: str, slope: float, temperature_error: float f"{effective_slope:.3f}" if effective_slope is not None else "n/a", ) - # Cleanup: keep only data within sliding window (7 days) + # Cleanup: keep only data within sliding window (7 days), + # but retain at least MIN_MODE_PROFILE_SAMPLES per profile so a rarely-used + # mode does not silently lose its learned profile after a quiet week. cutoff_time = time.time() - (self._learning_window_hours * 3600) before = len(self._slope_samples) - self._slope_samples = [s for s in self._slope_samples if s[0] > cutoff_time] + self._slope_samples = self.trim_with_min_retention(self._slope_samples, cutoff_time, MIN_MODE_PROFILE_SAMPLES) if len(self._slope_samples) < before: self.recompute_slope_stats() @@ -106,6 +108,46 @@ def add_slope_sample(self, fan_mode: str, slope: float, temperature_error: float before - len(self._slope_samples), ) + @staticmethod + def trim_with_min_retention( + samples: list, + cutoff_time: float, + min_per_profile: int, + ) -> list: + """Apply sliding-window cutoff while retaining at least min_per_profile + newest samples per (fan_mode, hvac_mode) profile. + + Prevents a rarely-used mode from losing its learned profile solely + because it hasn't been active in the past 7 days. + Samples are 4-tuples: (timestamp, fan_mode, slope, hvac_mode). + """ + within = [s for s in samples if s[0] > cutoff_time] + expired = [s for s in samples if s[0] <= cutoff_time] + if not expired: + return within + + # Count within-window samples per (fan_mode, hvac_mode) profile + profile_counts: dict[tuple, int] = {} + for _, fm, _, hm in within: + key = (fm, hm) + profile_counts[key] = profile_counts.get(key, 0) + 1 + + # Group expired samples by profile + expired_by_profile: dict[tuple, list] = {} + for s in expired: + key = (s[1], s[3]) + expired_by_profile.setdefault(key, []).append(s) + + extras: list = [] + for key, exp_list in expired_by_profile.items(): + shortfall = min_per_profile - profile_counts.get(key, 0) + if shortfall > 0: + # Keep the newest expired ones for this profile + exp_list.sort(key=lambda s: s[0], reverse=True) + extras.extend(exp_list[:shortfall]) + + return within + extras + def _update_slope_stats(self, abs_slope: float) -> None: """Update slope statistics incrementally using Welford's algorithm.""" self._slope_count += 1 @@ -416,9 +458,10 @@ def from_dict(cls, data: dict): instance._slope_m2 = data.get("slope_m2", data.get("slope_M2", 0.0)) instance._slope_max = data.get("slope_max", 0.0) - # Apply sliding window cleanup on restore + # Apply sliding window cleanup on restore, keeping at least MIN_MODE_PROFILE_SAMPLES + # per profile so learned modes survive a quiet week without new samples. cutoff_time = time.time() - (instance._learning_window_hours * 3600) - instance._slope_samples = [s for s in instance._slope_samples if s[0] > cutoff_time] + instance._slope_samples = ThermalLearning.trim_with_min_retention(instance._slope_samples, cutoff_time, MIN_MODE_PROFILE_SAMPLES) instance._response_events = [(ts, t) for ts, t in instance._response_events if ts > cutoff_time] # Rebuild stats from cleaned window diff --git a/scripts/README.md b/scripts/README.md index daa1879..50c1de2 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -38,15 +38,27 @@ python scripts/replay_bench.py data.csv \ --output /tmp/replay_results.csv ``` +### Analysing a specific time window + +```bash +python scripts/replay_bench.py data.csv \ + --start 2026-04-17T17:40:00Z \ + --end 2026-04-17T18:15:00Z \ + --fan-order silent,low,med,high,superhigh +``` + ### Options -| Option | Default | Description | -| ------------------------------ | ------------- | ------------------------------------------ | -| `--variant NAME[:KEY=VAL,...]` | `baseline` | Variant to replay (repeatable) | -| `--deadband FLOAT` | `0.2` | Deadband in degrees | -| `--min-interval INT` | `10` | Minimum interval between fan changes (min) | -| `--fan-order a,b,c` | auto-detected | Fan modes ordered weakest to strongest | -| `--output FILE` | — | Write per-row decisions to a CSV file | +| Option | Default | Description | +| ------------------------------ | ------------- | ------------------------------------------------------------ | +| `--variant NAME[:KEY=VAL,...]` | `baseline` | Variant to replay (repeatable) | +| `--deadband FLOAT` | `0.2` | Deadband in degrees | +| `--min-interval INT` | `10` | Minimum interval between fan changes (min) | +| `--fan-order a,b,c` | auto-detected | Fan modes ordered weakest to strongest | +| `--seed-snapshots-dir DIR` | CSV directory | Seed missing profiles from `*_effective_slope.csv` snapshots | +| `--start ISO_TIMESTAMP` | — | Inclusive start of the replay window | +| `--end ISO_TIMESTAMP` | — | Inclusive end of the replay window | +| `--output FILE` | — | Write per-row decisions to a CSV file | ### Tunable constants @@ -70,6 +82,15 @@ python scripts/replay_bench.py data.csv \ - **Prediction MAE T+10**: absolute prediction error for temperature at T+10 min - **Fan distribution**: percentage of time spent in each fan mode +If snapshot CSV files are present, the replay bench uses them in two ways: + +- it seeds profiles that are still missing near the start of the replay window +- it replays later snapshot changes during the trace, including transitions to + `unknown` / `unavailable` + +This makes long replays closer to the live controller state when profile +sensors are restored after a restart or disappear later in the trace. + --- ## `analyze_mpc_viability.py` — Shadow MPC vs production analysis diff --git a/scripts/replay_bench.py b/scripts/replay_bench.py index f465fad..3145c0d 100644 --- a/scripts/replay_bench.py +++ b/scripts/replay_bench.py @@ -23,7 +23,10 @@ import argparse import csv +from datetime import datetime +from importlib import import_module import logging +import re import sys from collections import Counter from dataclasses import dataclass, field @@ -35,9 +38,13 @@ _REPO = Path(__file__).resolve().parent.parent sys.path.insert(0, str(_REPO / "custom_components")) -from smart_fan_controller import mpc_controller as _mpc_mod # noqa: E402 -from smart_fan_controller.mpc_controller import MPCController # noqa: E402 -from smart_fan_controller.thermal_learning import ThermalLearning # noqa: E402 +_mpc_mod = import_module("smart_fan_controller.mpc_controller") +_const_mod = import_module("smart_fan_controller.const") +_thermal_mod = import_module("smart_fan_controller.thermal_learning") + +MPCController = _mpc_mod.MPCController +ThermalLearning = _thermal_mod.ThermalLearning +MIN_MODE_PROFILE_SAMPLES = _const_mod.MIN_MODE_PROFILE_SAMPLES # Quiet the debug chatter during bulk replay logging.getLogger("custom_components.smart_fan_controller").setLevel(logging.WARNING) @@ -56,6 +63,8 @@ "URGENCY_SENSITIVITY", ] _DEFAULTS = {k: getattr(_mpc_mod, k) for k in TUNABLE} +_PROFILE_ENTITY_RE = re.compile(r"_(heat|cool)_([^_]+)_effective_slope$") +_SNAPSHOT_START_GRACE_SECONDS = 120 # --------------------------------------------------------------------------- @@ -97,6 +106,47 @@ class Metrics: prediction_errors_10m: list[float] = field(default_factory=list) +@dataclass(frozen=True) +class SnapshotEvent: + """One effective-slope snapshot change.""" + + timestamp: datetime + fan_mode: str + hvac_mode: str + slope: float | None + + +def parse_timestamp(value: str) -> datetime: + """Parse an ISO timestamp with optional trailing Z.""" + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def filter_rows_by_time( + rows: list[Row], + *, + start: str | None, + end: str | None, +) -> list[Row]: + """Return rows filtered to an inclusive time window.""" + if start is None and end is None: + return rows + + start_ts = parse_timestamp(start) if start is not None else None + end_ts = parse_timestamp(end) if end is not None else None + if start_ts is not None and end_ts is not None and start_ts > end_ts: + raise ValueError("--start must be <= --end") + + filtered: list[Row] = [] + for row in rows: + row_ts = parse_timestamp(row.timestamp) + if start_ts is not None and row_ts < start_ts: + continue + if end_ts is not None and row_ts > end_ts: + continue + filtered.append(row) + return filtered + + # --------------------------------------------------------------------------- # CSV loading # --------------------------------------------------------------------------- @@ -159,6 +209,170 @@ def build_learning(rows: list[Row]) -> ThermalLearning: return learning +def clone_learning(learning: ThermalLearning) -> ThermalLearning: + """Clone learning state for one replay variant.""" + return ThermalLearning.from_dict(learning.to_dict()) + + +def load_snapshot_events( + snapshot_dir: str | None, + *, + default_hvac_mode: str, + fan_modes: list[str], +) -> list[SnapshotEvent]: + """Load all profile snapshot events from *_effective_slope.csv files.""" + if snapshot_dir is None: + return [] + + snapshot_path = Path(snapshot_dir) + if not snapshot_path.exists(): + return [] + + events: list[SnapshotEvent] = [] + for path in sorted(snapshot_path.glob("*_effective_slope.csv")): + fallback_mode = path.stem.removesuffix("_effective_slope") + with open(path, newline="", encoding="utf-8") as fh: + for row in csv.DictReader(fh): + entity_id = row.get("entity_id", "") + match = _PROFILE_ENTITY_RE.search(entity_id) + if match: + hvac_mode = match.group(1) + fan_mode = match.group(2) + else: + hvac_mode = default_hvac_mode + fan_mode = fallback_mode + + if fan_mode not in fan_modes: + continue + + try: + changed_at = parse_timestamp(row["last_changed"]) + except (KeyError, ValueError): + continue + + state = row.get("state", "").strip() + if state in ("", "unknown", "unavailable"): + slope = None + else: + try: + slope = float(state) + except ValueError: + continue + + events.append( + SnapshotEvent( + timestamp=changed_at, + fan_mode=fan_mode, + hvac_mode=hvac_mode, + slope=slope, + ) + ) + + events.sort(key=lambda event: (event.timestamp, event.hvac_mode, event.fan_mode)) + return events + + +def load_snapshot_profiles( + snapshot_events: list[SnapshotEvent], + *, + trace_start: datetime, +) -> tuple[dict[tuple[str, str], float], int]: + """Load the latest known profile values near trace start from snapshot CSV files. + + A short grace window is allowed after the first trace row to handle Home + Assistant restarts where the profile sensors briefly report unavailable and + then republish their restored value a few milliseconds later. + """ + snapshot_deadline = trace_start.timestamp() + _SNAPSHOT_START_GRACE_SECONDS + profiles: dict[tuple[str, str], float] = {} + next_index = 0 + for next_index, event in enumerate(snapshot_events): + if event.timestamp.timestamp() > snapshot_deadline: + break + profiles[(event.fan_mode, event.hvac_mode)] = event.slope + else: + next_index = len(snapshot_events) + + return {key: slope for key, slope in profiles.items() if slope is not None}, next_index + + +def seed_learning_from_snapshots( + learning: ThermalLearning, + snapshot_profiles: dict[tuple[str, str], float], +) -> list[tuple[str, str, float]]: + """Seed missing learned profiles from snapshot CSV values. + + Ready profiles learned from the trace are preserved. Snapshot values only + backfill modes that would otherwise remain unavailable in the replay. + """ + seeded: list[tuple[str, str, float]] = [] + for (fan_mode, hvac_mode), slope in snapshot_profiles.items(): + if learning.get_mode_effective_slope(fan_mode, hvac_mode) is not None: + continue + learning.set_mode_effective_slope(fan_mode, hvac_mode, slope) + seeded.append((fan_mode, hvac_mode, slope)) + return seeded + + +def _set_learning_profile_value( + learning: ThermalLearning, + *, + fan_mode: str, + hvac_mode: str, + effective_slope: float, + timestamp: datetime, +) -> None: + """Replace one profile with a synthetic snapshot value at a replay timestamp.""" + raw_slope = -effective_slope if hvac_mode == "cool" else effective_slope + retained = [sample for sample in learning.slope_samples if not (sample[1] == fan_mode and sample[3] == hvac_mode)] + base_ts = timestamp.timestamp() + retained.extend((base_ts + offset, fan_mode, raw_slope, hvac_mode) for offset in range(MIN_MODE_PROFILE_SAMPLES)) + learning.slope_samples = retained + learning.recompute_slope_stats() + + +def _clear_learning_profile_value( + learning: ThermalLearning, + *, + fan_mode: str, + hvac_mode: str, +) -> None: + """Remove one profile when its snapshot becomes unknown/unavailable.""" + retained = [sample for sample in learning.slope_samples if not (sample[1] == fan_mode and sample[3] == hvac_mode)] + if len(retained) == len(learning.slope_samples): + return + learning.slope_samples = retained + learning.recompute_slope_stats() + + +def apply_snapshot_events_until( + learning: ThermalLearning, + snapshot_events: list[SnapshotEvent], + start_index: int, + current_timestamp: datetime, +) -> int: + """Apply all snapshot events up to the current replay row timestamp.""" + index = start_index + while index < len(snapshot_events) and snapshot_events[index].timestamp <= current_timestamp: + event = snapshot_events[index] + if event.slope is None: + _clear_learning_profile_value( + learning, + fan_mode=event.fan_mode, + hvac_mode=event.hvac_mode, + ) + else: + _set_learning_profile_value( + learning, + fan_mode=event.fan_mode, + hvac_mode=event.hvac_mode, + effective_slope=event.slope, + timestamp=event.timestamp, + ) + index += 1 + return index + + def detect_fan_modes(rows: list[Row]) -> list[str]: """Return fan modes in first-seen order.""" seen: dict[str, None] = {} @@ -190,6 +404,8 @@ def replay( overrides: dict[str, float], deadband: float, min_interval: int, + snapshot_events: list[SnapshotEvent] | None = None, + snapshot_start_index: int = 0, cycle_minutes: int = 2, ) -> list[tuple[dict, str]]: """Replay CSV through MPC, tracking simulated fan state. @@ -207,8 +423,16 @@ def replay( results: list[tuple[dict, str]] = [] sim_fan = rows[0].current_fan if rows else fan_modes[0] sim_minutes = rows[0].minutes_since_change if rows else 0.0 + snapshot_index = snapshot_start_index for row in rows: + if snapshot_events: + snapshot_index = apply_snapshot_events_until( + learning, + snapshot_events, + snapshot_index, + parse_timestamp(row.timestamp), + ) payload = mpc.evaluate( current_temp=row.current_temp, target_temp=row.target_temp, @@ -243,7 +467,6 @@ def compute_metrics( overrides: dict[str, float], rows: list[Row], results: list[tuple[dict, str]], - deadband: float, cycle_minutes: int = 2, ) -> Metrics: """Compute aggregate metrics from a replay run.""" @@ -294,7 +517,6 @@ def print_report( fan_modes: list[str], learning: ThermalLearning, metrics_list: list[Metrics], - deadband: float, cycle_minutes: int = 2, ) -> None: """Print a formatted comparison report.""" @@ -371,7 +593,7 @@ def _row(label: str, values: list[str]) -> None: ) # Overrides summary - print(f"\n--- Cost Overrides ---") + print("\n--- Cost Overrides ---") for m in metrics_list: if m.overrides: parts = ", ".join(f"{k}={v}" for k, v in m.overrides.items()) @@ -470,6 +692,18 @@ def main() -> None: "--output", help="Write per-row replay decisions to this CSV path", ) + parser.add_argument( + "--seed-snapshots-dir", + help=("Directory containing *_effective_slope.csv snapshots used to seed missing " "profiles (defaults to the CSV file directory when such files are present)"), + ) + parser.add_argument( + "--start", + help="Inclusive start timestamp (ISO-8601, e.g. 2026-04-17T17:40:00Z)", + ) + parser.add_argument( + "--end", + help="Inclusive end timestamp (ISO-8601, e.g. 2026-04-17T18:15:00Z)", + ) args = parser.parse_args() # Parse variants @@ -490,6 +724,16 @@ def main() -> None: sys.exit(1) print(f" {len(rows)} rows loaded ({rows[0].timestamp} -> {rows[-1].timestamp})") + try: + rows = filter_rows_by_time(rows, start=args.start, end=args.end) + except ValueError as exc: + parser.error(str(exc)) + if not rows: + print("Error: no rows remain after applying the time filter.", file=sys.stderr) + sys.exit(1) + if args.start or args.end: + print(f" Windowed to {len(rows)} rows ({rows[0].timestamp} -> {rows[-1].timestamp})") + # Fan modes if args.fan_order: fan_modes = [m.strip() for m in args.fan_order.split(",")] @@ -502,22 +746,50 @@ def main() -> None: learning = build_learning(rows) print(f" {learning.slope_sample_count()} slope samples ingested") + snapshot_dir = args.seed_snapshots_dir or str(Path(args.csv_file).resolve().parent) + snapshot_events = load_snapshot_events( + snapshot_dir, + default_hvac_mode=rows[0].hvac_mode, + fan_modes=fan_modes, + ) + snapshot_profiles = load_snapshot_profiles( + snapshot_events, + trace_start=parse_timestamp(rows[0].timestamp), + ) + initial_snapshot_profiles, snapshot_start_index = snapshot_profiles + seeded_profiles = seed_learning_from_snapshots(learning, initial_snapshot_profiles) + if seeded_profiles: + seeded_summary = ", ".join(f"{hvac_mode}/{fan_mode}={slope:.3f}" for fan_mode, hvac_mode, slope in seeded_profiles) + print(f" Seeded {len(seeded_profiles)} missing profiles from snapshots: {seeded_summary}") + # Run variants all_metrics: list[Metrics] = [] all_results: dict[str, list[tuple[dict, str]]] = {} + report_learning = learning for name, overrides in variants: label = name if overrides: label += " (" + ", ".join(f"{k}={v}" for k, v in overrides.items()) + ")" print(f"Replaying variant '{label}'...") - results = replay(rows, learning, fan_modes, overrides, args.deadband, args.min_interval) - m = compute_metrics(name, overrides, rows, results, args.deadband) + variant_learning = clone_learning(learning) + results = replay( + rows, + variant_learning, + fan_modes, + overrides, + args.deadband, + args.min_interval, + snapshot_events=snapshot_events, + snapshot_start_index=snapshot_start_index, + ) + m = compute_metrics(name, overrides, rows, results) all_metrics.append(m) all_results[name] = results + report_learning = variant_learning # Report - print_report(rows, fan_modes, learning, all_metrics, args.deadband) + print_report(rows, fan_modes, report_learning, all_metrics) # Optional CSV output if args.output: diff --git a/tests/test_learning_persistence.py b/tests/test_learning_persistence.py index 4eb3a60..4f5bf5b 100644 --- a/tests/test_learning_persistence.py +++ b/tests/test_learning_persistence.py @@ -13,6 +13,7 @@ CONF_CLIMATE_ENTITY, CONF_DEADBAND, DEFAULT_DEADBAND, + MIN_MODE_PROFILE_SAMPLES, ) @@ -125,8 +126,8 @@ def test_learning_sliding_window_cleanup(self): days_in_seconds = 24 * 3600 old_timestamp = time.time() - (8 * days_in_seconds) # 8 days ago learning.slope_samples = [ - (old_timestamp, "medium", 0.5), - (time.time(), "high", 0.8), + (old_timestamp, "medium", 0.5, "heat"), + (time.time(), "high", 0.8, "heat"), ] learning.response_events = [ (old_timestamp, 12.5), @@ -137,10 +138,37 @@ def test_learning_sliding_window_cleanup(self): data = learning.to_dict() restored = ThermalLearning.from_dict(data) - # Old samples should be removed (only recent ones kept) - assert len(restored.slope_samples) == 1 + # Old "medium" sample expired and has no within-window replacement, + # but MIN_MODE_PROFILE_SAMPLES=10 means retention kicks in only when + # a profile already has >= 10 samples. With only 1 expired sample, + # 1 is retained to preserve the profile's last known value. + assert len(restored.slope_samples) == 2 # both kept (each profile < min) assert len(restored.response_events) == 1 + def test_learning_min_retention_preserves_rare_profile(self): + """Old samples for a rarely-used profile are kept up to MIN_MODE_PROFILE_SAMPLES.""" + learning = ThermalLearning() + days_in_seconds = 24 * 3600 + now = time.time() + old = now - (8 * days_in_seconds) + + # Build sample lists directly and assign via public property + expired_med = [(old + i, "med", 0.5, "heat") for i in range(10)] + recent_med = [(now + i, "med", 0.4, "heat") for i in range(15)] + expired_high = [(old + 100 + i, "high", 1.5, "heat") for i in range(8)] + learning.slope_samples = expired_med + recent_med + expired_high + + cutoff = now - (7 * days_in_seconds) + trimmed = ThermalLearning.trim_with_min_retention(learning.slope_samples, cutoff, MIN_MODE_PROFILE_SAMPLES) + + med_samples = [s for s in trimmed if s[1] == "med"] + high_samples = [s for s in trimmed if s[1] == "high"] + + # med: 15 within-window, no shortfall → all 10 expired dropped + assert len(med_samples) == 15 + # high: 0 within-window, shortfall=10, only 8 available → keep all 8 + assert len(high_samples) == 8 + @pytest.mark.asyncio async def test_integration_storage_persistence(self, hass: HomeAssistant): """Test that learning data is saved to and loaded from storage.""" diff --git a/tests/test_mpc_controller.py b/tests/test_mpc_controller.py index f5a89cd..489c722 100644 --- a/tests/test_mpc_controller.py +++ b/tests/test_mpc_controller.py @@ -2,7 +2,7 @@ import csv from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import pytest @@ -432,7 +432,6 @@ 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.""" learning = ThermalLearning() - fan_modes = ["silent", "low", "med", "high", "superhigh"] # Create inverted profiles: silent > med (the real-world bug) learning.set_mode_effective_slope("silent", "heat", 0.53) @@ -449,7 +448,7 @@ def test_monotone_constraint_enforces_ordering() -> None: ) monotone = mpc.build_monotone_slopes(["silent", "low", "med", "high", "superhigh"], "heat") - assert monotone is not None + 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) @@ -459,8 +458,8 @@ def test_monotone_constraint_enforces_ordering() -> None: assert monotone["superhigh"] >= monotone["high"] -def test_monotone_constraint_returns_none_for_partial_profiles() -> None: - """On a fresh install with incomplete profiles, monotone should return None.""" +def test_monotone_constraint_returns_partial_dict_for_partial_profiles() -> None: + """On a fresh install with incomplete profiles, monotone should return an empty dict.""" learning = ThermalLearning() mpc = MPCController( learning=learning, @@ -470,7 +469,37 @@ def test_monotone_constraint_returns_none_for_partial_profiles() -> None: ) # No profiles learned yet result = mpc.build_monotone_slopes(FAN_MODES, "heat") - assert result is None + assert isinstance(result, dict) + assert len(result) == 0 + + +def test_monotone_constraint_partial_profiles_enforces_known_pairs() -> None: + """With some profiles missing, monotone should still enforce ordering among known ones.""" + learning = ThermalLearning() + fan_modes = ["silent", "low", "med", "high", "superhigh"] + + # Real-world snapshot inversion seen in the collected data: + # high=1.59 while superhigh=1.075. + learning.set_mode_effective_slope("high", "heat", 1.59) + learning.set_mode_effective_slope("superhigh", "heat", 1.075) + + mpc = MPCController( + learning=learning, + deadband=0.3, + min_interval=10, + fan_modes=fan_modes, + ) + + result = mpc.build_monotone_slopes(fan_modes, "heat") + assert isinstance(result, dict) + # Only known profiles are in the dict + assert "silent" not in result + assert "low" not in result + 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) def test_monotone_constraint_noop_when_already_ordered() -> None: @@ -489,7 +518,7 @@ def test_monotone_constraint_noop_when_already_ordered() -> None: ) monotone = mpc.build_monotone_slopes(FAN_MODES, "heat") - assert monotone is not None + assert isinstance(monotone, dict) assert monotone["low"] == pytest.approx(0.15, abs=0.001) assert monotone["medium"] == pytest.approx(0.5, abs=0.001) assert monotone["high"] == pytest.approx(1.0, abs=0.001) From e740556d9a1871026d00d429bedb8d372b480d8d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 25 May 2026 12:21:06 +0000 Subject: [PATCH 07/11] fix: guard against None in data_collection projected_temperature round() Agent-Logs-Url: https://github.com/Gamso/smart_fan_controller/sessions/87b4ec4e-2fc0-4309-b6b8-ae03ea4b43b0 Co-authored-by: Gamso <13354981+Gamso@users.noreply.github.com> --- .../smart_fan_controller/data_collection.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/custom_components/smart_fan_controller/data_collection.py b/custom_components/smart_fan_controller/data_collection.py index 52662bf..1b2cc2a 100644 --- a/custom_components/smart_fan_controller/data_collection.py +++ b/custom_components/smart_fan_controller/data_collection.py @@ -50,6 +50,12 @@ _MAX_FILE_SIZE = 10 * 1024 * 1024 +def _coalesce(value, default): + """Return *value* if it is not None, otherwise *default*.""" + return value if value is not None else default + + + class DataCollector: """Appends one CSV row per control cycle to a rotating log file.""" @@ -91,8 +97,8 @@ async def async_record( round(decision.get("temperature_error", 0.0), 3), round(vtherm_slope, 4), round(effective_slope, 4), - round(decision.get("projected_temperature", current_temp), 3), - round(decision.get("projected_temperature_error", 0.0), 3), + round(_coalesce(decision.get("projected_temperature"), current_temp), 3), + round(_coalesce(decision.get("projected_temperature_error"), 0.0), 3), phase, round(decision.get("minutes_since_last_change", 0.0), 2), round(effective_timeout, 2), From aaf038a1f733a4b08133fe8d8b0351ef25d4b45a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 20:58:44 +0000 Subject: [PATCH 08/11] Add regression test for None projected values --- tests/test_data_collection.py | 38 +++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/test_data_collection.py b/tests/test_data_collection.py index 8f9bff0..e6660bf 100644 --- a/tests/test_data_collection.py +++ b/tests/test_data_collection.py @@ -98,6 +98,44 @@ async def test_async_record_appends_row(tmp_path: Path) -> None: assert hass.async_add_executor_job.await_count == 2 +@pytest.mark.asyncio +async def test_async_record_handles_none_projected_values(tmp_path: Path) -> None: + """None projected values should fallback without raising.""" + hass = _make_executor_hass() + collector = DataCollector(hass, str(tmp_path), "123456789") + + await collector.async_initialize() + await collector.async_record( + hvac_mode="heat", + current_temp=20.1234, + target_temp=21.0, + vtherm_slope=-0.2468, + is_window_open=False, + decision={ + "temperature_error": 0.8765, + "projected_temperature": None, + "projected_temperature_error": None, + "minutes_since_last_change": 12.345, + "current_fan": "low", + "fan_mode": "medium", + "reason": "Strong recovery", + }, + phase="TRANSIENT", + effective_slope=-0.2468, + effective_timeout=15.4321, + force=True, + learning_ready=False, + dead_time=10.987, + ) + + with open(collector.path, newline="", encoding="utf-8") as fh: + rows = list(csv.reader(fh)) + + assert len(rows) == 2 + assert rows[1][7:9] == ["20.123", "0.0"] + assert hass.async_add_executor_job.await_count == 2 + + @pytest.mark.asyncio async def test_async_initialize_rotates_file_on_header_change(tmp_path: Path) -> None: """The collector should rotate the active CSV when the schema changes.""" From be83afca7a7611fea2f06a5b60acb88e809df747 Mon Sep 17 00:00:00 2001 From: Gamso Date: Fri, 29 May 2026 00:02:10 +0200 Subject: [PATCH 09/11] feat(mpc): upgrade to Economic MPC-lite and resolve dead-time blindness - Implement dynamic, adaptive simulation horizon length based on dead times to eliminate lag blindness - Decouple dead-time tracking separately for heat and cool modes with robust schema fallback migrations - Transition mode-ranking weights to physical non-linear power curves [1.0, 1.5, 3.0, 6.0] - Refactor comfort cost to calculate step-by-step urgency dynamically inside the simulation loop - Re-establish strict zero comfort cost within target deadbands --- README.md | 12 ++- .../smart_fan_controller/__init__.py | 10 +- .../smart_fan_controller/mpc_controller.py | 52 +++++++--- .../smart_fan_controller/sensor.py | 11 +++ .../smart_fan_controller/thermal_learning.py | 78 +++++++++++---- tests/test_learning.py | 17 ++++ tests/test_mpc_controller.py | 94 +++++++++++++++++++ 7 files changed, 232 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 6106375..92a1a4f 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,11 @@ All parameters can be changed at any time via **Settings → Devices & Services ## MPC Controller -The MPC (Model Predictive Control) engine is the sole decision-maker for fan speed. Each cycle, it evaluates every available fan mode by simulating temperature evolution over a 30-minute horizon and selecting the mode with the lowest cost. +The MPC (Model Predictive Control) engine is the sole decision-maker for fan speed. Each cycle, it evaluates every available fan mode by simulating temperature evolution over an adaptive horizon and selecting the mode with the lowest cost. + +To eliminate "dead-time blindness" during startup or large setpoint changes when the physical system has lag, the simulation horizon resolves adaptively to: +$$\text{Horizon} = \max(30, \text{dead\_time} + 30) \text{ minutes}$$ +This ensures that every candidate fan speed is simulated for a full 30-minute window of active response after its transition delay. See [docs/mpc_mode.md](docs/mpc_mode.md) for the full technical design. @@ -128,18 +132,18 @@ Each candidate fan mode is scored with: | Component | Purpose | | --------------------------- | ---------------------------------------------------------------------------- | -| **Comfort error × urgency** | Penalizes being outside the deadband, amplified when far from target | +| **Comfort error × urgency** | Penalizes being outside the deadband, with dynamic step-by-step urgency | | **Overshoot²** | Strongly penalizes going past the target temperature | | **Floor violation** | Penalizes predicted temperature dropping below setpoint (linear + quadratic) | | **Mode-change cost** | Penalizes unnecessary fan jumps (proportional to step distance) | -| **Mode-rank cost** | Slight preference for lower fan speeds (energy savings) | +| **Mode-rank cost** | Slight preference for lower fan speeds using physical non-linear power curve | | **Min-interval penalty** | Blocks changes before the minimum interval has elapsed | ### Hysteresis and Guards - **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, or the configured limit timeout). +- **Min interval**: non-emergency changes respect the effective timeout (learned dead time × 1.5 for the specified HVAC mode, or the configured limit timeout). ### Phase Detection diff --git a/custom_components/smart_fan_controller/__init__.py b/custom_components/smart_fan_controller/__init__.py index 3f1b333..cbdc63c 100644 --- a/custom_components/smart_fan_controller/__init__.py +++ b/custom_components/smart_fan_controller/__init__.py @@ -594,7 +594,7 @@ async def run_control_loop(_): effective_reason = f"MPC paused: {mpc_decision.get('mpc_status', 'unknown')}" # Phase classification (for learning gating and data collection) - learned_dead_time = learning.get_dead_time() if learning.is_ready() else DEFAULT_DEAD_TIME + learned_dead_time = learning.get_dead_time(hvac_mode) if learning.is_ready() else DEFAULT_DEAD_TIME if minutes_since_change < learned_dead_time: phase = "DEAD_TIME" elif minutes_since_change < learned_dead_time * DEAD_TIME_SAFETY_FACTOR: @@ -619,8 +619,8 @@ async def run_control_loop(_): if slope_change and ctrl_state["last_change_time"] > 0: response_time = minutes_since_change if 2.0 <= response_time <= 60.0 and not is_window_open and not is_defrost_active and not is_hvac_idle: - learning.add_response_event(response_time) - _LOGGER.debug("Recorded thermal response event: %.1f min after last fan change", response_time) + learning.add_response_event(response_time, hvac_mode) + _LOGGER.debug("Recorded thermal response event: %.1f min after last fan change (hvac=%s)", response_time, hvac_mode) if slope_change: ctrl_state["previous_slope"] = vtherm_slope @@ -645,10 +645,10 @@ async def run_control_loop(_): decision=collector_decision, phase=phase, effective_slope=-float(vtherm_slope) if hvac_mode == "cool" else float(vtherm_slope), - effective_timeout=mpc_controller.get_effective_timeout(), + effective_timeout=mpc_controller.get_effective_timeout(hvac_mode), force=False, learning_ready=learning.is_ready(), - dead_time=learning.get_dead_time(), + dead_time=learning.get_dead_time(hvac_mode), mpc_decision=mpc_decision, defrost_active=is_defrost_active, is_hvac_idle=is_hvac_idle, diff --git a/custom_components/smart_fan_controller/mpc_controller.py b/custom_components/smart_fan_controller/mpc_controller.py index b518f8a..fd582b7 100644 --- a/custom_components/smart_fan_controller/mpc_controller.py +++ b/custom_components/smart_fan_controller/mpc_controller.py @@ -121,10 +121,10 @@ def limit_timeout(self) -> int: """Return the configured static limit timeout (minutes).""" return self._limit_timeout - def get_effective_timeout(self) -> float: + def get_effective_timeout(self, hvac_mode: str = "unknown") -> float: """Return the adaptive timeout based on learned dead time, or static fallback.""" if self._learning.is_ready(): - learned_dead_time = self._learning.get_dead_time() + 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 @@ -245,6 +245,7 @@ def evaluate( simulations: list[ModeSimulation] = [] known_profiles = 0 + worst_spread = 0.0 current_index = fan_modes.index(active_fan) # Build monotone-enforced slope map so higher fan modes are never @@ -253,6 +254,11 @@ def evaluate( # raw learned / fallback values are used as-is. monotone_slopes = self.build_monotone_slopes(fan_modes, hvac_mode) + # Ensure the horizon is at least dead_time + base_horizon so that + # any mode changes are simulated for at least a full default horizon (e.g. 30 minutes) + # of their actual candidate slope, preventing the "dead-time blindness". + sim_horizon = max(self._horizon_minutes, int(dead_time) + self._horizon_minutes) + for fan_mode in fan_modes: mode_slope, known_profile = self._get_mode_slope( fan_mode, @@ -275,9 +281,14 @@ def evaluate( current_index=current_index, change_allowed=change_allowed, known_profile=known_profile, + horizon_minutes=sim_horizon, ) simulations.append(sim) known_profiles += int(known_profile) + if known_profile: + spread = self._learning.get_profile_spread(fan_mode, hvac_mode) + if spread is not None: + 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) @@ -316,7 +327,7 @@ def evaluate( selection_note = hold_note best = current_simulation - confidence = self._compute_confidence(known_profiles, len(fan_modes), phase) + confidence = self._compute_confidence(known_profiles, len(fan_modes), phase, worst_spread) would_change_now = "yes" if change_allowed and best.fan_mode != active_fan else "no" status = "Ready" if confidence >= 0.5 else "Low confidence" _LOGGER.debug( @@ -516,9 +527,11 @@ def _simulate_mode( current_index: int, change_allowed: bool, known_profile: bool, + horizon_minutes: int | None = None, ) -> ModeSimulation: """Simulate one constant fan mode over the prediction horizon.""" - steps = max(1, int(self._horizon_minutes / self._cycle_minutes)) + horizon = horizon_minutes if horizon_minutes is not None else self._horizon_minutes + steps = max(1, int(horizon / self._cycle_minutes)) step_hours = self._cycle_minutes / 60.0 blend = 0.45 sim_temp = current_temp @@ -529,9 +542,6 @@ def _simulate_mode( change_delay = 0.0 if candidate_fan == current_fan else dead_time candidate_effective_slope = candidate_mode_slope + self._disturbance_bias - current_error = self._temperature_error(current_temp, target_temp, hvac_mode) - urgency_weight = 1.0 + max(current_error - self._deadband, 0.0) * URGENCY_SENSITIVITY - for step in range(1, steps + 1): elapsed = step * self._cycle_minutes if elapsed <= change_delay: @@ -552,13 +562,23 @@ def _simulate_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) - cost += COMFORT_ERROR_WEIGHT * comfort_error * urgency_weight + + # 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 * urgency_weight + cost += FLOOR_VIOLATION_LINEAR_WEIGHT * floor_violation * step_urgency_weight cost += FLOOR_VIOLATION_QUADRATIC_WEIGHT * floor_violation * floor_violation cost += MODE_CHANGE_DISTANCE_COST * abs(candidate_index - current_index) - cost += MODE_RANK_COST * (candidate_index + 1) + # Apply non-linear economic mode ranking cost to represent actual physical power scaling + # (Low: 1.0x, Medium: 1.5x, High: 3.0x, Superhigh: 6.0x equivalent) + # This replaces the abstract linear mode rank cost with a real physical relative power draw representation. + power_draw = [1.0, 1.5, 3.0, 6.0] + # Map indices to relative weights + p_index = min(candidate_index, len(power_draw) - 1) + relative_power = power_draw[p_index] + cost += MODE_RANK_COST * relative_power if candidate_fan != current_fan and not change_allowed: cost += MIN_INTERVAL_CHANGE_PENALTY @@ -628,13 +648,19 @@ def _step_down_hold_note( return None - def _compute_confidence(self, known_profiles: int, total_profiles: int, phase: str) -> float: - """Return a coarse confidence score for the current recommendation.""" + def _compute_confidence(self, known_profiles: int, total_profiles: int, phase: str, worst_spread: float = 0.0) -> float: + """Return a coarse confidence score for the current recommendation. + + worst_spread is the maximum MAD/median ratio across all known profiles. + Profiles with high spread (> 0.15) reduce confidence proportionally, + capped at a 0.20 penalty. + """ coverage = known_profiles / max(total_profiles, 1) base = 1.0 if self._learning.is_ready() else 0.45 phase_factor = 1.0 if phase == PHASE_ESTABLISHED else 0.85 disturbance_penalty = min(abs(self._disturbance_bias) / MAX_DISTURBANCE_BIAS, 0.35) - return max(0.1, min(1.0, (base * (0.5 + 0.5 * coverage) * phase_factor) - disturbance_penalty)) + spread_penalty = min(max(worst_spread - 0.15, 0.0) * 0.4, 0.20) + return max(0.1, min(1.0, (base * (0.5 + 0.5 * coverage) * phase_factor) - disturbance_penalty - spread_penalty)) def _payload( self, diff --git a/custom_components/smart_fan_controller/sensor.py b/custom_components/smart_fan_controller/sensor.py index ca3a39c..f6aeddd 100644 --- a/custom_components/smart_fan_controller/sensor.py +++ b/custom_components/smart_fan_controller/sensor.py @@ -485,12 +485,23 @@ def native_value(self) -> float | None: def extra_state_attributes(self) -> dict: """Expose sampling details for the current profile.""" samples = self._controller.learning.get_mode_sample_count(self._fan_mode, self._hvac_mode) + spread = self._controller.learning.get_profile_spread(self._fan_mode, self._hvac_mode) + if spread is None: + quality = "unknown" + elif spread < 0.15: + quality = "good" + elif spread < 0.30: + quality = "fair" + else: + quality = "poor" return { "hvac_mode": self._hvac_mode, "fan_mode": self._fan_mode, "samples": samples, "min_samples_required": MIN_MODE_PROFILE_SAMPLES, "ready": samples >= MIN_MODE_PROFILE_SAMPLES, + "spread": spread, + "quality": quality, } diff --git a/custom_components/smart_fan_controller/thermal_learning.py b/custom_components/smart_fan_controller/thermal_learning.py index 368c758..1d5a3a3 100644 --- a/custom_components/smart_fan_controller/thermal_learning.py +++ b/custom_components/smart_fan_controller/thermal_learning.py @@ -158,10 +158,15 @@ def _update_slope_stats(self, abs_slope: float) -> None: self._slope_max = max(self._slope_max, abs_slope) _LOGGER.debug("Learning: Updated stats (count=%d, mean=%.3f, max=%.3f)", self._slope_count, self._slope_mean, self._slope_max) - def add_response_event(self, minutes_to_response: float): + def add_response_event(self, minutes_to_response: float, hvac_mode: str = "unknown"): """Record time until slope changed significantly after fan change.""" - self._response_events.append((time.time(), minutes_to_response)) - _LOGGER.debug("Learning: Recorded response time #%d: %.1f min", len(self._response_events), minutes_to_response) + self._response_events.append((time.time(), minutes_to_response, hvac_mode)) + _LOGGER.debug( + "Learning: Recorded response time #%d: %.1f min (hvac=%s)", + len(self._response_events), + minutes_to_response, + hvac_mode, + ) # Invalidate cached optimal parameters self._optimal_cache = None @@ -169,7 +174,11 @@ def add_response_event(self, minutes_to_response: float): # Cleanup: keep only data within sliding window (7 days) cutoff_time = time.time() - (self._learning_window_hours * 3600) before = len(self._response_events) - self._response_events = [(ts, t) for ts, t in self._response_events if ts > cutoff_time] + self._response_events = [ + (ts, t, hm) if len(item) == 3 else (ts, t, "unknown") + for item in self._response_events + if (ts := item[0]) > cutoff_time and (t := item[1]) is not None and (hm := (item[2] if len(item) == 3 else "unknown")) + ] if len(self._response_events) < before: _LOGGER.debug( "Learning: dropped %d expired response events from the sliding window", @@ -253,12 +262,24 @@ def is_ready(self) -> bool: _LOGGER.info("Learning: Initial readiness reached with %d samples", self._min_samples) return self._ready_once - def get_dead_time(self) -> float: - """Return the learned dead time (median response time) in minutes. + def get_dead_time(self, hvac_mode: str = "unknown") -> float: + """Return the learned dead time (median response time) in minutes for specified HVAC mode. Falls back to DEFAULT_DEAD_TIME when no response events have been recorded yet. """ - response_times = [t for _, t in self._response_events if t > 0] + response_times = [] + for item in self._response_events: + ts = item[0] + t = item[1] + hm = item[2] if len(item) == 3 else "unknown" + if t > 0: + if hvac_mode == "unknown" or hm == hvac_mode or hm == "unknown": + response_times.append(t) + + if not response_times: + # Try any if specific not found + response_times = [item[1] for item in self._response_events if item[1] > 0] + if not response_times: return DEFAULT_DEAD_TIME return statistics.median(response_times) @@ -281,6 +302,25 @@ def get_mode_effective_slope(self, fan_mode: str, hvac_mode: str) -> float | Non med = statistics.median(matching_slopes) return -med if hvac_mode == "cool" else med + def get_profile_spread(self, fan_mode: str, hvac_mode: str) -> float | None: + """Return the MAD/median ratio for a profile's absolute slopes. + + Measures internal consistency of collected slope samples: + - 0.00–0.15 : good (tight cluster) + - 0.15–0.30 : fair + - > 0.30 : poor (high variability, low-confidence profile) + + Returns None if the profile has fewer than MIN_MODE_PROFILE_SAMPLES samples. + """ + abs_slopes = [abs(sl) for (_, fm, sl, hm) in self._slope_samples if fm == fan_mode and hm == hvac_mode] + if len(abs_slopes) < MIN_MODE_PROFILE_SAMPLES: + return None + med = statistics.median(abs_slopes) + if med < 0.01: + return None + mad = statistics.median([abs(s - med) for s in abs_slopes]) + return round(mad / med, 3) + def set_mode_effective_slope(self, fan_mode: str, hvac_mode: str, target_slope: float) -> None: """Replace all samples for a fan/HVAC profile with synthetic ones producing target_slope. @@ -358,7 +398,7 @@ def compute_optimal_parameters(self) -> dict: slope_stdev = slope_variance**0.5 # Standard deviation # Analyze response times - response_times = [t for _, t in self._response_events if t > 0] + 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 @@ -436,33 +476,33 @@ def from_dict(cls, data: dict): Handles backward compatibility: old 3-tuple samples (timestamp, fan_mode, slope) are migrated to 4-tuples by appending hvac_mode="unknown". + Old 2-tuple response_events are migrated to 3-tuples by appending hvac_mode="unknown". """ instance = cls() # Migrate slope_samples: support both 3-tuple (old) and 4-tuple (new) raw_samples = data.get("slope_samples", []) instance._slope_samples = [] - migrated_legacy_samples = 0 for sample in raw_samples: if len(sample) == 3: instance._slope_samples.append((sample[0], sample[1], sample[2], "unknown")) - migrated_legacy_samples += 1 else: instance._slope_samples.append(tuple(sample)) - instance._response_events = data.get("response_events", []) - - # Restore incremental statistics - instance._slope_count = data.get("slope_count", 0) - instance._slope_mean = data.get("slope_mean", 0.0) - instance._slope_m2 = data.get("slope_m2", data.get("slope_M2", 0.0)) - instance._slope_max = data.get("slope_max", 0.0) + # Migrate response_events: support both 2-tuple (old) and 3-tuple (new) + raw_response_events = data.get("response_events", []) + instance._response_events = [] + for event in raw_response_events: + if len(event) == 2: + instance._response_events.append((event[0], event[1], "unknown")) + else: + instance._response_events.append(tuple(event)) # Apply sliding window cleanup on restore, keeping at least MIN_MODE_PROFILE_SAMPLES # per profile so learned modes survive a quiet week without new samples. cutoff_time = time.time() - (instance._learning_window_hours * 3600) instance._slope_samples = ThermalLearning.trim_with_min_retention(instance._slope_samples, cutoff_time, MIN_MODE_PROFILE_SAMPLES) - instance._response_events = [(ts, t) for ts, t in instance._response_events if ts > cutoff_time] + instance._response_events = [item for item in instance._response_events if item[0] > cutoff_time] # Rebuild stats from cleaned window instance.recompute_slope_stats() @@ -472,8 +512,6 @@ def from_dict(cls, data: dict): instance._ready_once = True instance._optimal_cache = None - if migrated_legacy_samples: - _LOGGER.info("Learning: migrated %d legacy slope samples to include hvac_mode", migrated_legacy_samples) _LOGGER.debug( "Learning: restored %d slope samples and %d response events from storage", len(instance._slope_samples), diff --git a/tests/test_learning.py b/tests/test_learning.py index ade055a..ddcf337 100644 --- a/tests/test_learning.py +++ b/tests/test_learning.py @@ -177,3 +177,20 @@ def test_median_resists_outliers(self): # Median of [0.15]*12 + [1.29]*3 = 0.15 (most values are 0.15) assert slope is not None assert slope == pytest.approx(0.15, abs=0.01) + + def test_get_dead_time_decoupled_by_hvac_mode(self): + """Test that get_dead_time handles separate heating and cooling events correctly.""" + learning = ThermalLearning() + learning.add_response_event(12.0, "heat") + learning.add_response_event(14.0, "heat") + learning.add_response_event(16.0, "heat") + + learning.add_response_event(6.0, "cool") + learning.add_response_event(8.0, "cool") + learning.add_response_event(10.0, "cool") + + # Specific dead times + assert learning.get_dead_time("heat") == 14.0 + assert learning.get_dead_time("cool") == 8.0 + # Unknown fallback behavior (uses joint / all response times) + assert learning.get_dead_time("unknown") == 11.0 diff --git a/tests/test_mpc_controller.py b/tests/test_mpc_controller.py index 489c722..9704a1d 100644 --- a/tests/test_mpc_controller.py +++ b/tests/test_mpc_controller.py @@ -230,6 +230,67 @@ def test_profile_effective_slope_sensor_exposes_historizable_state() -> None: assert sensor.native_value == 0.9 assert sensor.extra_state_attributes["samples"] == 12 assert sensor.extra_state_attributes["ready"] is True + assert sensor.extra_state_attributes["spread"] == 0.0 + assert sensor.extra_state_attributes["quality"] == "good" + + + +def test_get_profile_spread_returns_none_when_insufficient_samples() -> None: + """get_profile_spread returns None when the profile has fewer than MIN_MODE_PROFILE_SAMPLES.""" + learning = ThermalLearning() + learning.add_slope_sample("high", 0.9, 0.3, "heat") + assert learning.get_profile_spread("high", "heat") is None + + +def test_get_profile_spread_returns_zero_for_identical_samples() -> None: + """get_profile_spread returns 0.0 for perfectly consistent slope samples.""" + learning = ThermalLearning() + for _ in range(12): + learning.add_slope_sample("high", 0.9, 0.3, "heat") + assert learning.get_profile_spread("high", "heat") == 0.0 + + +def test_get_profile_spread_reflects_variability() -> None: + """get_profile_spread increases with sample variability.""" + learning_tight = ThermalLearning() + learning_noisy = ThermalLearning() + slopes_tight = [0.88, 0.89, 0.90, 0.91, 0.92, 0.89, 0.90, 0.91, 0.88, 0.90] + slopes_noisy = [0.30, 0.60, 0.90, 1.20, 0.45, 0.80, 1.10, 0.50, 0.70, 1.00] + for s in slopes_tight: + learning_tight.add_slope_sample("high", s, 0.3, "heat") + for s in slopes_noisy: + learning_noisy.add_slope_sample("high", s, 0.3, "heat") + spread_tight = learning_tight.get_profile_spread("high", "heat") + spread_noisy = learning_noisy.get_profile_spread("high", "heat") + assert spread_tight is not None + assert spread_noisy is not None + assert spread_noisy > spread_tight + + +def test_confidence_penalised_by_high_spread() -> None: + """MPC confidence is lower when a known profile has high spread.""" + learning_tight = ThermalLearning() + learning_noisy = ThermalLearning() + slopes_tight = [0.88, 0.89, 0.90, 0.91, 0.92, 0.89, 0.90, 0.91, 0.88, 0.90] + slopes_noisy = [0.30, 0.60, 0.90, 1.20, 0.45, 0.80, 1.10, 0.50, 0.70, 1.00] + for i in range(100): + learning_tight.add_slope_sample("high", slopes_tight[i % len(slopes_tight)], 0.3, "heat") + learning_noisy.add_slope_sample("high", slopes_noisy[i % len(slopes_noisy)], 0.3, "heat") + + mpc_tight = _build_mpc(learning_tight) + mpc_noisy = _build_mpc(learning_noisy) + + common_kwargs = dict( + current_temp=19.5, + target_temp=20.0, + vtherm_slope=0.9, + hvac_mode="heat", + current_fan="high", + minutes_since_change=30.0, + ) + result_tight = mpc_tight.evaluate(**common_kwargs) + result_noisy = mpc_noisy.evaluate(**common_kwargs) + assert result_tight["mpc_confidence"] > result_noisy["mpc_confidence"] @pytest.mark.asyncio @@ -522,3 +583,36 @@ def test_monotone_constraint_noop_when_already_ordered() -> None: assert monotone["low"] == pytest.approx(0.15, abs=0.001) assert monotone["medium"] == pytest.approx(0.5, abs=0.001) assert monotone["high"] == pytest.approx(1.0, abs=0.001) + + +def test_mpc_handles_long_dead_time_without_blindness() -> None: + """MPC detects faster modes even with a long dead time due to adaptive horizon.""" + learning = ThermalLearning() + # Mock high and superhigh learned slopes + learning.set_mode_effective_slope("high", "cool", 1.2) + learning.set_mode_effective_slope("superhigh", "cool", 1.5) + # Mock a long dead time of 27 minutes + learning.add_response_event(27.0) + + mpc = MPCController( + learning=learning, + deadband=0.3, + min_interval=10, + fan_modes=["silent", "low", "med", "high", "superhigh"], + ) + + # Evaluate cooling with a large error (3.2), current_fan is 'med' (which is unlearned) + result = mpc.evaluate( + current_temp=25.2, + target_temp=22.0, + vtherm_slope=0.0, + hvac_mode="cool", + current_fan="med", + is_window_open=False, + minutes_since_change=50.0, + ) + + # Thanks to adaptive horizon, MPC sees past the 27-minute dead-time delay + # and correctly recommends superhigh (or high) over med, instead of remaining blind + assert result["mpc_fan_mode"] in ("high", "superhigh") + assert result["mpc_would_change_now"] == "yes" From 15897d036ed080af20df89c15b483fbc75d13f89 Mon Sep 17 00:00:00 2001 From: Gamso Date: Sun, 31 May 2026 13:47:25 +0200 Subject: [PATCH 10/11] fix(sensor): correct response_events tuple unpacking causing control loop abort The response_events list contains 3-tuples (timestamp, minutes, hvac_mode) since the hvac_mode field was added, but sensor.py was still unpacking them as 2-tuples (`for _, t in learning.response_events`), raising a ValueError. Because _update_sensors() calls sensor.async_write_ha_state() synchronously before _async_apply_fan_change(), this exception was propagating up through run_control_loop and aborting the cycle before any fan change could be applied. This caused the MPC-recommended mode (e.g. superhigh) to never be set on the climate entity, requiring manual intervention. - sensor.py: use item[1] indexing instead of 2-tuple unpacking - thermal_learning.py: remove unused `ts` variable in get_dead_time() - tests: add regression test covering mixed 2- and 3-tuple response_events --- .../smart_fan_controller/sensor.py | 2 +- .../smart_fan_controller/thermal_learning.py | 1 - tests/test_mpc_controller.py | 23 +++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/custom_components/smart_fan_controller/sensor.py b/custom_components/smart_fan_controller/sensor.py index f6aeddd..a7aad89 100644 --- a/custom_components/smart_fan_controller/sensor.py +++ b/custom_components/smart_fan_controller/sensor.py @@ -338,7 +338,7 @@ def extra_state_attributes(self) -> dict: """Return response time statistics.""" learning = self._controller.learning optimal = learning.compute_optimal_parameters() - response_times = [t for _, t in learning.response_events if t > 0] + response_times = [item[1] for item in learning.response_events if item[1] > 0] avg_response = sum(response_times) / len(response_times) if response_times else 0 return { diff --git a/custom_components/smart_fan_controller/thermal_learning.py b/custom_components/smart_fan_controller/thermal_learning.py index 1d5a3a3..ecabf55 100644 --- a/custom_components/smart_fan_controller/thermal_learning.py +++ b/custom_components/smart_fan_controller/thermal_learning.py @@ -269,7 +269,6 @@ def get_dead_time(self, hvac_mode: str = "unknown") -> float: """ response_times = [] for item in self._response_events: - ts = item[0] t = item[1] hm = item[2] if len(item) == 3 else "unknown" if t > 0: diff --git a/tests/test_mpc_controller.py b/tests/test_mpc_controller.py index 9704a1d..f44ccd6 100644 --- a/tests/test_mpc_controller.py +++ b/tests/test_mpc_controller.py @@ -9,6 +9,7 @@ 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 ( + SmartFanLearningResponseSensor, SmartFanMpcProfilesSensor, SmartFanProfileEffectiveSlopeSensor, SmartFanSensor, @@ -616,3 +617,25 @@ def test_mpc_handles_long_dead_time_without_blindness() -> None: # and correctly recommends superhigh (or high) over med, instead of remaining blind assert result["mpc_fan_mode"] in ("high", "superhigh") assert result["mpc_would_change_now"] == "yes" + + +def test_learning_response_sensor_with_mixed_tuple_lengths() -> None: + """SmartFanLearningResponseSensor extra_state_attributes handles mixed lengths in response_events.""" + import time + learning = ThermalLearning() + mpc = _build_mpc(learning) + + # Inject mixed length response events + learning.response_events = [ + (time.time() - 100, 12.0), # 2-tuple (old format) + (time.time() - 200, 15.0, "heat"), # 3-tuple (new format with hvac_mode) + (time.time() - 300, 0.0, "cool"), # 3-tuple to be ignored (t <= 0) + ] + + sensor = SmartFanLearningResponseSensor("entry-1", "climate.living_room", mpc) + + assert sensor.native_value == 3 + attrs = sensor.extra_state_attributes + assert attrs["response_samples"] == 0 # because is_ready() is False, returns fallback 0 + assert attrs["avg_response_time_min"] == pytest.approx(13.5) + From bac95bcb3d64c3f30bf1e791c587f1200a87ae54 Mon Sep 17 00:00:00 2001 From: Gamso Date: Sun, 14 Jun 2026 00:14:52 +0200 Subject: [PATCH 11/11] Bump version to 2.0.0 --- custom_components/smart_fan_controller/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/smart_fan_controller/manifest.json b/custom_components/smart_fan_controller/manifest.json index 25b07d2..e5e67c8 100644 --- a/custom_components/smart_fan_controller/manifest.json +++ b/custom_components/smart_fan_controller/manifest.json @@ -9,5 +9,5 @@ "codeowners": [ "@Gamso" ], - "version": "1.5.0" + "version": "2.0.0" }