Skip to content
Open
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
67 changes: 57 additions & 10 deletions custom_components/smart_fan_controller/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@
CONF_DATA_COLLECTION,
CONF_DEADBAND,
CONF_DEFROST_ENTITY,
CONF_LIMIT_TIMEOUT,
CONF_FAN_MODE_ORDER,
CONF_MIN_INTERVAL,
CONF_OPERATING_ENTITY,
CONF_OUTDOOR_ENTITY,
DEAD_TIME_SAFETY_FACTOR,
DEFAULT_DATA_COLLECTION,
DEFAULT_DEAD_TIME,
DEFAULT_DEADBAND,
DEFAULT_LIMIT_TIMEOUT,
DEFAULT_MIN_INTERVAL,
DELTA_TIME_CONTROL_LOOP,
DOMAIN,
Expand Down Expand Up @@ -69,6 +69,29 @@ def _extract_supported_fan_modes(state) -> list[str]:
return _filter_supported_fan_modes(state.attributes.get("fan_modes"))


def _apply_configured_fan_order(detected: list[str], configured: list[str] | None) -> list[str]:
"""Reorder *detected* fan modes to follow the user's explicit weakest-to-strongest order.

The whole controller treats a fan mode's index as its strength: the energy
term (MODE_POWER_RATIO ** index), the step-down guard, the monotone slope
enforcement and the rank-scaling fallback for unlearned profiles all read
it. The climate entity's own ``fan_modes`` order is normally correct but is
trusted blindly, so the options flow lets it be overridden.

Modes present in *configured* keep that order; anything detected but not
listed (a mode added by the climate entity after configuration) is appended
so it stays usable rather than silently disappearing. Entries in
*configured* that no longer exist are dropped.
"""
if not configured:
return detected
ordered = [mode for mode in configured if mode in detected]
ordered += [mode for mode in detected if mode not in ordered]
if ordered != detected:
_LOGGER.debug("Applied configured fan-mode order: %s -> %s", detected, ordered)
return ordered


def _iter_loaded_entries(hass: HomeAssistant) -> list[tuple[str, dict]]:
"""Return loaded Smart Fan Controller entries, excluding internal metadata."""
domain_data = hass.data.get(DOMAIN, {})
Expand Down Expand Up @@ -233,16 +256,13 @@ async def _apply_optimal_parameters(
new_data = {**entry.data}
new_data["learning_auto_applied"] = True
new_data[CONF_DEADBAND] = optimal["deadband"]
new_data[CONF_LIMIT_TIMEOUT] = optimal["limit_timeout"]
new_options = {**entry.options}
new_options[CONF_DEADBAND] = optimal["deadband"]
new_options[CONF_LIMIT_TIMEOUT] = optimal["limit_timeout"]

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

hass.config_entries.async_update_entry(entry, data=new_data, options=new_options)
Expand Down Expand Up @@ -483,6 +503,25 @@ def _detect_disturbances(hass, conf: dict, defrost_state: dict) -> tuple[bool, b
return is_defrost, is_hvac_idle


def _read_outdoor_temp(hass, conf: dict) -> float | None:
"""Return the configured outdoor temperature (°C), or None when unavailable.

Optional, telemetry only: logged to the data-collection CSV so the trace can
be correlated with outdoor conditions offline. No control decision reads it.
A missing, non-numeric, or unavailable sensor simply leaves the column empty.
"""
outdoor_entity_id = conf.get(CONF_OUTDOOR_ENTITY)
if not outdoor_entity_id:
return None
state = hass.states.get(outdoor_entity_id)
if state is None or state.state in ("unknown", "unavailable", "", None):
return None
try:
return float(state.state)
except (TypeError, ValueError):
return None


async def _async_apply_fan_change(
hass,
climate_id: str,
Expand Down Expand Up @@ -524,7 +563,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
domain_data = hass.data.setdefault(DOMAIN, {})
climate_id = conf[CONF_CLIMATE_ENTITY]
current_state = hass.states.get(climate_id)
initial_fan_modes = _extract_supported_fan_modes(current_state)
configured_fan_order = conf.get(CONF_FAN_MODE_ORDER)
initial_fan_modes = _apply_configured_fan_order(
_extract_supported_fan_modes(current_state), configured_fan_order
)

_LOGGER.info(
"Setting up Smart Fan Controller for %s (data_collection=%s)",
Expand Down Expand Up @@ -558,7 +600,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
learning=learning,
deadband=conf.get(CONF_DEADBAND, DEFAULT_DEADBAND),
min_interval=conf.get(CONF_MIN_INTERVAL, DEFAULT_MIN_INTERVAL),
limit_timeout=conf.get(CONF_LIMIT_TIMEOUT, DEFAULT_LIMIT_TIMEOUT),
fan_modes=initial_fan_modes or None,
)

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

# Mutable state shared between control-loop callbacks (closure variables)
ctrl_state: dict = {
"last_change_time": time.time() - (conf.get(CONF_LIMIT_TIMEOUT, DEFAULT_LIMIT_TIMEOUT) * 60),
# Seed as if the min interval had just elapsed so the first cycle can act
# immediately instead of waiting out a fresh cooldown.
"last_change_time": time.time() - (conf.get(CONF_MIN_INTERVAL, DEFAULT_MIN_INTERVAL) * 60),
"last_setpoint_drop_time": 0.0,
"previous_slope": None,
"last_hvac_mode": None,
Expand All @@ -616,7 +659,9 @@ async def _execute_control_cycle():
return

if not mpc_controller.fan_modes:
detected_modes = _extract_supported_fan_modes(current_state)
detected_modes = _apply_configured_fan_order(
_extract_supported_fan_modes(current_state), configured_fan_order
)
if detected_modes:
mpc_controller.fan_modes = detected_modes
_LOGGER.info("Detected fan modes for %s during runtime: %s", climate_id, detected_modes)
Expand All @@ -629,6 +674,7 @@ async def _execute_control_cycle():
return
vtherm_slope, current_temp, target_temp, hvac_mode, current_fan, is_window_open = cycle_data
is_defrost_active, is_hvac_idle = _detect_disturbances(hass, conf, ctrl_state["defrost"])
outdoor_temp = _read_outdoor_temp(hass, conf)

now = time.time()
minutes_since_change = (now - ctrl_state["last_change_time"]) / 60.0
Expand Down Expand Up @@ -764,6 +810,7 @@ async def _execute_control_cycle():
mpc_decision=mpc_decision,
defrost_active=is_defrost_active,
is_hvac_idle=is_hvac_idle,
outdoor_temp=outdoor_temp,
)

_update_sensors(hass, entry.entry_id, {
Expand Down
59 changes: 50 additions & 9 deletions custom_components/smart_fan_controller/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@
from .const import (
DOMAIN,
CONF_CLIMATE_ENTITY,
CONF_FAN_MODE_ORDER,
CONF_DEADBAND,
CONF_MIN_INTERVAL,
CONF_LIMIT_TIMEOUT,
CONF_DATA_COLLECTION,
CONF_DEFROST_ENTITY,
CONF_OPERATING_ENTITY,
CONF_OUTDOOR_ENTITY,
DEFAULT_DEADBAND,
DEFAULT_MIN_INTERVAL,
DEFAULT_LIMIT_TIMEOUT,
DEFAULT_DATA_COLLECTION,
)

Expand All @@ -42,6 +42,31 @@ def _validate_climate_state(state) -> str | None:
return None


def _extract_fan_modes(state) -> list[str]:
"""Return the manual fan modes exposed by a climate state (excludes auto/off)."""
if state is None:
return []
raw_modes = state.attributes.get("fan_modes")
if not raw_modes:
return []
return [mode for mode in raw_modes if isinstance(mode, str) and mode.lower() not in {"auto", "off"}]


def _validate_fan_order(selected: list[str] | None, detected: list[str]) -> str | None:
"""Return an error key when the submitted fan order is unusable, else None.

An empty selection means "keep the climate entity's own order" and is valid.
A partial selection is rejected: a half-specified ladder is worse than none,
since the unlisted modes would be silently appended in detected order and
the user would believe they had set the strength order explicitly.
"""
if not selected:
return None
if set(selected) != set(detected):
return "fan_order_incomplete"
return None


def _is_climate_entity_already_configured(
hass,
climate_entity: str,
Expand Down Expand Up @@ -105,12 +130,10 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con
vol.Optional(CONF_MIN_INTERVAL, default=DEFAULT_MIN_INTERVAL): selector.NumberSelector(
selector.NumberSelectorConfig(min=1, max=60, step=1, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="min")
),
vol.Optional(CONF_LIMIT_TIMEOUT, default=DEFAULT_LIMIT_TIMEOUT): selector.NumberSelector(
selector.NumberSelectorConfig(min=10, max=120, step=5, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="min")
),
vol.Optional(CONF_DATA_COLLECTION, default=DEFAULT_DATA_COLLECTION): selector.BooleanSelector(),
vol.Optional(CONF_DEFROST_ENTITY): selector.EntitySelector(selector.EntitySelectorConfig(domain=["binary_sensor", "sensor", "input_boolean"])),
vol.Optional(CONF_OPERATING_ENTITY): selector.EntitySelector(selector.EntitySelectorConfig(domain=["binary_sensor", "sensor", "input_boolean"])),
vol.Optional(CONF_OUTDOOR_ENTITY): selector.EntitySelector(selector.EntitySelectorConfig(domain="sensor", device_class="temperature")),
}
)

Expand All @@ -130,11 +153,17 @@ async def async_step_init(self, user_input: dict[str, Any] | None = None) -> Con
available_climates = _get_climates_with_fan_modes_and_slope(self.hass)
errors: dict[str, str] = {}
current_data = {**self.config_entry.data, **self.config_entry.options}
current_climate = current_data.get(CONF_CLIMATE_ENTITY)
detected_fan_modes = _extract_fan_modes(self.hass.states.get(current_climate)) if current_climate else []

if user_input is not None:
climate_id = user_input[CONF_CLIMATE_ENTITY]
current_climate_id = current_data.get(CONF_CLIMATE_ENTITY)

order_error = _validate_fan_order(user_input.get(CONF_FAN_MODE_ORDER), detected_fan_modes)
if order_error:
errors[CONF_FAN_MODE_ORDER] = order_error

# Only validate the climate entity if the user is changing it
if climate_id != current_climate_id:
state = self.hass.states.get(climate_id)
Expand All @@ -153,7 +182,6 @@ async def async_step_init(self, user_input: dict[str, Any] | None = None) -> Con

# Build selector config for options flow
# Always include the current climate entity even if it's not in the filtered list
current_climate = current_data.get(CONF_CLIMATE_ENTITY)
selector_config_kwargs: dict[str, Any] = {"domain": CLIMATE_DOMAIN}
if available_climates:
selector_config_kwargs["include_entities"] = available_climates
Expand All @@ -173,16 +201,29 @@ 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"])
),
vol.Optional(CONF_OPERATING_ENTITY, default=current_data.get(CONF_OPERATING_ENTITY, vol.UNDEFINED)): selector.EntitySelector(
selector.EntitySelectorConfig(domain=["binary_sensor", "sensor", "input_boolean"])
),
vol.Optional(CONF_OUTDOOR_ENTITY, default=current_data.get(CONF_OUTDOOR_ENTITY, vol.UNDEFINED)): selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor", device_class="temperature")
),
# Pick every speed, weakest first. Left empty, the climate entity's
# own fan_modes order is used (see _apply_configured_fan_order).
vol.Optional(
CONF_FAN_MODE_ORDER,
default=current_data.get(CONF_FAN_MODE_ORDER, detected_fan_modes),
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=detected_fan_modes,
multiple=True,
sort=False,
mode=selector.SelectSelectorMode.LIST,
)
),
}
)

Expand Down
5 changes: 2 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,15 @@
CONF_CLIMATE_ENTITY = "climate_entity"
CONF_DEADBAND = "deadband"
CONF_MIN_INTERVAL = "min_interval"
CONF_LIMIT_TIMEOUT = "limit_timeout"
CONF_DATA_COLLECTION = "data_collection"
CONF_DEFROST_ENTITY = "defrost_entity"
CONF_OPERATING_ENTITY = "operating_entity"
CONF_OUTDOOR_ENTITY = "outdoor_entity"
CONF_FAN_MODE_ORDER = "fan_mode_order" # explicit weakest-to-strongest order, overrides the climate entity's

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

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

# Learning
MIN_SAMPLES_LEARNING = 240 # Minimum slope samples required for initial readiness
MIN_LIMIT_TIMEOUT = 5 # Minimum limit_timeout (minutes) derived from learning
MIN_MODE_PROFILE_SAMPLES = 10 # Minimum samples per fan mode to consider profile reliable
REFERENCE_SLOPE_ERROR = 1.0 # °C – reference comfort error at which the representative
# "working" effective slope is reported. The learned slope model is slope(error) = a + b·error;
Expand Down
3 changes: 3 additions & 0 deletions custom_components/smart_fan_controller/data_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"mpc_disturbance",
"defrost_active",
"hvac_idle",
"outdoor_temp",
]

# Rotate the file when it exceeds this size (bytes). 10 MB keeps ~200 000 rows.
Expand Down Expand Up @@ -101,6 +102,7 @@ async def async_record(
mpc_decision: dict | None = None,
defrost_active: bool = False,
is_hvac_idle: bool = False,
outdoor_temp: float | None = None,
) -> None:
"""Append one row to the CSV file outside the event loop."""
mpc = mpc_decision or {}
Expand Down Expand Up @@ -141,6 +143,7 @@ async def async_record(
else "",
int(defrost_active),
int(is_hvac_idle),
round(outdoor_temp, 2) if outdoor_temp is not None else "",
]
async with self._io_lock:
await self._hass.async_add_executor_job(self._write_row, row)
Expand Down
Loading
Loading