Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 6 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,6 @@ All parameters can be changed at any time via **Settings → Devices & Services
| -------------------- | -------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Deadband** | `0.2°C` | `0.0` – `5.0°C` | Comfort zone around target — no action taken within this range. Increase to reduce fan changes. |
| **Min Interval** | `10 min` | `1` – `60 min` | Minimum time between non-emergency fan changes. Prevents rapid oscillations. |
| **Limit Timeout** | `15 min` | `10` – `120 min` | Fallback timeout used before learning calibrates the dead time. |
| **Data Collection** | `true` | — | Records one CSV row every 2 minutes in the HA config folder (`smart_fan_controller_data_XXXXXXXX.csv`, max 10 MB, auto-rotated). Useful for offline analysis. |
| **Defrost Entity** | *(none)* | — | Optional entity (`binary_sensor`, `sensor`, or `input_boolean`) that reports when the heat pump is in defrost cycle. See [Defrost Detection](#defrost-detection). |
| **Operating Entity** | *(none)* | — | Optional entity (`binary_sensor`, `sensor`, or `input_boolean`) that reports whether the heat pump compressor is actively running. See [HVAC Idle Detection](#hvac-idle-detection). |
Expand Down Expand Up @@ -144,7 +143,7 @@ Each candidate fan mode is scored with:

- **Hysteresis**: a recommendation that changes the fan must beat the current mode by a minimum cost margin. The margin is larger when near the target (0.30) and smaller when far away (0.10).
- **Step-down hold**: blocks downward moves when still under target and the temperature slope hasn't established yet.
- **Min interval**: non-emergency changes respect the effective timeout (learned dead time × 1.5 for the specified HVAC mode, or the configured limit timeout).
- **Min interval**: non-emergency changes respect the configured minimum interval between fan changes.

### Phase Detection

Expand Down Expand Up @@ -199,14 +198,13 @@ The integration includes an **automatic learning system** that collects data dur

**Parameters computed from data**:

| Parameter | Formula |
| --------------- | ------------------------------------------------- |
| `deadband` | `0.15 + (volatility_factor × 0.2)` |
| `limit_timeout` | rounded median of measured thermal response times |
| Parameter | Formula |
| ---------- | ---------------------------------- |
| `deadband` | `0.15 + (volatility_factor × 0.2)` |

Where `volatility_factor = min(slope_stdev / slope_mean, 3.0)`.

> **Important**: the learned `limit_timeout` is the stored base response estimate. At runtime, non-emergency decisions use `effective_timeout = max(min_interval, dead_time × 1.5)` once learning is ready.
> **Note**: the `effective_timeout` diagnostic (`max(min_interval, dead_time × 1.5)` once learning is ready) is exposed as a sensor for insight into the learned thermal lag, but it does not gate control decisions — the minimum interval between fan changes does.

Once learning is ready, parameters are **automatically applied** and the integration reloads. To apply manually, use the `apply_learned_settings` service. To start over, use `reset_learning`.

Expand Down Expand Up @@ -278,9 +276,8 @@ Entity IDs are scoped by the configured climate entity. For example, a controlle
| `sensor.smart_fan_controller_living_room_learning_samples` | count | Number of slope samples collected |
| `sensor.smart_fan_controller_living_room_learning_response_events` | count | Number of thermal response time measurements |
| `sensor.smart_fan_controller_living_room_learned_dead_time` | min | Median learned thermal response delay (`dead_time`) |
| `sensor.smart_fan_controller_living_room_effective_timeout` | min | Actual non-emergency timeout currently used |
| `sensor.smart_fan_controller_living_room_effective_timeout` | min | Advisory adaptive timeout (diagnostic only) |
| `sensor.smart_fan_controller_living_room_learned_deadband` | °C | Learned optimal deadband |
| `sensor.smart_fan_controller_living_room_learned_limit_timeout` | min | Learned base timeout stored in config |

### Learning Profile Sensors

Expand Down
12 changes: 4 additions & 8 deletions custom_components/smart_fan_controller/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,12 @@
CONF_DATA_COLLECTION,
CONF_DEADBAND,
CONF_DEFROST_ENTITY,
CONF_LIMIT_TIMEOUT,
CONF_MIN_INTERVAL,
CONF_OPERATING_ENTITY,
DEAD_TIME_SAFETY_FACTOR,
DEFAULT_DATA_COLLECTION,
DEFAULT_DEAD_TIME,
DEFAULT_DEADBAND,
DEFAULT_LIMIT_TIMEOUT,
DEFAULT_MIN_INTERVAL,
DELTA_TIME_CONTROL_LOOP,
DOMAIN,
Expand Down Expand Up @@ -233,16 +231,13 @@ async def _apply_optimal_parameters(
new_data = {**entry.data}
new_data["learning_auto_applied"] = True
new_data[CONF_DEADBAND] = optimal["deadband"]
new_data[CONF_LIMIT_TIMEOUT] = optimal["limit_timeout"]
new_options = {**entry.options}
new_options[CONF_DEADBAND] = optimal["deadband"]
new_options[CONF_LIMIT_TIMEOUT] = optimal["limit_timeout"]

_LOGGER.info(
"Applying learned settings for %s: deadband=%.2f limit_timeout=%d",
"Applying learned settings for %s: deadband=%.2f",
entry.entry_id,
optimal["deadband"],
optimal["limit_timeout"],
)

hass.config_entries.async_update_entry(entry, data=new_data, options=new_options)
Expand Down Expand Up @@ -558,7 +553,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
learning=learning,
deadband=conf.get(CONF_DEADBAND, DEFAULT_DEADBAND),
min_interval=conf.get(CONF_MIN_INTERVAL, DEFAULT_MIN_INTERVAL),
limit_timeout=conf.get(CONF_LIMIT_TIMEOUT, DEFAULT_LIMIT_TIMEOUT),
fan_modes=initial_fan_modes or None,
)

Expand Down Expand Up @@ -595,7 +589,9 @@ def _ensure_profile_sensors() -> None:

# Mutable state shared between control-loop callbacks (closure variables)
ctrl_state: dict = {
"last_change_time": time.time() - (conf.get(CONF_LIMIT_TIMEOUT, DEFAULT_LIMIT_TIMEOUT) * 60),
# Seed as if the min interval had just elapsed so the first cycle can act
# immediately instead of waiting out a fresh cooldown.
"last_change_time": time.time() - (conf.get(CONF_MIN_INTERVAL, DEFAULT_MIN_INTERVAL) * 60),
"last_setpoint_drop_time": 0.0,
"previous_slope": None,
"last_hvac_mode": None,
Expand Down
8 changes: 0 additions & 8 deletions custom_components/smart_fan_controller/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,11 @@
CONF_CLIMATE_ENTITY,
CONF_DEADBAND,
CONF_MIN_INTERVAL,
CONF_LIMIT_TIMEOUT,
CONF_DATA_COLLECTION,
CONF_DEFROST_ENTITY,
CONF_OPERATING_ENTITY,
DEFAULT_DEADBAND,
DEFAULT_MIN_INTERVAL,
DEFAULT_LIMIT_TIMEOUT,
DEFAULT_DATA_COLLECTION,
)

Expand Down Expand Up @@ -105,9 +103,6 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con
vol.Optional(CONF_MIN_INTERVAL, default=DEFAULT_MIN_INTERVAL): selector.NumberSelector(
selector.NumberSelectorConfig(min=1, max=60, step=1, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="min")
),
vol.Optional(CONF_LIMIT_TIMEOUT, default=DEFAULT_LIMIT_TIMEOUT): selector.NumberSelector(
selector.NumberSelectorConfig(min=10, max=120, step=5, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="min")
),
vol.Optional(CONF_DATA_COLLECTION, default=DEFAULT_DATA_COLLECTION): selector.BooleanSelector(),
vol.Optional(CONF_DEFROST_ENTITY): selector.EntitySelector(selector.EntitySelectorConfig(domain=["binary_sensor", "sensor", "input_boolean"])),
vol.Optional(CONF_OPERATING_ENTITY): selector.EntitySelector(selector.EntitySelectorConfig(domain=["binary_sensor", "sensor", "input_boolean"])),
Expand Down Expand Up @@ -173,9 +168,6 @@ async def async_step_init(self, user_input: dict[str, Any] | None = None) -> Con
vol.Optional(CONF_MIN_INTERVAL, default=current_data.get(CONF_MIN_INTERVAL, DEFAULT_MIN_INTERVAL)): selector.NumberSelector(
selector.NumberSelectorConfig(min=1, max=60, step=1, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="min")
),
vol.Optional(CONF_LIMIT_TIMEOUT, default=current_data.get(CONF_LIMIT_TIMEOUT, DEFAULT_LIMIT_TIMEOUT)): selector.NumberSelector(
selector.NumberSelectorConfig(min=10, max=120, step=5, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="min")
),
vol.Optional(CONF_DATA_COLLECTION, default=current_data.get(CONF_DATA_COLLECTION, DEFAULT_DATA_COLLECTION)): selector.BooleanSelector(),
vol.Optional(CONF_DEFROST_ENTITY, default=current_data.get(CONF_DEFROST_ENTITY, vol.UNDEFINED)): selector.EntitySelector(
selector.EntitySelectorConfig(domain=["binary_sensor", "sensor", "input_boolean"])
Expand Down
3 changes: 0 additions & 3 deletions custom_components/smart_fan_controller/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,13 @@
CONF_CLIMATE_ENTITY = "climate_entity"
CONF_DEADBAND = "deadband"
CONF_MIN_INTERVAL = "min_interval"
CONF_LIMIT_TIMEOUT = "limit_timeout"
CONF_DATA_COLLECTION = "data_collection"
CONF_DEFROST_ENTITY = "defrost_entity"
CONF_OPERATING_ENTITY = "operating_entity"

# Default values
DEFAULT_DEADBAND = 0.2
DEFAULT_MIN_INTERVAL = 10
DEFAULT_LIMIT_TIMEOUT = 15
DEFAULT_DATA_COLLECTION = True

DELTA_TIME_CONTROL_LOOP = 2 # minutes between each control loop execution
Expand All @@ -43,7 +41,6 @@

# Learning
MIN_SAMPLES_LEARNING = 240 # Minimum slope samples required for initial readiness
MIN_LIMIT_TIMEOUT = 5 # Minimum limit_timeout (minutes) derived from learning
MIN_MODE_PROFILE_SAMPLES = 10 # Minimum samples per fan mode to consider profile reliable
REFERENCE_SLOPE_ERROR = 1.0 # °C – reference comfort error at which the representative
# "working" effective slope is reported. The learned slope model is slope(error) = a + b·error;
Expand Down
Loading
Loading