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 68580ef..6f886ff 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_shadow.py` | `MPCShadowController` — observation-only MPC-lite, never writes fan commands | +| `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 @@ -59,16 +69,24 @@ 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) -- `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 +- `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**: 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-shadow/SKILL.md deleted file mode 100644 index bb7cc68..0000000 --- a/.github/skills/modify-mpc-shadow/SKILL.md +++ /dev/null @@ -1,134 +0,0 @@ ---- -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')" ---- - -# Modify MPC Shadow Controller - -## 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. - -### Key Flow in `evaluate()` - -``` -1. Early exits: disabled, idle HVAC mode -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" -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 -``` - -### Cost Function (`_simulate_mode`) - -Each candidate fan mode is simulated step-by-step over the horizon: - -| Cost Component | Weight | Purpose | -|---|---|---| -| `comfort_error × urgency` | 1.0 × (1 + excess error × 2) | Penalizes being outside deadband | -| `overshoot²` | 3.0 | Strongly penalizes going past target | -| `floor_violation` (linear) | 12.0 × urgency | Penalizes being below target (heat) | -| `floor_violation²` | 30.0 | Strongly penalizes large shortfalls | -| `mode_change_cost` | 0.15 × distance | Penalizes switching fan modes | -| `mode_rank_cost` | 0.05 × (rank+1) | Slight preference for lower fan speeds | -| `min_interval_penalty` | 25.0 | Blocks changes before min_interval | - -### Hysteresis (`_required_switch_gain`) - -The shadow requires a minimum cost improvement before switching: - -| Situation | Base Margin | -|---|---| -| Over-target or far under | 0.10 | -| Approaching target | 0.15 | -| Near target (within deadband) | 0.30 | - -Plus bonuses for non-established phase (+0.10) and step distance (+0.05/step). - -### Step-Down Guards - -- `_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) - -### 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. - -### Pause Conditions - -The shadow pauses (returns "Disturbed") during: -- **Window open**: thermal model unreliable -- **Defrost active**: slope data is corrupted by heat-pump defrost cycle - -Both conditions decay the disturbance bias without updating it. - -## Procedure - -### 1. Identify the Change Area - -| Change | File/Method | -|---|---| -| Cost weights | `_simulate_mode()` constants at module top | -| Hysteresis margins | `_required_switch_gain()` constants at module top | -| 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()` | - -### 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`. - -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 - -### 3. Write Tests - -Tests go in `tests/test_mpc_shadow.py`. Use the existing helpers: - -```python -def test_shadow_() -> None: - """.""" - controller = _build_controller() - _prime_learning_profiles(controller) - shadow = MPCShadowController( - learning=controller.learning, - deadband=0.3, - min_interval=10, - fan_modes=FAN_MODES, - enabled=True, - ) - - result = shadow.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"] == ... -``` - -### 4. Update Documentation - -- **`README.md`** — MPC Shadow section if user-visible behavior changes -- **`docs/mpc_shadow_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 -``` diff --git a/.github/skills/modify-mpc/SKILL.md b/.github/skills/modify-mpc/SKILL.md new file mode 100644 index 0000000..863cef9 --- /dev/null +++ b/.github/skills/modify-mpc/SKILL.md @@ -0,0 +1,185 @@ +--- +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')" +--- + +# 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 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. + +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: 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) +5. Pause conditions: window-open, defrost, HVAC idle → return "Disturbed" +6. Setpoint drop → return lowest mode immediately +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 +11. Build and return payload +``` + +### Cost Function (`_simulate_mode`) + +Each candidate fan mode is simulated step-by-step over the horizon: + +| Cost Component | Weight | Purpose | +|---|---|---| +| `comfort_error × urgency` | 1.0 × (1 + excess error × 2) | Penalizes being outside deadband | +| `overshoot²` | 3.0 | Strongly penalizes going past target | +| `floor_violation` (linear) | 12.0 × urgency | Penalizes being below target (heat) | +| `floor_violation²` | 30.0 | Strongly penalizes large shortfalls | +| `mode_change_cost` | 0.15 × distance | Penalizes switching fan modes | +| `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 MPC requires a minimum cost improvement before switching: + +| Situation | Base Margin | +|---|---| +| Over-target or far under | 0.10 | +| Approaching target | 0.15 | +| Near target (within deadband) | 0.30 | + +Plus bonuses for non-established phase (+0.10) and step distance (+0.05/step). + +### Step-Down Hold (`_step_down_hold_note`) + +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`) + +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 MPC pauses (returns "Disturbed") during: +- **Window open**: thermal model unreliable +- **Defrost active**: slope data corrupted by heat-pump defrost cycle +- **HVAC idle**: compressor off, no thermal slope to predict + +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 | +|--------|------|---------| +| `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 + +### 1. Identify the Change Area + +| Change | File/Method | +|---|---| +| Cost weights | module-level constants in `mpc_controller.py` | +| Hysteresis margins | module-level constants + `_required_switch_gain()` | +| 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 + +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 MPC controller +- **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_controller.py`. Use the existing helpers: + +```python +def test_mpc_() -> None: + """.""" + 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", + is_window_open=False, minutes_since_change=20.0, + ) + + 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 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_controller.py -q # MPC tests +python -m pytest tests/ -q # all tests +``` diff --git a/README.md b/README.md index 57d058d..92a1a4f 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,11 +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) - - [Defrost Diagnostic](#defrost-diagnostic) + - [MPC Sensors](#mpc-sensors) + - [Learning Sensors](#learning-sensors) + - [Learning Profile Sensors](#learning-profile-sensors) - [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) @@ -68,20 +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. Optionally runs an MPC shadow controller in the background for dry-run comparison +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 -> 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). -> It supports both `heat` and `cool`, and includes a hysteresis guard so tiny cost differences do not create fan yo-yo around the setpoint. +> 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. --- @@ -105,62 +105,49 @@ 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 Shadow Mode** | `false` | — | Runs the learned model + MPC-lite in the background for comparison only. No real fan command is applied. | -| **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). | - -> **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 - -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: - -### Decision Priority (Zones A–F) +## MPC Controller -| 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) | +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. -> **Note**: In heat mode, error = `target - current`. In cool mode, error = `current - target`. A positive value always means the system needs more action. +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. -**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. +See [docs/mpc_mode.md](docs/mpc_mode.md) for the full technical design. -**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 +### Cost Function -### Temperature Projection +Each candidate fan mode is scored with: -The controller projects temperature 10 minutes ahead using a **clamped linear model**: +| Component | Purpose | +| --------------------------- | ---------------------------------------------------------------------------- | +| **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 using physical non-linear power curve | +| **Min-interval penalty** | Blocks changes before the minimum interval has elapsed | -``` -projected_temperature = current_temp + (vtherm_slope × 10/60) -``` +### Hysteresis and Guards -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 for the specified HVAC mode, 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 | | --------------- | ------------------------------------- | --------------------------------- | @@ -168,37 +155,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. + +### Disturbance Handling -### Safety Constraints +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. -- **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. +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 --- @@ -216,8 +201,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)`. @@ -226,13 +209,18 @@ 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. -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 @@ -242,16 +230,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 shadow 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 @@ -263,50 +250,53 @@ 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_shadow_mode` | Switch | Enable / disable observation-only MPC shadow mode | - -### 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 | -| `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. - -### Defrost Diagnostic - -| Entity | Type | Description | -| ------------------------------------ | ------ | ----------------------------------------------------------- | -| `sensor.smart_fan_controller_status` | Sensor | Shows `"Defrost hold: …"` when defrost protection is active | +### MPC Sensors + +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_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_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. --- @@ -316,25 +306,52 @@ See [docs/mpc_shadow_mode.md](docs/mpc_shadow_mode.md) for the full technical de 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 | +| ----------------- | -------- | --------------------- | ----------------------------------------------------------------- | +| `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 +``` + --- ## 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_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/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..cbdc63c 100644 --- a/custom_components/smart_fan_controller/__init__.py +++ b/custom_components/smart_fan_controller/__init__.py @@ -2,12 +2,14 @@ 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 @@ -17,32 +19,39 @@ CONF_DATA_COLLECTION, CONF_DEADBAND, CONF_DEFROST_ENTITY, - CONF_HARD_ERROR, - CONF_LEARNING_ENABLED, CONF_LIMIT_TIMEOUT, CONF_MIN_INTERVAL, 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_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, + build_entity_id, + build_scoped_entity_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] +PLATFORMS = [Platform.SENSOR] +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,83 +68,154 @@ 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 +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("_") + ] + - entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) +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: - return + 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] - migrated = 0 - reserved_entity_ids: set[str] = set() + if len(entries) > 1: + raise HomeAssistantError( + "Multiple Smart Fan Controller entries are configured; specify climate_entity in the service call" + ) - for registry_entry in entries: - updates: dict[str, object] = {} - object_key = extract_object_key_from_unique_id(registry_entry.unique_id, entry.entry_id) + 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 + ) - 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 + 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, + } - 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) +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 + - if registry_entry.entity_id != desired_entity_id: - updates["new_entity_id"] = desired_entity_id +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) - if not registry_entry.has_entity_name: - updates["has_entity_name"] = True + 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 - if not updates: + desired_entity_id = build_scoped_entity_id(entity_entry.domain, climate_entity, object_key) + if entity_entry.entity_id == desired_entity_id: 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 + 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 entity %s -> %s (unique_id %s -> %s)", - old_entity_id, - updated_entry.entity_id, - old_unique_id, - updated_entry.unique_id, + "Migrated %s entity_id from %s to %s", + entry.entry_id, + entity_entry.entity_id, + available_entity_id, ) - if migrated: - _LOGGER.info("Entity registry migration completed for %s: %d entities updated", entry.entry_id, migrated) + +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( @@ -147,34 +227,221 @@ 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"] + 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 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"], ) - 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"}) + + +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, defrost_state: dict) -> tuple[bool, bool]: + """Check external entities and update defrost state dict. + + 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: + 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) + 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", "idle"): + is_hvac_idle = True + + return is_defrost, is_hvac_idle + + +async def _async_apply_fan_change( + hass, + climate_id: str, + effective_fan: str, + current_fan: str | None, + reason: str, + on_confirmed, +) -> 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, + ) + on_confirmed() # update last_change_time to prevent retry storm + else: + on_confirmed() + + 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) _LOGGER.info( - "Setting up Smart Fan Controller for %s (learning=%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_DATA_COLLECTION, DEFAULT_DATA_COLLECTION), ) @@ -186,51 +453,48 @@ 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", [])), ) - controller = SmartFanController( - fan_modes=initial_fan_modes or None, + learning = ThermalLearning.from_dict(learning_data) if learning_data else ThermalLearning() + + mpc_controller = MPCController( + learning=learning, 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), + fan_modes=initial_fan_modes or None, ) - 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 - ) + 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, + domain_data[entry.entry_id] = { + "entry": entry, + "learning": learning, + "mpc_controller": mpc_controller, "climate_entity": climate_id, "sensors": [], "ensure_profile_sensors": None, "store": store, } - await _async_migrate_entity_registry(hass, entry) + if not hass.services.has_service(DOMAIN, SERVICE_APPLY_LEARNED_SETTINGS): + _register_services(hass) + + await _async_migrate_entity_ids(hass, entry, climate_id) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) def _ensure_profile_sensors() -> None: @@ -239,67 +503,56 @@ 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) - if not current_state: _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 = 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: - _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" - ) + _LOGGER.debug("No fan modes available yet for %s, retrying next cycle", climate_id) - # 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) + 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_defrost_active, is_hvac_idle = _detect_disturbances(hass, conf, ctrl_state["defrost"]) - 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 + 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", @@ -312,85 +565,116 @@ async def run_control_loop(_): is_window_open, ) - decision = controller.calculate_decision( - float(current_temp), - float(target_temp), - float(vtherm_slope), - str(hvac_mode), - current_fan, - is_window_open, - is_hvac_idle, + 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, + is_window_open=is_window_open, + is_defrost_active=is_defrost_active, + is_hvac_idle=is_hvac_idle, + minutes_since_change=minutes_since_change, ) _LOGGER.debug( - "Cycle result for %s: live=%s reason=%s", + "Cycle result for %s: mpc=%s mpc_status=%s mpc_confidence=%s%%", climate_id, - decision.get("fan_mode"), - decision.get("reason"), + 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(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: + phase = "TRANSIENT" + else: + phase = PHASE_ESTABLISHED + + # Slope sample collection + 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: + 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, 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 + 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) + 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=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, - 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, - defrost_active=controller.is_defrost_active, + 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(hvac_mode), + force=False, + learning_ready=learning.is_ready(), + dead_time=learning.get_dead_time(hvac_mode), + mpc_decision=mpc_decision, + defrost_active=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() - - 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: + _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() + 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, - ) + if effective_fan is not None and effective_fan != current_fan: + + 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, 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.""" @@ -406,25 +690,19 @@ async def _handle_manual_change(event): return _LOGGER.info("Manual fan mode change detected for %s: %s -> %s", climate_id, old_fan, new_fan) + ctrl_state["last_change_time"] = time.time() + manual_data = {"fan_mode": new_fan, "minutes_since_last_change": 0.0, "reason": "Manual Override"} - manual_data = controller.record_manual_override(new_fan) + _update_sensors(hass, entry.entry_id, manual_data) - 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) - 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)) 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)", @@ -433,44 +711,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) - - async def apply_learned_settings(_): - """Service to apply optimal parameters from learning.""" - if not controller.learning.is_ready(): - _LOGGER.warning( - "Learning not complete yet for %s (%.1f%%), cannot apply settings", - climate_id, - controller.learning.get_progress(), - ) - return - - optimal = controller.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.""" - controller.learning.reset() - - learning_data_to_save = controller.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) + entry.async_on_unload(async_track_time_interval(hass, periodic_save_learning, LEARNING_DATA_SAVE_INTERVAL)) return True @@ -479,10 +720,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: @@ -496,7 +737,14 @@ 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) + 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 c4ec5e4..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 @@ -12,19 +13,13 @@ CONF_CLIMATE_ENTITY, CONF_DEADBAND, CONF_MIN_INTERVAL, - CONF_SOFT_ERROR, - CONF_HARD_ERROR, CONF_LIMIT_TIMEOUT, - CONF_LEARNING_ENABLED, CONF_DATA_COLLECTION, 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, ) @@ -47,18 +42,22 @@ 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. +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 - 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 + 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): @@ -87,12 +86,10 @@ 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: - 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} @@ -108,16 +105,9 @@ 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_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"])), @@ -139,10 +129,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: @@ -150,18 +141,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 - - if not errors: - threshold_error = _validate_thresholds(user_input) - if threshold_error: - errors["base"] = threshold_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) @@ -184,19 +173,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_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 ac8682b..09413e0 100644 --- a/custom_components/smart_fan_controller/const.py +++ b/custom_components/smart_fan_controller/const.py @@ -1,9 +1,10 @@ """Constants for Smart Fan Controller.""" from datetime import timedelta +from homeassistant.util import slugify + 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,10 +12,7 @@ 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_DEFROST_ENTITY = "defrost_entity" CONF_OPERATING_ENTITY = "operating_entity" @@ -22,10 +20,7 @@ # 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 DELTA_TIME_CONTROL_LOOP = 2 # minutes between each control loop execution @@ -51,26 +46,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 - -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", - "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", - "deadband": "learned_deadband", - "soft_error": "learned_soft_error", - "hard_error": "learned_hard_error", - "limit_timeout": "learned_limit_timeout", - "learning_enabled": "learning_enabled", -} +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 def build_unique_id(object_key: str, entry_id: str) -> str: @@ -83,16 +60,17 @@ def build_entity_id(platform_domain: str, object_key: str) -> str: 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 logical object key from either a legacy or canonical unique_id.""" + """Extract the object key from the canonical unique_id format.""" + prefix = f"{ENTITY_UNIQUE_ID_PREFIX}_" suffix = f"_{entry_id}" - if not unique_id.endswith(suffix): + if not unique_id.startswith(prefix) or 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 + return unique_id[len(prefix) : -len(suffix)] diff --git a/custom_components/smart_fan_controller/controller.py b/custom_components/smart_fan_controller/controller.py deleted file mode 100644 index b8d46df..0000000 --- a/custom_components/smart_fan_controller/controller.py +++ /dev/null @@ -1,498 +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, -) -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 - - # 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 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 - - @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 - 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. - 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) - - _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 52662bf..36e5c6a 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_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,15 @@ "learning_ready", "dead_time", "is_window_open", + "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", ] @@ -50,13 +62,20 @@ _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.""" 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: @@ -79,10 +98,12 @@ async def async_record( force: bool, learning_ready: bool, dead_time: float, + 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.""" + mpc = mpc_decision or {} row = [ datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), hvac_mode, @@ -91,8 +112,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), @@ -103,6 +124,21 @@ async def async_record( int(learning_ready), round(dead_time, 2), int(is_window_open), + 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(mpc.get("mpc_predicted_temperature_30m", 0.0), 3) + if mpc.get("mpc_predicted_temperature_30m") is not None + else "", + 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/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" } 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..fd582b7 --- /dev/null +++ b/custom_components/smart_fan_controller/mpc_controller.py @@ -0,0 +1,698 @@ +"""MPC controller: learned thermal model with cost-based fan selection.""" +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__) + +# 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 +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 + + +@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, + limit_timeout: int = 15, + fan_modes: list[str] | None = None, + 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._horizon_minutes = horizon_minutes + self._cycle_minutes = cycle_minutes + self._disturbance_bias = 0.0 + + @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. + + 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: + """Return the ThermalLearning instance used by this controller.""" + return self._learning + + @property + def limit_timeout(self) -> int: + """Return the configured static limit timeout (minutes).""" + return self._limit_timeout + + def get_effective_timeout(self, hvac_mode: str = "unknown") -> float: + """Return the adaptive timeout based on learned dead time, or static fallback.""" + if self._learning.is_ready(): + learned_dead_time = self._learning.get_dead_time(hvac_mode) + return max(self._min_interval, learned_dead_time * DEAD_TIME_SAFETY_FACTOR) + return self._limit_timeout + + @property + def disturbance_bias(self) -> float: + """Return the current disturbance bias estimate (°C/h).""" + return self._disturbance_bias + + def evaluate( + self, + *, + current_temp: float, + target_temp: float, + vtherm_slope: float, + hvac_mode: str, + current_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 fan mode for the current cycle.""" + _LOGGER.debug( + "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, + minutes_since_change, + is_window_open, + ) + + 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", + 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", + 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: MPC paused", + 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: MPC paused", + 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, MPC paused", + would_change_now="no", + dead_time=dead_time, + disturbance_bias=self._disturbance_bias, + ) + + # 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] + 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", + would_change_now=would_change, + dead_time=dead_time, + disturbance_bias=self._disturbance_bias, + ) + + 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 + # 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) + + # 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, + hvac_mode, + active_fan, + current_effective_slope, + fan_modes, + monotone_slopes, + ) + 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, + 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) + 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 + + 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( + "MPC 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"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: + 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( + "MPC 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, + 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], + monotone_slopes: dict[str, float] | None = None, + ) -> tuple[float, bool]: + """Return the effective slope estimate for a candidate fan 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( + "MPC 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( + "MPC 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 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. + """ + enforced: dict[str, float] = {} + prev = float("-inf") + changed = False + for fm in fan_modes: + slope = self._learning.get_mode_effective_slope(fm, hvac_mode) + if slope is None: + continue # unknown profile — skip, caller uses rank-scaling fallback + clamped = max(slope, prev) + if clamped != slope: + changed = True + enforced[fm] = clamped + prev = clamped + + if changed: + _LOGGER.debug( + "Monotone enforcement applied for %s: %s", + hvac_mode, + {fm: round(v, 3) for fm, v in enforced.items()}, + ) + return enforced + + 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( + "MPC 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( + "MPC 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( + "MPC 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, + horizon_minutes: int | None = None, + ) -> ModeSimulation: + """Simulate one constant fan mode over the prediction horizon.""" + 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 + 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 + + 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 + sim_temp += step_hours * raw_slope + + if elapsed >= 10 and predicted_10m is None: + predicted_10m = sim_temp + if elapsed >= 30 and predicted_30m is None: + predicted_30m = sim_temp + + error = self._temperature_error(sim_temp, target_temp, hvac_mode) + comfort_error = max(abs(error) - self._deadband, 0.0) + overshoot = max(-error, 0.0) + floor_violation = max(target_temp - sim_temp, 0.0) if hvac_mode == "heat" else max(sim_temp - target_temp, 0.0) + + # Step-by-step urgency weight calculated dynamically based on current simulated step comfort error + step_urgency_weight = 1.0 + comfort_error * URGENCY_SENSITIVITY + cost += COMFORT_ERROR_WEIGHT * comfort_error * step_urgency_weight + cost += OVERSHOOT_QUADRATIC_WEIGHT * overshoot * overshoot + cost += FLOOR_VIOLATION_LINEAR_WEIGHT * floor_violation * step_urgency_weight + cost += FLOOR_VIOLATION_QUADRATIC_WEIGHT * floor_violation * floor_violation + + cost += MODE_CHANGE_DISTANCE_COST * abs(candidate_index - current_index) + # 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 + + 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 + + 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) + 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, + *, + 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, + would_change_now: str = "no", + dead_time: float | None = None, + known_profiles: int = 0, + disturbance_bias: float | None = None, + ) -> dict: + """Build the MPC 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_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..a7aad89 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 @@ -63,10 +81,10 @@ 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"] + climate_entity = data["climate_entity"] 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", @@ -77,57 +95,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e "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 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] = [ - 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, 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), - SmartFanLearnedDeadbandSensor(entry.entry_id, controller), - SmartFanLearnedSoftErrorSensor(entry.entry_id, controller), - SmartFanLearnedHardErrorSensor(entry.entry_id, controller), - SmartFanLearnedLimitTimeoutSensor(entry.entry_id, controller), + 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, controller, 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: @@ -145,7 +162,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, controller, profile_sensor_keys) + new_entities = _build_profile_effective_slope_entities( + entry.entry_id, + climate_entity, + mpc, + profile_sensor_keys, + ) if not new_entities: return @@ -169,6 +191,7 @@ class SmartFanSensor(_SmartFanEntity): def __init__( self, entry_id: str, + climate_entity: str, name_suffix: str, object_key: str, data_key: str, @@ -178,6 +201,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) @@ -186,9 +210,9 @@ 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_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) @@ -197,15 +221,16 @@ def update_from_controller(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: @@ -226,8 +251,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") @@ -238,14 +261,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: @@ -259,15 +283,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: @@ -292,15 +317,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: @@ -312,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 { @@ -327,8 +353,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) @@ -336,7 +363,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: @@ -355,8 +382,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) @@ -364,7 +392,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: @@ -381,11 +409,60 @@ 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, 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._set_entity_id(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_scoped_entity_id( + "sensor", + self._climate_entity, + _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.""" - 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 @@ -396,7 +473,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: @@ -408,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, } @@ -423,6 +511,7 @@ class _BaseLearnedParameterSensor(_SmartFanEntity): def __init__( self, entry_id: str, + climate_entity: str, controller, *, name: str, @@ -434,6 +523,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 @@ -444,7 +534,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): @@ -474,9 +564,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", @@ -488,46 +579,13 @@ 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.""" - 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 d4a7b9b..19503af 100644 --- a/custom_components/smart_fan_controller/services.yaml +++ b/custom_components/smart_fan_controller/services.yaml @@ -1,7 +1,67 @@ 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). + 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/switch.py b/custom_components/smart_fan_controller/switch.py deleted file mode 100644 index 6b7b2cc..0000000 --- a/custom_components/smart_fan_controller/switch.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Switch platform for Smart Fan Controller.""" -from __future__ import annotations - -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, - DEVICE_NAME, - DOMAIN, - build_entity_id, - build_unique_id, -) - -_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"] - - entities = [ - SmartFanLearningSwitch(entry.entry_id, 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() - diff --git a/custom_components/smart_fan_controller/thermal_learning.py b/custom_components/smart_fan_controller/thermal_learning.py index 33047b4..ecabf55 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 @@ -116,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 @@ -127,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", @@ -211,18 +262,32 @@ 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: + 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) 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 +298,53 @@ 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 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. + + 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.""" @@ -287,7 +397,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 @@ -301,8 +411,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", @@ -315,8 +423,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), @@ -336,7 +442,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, } @@ -369,32 +475,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", 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 + # 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._response_events = [(ts, t) for ts, t in instance._response_events if ts > cutoff_time] + instance._slope_samples = ThermalLearning.trim_with_min_retention(instance._slope_samples, cutoff_time, MIN_MODE_PROFILE_SAMPLES) + instance._response_events = [item for item in instance._response_events if item[0] > cutoff_time] # Rebuild stats from cleaned window instance.recompute_slope_stats() @@ -404,8 +511,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/custom_components/smart_fan_controller/translations/en.json b/custom_components/smart_fan_controller/translations/en.json index cd4fd1c..17dc82e 100644 --- a/custom_components/smart_fan_controller/translations/en.json +++ b/custom_components/smart_fan_controller/translations/en.json @@ -8,10 +8,7 @@ "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)", "defrost_entity": "Defrost Entity (optional)", "operating_entity": "Heat Pump Operating Entity (optional)" @@ -20,18 +17,15 @@ "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.", "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": { + "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.", - "invalid_thresholds": "Deadband must be less than Soft Error, which must be less than Hard Error." + "no_versatile_thermostat": "No Versatile Thermostat entity found." } } } @@ -44,10 +38,7 @@ "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)", "defrost_entity": "Defrost Entity (optional)", "operating_entity": "Heat Pump Operating Entity (optional)" @@ -56,18 +47,15 @@ "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.", "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": { + "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.", - "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 5efa2df..e8e34fe 100644 --- a/custom_components/smart_fan_controller/translations/fr.json +++ b/custom_components/smart_fan_controller/translations/fr.json @@ -8,10 +8,7 @@ "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)", "defrost_entity": "Entité dégivrage (optionnel)", "operating_entity": "Entité fonctionnement PAC (optionnel)" @@ -20,18 +17,15 @@ "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.", "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": { + "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.", - "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." } } } @@ -44,10 +38,7 @@ "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)", "defrost_entity": "Entité dégivrage (optionnel)", "operating_entity": "Entité fonctionnement PAC (optionnel)" @@ -56,18 +47,15 @@ "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.", "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": { + "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.", - "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." } } } diff --git a/docs/mpc_mode.md b/docs/mpc_mode.md new file mode 100644 index 0000000..630ea6c --- /dev/null +++ b/docs/mpc_mode.md @@ -0,0 +1,172 @@ +# MPC Controller — Technical Design + +## Purpose + +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. +- 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. 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 | +| -------- | -------------------------------------------------------------- | -------------------------------------------------- | +| `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_*`. + +The MPC controller owns its runtime parameters (`deadband`, `min_interval`, `fan_modes`) and consumes learned profiles from `ThermalLearning`. + +## 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 × 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)`, 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` 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. + +## Diagnostics + +The MPC exposes: + +- recommendation status +- recommendation reason +- recommended fan mode +- 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. + +## Implementation Map + +Current files involved: + +- `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 + +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/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..50c1de2 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,131 @@ +# 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 +``` + +### 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 | +| `--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 + +| 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 + +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 + +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/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/scripts/replay_bench.py b/scripts/replay_bench.py new file mode 100644 index 0000000..3145c0d --- /dev/null +++ b/scripts/replay_bench.py @@ -0,0 +1,801 @@ +#!/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 +from datetime import datetime +from importlib import import_module +import logging +import re +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")) + +_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) + +# --------------------------------------------------------------------------- +# 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} +_PROFILE_ENTITY_RE = re.compile(r"_(heat|cool)_([^_]+)_effective_slope$") +_SNAPSHOT_START_GRACE_SECONDS = 120 + + +# --------------------------------------------------------------------------- +# 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) + + +@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 +# --------------------------------------------------------------------------- +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 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] = {} + 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, + 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. + + 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 + 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, + 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]], + 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], + 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("\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", + ) + 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 + 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})") + + 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(",")] + 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") + + 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}'...") + 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, report_learning, all_metrics) + + # 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 new file mode 100644 index 0000000..49e4628 --- /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 + ) diff --git a/tests/test_cool.py b/tests/test_cool.py deleted file mode 100644 index 0293133..0000000 --- a/tests/test_cool.py +++ /dev/null @@ -1,200 +0,0 @@ -"""Tests for SmartFanController logic - COOL mode.""" -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 8f9bff0..53cb9ec 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 @@ -92,12 +95,59 @@ async def test_async_record_appends_row(tmp_path: Path) -> None: "0", "10.99", "0", + "Not ready", + "", + "no", + "", + "", + "", + "", + "0", + "", "0", "0", ] 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.""" @@ -152,8 +202,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_defrost.py b/tests/test_defrost.py deleted file mode 100644 index b771b8f..0000000 --- a/tests/test_defrost.py +++ /dev/null @@ -1,165 +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 - - 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 - - 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 - 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 8aff3d2..0000000 --- a/tests/test_heat.py +++ /dev/null @@ -1,200 +0,0 @@ -"""Tests for SmartFanController logic - HEAT mode.""" -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 f58b0e5..0000000 --- a/tests/test_hvac_idle.py +++ /dev/null @@ -1,214 +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.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() - 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() - 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 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 6d6ebba..ddcf337 100644 --- a/tests/test_learning.py +++ b/tests/test_learning.py @@ -1,10 +1,23 @@ """Tests for ThermalLearning auto-calibration.""" import pytest -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.""" @@ -15,7 +28,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 +53,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 +71,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 +88,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 @@ -87,96 +100,97 @@ 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 i 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", "climate.test", 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", "climate.test", mpc) 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_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_learning_enabled.py b/tests/test_learning_enabled.py deleted file mode 100644 index 952dc73..0000000 --- a/tests/test_learning_enabled.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Test for learning enabled switch functionality.""" -import pytest -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, - ) - - # 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, - ) - - # 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 8aaa596..4f5bf5b 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 @@ -12,6 +13,7 @@ CONF_CLIMATE_ENTITY, CONF_DEADBAND, DEFAULT_DEADBAND, + MIN_MODE_PROFILE_SAMPLES, ) @@ -36,7 +38,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,11 +123,11 @@ 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), + (old_timestamp, "medium", 0.5, "heat"), + (time.time(), "high", 0.8, "heat"), ] learning.response_events = [ (old_timestamp, 12.5), @@ -136,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.""" @@ -186,7 +215,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 new file mode 100644 index 0000000..f44ccd6 --- /dev/null +++ b/tests/test_mpc_controller.py @@ -0,0 +1,641 @@ +"""Tests for the MPC diagnostics and guardrails.""" + +import csv +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +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, +) +from custom_components.smart_fan_controller.thermal_learning import ThermalLearning + +FAN_MODES = ["low", "medium", "high"] + + +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, + limit_timeout=18, + fan_modes=fan_modes or FAN_MODES, + ) + + +def _prime_learning_profiles(learning: ThermalLearning) -> None: + """Feed enough slope samples for all profiles to become ready.""" + for _ in range(60): + 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: + """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_mpc_idle_for_unsimulated_hvac_modes() -> None: + """MPC reports idle status for unsimulated HVAC modes.""" + learning = ThermalLearning() + mpc = MPCController( + learning=learning, + deadband=0.3, + min_interval=10, + fan_modes=FAN_MODES, + ) + + result = mpc.evaluate( + current_temp=19.2, + target_temp=20.0, + vtherm_slope=0.4, + hvac_mode="off", + current_fan="medium", + is_window_open=False, + ) + + assert result["mpc_status"] == "Idle" + assert result["mpc_fan_mode"] == "medium" + assert result["mpc_would_change_now"] == "no" + + +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=learning, + deadband=0.3, + min_interval=10, + fan_modes=FAN_MODES, + ) + + result = mpc.evaluate( + current_temp=19.0, + target_temp=20.0, + vtherm_slope=0.25, + hvac_mode="heat", + current_fan="low", + is_window_open=False, + minutes_since_change=20.0, + ) + + assert result["mpc_fan_mode"] == "high" + assert result["mpc_would_change_now"] == "yes" + assert result["mpc_known_profiles"] == 3 + + +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"] + learning = ThermalLearning() + for _ in range(60): + 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=learning, + deadband=0.2, + min_interval=10, + fan_modes=fan_modes, + ) + + result = mpc.evaluate( + current_temp=19.95, + target_temp=20.0, + vtherm_slope=0.2, + hvac_mode="heat", + current_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_mpc_pauses_when_window_is_open() -> None: + """MPC pauses evaluation when a window is open.""" + learning = ThermalLearning() + _prime_learning_profiles(learning) + mpc = MPCController( + learning=learning, + deadband=0.3, + min_interval=10, + fan_modes=FAN_MODES, + ) + + result = mpc.evaluate( + current_temp=19.3, + target_temp=20.0, + vtherm_slope=0.2, + hvac_mode="heat", + current_fan="medium", + 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_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", + None, + None, + "mdi:calculator", + ) + + sensor.update_from_mpc({"mpc_cost": 3.2}) + assert sensor.native_value == 3.2 + + 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.""" + learning = ThermalLearning() + mpc = _build_mpc(learning) + for _ in range(15): + learning.add_slope_sample("medium", 0.5, 0.3, "heat") + for _ in range(8): + learning.add_slope_sample("high", 0.9, 0.3, "heat") + for _ in range(12): + learning.add_slope_sample("low", -0.4, 0.3, "cool") + + sensor = SmartFanMpcProfilesSensor("entry-1", "climate.living_room", mpc, "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_living_room_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.""" + learning = ThermalLearning() + mpc = _build_mpc(learning) + for _ in range(12): + learning.add_slope_sample("high", 0.9, 0.3, "heat") + + sensor = SmartFanProfileEffectiveSlopeSensor( + "entry-1", + "climate.living_room", + mpc, + "heat", + "high", + ) + + 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 + 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 +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") + + 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, + mpc_decision={ + "mpc_status": "Ready", + "mpc_fan_mode": "high", + "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_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=learning, + deadband=0.3, + min_interval=10, + fan_modes=FAN_MODES, + ) + + result = mpc.evaluate( + current_temp=20.4, + target_temp=17.5, + vtherm_slope=0.0, + hvac_mode="heat", + current_fan="high", + 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_mpc_setpoint_drop_reports_would_change() -> None: + """Setpoint drop should report would_change correctly.""" + learning = ThermalLearning() + mpc = MPCController( + learning=learning, + deadband=0.3, + min_interval=10, + fan_modes=FAN_MODES, + ) + + result = mpc.evaluate( + current_temp=20.0, + target_temp=17.5, + vtherm_slope=-0.2, + hvac_mode="heat", + current_fan="medium", + is_window_open=False, + minutes_since_change=15.0, + ) + + assert result["mpc_fan_mode"] == "low" + assert result["mpc_would_change_now"] == "yes" + + +def test_mpc_no_setpoint_drop_when_error_above_threshold() -> None: + """Normal over-target should NOT trigger setpoint drop.""" + learning = ThermalLearning() + _prime_learning_profiles(learning) + mpc = MPCController( + learning=learning, + deadband=0.3, + min_interval=10, + fan_modes=FAN_MODES, + ) + + result = mpc.evaluate( + current_temp=20.3, + target_temp=20.0, + vtherm_slope=0.0, + hvac_mode="heat", + current_fan="high", + is_window_open=False, + minutes_since_change=15.0, + ) + + assert result["mpc_status"] != "Setpoint drop" + + +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=learning, + deadband=0.3, + min_interval=10, + fan_modes=FAN_MODES, + ) + + result = mpc.evaluate( + current_temp=19.3, + target_temp=20.0, + vtherm_slope=0.2, + hvac_mode="heat", + current_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_mpc_disturbance_bias_decays_during_defrost() -> None: + """Disturbance bias should decay, not update, during defrost.""" + learning = ThermalLearning() + _prime_learning_profiles(learning) + mpc = MPCController( + learning=learning, + deadband=0.3, + min_interval=10, + fan_modes=FAN_MODES, + ) + + # Prime the disturbance bias with a normal cycle + mpc.evaluate( + current_temp=19.5, + target_temp=20.0, + vtherm_slope=0.5, + hvac_mode="heat", + current_fan="medium", + is_window_open=False, + minutes_since_change=20.0, + ) + bias_before = mpc.disturbance_bias + + # Defrost cycle with sharp slope drop — should NOT poison the bias + mpc.evaluate( + current_temp=19.5, + target_temp=20.0, + vtherm_slope=-1.0, + hvac_mode="heat", + current_fan="high", + is_window_open=False, + is_defrost_active=True, + minutes_since_change=25.0, + ) + bias_after = mpc.disturbance_bias + + # Bias should have decayed, not grown from the -1.0 slope residual + assert abs(bias_after) <= abs(bias_before) + + +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() + + # Create inverted profiles: silent > med (the real-world bug) + learning.set_mode_effective_slope("silent", "heat", 0.53) + learning.set_mode_effective_slope("low", "heat", 0.0) + learning.set_mode_effective_slope("med", "heat", 0.45) + learning.set_mode_effective_slope("high", "heat", 0.96) + learning.set_mode_effective_slope("superhigh", "heat", 1.35) + + mpc = MPCController( + learning=learning, + deadband=0.3, + min_interval=10, + fan_modes=["silent", "low", "med", "high", "superhigh"], + ) + + monotone = mpc.build_monotone_slopes(["silent", "low", "med", "high", "superhigh"], "heat") + assert isinstance(monotone, dict) + + # silent (0.53) should stay, low (0.0 → clamped to 0.53), med (0.45 → clamped to 0.53) + assert monotone["silent"] == pytest.approx(0.53, abs=0.001) + assert monotone["low"] >= monotone["silent"] + assert monotone["med"] >= monotone["low"] + assert monotone["high"] >= monotone["med"] + assert monotone["superhigh"] >= monotone["high"] + + +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, + deadband=0.3, + min_interval=10, + fan_modes=FAN_MODES, + ) + # No profiles learned yet + result = mpc.build_monotone_slopes(FAN_MODES, "heat") + 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: + """When profiles are already monotone, the constraint should not change values.""" + learning = ThermalLearning() + + 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=learning, + deadband=0.3, + min_interval=10, + fan_modes=FAN_MODES, + ) + + monotone = mpc.build_monotone_slopes(FAN_MODES, "heat") + 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) + + +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" + + +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) + diff --git a/tests/test_phase_detection.py b/tests/test_phase_detection.py deleted file mode 100644 index c7d6d2d..0000000 --- a/tests/test_phase_detection.py +++ /dev/null @@ -1,516 +0,0 @@ -"""Tests for phase detection, adaptive timeout, descent path, and window filtering.""" -import pytest -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(): - 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.""" - result = 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.""" - result = 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.""" - import time - 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.""" - import time - 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 4cd3c2f..04d5efb 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1,18 +1,24 @@ """Tests for Smart Fan Controller HA services (apply_learned_settings, reset_learning).""" import asyncio +from unittest.mock import AsyncMock, MagicMock + import pytest -from unittest.mock import AsyncMock, MagicMock, patch +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er -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 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 ( - DOMAIN, + CONF_CLIMATE_ENTITY, CONF_DEADBAND, - CONF_SOFT_ERROR, - CONF_HARD_ERROR, CONF_LIMIT_TIMEOUT, + DOMAIN, MIN_SAMPLES_LEARNING, + build_unique_id, ) @@ -27,20 +33,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.""" @@ -48,50 +40,41 @@ 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} + entry.options = {} 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() - # 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.""" @@ -101,20 +84,97 @@ 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} + entry.options = {CONF_DEADBAND: 0.2, CONF_LIMIT_TIMEOUT: 15, CONF_CLIMATE_ENTITY: "climate.test"} - 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)) # 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_SOFT_ERROR] == 0.4 - assert updated_data[CONF_HARD_ERROR] == 0.7 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: diff --git a/tests/test_system.py b/tests/test_system.py deleted file mode 100644 index e25fbb6..0000000 --- a/tests/test_system.py +++ /dev/null @@ -1,505 +0,0 @@ -"""Tests for SmartFanController system constraints and edge cases.""" -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