"""Auto TPI Manager implementing TPI algorithm.""" import logging from vtherm_api.log_collector import get_vtherm_logger import json import os import math import statistics from datetime import datetime, timedelta from typing import Optional from homeassistant.util.unit_conversion import TemperatureConverter from homeassistant.const import UnitOfTemperature from dataclasses import dataclass, asdict, field import asyncio from typing import Callable from homeassistant.core import HomeAssistant, callback from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.event import async_call_later from homeassistant.helpers import entity_platform, service, translation from homeassistant.helpers.storage import Store from homeassistant.components.recorder import history, get_instance from homeassistant.util import dt as dt_util from functools import partial from .const import ( DOMAIN, CONF_TPI_COEF_INT, CONF_TPI_COEF_EXT, CONF_AUTO_TPI_HEATING_POWER, CONF_AUTO_TPI_COOLING_POWER, ) from .vtherm_central_api import VersatileThermostatAPI _LOGGER = get_vtherm_logger(__name__) STORAGE_VERSION = 8 STORAGE_KEY_PREFIX = "versatile_thermostat.auto_tpi" # Configurable constants for learning algorithm behavior MIN_KINT = 0.01 # Minimum Kint threshold to maintain temperature responsiveness OVERSHOOT_THRESHOLD = 0.2 # Temperature overshoot threshold (°C) to trigger aggressive Kext correction OVERSHOOT_POWER_THRESHOLD = 0.05 # Minimum power (5%) to consider overshoot as Kext error OVERSHOOT_CORRECTION_BOOST = 2.0 # Multiplier for alpha during overshoot correction NATURAL_RECOVERY_POWER_THRESHOLD = 0.10 # Max power (10%) to consider temperature change as natural recovery KEXT_LEARNING_MAX_GAP = 1.0 # Max gap (°C) to allow Kext learning (Near-Field vs Far-Field) INSUFFICIENT_RISE_GAP_THRESHOLD = KEXT_LEARNING_MAX_GAP # Min gap (°C) to trigger Kint correction when temp stagnates INSUFFICIENT_RISE_BOOST_FACTOR = 1.08 # Kint increase factor (8%) per stagnating cycle MAX_CONSECUTIVE_KINT_BOOSTS = 5 # Max consecutive Kint boosts before warning (undersized heating) MIN_PRE_BOOTSTRAP_CALIBRATION_RELIABILITY = 20.0 # Min reliability (%) to use calibration instead of bootstrap MIN_EFFICIENCY_FOR_CAPACITY = 0.60 # Min efficiency (60%) to learn capacity - prevents outliers from external factors @dataclass class AutoTpiState: """Persistent state for Auto TPI algorithm.""" # Learning coefficients (heat) coeff_indoor_heat: float = 0.1 coeff_outdoor_heat: float = 0.01 coeff_indoor_autolearn: int = 1 # Counter coeff_outdoor_autolearn: int = 0 # Learning coefficients for Cool coeff_indoor_cool: float = 0.1 coeff_outdoor_cool: float = 0.01 coeff_indoor_cool_autolearn: int = 1 coeff_outdoor_cool_autolearn: int = 0 # Max Capacity (physical power of the system) max_capacity_heat: float = 0.0 max_capacity_cool: float = 0.0 # Offsets. offset: float = 0.0 # Previous cycle state (Snapshot for learning) last_power: float = 0.0 last_order: float = 0.0 last_temp_in: float = 0.0 last_temp_out: float = 0.0 last_state: str = "stop" # 'heat', 'cool', 'stop' previous_state: str = "stop" # State of the previous cycle last_on_temp_in: float = 0.0 # Temp at the end of ON time last_update_date: Optional[datetime] = None last_heater_stop_time: Optional[datetime] = None # When heater stopped # Cycle management cycle_start_date: Optional[datetime] = None # Start of current cycle cycle_active: bool = False current_cycle_cold_factor: float = 0.0 # 1.0 = cold, 0.0 = hot current_cycle_params: dict = None # Parameters of the current/last cycle # Management consecutive_failures: int = 0 autolearn_enabled: bool = False last_learning_status: str = "startup" total_cycles: int = 0 # Total number of TPI cycles consecutive_boosts: int = 0 # Track consecutive boost attempts recent_errors: list = field(default_factory=list) # Store last N errors for regime change detection regime_change_detected: bool = False # Flag for temporary alpha boost learning_start_date: Optional[datetime] = None # Date when learning started # Capacity learning (Heat only) capacity_heat_learn_count: int = 0 bootstrap_failure_count: int = 0 # Number of consecutive failures to learn capacity during bootstrap # Bootstrap is implied when capacity_heat_learn_count < 3 # Optional features configuration allow_kint_boost: bool = False allow_kext_overshoot: bool = False def to_dict(self): """Convert to a JSON-safe dict for HA state attributes and storage.""" def make_json_safe(value): """Convert non-JSON-serializable types to JSON-safe equivalents.""" if value is None: return None if isinstance(value, datetime): return value.isoformat() if isinstance(value, (list, tuple)): return [make_json_safe(v) for v in value] if isinstance(value, dict): return {k: make_json_safe(v) for k, v in value.items()} return value result = asdict(self) return {k: make_json_safe(v) for k, v in result.items()} @classmethod def from_dict(cls, data): d = data.copy() # Date conversion from ISO format for date_field in ["last_update_date", "cycle_start_date", "last_heater_stop_time", "learning_start_date"]: if d.get(date_field): try: # Use dt_util.parse_datetime to preserve timezone information parsed = dt_util.parse_datetime(d[date_field]) d[date_field] = parsed if parsed else datetime.fromisoformat(d[date_field]) except (ValueError, TypeError): d[date_field] = None # Create instance with defaults first instance = cls() # Filter unknown fields and update only valid ones valid_fields = {k for k in cls.__annotations__} for key, value in d.items(): if key in valid_fields: setattr(instance, key, value) return instance class AutoTpiManager: """Auto TPI Manager implementing TPI algorithm.""" def __init__( self, hass: HomeAssistant, config_entry: ConfigEntry, unique_id: str, name: str, cycle_min: int, tpi_threshold_low: float = 0.0, tpi_threshold_high: float = 0.0, minimal_deactivation_delay: int = 0, coef_int: float = 0.6, coef_ext: float = 0.04, heater_heating_time: int = 5, heater_cooling_time: int = 5, calculation_method: str = "ema", heating_rate: float = 1.0, cooling_rate: float = 1.0, avg_initial_weight: int = 1, ema_alpha: float = 0.15, ema_decay_rate: float = 0.08, aggressiveness: float = 0.9, continuous_kext: bool = False, continuous_kext_alpha: float = 0.04, ): self._hass = hass self._name = name self._cycle_min = cycle_min self._config_entry = config_entry self._enable_update_config = True self._enable_notification = True self._unique_id = unique_id self._entity_id: str | None = None # Set by thermostat after entity registration self._tpi_threshold_low = tpi_threshold_low self._tpi_threshold_high = tpi_threshold_high self._minimal_deactivation_delay_sec = minimal_deactivation_delay self._heater_heating_time = heater_heating_time self._heater_cooling_time = heater_cooling_time self._continuous_kext = continuous_kext self._continuous_kext_alpha = continuous_kext_alpha self._temp_unit = self._hass.config.units.temperature_unit self._unit_factor = 1.8 if self._temp_unit == UnitOfTemperature.FAHRENHEIT else 1.0 self._calculation_method = calculation_method self._ema_alpha = ema_alpha self._avg_initial_weight = avg_initial_weight self._max_coef_int = 1.0 # Convert rates to Celsius/h if needed self._heating_rate = heating_rate / self._unit_factor self._cooling_rate = cooling_rate / self._unit_factor self._ema_decay_rate = ema_decay_rate self._continuous_learning = False self._keep_ext_learning = True self._aggressiveness = aggressiveness # Notification management self._last_notified_coef_int: Optional[float] = None self._last_notified_coef_ext: Optional[float] = None storage_key = f"{STORAGE_KEY_PREFIX}.{unique_id.replace('.', '_')}" self._store = Store(hass, STORAGE_VERSION, storage_key) # Convert config coefficients (User Unit) to Internal (Celsius) # K_C = K_F * 1.8 self._default_coef_int = (coef_int if coef_int is not None else 0.6) * self._unit_factor self._default_coef_ext = (coef_ext if coef_ext is not None else 0.04) * self._unit_factor self.state = AutoTpiState( coeff_indoor_heat=self._default_coef_int, coeff_outdoor_heat=self._default_coef_ext, coeff_indoor_cool=self._default_coef_int, coeff_outdoor_cool=self._default_coef_ext ) self._calculated_params = {} # Transient state self._current_temp_in: float = 0.0 self._current_temp_out: float = 0.0 self._current_target_temp: float = 0.0 self._current_hvac_mode: str = "heat" # 'heat' or 'cool' (or 'off' etc) self._last_cycle_power_efficiency: float = 1.0 self._save_lock = asyncio.Lock() self._timer_capture_remove_callback: Callable[[], None] | None = None self._learning_just_completed: bool = False # Transient flag to suppress 'cycle interrupted' log after learning # Interruption management self._current_cycle_interrupted: bool = False self._central_boiler_off: bool = False self._current_is_heating_failure: bool = False # Shutdown safety check self._is_vtherm_stopping_callback: Callable[[], bool] | None = None def get_filtered_state(self) -> dict: """Get the AutoTpiState as a dict, but filtered for public exposure.""" data = self.state.to_dict() # 1. Remove internal debugging attributes if "recent_errors" in data: del data["recent_errors"] # 2. Filter counters if learning session is NOT active (Continuous Kext only) if not self.state.autolearn_enabled: # If main learning is off, hide indoor counters as they are not updated/relevant in continuous mode # (Continuous mode only updates Kext/Outdoor) keys_to_hide = [ "coeff_indoor_autolearn", "coeff_indoor_cool_autolearn", "learning_start_date", "last_learning_status" ] for k in keys_to_hide: if k in data: del data[k] # 2. Filter based on Mode is_cool_mode = self._current_hvac_mode == "cool" keys_to_remove = [] for key in data.keys(): if is_cool_mode: # In Cool mode, remove heat-related keys if "_heat" in key: keys_to_remove.append(key) else: # In Heat mode, remove cool-related keys if "_cool" in key: keys_to_remove.append(key) for key in keys_to_remove: del data[key] return data def set_is_vtherm_stopping_callback(self, callback: Callable[[], bool]): """Set a callback to check if the VTherm is stopping.""" self._is_vtherm_stopping_callback = callback def _to_celsius(self, temp: float) -> float: """Convert temperature to Celsius if needed.""" if temp is None: return 0.0 if self._temp_unit == UnitOfTemperature.FAHRENHEIT: return TemperatureConverter.convert(temp, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS) return temp async def async_update_learning_data(self, coef_int: float = None, coef_ext: float = None, capacity: float = None, is_heat_mode: bool = True): """Update coefficients and/or capacity in one go to avoid double reload.""" updates = {} # 1. Update coefficients if provided if coef_int is not None: updates[CONF_TPI_COEF_INT] = round(coef_int / self._unit_factor, 3) if coef_ext is not None: updates[CONF_TPI_COEF_EXT] = round(coef_ext / self._unit_factor, 3) # 2. Update capacity if provided and valid if capacity is not None and capacity > 0.0: rate_key = CONF_AUTO_TPI_HEATING_POWER if is_heat_mode else CONF_AUTO_TPI_COOLING_POWER updates[rate_key] = round(capacity * self._unit_factor, 3) # Also update local state (Memory) if is_heat_mode: self.state.max_capacity_heat = capacity self._heating_rate = capacity else: self.state.max_capacity_cool = capacity self._cooling_rate = capacity # 3. Always save to local storage (Persistence) await self.async_save_data() # 4. Check if we should skip config update (Restart/Shutdown safety) if not self._enable_update_config: _LOGGER.debug("%s - Auto TPI: update_learning_data - enable_update_config is False", self._name) return if self._is_vtherm_stopping_callback and self._is_vtherm_stopping_callback(): _LOGGER.debug("%s - Auto TPI: update_learning_data - VTherm is stopping, skipping config update", self._name) return if not updates: _LOGGER.debug("%s - Auto TPI: update_learning_data - no updates to apply", self._name) return # 5. Apply atomic config update (no reload: flag prevents update_listener from reloading) api = VersatileThermostatAPI.get_vtherm_api(self._hass) if api: api.skip_reload_on_config_update = True try: new_data = {**self._config_entry.data, **updates} self._hass.config_entries.async_update_entry(self._config_entry, data=new_data) finally: if api: api.skip_reload_on_config_update = False _LOGGER.info( "%s - Auto TPI: ATOMIC UPDATE: Kint=%s, Kext=%s, Capacity=%s", self._name, f"{coef_int:.3f}" if coef_int is not None else "N/A", f"{coef_ext:.3f}" if coef_ext is not None else "N/A", f"{capacity:.3f}" if capacity is not None else "N/A" ) async def process_learning_completion(self) -> Optional[dict]: """ Processes the learned coefficients after a cycle to: 1. Check if learning is finished/stabilized. 2. Apply continuous learning if enabled. 3. Persist coefficients to HA config if enabled. 4. Send persistent notifications if enabled. Returns a dict of finalized coefficients if persisted, or None. """ is_cool_mode = self._current_hvac_mode == "cool" if is_cool_mode: k_int = self.state.coeff_indoor_cool k_ext = self.state.coeff_outdoor_cool int_cycles_count = self.state.coeff_indoor_cool_autolearn ext_cycles_count = self.state.coeff_outdoor_cool_autolearn else: k_int = self.state.coeff_indoor_heat k_ext = self.state.coeff_outdoor_heat int_cycles_count = self.state.coeff_indoor_autolearn ext_cycles_count = self.state.coeff_outdoor_autolearn # 1. Check if learning is finished/stabilized (for non-continuous learning) # We check if the *raw* counter has reached the threshold, which accounts for _avg_initial_weight. INT_CYCLES_THRESHOLD = 50 + self._avg_initial_weight EXT_CYCLES_THRESHOLD = 50 is_int_finished = int_cycles_count >= INT_CYCLES_THRESHOLD is_kext_standard_finished = ext_cycles_count >= EXT_CYCLES_THRESHOLD is_ext_finished = is_kext_standard_finished and (not self._keep_ext_learning or is_int_finished) if self._continuous_learning: # For continuous learning, we persist if the base threshold is met (stabilized). if is_int_finished: _LOGGER.debug("%s - Auto TPI: Continuous learning stabilized (Kint > %d cycles). Persisting values.", self._name, INT_CYCLES_THRESHOLD - self._avg_initial_weight) else: _LOGGER.debug("%s - Auto TPI: Continuous learning in progress (Kint cycles: %d). Skipping persistence.", self._name, int_cycles_count - self._avg_initial_weight) return None else: # Standard learning: stop if both finished. if not (is_int_finished and is_ext_finished): _LOGGER.debug( "%s - Auto TPI: Learning in progress (Kint cycles: %d/%d, Kext cycles: %d/%d). Skipping persistence.", self._name, int_cycles_count - self._avg_initial_weight, INT_CYCLES_THRESHOLD - self._avg_initial_weight, ext_cycles_count, EXT_CYCLES_THRESHOLD, ) return None else: _LOGGER.info("%s - Auto TPI: Learning completed. Persisting final coefficients and stopping learning.", self._name) # Calling stop_learning will NOT save capacity immediately (we do it in atomic update below) await self.stop_learning(save_capacity=False) # Check if we also need to update capacity (if it was learned) # This combines both updates into ONE config entry update capacity_to_save = None if self.state.capacity_heat_learn_count >= 3: # Check if value has changed current_conv_capacity = self._config_entry.data.get(CONF_AUTO_TPI_HEATING_POWER) current_capacity = current_conv_capacity / self._unit_factor if current_conv_capacity else 0.0 if abs(current_capacity - self.state.max_capacity_heat) > 0.01: capacity_to_save = self.state.max_capacity_heat await self.async_update_learning_data(coef_int=k_int, coef_ext=k_ext, capacity=capacity_to_save, is_heat_mode=True) # 4. Send persistent notifications if enabled if self._enable_notification: # Implement "notify once" logic # Check for a significant change (> 0.005) or if it's the first time if (self._last_notified_coef_int is None or abs(self._last_notified_coef_int - k_int) > 0.005) or ( self._last_notified_coef_ext is None or abs(self._last_notified_coef_ext - k_ext) > 0.005 ): # Get translated message. Since I cannot read translations, I will use a simple English message. title = f"Versatile Thermostat: Auto TPI Learned Coefficients for {self._name}" message = f"Auto TPI has learned new coefficients: Indoor={round(k_int, 3)}, Outdoor={round(k_ext, 3)} (Cycles: Int={int_cycles_count - self._avg_initial_weight}, Ext={ext_cycles_count}). These values have been saved to the configuration." try: await self._hass.services.async_call( "persistent_notification", "create", { "title": title, "message": message, "notification_id": f"autotpi_learning_completed_{self._unique_id}", }, blocking=False, ) self._last_notified_coef_int = k_int self._last_notified_coef_ext = k_ext _LOGGER.info("%s - Auto TPI: Persistent notification sent for final coefficients.", self._name) except Exception as e: _LOGGER.error("%s - Auto TPI: Error sending persistent notification: %s", self._name, e) # Return learned coefficients converted back to User Unit # Internal: C. Output: F/C. If F, output = internal / 1.8 (internal * 0.55 ?) # K_F = K_C / 1.8. return { CONF_TPI_COEF_INT: k_int / self._unit_factor, CONF_TPI_COEF_EXT: k_ext / self._unit_factor } async def async_save_data(self): """Save data.""" await self._store.async_save(self.state.to_dict()) async def async_load_data(self): """Load data.""" data = await self._store.async_load() if not data: # Try to migrate from old JSON file old_storage_key = f"versatile_thermostat_{self._unique_id}_auto_tpi_v2.json" old_path = self._hass.config.path(f".storage/{old_storage_key}") if os.path.exists(old_path): _LOGGER.debug("%s - Auto TPI: Migrating from old storage %s", self._name, old_path) try: with open(old_path, "r") as f: old_json = json.load(f) # Extract state from old format data = old_json.get("state", old_json) await self._store.async_save(data) # Save to new format os.remove(old_path) # Clean up old file except Exception as e: _LOGGER.error("%s - Auto TPI: Migration error: %s", self._name, e) if data: self.state = AutoTpiState.from_dict(data) # Clamping: Apply new max_coef_int to loaded coefficients immediately, # in case the user lowered the limit via config flow. self.state.coeff_indoor_heat = min(self.state.coeff_indoor_heat, self._max_coef_int) self.state.coeff_indoor_cool = min(self.state.coeff_indoor_cool, self._max_coef_int) # Capacity update logic on load (to fix startup issue after config change) # We prioritize the capacity from the latest config flow over the persisted state # if they are different and the config value is valid. This handles both an initial # state with 0 capacity and a configuration change on restart. is_capacity_heat_outdated = self.state.max_capacity_heat != self._heating_rate is_capacity_cool_outdated = self.state.max_capacity_cool != self._cooling_rate # Handle capacity reset: if configured heat_rate is 0, reset capacity to trigger bootstrap if self._heating_rate == 0.0 and self.state.max_capacity_heat > 0.0: _LOGGER.info( "%s - Auto TPI: Configured heat_rate is 0, resetting capacity (was %.3f) to trigger bootstrap.", self._name, self.state.max_capacity_heat, ) self.state.max_capacity_heat = 0.0 self.state.capacity_heat_learn_count = 0 elif is_capacity_heat_outdated and self._heating_rate > 0.0: if self.state.max_capacity_heat == 0.0: _LOGGER.info( "%s - Auto TPI: Overwriting persisted max_capacity_heat (0.000) with new configured value (%.3f) on load.", self._name, self._heating_rate, ) self.state.max_capacity_heat = self._heating_rate self.state.capacity_heat_learn_count = 3 # Assume learned if we take config value else: _LOGGER.info( "%s - Auto TPI: Persisted max_capacity_heat (%.3f) differs from config (%.3f). Keeping persisted value.", self._name, self.state.max_capacity_heat, self._heating_rate, ) # Sync the effective rate to the persisted one self._heating_rate = self.state.max_capacity_heat # Handle capacity reset for cooling mode if self._cooling_rate == 0.0 and self.state.max_capacity_cool > 0.0: _LOGGER.info( "%s - Auto TPI: Configured cooling_rate is 0, resetting capacity (was %.3f) to trigger bootstrap.", self._name, self.state.max_capacity_cool, ) self.state.max_capacity_cool = 0.0 # Note: capacity_cool_learn_count does not exist, cooling bootstrap uses different logic elif is_capacity_cool_outdated and self._cooling_rate > 0.0: # Same logic for cooling capacity if self.state.max_capacity_cool == 0.0: _LOGGER.info( "%s - Auto TPI: Overwriting persisted max_capacity_cool (0.000) with new configured value (%.3f) on load.", self._name, self._cooling_rate, ) self.state.max_capacity_cool = self._cooling_rate else: _LOGGER.info( "%s - Auto TPI: Persisted max_capacity_cool (%.3f) differs from config (%.3f). Keeping persisted value.", self._name, self.state.max_capacity_cool, self._cooling_rate, ) # Sync the effective rate to the persisted one self._cooling_rate = self.state.max_capacity_cool if is_capacity_heat_outdated or is_capacity_cool_outdated: await self.async_save_data() # Save the new correct config value # If no learning has been done yet, force the configured defaults if self.state.total_cycles == 0: _LOGGER.info("%s - Auto TPI: No learning cycles yet. Enforcing configured coefficients.", self._name) self.state.coeff_indoor_heat = self._default_coef_int self.state.coeff_outdoor_heat = self._default_coef_ext self.state.coeff_indoor_cool = self._default_coef_int self.state.coeff_outdoor_cool = self._default_coef_ext # Initialize counters with the configured weight self.state.coeff_indoor_autolearn = self._avg_initial_weight self.state.coeff_indoor_cool_autolearn = self._avg_initial_weight self.state.coeff_outdoor_autolearn = 0 self.state.coeff_outdoor_cool_autolearn = 0 _LOGGER.info("%s - Auto TPI: State loaded. Cycles: %d, Indoor learn count: %d", self._name, self.state.total_cycles, self.state.coeff_indoor_autolearn) else: self.state = AutoTpiState( coeff_indoor_heat=self._default_coef_int, coeff_outdoor_heat=self._default_coef_ext, coeff_indoor_cool=self._default_coef_int, coeff_outdoor_cool=self._default_coef_ext, ) # Reset last_learning_status on load to avoid stale messages from previous sessions self.state.last_learning_status = "learning_started" # Reset cycle state to discard any cycle interrupted by the reboot # This prevents "cycle_gap_detected" or validation failures on the first cycle after restart self.state.cycle_active = False self.state.cycle_start_date = None # MIGRATION FIX: If capacity is already known (legacy or manual), mark as learned # This prevents re-triggering bootstrap (count=0) for existing users if self.state.max_capacity_heat > 0 and self.state.capacity_heat_learn_count == 0: _LOGGER.info("%s - Auto TPI: Existing capacity found (%.3f), marking as learned (count=3)", self._name, self.state.max_capacity_heat) self.state.capacity_heat_learn_count = 3 await self.calculate() async def update(self, room_temp: float, ext_temp: float, hvac_mode: str, target_temp: float, is_overpowering_detected: bool = False, is_central_boiler_off: bool = False, is_heating_failure: bool = False) -> float: """Update state with new data. This method is called at each control_heating cycle. It updates the transient state used for power calculation and future learning. Returns the calculated power for validation/indication. """ # Check for Overpowering Interruption # If overpowering is detected, the heating/cooling is artificially stopped/limited. # We must mark this cycle as interrupted so we don't learn from it (false data). if is_overpowering_detected: if not self._current_cycle_interrupted: _LOGGER.info("%s - Auto TPI: Cycle interrupted by Overpowering/Power Shedding. Learning will be skipped for this cycle.", self._name) self._current_cycle_interrupted = True # Store current values for later use in cycle callbacks # Convert inputs to Celsius for internal logic self._current_temp_in = self._to_celsius(room_temp) if room_temp is not None else 0.0 self._current_temp_out = self._to_celsius(ext_temp) if ext_temp is not None else 0.0 self._current_target_temp = self._to_celsius(target_temp) if target_temp is not None else 0.0 self._current_hvac_mode = hvac_mode self._central_boiler_off = is_central_boiler_off self._current_is_heating_failure = is_heating_failure # Calculate and return power # Use hvac_mode to force direction calc_state_str = "stop" if hvac_mode == "cool": calc_state_str = "cool" elif hvac_mode == "heat": calc_state_str = "heat" return self.calculate_power(self._current_target_temp, self._current_temp_in, self._current_temp_out, calc_state_str) async def calculate(self) -> Optional[dict]: """Calculate TPI parameters, using aggressive coefficients during bootstrap.""" # Determine if in bootstrap (capacity not yet learned) in_bootstrap = ( self.state.max_capacity_heat == 0 or self.state.capacity_heat_learn_count < 3 ) # Temporarily override learned coefficients if in bootstrap saved_kint = self.state.coeff_indoor_heat saved_kext = self.state.coeff_outdoor_heat if in_bootstrap: # Use aggressive coefficients for bootstrap # User requested 1.0 / 0.1 as "normal" values (sufficiently aggressive vs 0.6 default) KINT_BOOTSTRAP = 1.0 KEXT_BOOTSTRAP = 0.1 self.state.coeff_indoor_heat = KINT_BOOTSTRAP self.state.coeff_outdoor_heat = KEXT_BOOTSTRAP try: # Return current coefficients for the thermostat to use params = {} # Use hvac_mode to determine which coefficients to return # This prevents flapping when switching between heating/cooling actions while in the same mode (e.g. idle) # Note: hvac_mode usually comes from VThermHvacMode (heat, cool, off, auto...) is_cool_mode = self._current_hvac_mode == "cool" if is_cool_mode: params[CONF_TPI_COEF_INT] = self.state.coeff_indoor_cool / self._unit_factor params[CONF_TPI_COEF_EXT] = self.state.coeff_outdoor_cool / self._unit_factor else: params[CONF_TPI_COEF_INT] = self.state.coeff_indoor_heat / self._unit_factor params[CONF_TPI_COEF_EXT] = self.state.coeff_outdoor_heat / self._unit_factor self._calculated_params = params return params finally: # Restore original values if in_bootstrap: self.state.coeff_indoor_heat = saved_kint self.state.coeff_outdoor_heat = saved_kext def _get_adaptive_alpha(self, cycle_count: int) -> float: """Calculate adaptive alpha for EMA, with temporary boost on regime change.""" # Standard calculation base_alpha = self._ema_alpha / (1 + self._ema_decay_rate * cycle_count) # If continuous learning is enabled and regime change detected, temporary boost if self._continuous_learning and self.state.regime_change_detected: # Max boost alpha is min(base_alpha * 3.0, 0.15) # We want to ensure the base alpha is not too small before boosting. # If base_alpha is very small (after many cycles), boost will still be limited. boost_alpha = min(base_alpha * 3.0, 0.15) _LOGGER.info(f"%s - Auto TPI: Regime change detected, boosting alpha: {base_alpha:.3f} -> {boost_alpha:.3f}", self._name) # The flag will be reset in _learn_indoor after consumption return boost_alpha return base_alpha def _detect_regime_change(self, recent_errors: list) -> bool: """ Detects a thermal regime change (systematic bias). If detected, we can temporarily increase alpha for faster adaptation. """ N = 10 if not self._continuous_learning or len(recent_errors) < N: return False # We only look at the last N errors errors_to_check = recent_errors[-N:] # Simple statistical test: # Do the last N errors have a systematic bias? # mean_error is the average 'correction needed' in °C mean_error = sum(errors_to_check) / N # Calculate standard deviation # Avoid zero division std_error = (sum((e - mean_error) ** 2 for e in errors_to_check) / N) ** 0.5 if std_error == 0: return False # Student's t-test: significant systematic error? t_stat = abs(mean_error) / (std_error / math.sqrt(N)) # 95% confidence threshold (t > 2.0 for n=10) return t_stat > 2.0 def _should_learn(self) -> bool: """Check if learning should be performed.""" # We learn if: # 1. Main learning session is active (autolearn_enabled) # 2. OR Continuous Kext is enabled (we will filter Kint vs Kext inside _perform_learning) if not self.state.autolearn_enabled and not self._continuous_kext: return False # Power conditions: 0 < last_power < saturation_threshold # If power is >= saturation_threshold, the cycle is saturated and we skip learning. saturation_threshold = self.saturation_threshold if not (0 < self.state.last_power < saturation_threshold): _LOGGER.debug("%s - Auto TPI: Not learning - Power out of range (%.3f not in 0 < power < %.3f)", self._name, self.state.last_power, saturation_threshold) return False if self._current_cycle_interrupted: _LOGGER.debug("%s - Auto TPI: Not learning - Cycle was interrupted (e.g. Power Shedding)", self._name) return False if self._central_boiler_off: _LOGGER.debug("%s - Auto TPI: Not learning - Central boiler is OFF although VTherm is active (boiler below activation threshold)", self._name) return False if self._current_is_heating_failure: _LOGGER.debug("%s - Auto TPI: Not learning - Heating/Cooling failure detected", self._name) return False # Failures check if self.state.consecutive_failures >= 3: return False # 1. First Cycle Exclusion if self.state.previous_state == "stop": _LOGGER.debug("%s - Auto TPI: Not learning - First cycle (previous state was stop)", self._name) return False if self.state.last_order == 0: _LOGGER.debug("%s - Auto TPI: Not learning - Last order is 0", self._name) return False # 2. Mild Weather Exclusion (Safe Ratio) # Avoid division by small numbers or learning when delta is too small to be significant delta_out = self.state.last_order - self._current_temp_out delta_out_threshold = 1.0 # Celsius if abs(delta_out) < delta_out_threshold: _LOGGER.debug("%s - Auto TPI: Not learning - Delta out too small (< %.1f)", self._name, delta_out_threshold) return False # Natural drift exclusion - check temperature at CYCLE START # If temp was already past setpoint at cycle START, this is passive drift, not active regulation # is_heat = self.state.last_state == 'heat' # is_cool = self.state.last_state == 'cool' # if is_heat and self.state.last_temp_in > self.state.last_order + 0.05: # _LOGGER.debug("%s - Auto TPI: Not learning - Passive cooling at cycle start (T_in %.2f > Target %.2f + 0.05)", # self._name, self.state.last_temp_in, self.state.last_order) # return False # if is_cool and self.state.last_temp_in < self.state.last_order - 0.05: # _LOGGER.debug("%s - Auto TPI: Not learning - Passive heating at cycle start (T_in %.2f < Target %.2f - 0.05)", # self._name, self.state.last_temp_in, self.state.last_order) # return False return True def _get_no_learn_reason(self) -> str: """Get reason why learning is not happening.""" if not self.state.autolearn_enabled and not self._continuous_kext: return "learning_disabled" saturation_threshold = self.saturation_threshold # pylint: disable=no-member if not (0 < self.state.last_power < saturation_threshold): return f"power_out_of_range({self.state.last_power * 100:.1f}% vs Saturation {saturation_threshold * 100:.1f}%)" if self._current_cycle_interrupted: return "cycle_interrupted_by_overpowering" if self._central_boiler_off: return "central_boiler_off" if self._current_is_heating_failure: return "heating_failure_detected" if self.state.consecutive_failures >= 3: return f"too_many_failures({self.state.consecutive_failures})" if self.state.previous_state == "stop": return "startup_cycle" if self.state.last_order == 0: return "target_temp_is_zero" delta_out = self.state.last_order - self._current_temp_out if abs(delta_out) < 1.0: return f"outdoor_delta_too_small({delta_out:.1f})" return "unknown" async def _perform_learning(self, current_temp_in: float, current_temp_out: float): """Execute the learning logic based on previous state and current observations.""" is_heat = self.state.last_state == "heat" is_cool = self.state.last_state == "cool" if not (is_heat or is_cool): self.state.last_learning_status = "not_heating_or_cooling" _LOGGER.debug("%s - Auto TPI: Not learning - system was in %s mode", self._name, self.state.last_state) return # Check if setpoint changed during the cycle - if so, skip ALL learning # This prevents incorrect coefficient updates when user adjusts temperature mid-cycle setpoint_changed = abs(self._current_target_temp - self.state.last_order) > 0.1 if setpoint_changed: self.state.last_learning_status = "setpoint_changed_during_cycle" _LOGGER.debug( "%s - Auto TPI: Skipping learning - setpoint changed during cycle (%.1f → %.1f)", self._name, self.state.last_order, self._current_target_temp ) return target_temp = self.state.last_order # Calculate deltas based on direction if is_heat: temp_progress = current_temp_in - self.state.last_temp_in target_diff = self.state.last_order - self.state.last_temp_in outdoor_condition = current_temp_out < self.state.last_order else: # Cool temp_progress = self.state.last_temp_in - current_temp_in target_diff = self.state.last_temp_in - self.state.last_order outdoor_condition = current_temp_out > self.state.last_order # CASE 0: Overshoot Correction (BEFORE standard learning) # ---------------------------------------------------------- # When room is overheating despite heat still being applied, # Kext is clearly too high. Correct it aggressively before # attempting normal learning. # # IMPORTANT: Only correct if temperature is NOT FALLING despite overshoot. # If temp is falling naturally (e.g., after setpoint was lowered), the # system is working correctly - no need to reduce Kext. # If temp stagnates or rises, Kext is too high (preventing natural cooling). # A small threshold (0.02°C) filters out sensor noise. temp_not_falling = current_temp_in >= self.state.last_temp_in - 0.02 if is_heat: overshoot = current_temp_in - target_temp if overshoot > OVERSHOOT_THRESHOLD and self.state.last_power > OVERSHOOT_POWER_THRESHOLD and temp_not_falling: _LOGGER.info( "%s - Auto TPI: Overshoot detected (%.2f°C > %.2f°C threshold, power=%.1f%%, temp not falling)", self._name, overshoot, OVERSHOOT_THRESHOLD, self.state.last_power * 100 ) if self._correct_kext_overshoot(overshoot, is_cool=False): return # Skip other learning for this cycle elif is_cool: temp_not_rising = current_temp_in <= self.state.last_temp_in + 0.02 overshoot = target_temp - current_temp_in if overshoot > OVERSHOOT_THRESHOLD and self.state.last_power > OVERSHOOT_POWER_THRESHOLD and temp_not_rising: _LOGGER.info( "%s - Auto TPI: Overcooling detected (%.2f°C > %.2f°C threshold, power=%.1f%%, temp not rising)", self._name, overshoot, OVERSHOOT_THRESHOLD, self.state.last_power * 100 ) if self._correct_kext_overshoot(overshoot, is_cool=True): return # Skip other learning for this cycle # CASE 0.5: Insufficient Rise Correction # ---------------------------------------- # When temperature stagnates despite a significant gap (> 0.3°C) # and power is not saturated, Kint is likely too low. # Instead of incorrectly adjusting Kext, we boost Kint. # # This handles the scenario where: # - target_diff > 0.3°C (significant gap to setpoint) # - temp_progress < 0.02 (temperature is stagnating or dropping) # - power < 0.99 (not saturated, so we CAN increase power) # # In this case, standard indoor learning fails (requires temp_progress > 0.05) # and the system incorrectly falls through to outdoor learning, increasing Kext. temp_stagnating = temp_progress < 0.02 if target_diff > INSUFFICIENT_RISE_GAP_THRESHOLD and temp_stagnating and self.state.last_power < 0.99: if self._correct_kint_insufficient_rise(target_diff, temp_progress, is_cool): return # Kint corrected, skip other learning for this cycle # CASE 1: Indoor Learning # --------------------------- # Strict conditions to avoid false positives: # - Significant temperature progress (> 0.05°C) # - Significant gap to cover (> 0.1°C) # - Power not saturated (0 < power < 0.99) # - Main Learning Session MUST be active (we don't learn Kint in continuous mode) if self.state.autolearn_enabled and 0 < self.state.last_power < 0.99: temp_progress_threshold = 0.05 target_diff_threshold = 0.01 if temp_progress > temp_progress_threshold and target_diff > target_diff_threshold: # Indoor learning attempt error = self._learn_indoor(target_diff, temp_progress, self._last_cycle_power_efficiency, is_cool) if error is not None: # Learning was successful - temperature is rising self.state.last_learning_status = f"learned_indoor_{'cool' if is_cool else 'heat'}" _LOGGER.info("%s - Auto TPI: Indoor coefficient learned successfully (Error: %.3f)", self._name, error) self._learning_just_completed = True # Reset consecutive Kint boosts counter since temperature is now rising if self.state.consecutive_boosts > 0: _LOGGER.debug("%s - Auto TPI: Resetting consecutive_boosts counter (was %d)", self._name, self.state.consecutive_boosts) self.state.consecutive_boosts = 0 # Continuous Learning: Track error and detect regime change if self._continuous_learning: self.state.recent_errors.append(error) # Keep only the last 20 errors (N=10 for detection + buffer) if len(self.state.recent_errors) > 20: self.state.recent_errors = self.state.recent_errors[-20:] is_regime_change = self._detect_regime_change(self.state.recent_errors) if is_regime_change and not self.state.regime_change_detected: self.state.regime_change_detected = True _LOGGER.warning("%s - Auto TPI: SYSTEMIC REGIME CHANGE DETECTED. Alpha boost activated.", self._name) return # Indoor success, we exit else: # Indoor failed, reason already logged in _learn_indoor _LOGGER.debug("%s - Auto TPI: Indoor learning failed, will try outdoor", self._name) else: _LOGGER.debug("%s - Auto TPI: Indoor conditions not met (progress=%.3f, target_diff=%.3f)", self._name, temp_progress, target_diff) else: _LOGGER.debug("%s - Auto TPI: Skipping indoor coeff learning because power is saturated (%.1f%%)", self._name, self.state.last_power * 100) # CASE 2: Outdoor Learning # ---------------------------- # Executed when: # - Indoor was not applicable (conditions not met) # - OR indoor failed (_learn_indoor returned False) # Conditions: # - Relevant outdoor temperature (outdoor_condition) # - Significant remaining gap gap_in = target_temp - current_temp_in gap_threshold = 0.05 if outdoor_condition and abs(gap_in) > gap_threshold: # Domain Separation: Far-Field vs Near-Field # If the gap is large (> KEXT_LEARNING_MAX_GAP), it's a transient state (Kint domain). # Kext (Steady State) should only be learned in Near-Field. # # EXCEPTION: During overshoot with significant power, we MUST learn Kext regardless # of gap size. Overshoot means temp > target (heat) or temp < target (cool), # which is gap_in < 0 (heat) or gap_in > 0 (cool) - the opposite of normal Far-Field. is_overshoot_heat = is_heat and gap_in < 0 is_overshoot_cool = is_cool and gap_in > 0 has_significant_power = self.state.last_power >= NATURAL_RECOVERY_POWER_THRESHOLD is_active_overshoot = (is_overshoot_heat or is_overshoot_cool) and has_significant_power if abs(gap_in) > KEXT_LEARNING_MAX_GAP and not is_active_overshoot: self.state.last_learning_status = f"gap_too_large_for_outdoor(gap={gap_in:.2f} > {KEXT_LEARNING_MAX_GAP})" _LOGGER.debug( "%s - Auto TPI: Skipping outdoor learning: Gap %.2f > %.2f - Far field stagnation is a Kint/Capacity issue, not Kext.", self._name, abs(gap_in), KEXT_LEARNING_MAX_GAP ) return if self._learn_outdoor(current_temp_in, current_temp_out, is_cool): if "naturally" not in self.state.last_learning_status: self.state.last_learning_status = f"learned_outdoor_{'cool' if is_cool else 'heat'}" _LOGGER.info("%s - Auto TPI: Outdoor coefficient learned successfully", self._name) self._learning_just_completed = True return # Outdoor success else: _LOGGER.debug("%s - Auto TPI: Outdoor learning failed", self._name) else: _LOGGER.debug("%s - Auto TPI: Outdoor conditions not met (outdoor_condition=%s, gap_in=%.3f)", self._name, outdoor_condition, gap_in) # No learning was possible self.state.last_learning_status = f"no_learning_possible(progress={temp_progress:.2f},target_diff={target_diff:.2f},gap_in={gap_in:.2f})" _LOGGER.debug("%s - Auto TPI: No learning possible - %s", self._name, self.state.last_learning_status) def _learn_indoor(self, delta_theoretical: float, delta_real: float, efficiency: float = 1.0, is_cool: bool = False) -> Optional[float]: """Learn indoor coefficient and optionally capacity.""" real_rise = delta_real rise_threshold = 0.01 if real_rise <= rise_threshold: _LOGGER.debug("%s - Auto TPI: Cannot learn indoor - real_rise %.3f <= %.3f. Will try outdoor learning.", self._name, real_rise, rise_threshold) self.state.last_learning_status = "real_rise_too_small" return None # === KINT LEARNING === # 1. Get adiabatic capacity ref_capacity_h = self.state.max_capacity_heat if not is_cool else self.state.max_capacity_cool # Fallback if not learned yet if ref_capacity_h <= 0: count = self.state.capacity_heat_learn_count if count == 0: ref_capacity_h = 0.5 # Very conservative for first cycle _LOGGER.warning( "%s - First cycle: using very conservative capacity 0.5°C/h", self._name ) else: ref_capacity_h = 1.0 # Standard fallback _LOGGER.debug( "%s - Capacity not yet converged (count=%d), using fallback 1.0°C/h", self._name, count ) # If no capacity defined, skip learning for this cycle if ref_capacity_h <= 0: _LOGGER.debug("%s - Auto TPI: Cannot learn indoor - no capacity defined (ref_capacity_h=%.2f)", self._name, ref_capacity_h) self.state.last_learning_status = "no_capacity_defined" return False # 2. Calculate Effective Capacity with thermal losses if is_cool: k_ext = self.state.coeff_outdoor_cool delta_t = self._current_temp_out - self._current_temp_in else: k_ext = self.state.coeff_outdoor_heat delta_t = self._current_temp_in - self._current_temp_out loss_factor = k_ext * max(0.0, delta_t) loss_factor = min(loss_factor, 0.95) # Prevent going negative effective_capacity_h = ref_capacity_h * (1.0 - loss_factor) # 3. Calculate Max Achievable Rise in this cycle (°C) cycle_duration_h = self._cycle_min / 60.0 max_achievable_rise = effective_capacity_h * cycle_duration_h * efficiency _LOGGER.debug( "%s - Auto TPI: Capacity calc: ref=%.3f °C/h, loss=%.2f, eff=%.3f °C/h, max_rise=%.3f °C (cycle=%.1f min, eff=%.2f)", self._name, ref_capacity_h, loss_factor, effective_capacity_h, max_achievable_rise, self._cycle_min, efficiency, ) # 4. Calculate adjusted_theoretical: aim for full gap, capped by capacity adjusted_theoretical = min(delta_theoretical, max_achievable_rise) if max_achievable_rise < delta_theoretical: mode_str = "cooling" if is_cool else "heating" _LOGGER.debug("%s - Auto TPI: Target rise clamped from %.3f to %.3f (Max %s Capacity)", self._name, delta_theoretical, max_achievable_rise, mode_str) if adjusted_theoretical <= 0: _LOGGER.warning("%s - Auto TPI: Cannot learn indoor - adjusted_theoretical <= 0 (max_rise=%.3f, target_diff=%.3f)", self._name, max_achievable_rise, delta_theoretical) self.state.last_learning_status = "adjusted_theoretical_lte_0" return False ratio = adjusted_theoretical / real_rise # Apply aggressiveness to the ratio to get more conservative Kint # This ensures aggressiveness always has an effect, regardless of capacity saturation ratio = ratio * self._aggressiveness current_coeff = self.state.coeff_indoor_cool if is_cool else self.state.coeff_indoor_heat coeff_new = current_coeff * ratio # Validate coefficient - reject only truly invalid values (non-finite or <= 0) if not math.isfinite(coeff_new) or coeff_new <= 0: _LOGGER.warning("%s - Auto TPI: Invalid new indoor coeff: %.3f (non-finite or <= 0), skipping", self._name, coeff_new) self.state.last_learning_status = "invalid_indoor_coeff" return False # 4. Cap Coefficient MAX_COEFF = self._max_coef_int if coeff_new > MAX_COEFF: _LOGGER.info("%s - Auto TPI: Calculated indoor coeff %.3f > %.1f, capping to %.1f before averaging", self._name, coeff_new, MAX_COEFF, MAX_COEFF) coeff_new = MAX_COEFF old_coeff = self.state.coeff_indoor_cool if is_cool else self.state.coeff_indoor_heat count = self.state.coeff_indoor_cool_autolearn if is_cool else self.state.coeff_indoor_autolearn # 5. Calculation Method # 5. Calculation Method # Cap the effective count to keep the system responsive # Even if we have 1000 cycles history, we weigh the new sample as if we had at most 50 cycles. effective_count = min(count, 50) if self._calculation_method == "average": # Weighted average # avg_coeff = ((old_coeff * count + coeff_new) / (count + 1)) # We must use the current count (not incremented) as weight for old_coeff # If count is 0 (should not happen for valid state), treat as 1 weight_old = max(effective_count, 1) avg_coeff = ((old_coeff * weight_old) + coeff_new) / (weight_old + 1) _LOGGER.debug("%s - Auto TPI: Weighted Average: old=%.3f (weight=%d, real_count=%d), new=%.3f, result=%.3f", self._name, old_coeff, weight_old, count, coeff_new, avg_coeff) else: # EMA # EMA Smoothing (20% weight by default) # new_avg = (old_avg * (1 - alpha)) + (new_sample * alpha) alpha = self._get_adaptive_alpha(effective_count) avg_coeff = (old_coeff * (1.0 - alpha)) + (coeff_new * alpha) _LOGGER.debug("%s - Auto TPI: EMA: old=%.3f, new=%.3f, alpha=%.3f (eff_count=%d, real_count=%d), result=%.3f", self._name, old_coeff, coeff_new, alpha, effective_count, count, avg_coeff) # Apply minimum Kint threshold to maintain temperature responsiveness if avg_coeff < MIN_KINT: _LOGGER.warning( "%s - Auto TPI: Calculated Kint %.4f is below minimum %.4f, capping to minimum", self._name, avg_coeff, MIN_KINT ) avg_coeff = MIN_KINT # Update counters new_count = count + 1 if is_cool: self.state.coeff_indoor_cool = avg_coeff self.state.coeff_indoor_cool_autolearn = new_count else: self.state.coeff_indoor_heat = avg_coeff self.state.coeff_indoor_autolearn = new_count _LOGGER.info( "%s - Auto TPI: Learn indoor (%s). Old: %.3f, New calculated: %.3f (rise=%.3f), Averaged: %.3f (count: %d)", self._name, "cool" if is_cool else "heat", old_coeff, coeff_new, real_rise, avg_coeff, new_count, ) # Reset boost counter after successful learning if hasattr(self.state, "consecutive_boosts"): self.state.consecutive_boosts = 0 # Reset regime change flag after consuming the boost if self._continuous_learning and self.state.regime_change_detected: _LOGGER.debug("%s - Auto TPI: Regime change alpha consumed, resetting flag", self._name) self.state.regime_change_detected = False return adjusted_theoretical - real_rise # Return the error: Expected Rise - Actual Rise def _learn_outdoor(self, current_temp_in: float, current_temp_out: float, is_cool: bool = False) -> bool: """Learn outdoor coefficient.""" gap_in = self.state.last_order - current_temp_in gap_out = self.state.last_order - current_temp_out # Validation delta_out (moved here) if abs(gap_out) < 0.05: _LOGGER.debug("%s - Auto TPI: Cannot learn outdoor - gap_out too small (%.3f)", self._name, abs(gap_out)) self.state.last_learning_status = "gap_out_too_small" return False if gap_out == 0: _LOGGER.debug("%s - Auto TPI: Cannot learn outdoor - gap_out is 0", self._name) self.state.last_learning_status = "gap_out_is_zero" return False # ============================================================================= # INTELLIGENT VALIDATION : Overshoot # ============================================================================= # An overshoot indicates that the model OVERESTIMATED the necessary power. # This is VALUABLE information to correct Kext. # # BUT: We must filter external anomalies (open door, sun, etc.) # → Overshoot without significant power = anomaly, not model error # CASE 1: Check that the setpoint did not change during the cycle consigne_changed = abs(self._current_target_temp - self.state.last_order) > 0.1 if consigne_changed: _LOGGER.debug("%s - Auto TPI: Cannot learn outdoor - consigne changed during cycle (%.1f → %.1f)", self._name, self.state.last_order, self._current_target_temp) self.state.last_learning_status = "consigne_changed" return False # CASE 2: Overshoot without significant power = external anomaly # If we have an overshoot but we barely heated/cooled (power < 20%), # it is an external anomaly (open door, sun), not a model error if is_cool: # In cool mode: overcooled if gap_in > 0 (temp < target) # Acceptable only if we really cooled (power > 1% instead of 20%) # If power is > 1% and we are overcooling, it means Kext is too high and should be reduced. if gap_in > 0 and self.state.last_power < 0.01: _LOGGER.debug("%s - Auto TPI: Cannot learn outdoor - Anomalous overcooling (gap_in=%.2f, power=%.1f%%)", self._name, gap_in, self.state.last_power * 100) self.state.last_learning_status = "anomalous_overcooling" return False # Directional Protection # If we are overcooling (below target) BUT the temperature is Rising (going back to target) # AND power is low, then natural recovery is happening. Do not lower Kext. # However, if power is still significant, the system is actively cooling and Kext might be too high. if gap_in > 0 and current_temp_in > self.state.last_temp_in and self.state.last_power < NATURAL_RECOVERY_POWER_THRESHOLD: _LOGGER.debug( "%s - Auto TPI: Skipping outdoor learning during natural undershoot recovery (Temp rising %.2f -> %.2f, power=%.1f%%)", self._name, self.state.last_temp_in, current_temp_in, self.state.last_power * 100 ) self.state.last_learning_status = "warming_up_naturally" return True # Considered handled (skipped) else: # In heat mode: overheated if gap_in < 0 (temp > target) # Acceptable only if we really heated (power > 1% instead of 20%) # If power is > 1% and we are overheating, it means Kext is too high and should be reduced. if gap_in < 0 and self.state.last_power < 0.01: _LOGGER.debug("%s - Auto TPI: Cannot learn outdoor - Anomalous overheating (gap_in=%.2f, power=%.1f%%)", self._name, gap_in, self.state.last_power * 100) self.state.last_learning_status = "anomalous_overheating" return False # Directional Protection # If we are overheating (above target) BUT the temperature is Falling (going back to target) # AND power is low, then natural recovery is happening. Do not lower Kext. # However, if power is still significant, the system is actively heating and Kext is likely too high. if gap_in < 0 and current_temp_in < self.state.last_temp_in and self.state.last_power < NATURAL_RECOVERY_POWER_THRESHOLD: _LOGGER.debug( "%s - Auto TPI: Skipping outdoor learning during natural overshoot recovery (Temp falling %.2f -> %.2f, power=%.1f%%)", self._name, self.state.last_temp_in, current_temp_in, self.state.last_power * 100 ) self.state.last_learning_status = "cooling_down_naturally" return True # Considered handled (skipped) # If we get here with an overshoot AND significant power: # → It is a real model error, we MUST learn from it # → The Kext correction will help correct the underestimated external influence _LOGGER.debug("%s - Auto TPI: Overshoot validation passed (gap_in=%.2f, power=%.1f%%) - proceeding with learning", self._name, gap_in, self.state.last_power * 100) # ratio_influence = gap_in / gap_out current_indoor = self.state.coeff_indoor_cool if is_cool else self.state.coeff_indoor_heat current_outdoor = self.state.coeff_outdoor_cool if is_cool else self.state.coeff_outdoor_heat # Calculate corrective term based on indoor error (Missing power = Gap_In * Kint) # Shift this missing power to Outdoor term (Equivalent Kext = Missing Power / Gap_Out) # correction = (Gap_In / Gap_Out) * Kint # Target = Current_Kext + Correction correction = current_indoor * (gap_in / gap_out) target_outdoor = current_outdoor + correction # Use target_outdoor as the new sample coeff_new = target_outdoor # Validate coefficient if not math.isfinite(coeff_new) or coeff_new <= 0: _LOGGER.warning("%s - Auto TPI: Invalid new outdoor coeff: %.3f (non-finite or <= 0), skipping", self._name, coeff_new) self.state.last_learning_status = "invalid_outdoor_coeff" return False # Cap at 1.2 (Slightly relaxed to allow logic to work in extreme cases, but bounded) MAX_KEXT = 1.2 if coeff_new > MAX_KEXT: _LOGGER.info("%s - Auto TPI: Calculated outdoor coeff %.3f > %.1f, capping to %.1f before averaging", self._name, coeff_new, MAX_KEXT, MAX_KEXT) coeff_new = MAX_KEXT count = self.state.coeff_outdoor_cool_autolearn if is_cool else self.state.coeff_outdoor_autolearn old_coeff = current_outdoor # Apply EMA or average effective_count = min(count, 50) if self._calculation_method == "average": # Kext counter starts at 0, so first cycle should have weight 0 weight_old = effective_count avg_coeff = ((old_coeff * weight_old) + coeff_new) / (weight_old + 1) _LOGGER.debug("%s - Auto TPI: Outdoor Weighted Average: old=%.3f (weight=%d, real_count=%d), new=%.3f, result=%.3f", self._name, old_coeff, weight_old, count, coeff_new, avg_coeff) else: # EMA alpha = self._get_adaptive_alpha(effective_count) avg_coeff = (old_coeff * (1.0 - alpha)) + (coeff_new * alpha) _LOGGER.debug("%s - Auto TPI: Outdoor EMA: old=%.3f, new=%.3f, alpha=%.3f (eff_count=%d, real_count=%d), result=%.3f", self._name, old_coeff, coeff_new, alpha, effective_count, count, avg_coeff) new_count = count + 1 # We only cap if continuous learning is OFF, and we want to stop learning if not self._continuous_learning: # The standard threshold is 50 + initial weight INT_CYCLES_THRESHOLD = 50 + self._avg_initial_weight indoor_autolearn_count = self.state.coeff_indoor_cool_autolearn if is_cool else self.state.coeff_indoor_autolearn is_indoor_finished = indoor_autolearn_count >= INT_CYCLES_THRESHOLD # Kext learning stops (capped at 50) ONLY if: # (kext_cycles >= 50) AND (not keep_ext_learning OR kint_cycles >= 50 + initial_weight). # This ensures Kext always learns a minimum of 50 cycles (Standard minimum). EXT_CYCLES_THRESHOLD = 50 is_kext_standard_finished = count >= EXT_CYCLES_THRESHOLD # stop_learning_now is not used here, only for final persistence check # new_count is NOT capped anymore to reflect the real number of cycles pass # No cap self._calculate_retroactive_capacity(avg_coeff, old_coeff, is_cool) if is_cool: self.state.coeff_outdoor_cool = avg_coeff self.state.coeff_outdoor_cool_autolearn = new_count else: self.state.coeff_outdoor_heat = avg_coeff self.state.coeff_outdoor_autolearn = new_count _LOGGER.info( "%s - Auto TPI: Learn outdoor (%s). Old: %.3f, Correction: %.3f, Target: %.3f, Averaged: %.3f (count: %d)", self._name, "cool" if is_cool else "heat", old_coeff, correction, coeff_new, avg_coeff, new_count, ) return True def _calculate_retroactive_capacity(self, avg_coeff: float, old_coeff: float, is_cool: bool) -> None: """Calculate and apply retroactive capacity adjustment based on Kext change.""" # RETRO-ACTIVE CAPACITY ADJUSTMENT kext_diff = avg_coeff - old_coeff delta_t_losses = 0.0 max_capacity_attr = "" if is_cool: delta_t_losses = self._current_temp_out - self._current_temp_in max_capacity_attr = "max_capacity_cool" else: delta_t_losses = self._current_temp_in - self._current_temp_out max_capacity_attr = "max_capacity_heat" delta_t_losses = max(0.0, delta_t_losses) if abs(kext_diff) > 0.0001 and delta_t_losses > 0.0: current_capacity = getattr(self.state, max_capacity_attr) if current_capacity > 0: # We need to reverse the adiabatic calculation to find the implicit current "rise_rate" # Old_Capacity = Rise_Rate + (Old_Kext * dT) # Rise_Rate = Old_Capacity - (Old_Kext * dT) # # New_Capacity = Old_Capacity + kext_diff * dT # Using the shared formula: # 1. Reverse to get invariant rise_rate implicit_rise_rate = current_capacity - (old_coeff * delta_t_losses) # 2. Recalculate with new coefficient new_capacity = self._calculate_adiabatic_capacity(implicit_rise_rate, avg_coeff, delta_t_losses) new_capacity = max(0.01, new_capacity) setattr(self.state, max_capacity_attr, new_capacity) _LOGGER.info( "%s - Auto TPI: Adjusted %s: %.3f -> %.3f due to Kext change (diff: %.4f, dT: %.1f)", self._name, max_capacity_attr, current_capacity, new_capacity, kext_diff, delta_t_losses ) def _calculate_adiabatic_capacity(self, observed_rise_rate: float, k_ext: float, delta_t: float) -> float: """Calculate adiabatic capacity (decoupled from losses). Formula: Capacity_adiabatic = Rise_Rate + (Kext * DeltaT) """ return observed_rise_rate + (k_ext * delta_t) def _should_learn_capacity(self) -> bool: """Check if capacity learning should occur this cycle.""" if not self.learning_active and not self._continuous_kext: _LOGGER.debug("%s - Not learning capacity: learning and continuous kext are disabled", self._name) return False # Determine if we are in bootstrap in_bootstrap = ( self.state.max_capacity_heat == 0 or self.state.capacity_heat_learn_count < 3 ) # Baseline thresholds power_threshold = 0.80 # Dynamic rise threshold: # normally 0.05°C to avoid noise. # BUT if power is near saturation (>95%), we might be limited by capacity, so we accept almost any rise (0.01°C). # This allows max_capacity to decrease if the system struggles to heat (high power, low rise). rise_threshold = 0.01 if self.state.last_power > 0.95 else 0.05 min_gap = 1.0 if self.state.capacity_heat_learn_count < 3 else 0.3 # Timeout Strategy: Force default capacity if bootstrap fails too many times if in_bootstrap: failures = self.state.bootstrap_failure_count if failures > 5: # Force exit bootstrap with conservative capacity _LOGGER.warning( "%s - Bootstrap timeout after %d failures. Forcing default capacity 0.3°C/h and exiting bootstrap.", self._name, failures ) self.state.max_capacity_heat = 0.3 # We interpret this forced exit as having "learned" enough to stabilize # Setting count to 3 ensures we use alpha=0.15 (stabilized) for future updates self.state.capacity_heat_learn_count = 3 self.state.bootstrap_failure_count = 0 # Reset counter # Persist default capacity to config if self._hass and self._hass.loop and not self._hass.loop.is_closed(): self._hass.async_create_task( self.async_update_learning_data(capacity=0.3, is_heat_mode=True) ) return False # Cycle handled (we set default), skip calculation logic for this cycle # Check Condition 1: Power if self.state.last_power < power_threshold: _LOGGER.debug( "%s - Not learning capacity: power too low (%.1f%% < %.0f%%)", self._name, self.state.last_power * 100, power_threshold * 100 ) if in_bootstrap: self.state.bootstrap_failure_count += 1 return False # Condition 1b: Minimum efficiency (heater on-time ratio) # When efficiency is low, temperature rise from external factors (sun, window close) # gets amplified in capacity calculation, causing outlier spikes. if self._last_cycle_power_efficiency < MIN_EFFICIENCY_FOR_CAPACITY: _LOGGER.debug( "%s - Not learning capacity: efficiency too low (%.1f%% < %.0f%%) - external factors may dominate", self._name, self._last_cycle_power_efficiency * 100, MIN_EFFICIENCY_FOR_CAPACITY * 100 ) if in_bootstrap: self.state.bootstrap_failure_count += 1 return False # Condition 2: Significant rise real_rise = self._current_temp_in - self.state.last_temp_in if real_rise < rise_threshold: _LOGGER.debug( "%s - Not learning capacity: rise too small (%.3f < %.2f°C)", self._name, real_rise, rise_threshold ) if in_bootstrap: self.state.bootstrap_failure_count += 1 return False # Condition 3: Adequate gap (stricter during bootstrap) target_diff = self._current_target_temp - self.state.last_temp_in if target_diff < min_gap: _LOGGER.debug( "%s - Not learning capacity: gap too small (%.2f < %.1f°C)", self._name, target_diff, min_gap ) # Note: We don't necessarily increment failure count for "small gap" # as this is not a "failed attempt" to heat, but rather "no need to heat much". # But if we are in bootstrap, we WANT larger gaps. # Let's be conservative and NOT increment here to avoid relaxing just because setpoint is close. return False return True async def _learn_capacity(self, power: float, delta_t: float, rise: float, efficiency: float, k_ext: float) -> bool: """Learn heating capacity using simple EWMA with adiabatic correction. Inspired by regul2.py parameter estimation approach. Args: power: Heating power ratio (0-1) delta_t: Temperature gap (Tin - Tout) in °C rise: Observed temperature rise in °C efficiency: Cycle efficiency (0-1) k_ext: Current external coefficient Returns: True if capacity was updated and RELOAD triggered, False otherwise (or no reload needed) """ # Calculate observed capacity (with thermal losses included) cycle_duration_h = self._cycle_min / 60.0 # Check for division by zero if cycle_duration_h * efficiency <= 0: return False observed_rise_rate = rise / (cycle_duration_h * efficiency) # Adiabatic correction: add back the estimated losses # This decouples heating capacity from thermal losses adiabatic_capacity = self._calculate_adiabatic_capacity(observed_rise_rate, k_ext, delta_t) # Basic validation (physical bounds) if adiabatic_capacity <= 0 or adiabatic_capacity > 20.0: _LOGGER.debug( "%s - Capacity measurement out of bounds: %.2f°C/h, skipping", self._name, adiabatic_capacity ) return False # Capacity learning with adaptive weighting: # - Bootstrap (<3 cycles): EMA with alpha=0.4 for fast convergence # - Transition (3-MAX_WEIGHT cycles):EWMA alpha decreases as 1/(count+1) # - Stable (>MAX_WEIGHT cycles): EMA with alpha=0.05 for outlier resistance count = self.state.capacity_heat_learn_count MAX_CAPACITY_WEIGHT = 20 # After 20 cycles, switch to pure EMA STABLE_ALPHA = 0.05 # Fixed alpha for mature model old_capacity = self.state.max_capacity_heat if old_capacity == 0: # First measurement: take directly self.state.max_capacity_heat = adiabatic_capacity effective_alpha = 1.0 elif count < 3: # Bootstrap: EWMA with high alpha for fast convergence alpha = 0.4 self.state.max_capacity_heat = (1 - alpha) * old_capacity + alpha * adiabatic_capacity effective_alpha = alpha elif count < MAX_CAPACITY_WEIGHT: # Transition: weighted average (alpha equivalent = 1/(count+1)) # New value gets weight=1, old value gets weight=count self.state.max_capacity_heat = (old_capacity * count + adiabatic_capacity) / (count + 1) effective_alpha = 1.0 / (count + 1) else: # Stable: pure EMA with fixed low alpha self.state.max_capacity_heat = (1 - STABLE_ALPHA) * old_capacity + STABLE_ALPHA * adiabatic_capacity effective_alpha = STABLE_ALPHA # Clamp protection: limit capacity change to ±50% per cycle (except during bootstrap) MAX_CHANGE_RATIO = 1.5 if old_capacity > 0 and count >= 3: min_allowed = old_capacity / MAX_CHANGE_RATIO max_allowed = old_capacity * MAX_CHANGE_RATIO if self.state.max_capacity_heat < min_allowed or self.state.max_capacity_heat > max_allowed: clamped_value = max(min_allowed, min(max_allowed, self.state.max_capacity_heat)) _LOGGER.warning( "%s - Capacity clamped: %.2f -> %.2f (allowed: %.2f - %.2f)", self._name, self.state.max_capacity_heat, clamped_value, min_allowed, max_allowed ) self.state.max_capacity_heat = clamped_value self.state.capacity_heat_learn_count += 1 # Store in history for confidence calculation if not hasattr(self, '_capacity_history'): self._capacity_history = [] self._capacity_history.append(self.state.max_capacity_heat) if len(self._capacity_history) > 10: self._capacity_history.pop(0) _LOGGER.info( "%s - Capacity learned: %.2f°C/h (count: %d, alpha: %.3f)", self._name, self.state.max_capacity_heat, self.state.capacity_heat_learn_count, effective_alpha ) # Reset failure count on success self.state.bootstrap_failure_count = 0 return False def _get_capacity_confidence(self) -> float: """Calculate capacity learning confidence based on CV (coefficient of variation). Similar to tau_reliability() in regul2.py. Returns: Confidence score from 0.0 (no confidence) to 1.0 (high confidence) """ # Need minimum samples if self.state.capacity_heat_learn_count < 3: return 0.3 # Need history if not hasattr(self, '_capacity_history'): self._capacity_history = [] if len(self._capacity_history) < 3: return 0.5 # Calculate coefficient of variation (CV) mean_cap = statistics.mean(self._capacity_history) if mean_cap <= 0: return 0.0 std_cap = statistics.pstdev(self._capacity_history) cv = std_cap / mean_cap # Confidence decreases with CV # CV = 0.1 → confidence = 0.90 # CV = 0.3 → confidence = 0.70 # CV = 0.5 → confidence = 0.50 # CV > 1.0 → confidence = 0.0 confidence = max(0.0, min(1.0, 1.0 - cv)) return confidence def _check_deboost(self, is_heat: bool, real_rise: float, adjusted_theoretical: float) -> bool: """Check if we should reduce indoor coefficient after excessive performance. Only activates after MIN_DEBOOST_CYCLES to let normal learning stabilize first. Returns True if deboost was applied. """ MIN_DEBOOST_CYCLES = 20 if is_heat: count = self.state.coeff_indoor_autolearn current_kint = self.state.coeff_indoor_heat else: count = self.state.coeff_indoor_cool_autolearn current_kint = self.state.coeff_indoor_cool # Wait for learning to stabilize before applying deboost if count < MIN_DEBOOST_CYCLES: return False # If we achieved more than expected, consider reducing coefficient if real_rise <= adjusted_theoretical * 1.2: # Need 20% overshoot to trigger return False DEBOOST_FACTOR = 0.95 # Calculate target Kint target_kint = current_kint * DEBOOST_FACTOR effective_count = min(count, 50) old = current_kint # Apply same weighting logic as Kext overshoot correction if self._calculation_method == "average": boosted_weight = max(1, int(effective_count / OVERSHOOT_CORRECTION_BOOST)) new_kint = ((old * boosted_weight) + target_kint) / (boosted_weight + 1) _LOGGER.debug( "%s - Deboost Kint (Average): old=%.4f, target=%.4f, weight=%d (boosted from %d), result=%.4f", self._name, old, target_kint, boosted_weight, effective_count, new_kint ) else: # EMA base_alpha = self._get_adaptive_alpha(effective_count) boosted_alpha = min(base_alpha * OVERSHOOT_CORRECTION_BOOST, 0.3) new_kint = (old * (1.0 - boosted_alpha)) + (target_kint * boosted_alpha) _LOGGER.debug( "%s - Deboost Kint (EMA): old=%.4f, target=%.4f, alpha=%.3f (boosted from %.3f), result=%.4f", self._name, old, target_kint, boosted_alpha, base_alpha, new_kint ) if is_heat: if old > self._default_coef_int: self.state.coeff_indoor_heat = max(new_kint, self._default_coef_int) _LOGGER.info("%s - Deboosting Kint heat: %.3f → %.3f (weighted)", self._name, old, self.state.coeff_indoor_heat) else: if old > self._default_coef_int: self.state.coeff_indoor_cool = max(new_kint, self._default_coef_int) _LOGGER.info("%s - Deboosting Kint cool: %.3f → %.3f (weighted)", self._name, old, self.state.coeff_indoor_cool) # Reset boost counter if hasattr(self.state, "consecutive_boosts"): self.state.consecutive_boosts = 0 return True def _correct_kext_overshoot(self, overshoot: float, is_cool: bool) -> bool: """Aggressively reduce Kext when room is overshooting with significant power. This method is called when the room temperature exceeds the setpoint while heat is still being applied. This indicates Kext is too high. Args: overshoot: Temperature above setpoint (positive value) in °C is_cool: True if in cooling mode Returns: True if correction was applied, False otherwise """ # Feature flag check if not self.state.allow_kext_overshoot: return False current_kext = self.state.coeff_outdoor_cool if is_cool else self.state.coeff_outdoor_heat current_kint = self.state.coeff_indoor_cool if is_cool else self.state.coeff_indoor_heat # Calculate delta_ext for the correction delta_ext = self.state.last_order - self._current_temp_out if abs(delta_ext) < 0.1: _LOGGER.debug("%s - Auto TPI: Cannot correct Kext overshoot - delta_ext too small (%.2f)", self._name, delta_ext) return False # Calculate how much Kext should be reduced # At setpoint, Power = Kext * delta_ext # To allow temperature to fall, we need to reduce power by at least: overshoot * Kint # So: needed_power_reduction = overshoot * Kint # And: needed_kext_reduction = needed_power_reduction / delta_ext needed_reduction = (overshoot * current_kint) / delta_ext # Target Kext that would produce correct power at setpoint target_kext = max(0.001, current_kext - needed_reduction) # Get base alpha for calculation count = self.state.coeff_outdoor_cool_autolearn if is_cool else self.state.coeff_outdoor_autolearn effective_count = min(count, 50) old_kext = current_kext if self._calculation_method == "average": # For average mode: reduce effective weight to give more influence to correction # Instead of weight = effective_count, use weight / OVERSHOOT_CORRECTION_BOOST boosted_weight = max(1, int(effective_count / OVERSHOOT_CORRECTION_BOOST)) new_kext = ((old_kext * boosted_weight) + target_kext) / (boosted_weight + 1) _LOGGER.debug( "%s - Auto TPI: Overshoot correction (Average): old=%.4f, target=%.4f, weight=%d (boosted from %d), result=%.4f", self._name, old_kext, target_kext, boosted_weight, effective_count, new_kext ) else: # EMA # Use boosted alpha for faster correction base_alpha = self._get_adaptive_alpha(effective_count) boosted_alpha = min(base_alpha * OVERSHOOT_CORRECTION_BOOST, 0.3) new_kext = (old_kext * (1.0 - boosted_alpha)) + (target_kext * boosted_alpha) _LOGGER.debug( "%s - Auto TPI: Overshoot correction (EMA): old=%.4f, target=%.4f, alpha=%.3f (boosted from %.3f), result=%.4f", self._name, old_kext, target_kext, boosted_alpha, base_alpha, new_kext ) # Ensure Kext doesn't go below minimum new_kext = max(0.001, new_kext) if is_cool: self.state.coeff_outdoor_cool = new_kext else: self.state.coeff_outdoor_heat = new_kext self.state.last_learning_status = "corrected_kext_overshoot" self._learning_just_completed = True _LOGGER.info( "%s - Auto TPI: Overshoot correction applied! Kext: %.4f -> %.4f (overshoot=%.2f°C, power=%.1f%%)", self._name, old_kext, new_kext, overshoot, self.state.last_power * 100 ) return True def _correct_kint_insufficient_rise(self, target_diff: float, temp_progress: float, is_cool: bool) -> bool: """Boost Kint when temperature stagnates despite significant gap to setpoint. This method is called when: - target_diff > INSUFFICIENT_RISE_GAP_THRESHOLD (0.3°C) - temp_progress < 0.02 (temperature stagnating) - power < 0.99 (not saturated) Instead of incorrectly adjusting Kext (which would happen in outdoor learning), we boost Kint to increase power output. Args: target_diff: The gap between setpoint and room temperature (positive value) temp_progress: Temperature change during the cycle (can be negative) is_cool: True if in cooling mode Returns: True if correction was applied, False otherwise """ # Feature flag check if not self.state.allow_kint_boost: return False # Check if we've hit the max consecutive boosts limit if self.state.consecutive_boosts >= MAX_CONSECUTIVE_KINT_BOOSTS: _LOGGER.warning( "%s - Auto TPI: Kint boost skipped - max consecutive boosts (%d) reached. Possible undersized heating.", self._name, MAX_CONSECUTIVE_KINT_BOOSTS ) self.state.last_learning_status = "max_kint_boosts_reached" # Send notification if enabled (only once per limit hit) if self._enable_notification and self.state.consecutive_boosts == MAX_CONSECUTIVE_KINT_BOOSTS: self._hass.async_create_task(self._notify_undersized_heating()) return False current_kint = self.state.coeff_indoor_cool if is_cool else self.state.coeff_indoor_heat # Calculate proportional boost based on gap size # Base boost is 8%, but increases slightly with larger gaps # For gap = 0.3°C: boost = 8%, for gap = 0.6°C: boost ≈ 10% gap_factor = min(target_diff / INSUFFICIENT_RISE_GAP_THRESHOLD, 2.0) # Cap at 2x base_boost_percent = (INSUFFICIENT_RISE_BOOST_FACTOR - 1.0) * gap_factor target_kint = current_kint * (1.0 + base_boost_percent) count = self.state.coeff_indoor_cool_autolearn if is_cool else self.state.coeff_indoor_autolearn effective_count = min(count, 50) old_kint = current_kint # Apply same weighting logic as Kext overshoot correction if self._calculation_method == "average": boosted_weight = max(1, int(effective_count / OVERSHOOT_CORRECTION_BOOST)) new_kint = ((old_kint * boosted_weight) + target_kint) / (boosted_weight + 1) _LOGGER.debug( "%s - Boost Kint (Average): old=%.4f, target=%.4f, weight=%d (boosted from %d), result=%.4f", self._name, old_kint, target_kint, boosted_weight, effective_count, new_kint ) else: # EMA base_alpha = self._get_adaptive_alpha(effective_count) boosted_alpha = min(base_alpha * OVERSHOOT_CORRECTION_BOOST, 0.3) new_kint = (old_kint * (1.0 - boosted_alpha)) + (target_kint * boosted_alpha) _LOGGER.debug( "%s - Boost Kint (EMA): old=%.4f, target=%.4f, alpha=%.3f (boosted from %.3f), result=%.4f", self._name, old_kint, target_kint, boosted_alpha, base_alpha, new_kint ) # Cap to max coefficient new_kint = min(new_kint, self._max_coef_int) # Ensure minimum Kint new_kint = max(new_kint, MIN_KINT) # Check if we actually changed anything (might hit cap) if abs(new_kint - current_kint) < 0.001: _LOGGER.debug( "%s - Auto TPI: Kint correction skipped - already at limit (current=%.3f, max=%.3f)", self._name, current_kint, self._max_coef_int ) return False old_kint = current_kint if is_cool: self.state.coeff_indoor_cool = new_kint else: self.state.coeff_indoor_heat = new_kint self.state.last_learning_status = "corrected_kint_insufficient_rise" self._learning_just_completed = True _LOGGER.info( "%s - Auto TPI: Kint correction applied! Kint: %.4f -> %.4f (gap=%.2f°C, progress=%.2f°C, power=%.1f%%, boost #%d)", self._name, old_kint, new_kint, target_diff, temp_progress, self.state.last_power * 100, self.state.consecutive_boosts + 1 ) # Increment consecutive boosts counter self.state.consecutive_boosts += 1 return True async def _notify_undersized_heating(self): """Send notification when max consecutive Kint boosts is reached.""" title = f"Versatile Thermostat: Auto TPI Warning for {self._name}" message = ( f"Auto TPI has reached the maximum consecutive Kint boost limit ({MAX_CONSECUTIVE_KINT_BOOSTS}). " f"The temperature is not rising despite increased power demand. " f"This may indicate undersized heating or abnormal heat loss. " f"Learning will continue normally, but Kint boosting is paused until external temperature rises." ) try: await self._hass.services.async_call( "persistent_notification", "create", { "title": title, "message": message, "notification_id": f"autotpi_undersized_heating_{self._unique_id}", }, blocking=False, ) _LOGGER.warning("%s - Auto TPI: Undersized heating notification sent.", self._name) except Exception as e: _LOGGER.error("%s - Auto TPI: Error sending undersized heating notification: %s", self._name, e) async def _detect_failures(self, current_temp_in: float): """Detect system failures.""" OFFSET_FAILURE = 1.0 MIN_LEARN_FOR_DETECTION = 25 failure_detected = False reason = "unknown" if ( self.state.last_state == "heat" and self.state.last_power >= self.saturation_threshold and current_temp_in < self.state.last_order - OFFSET_FAILURE and current_temp_in < self.state.last_temp_in and self.state.coeff_indoor_autolearn > MIN_LEARN_FOR_DETECTION ): failure_detected = True reason = "Temperature dropped while heating at full power" _LOGGER.warning("%s - Auto TPI: Failure detected in HEAT mode at saturation (power=%.1f%%)", self._name, self.state.last_power * 100) elif ( self.state.last_state == "cool" and self.state.last_power >= self.saturation_threshold and current_temp_in > self.state.last_order + OFFSET_FAILURE and current_temp_in > self.state.last_temp_in and self.state.coeff_indoor_autolearn > MIN_LEARN_FOR_DETECTION ): failure_detected = True reason = "Temperature rose while cooling at full power" _LOGGER.warning("%s - Auto TPI: Failure detected in COOL mode at saturation (power=%.1f%%)", self._name, self.state.last_power * 100) if failure_detected: self.state.consecutive_failures += 1 if self.state.consecutive_failures >= 3: if self._continuous_learning: # In continuous learning mode, don't stop learning - just skip faulty cycles _LOGGER.warning( "%s - Auto TPI: %d consecutive failures detected in continuous mode. " "Skipping faulty cycles and continuing learning. Reason: %s", self._name, self.state.consecutive_failures, reason, ) # Reset the counter to allow future failure detection self.state.consecutive_failures = 0 else: # Standard mode: disable learning after 3 consecutive failures self.state.autolearn_enabled = False _LOGGER.error( "%s - Auto TPI: Learning disabled due to %d consecutive failures.", self._name, self.state.consecutive_failures, ) # Send persistent notification # Retrieve the message from translations # We use the "exceptions" category in strings.json # The key is "component.versatile_thermostat.exceptions.auto_tpi_learning_stopped.message" title = "Versatile Thermostat: Auto TPI Learning Stopped" try: translations = await translation.async_get_translations( self._hass, self._hass.config.language, "exceptions", {DOMAIN} ) # Key format for exceptions: component.{domain}.exceptions.{key}.message key = f"component.{DOMAIN}.exceptions.auto_tpi_learning_stopped.message" message_template = translations.get(key) if message_template: message = message_template.format(name=self._name, reason=reason) else: # Fallback if translation not found message = f"Auto TPI learning for {self._name} has been stopped due to 3 consecutive failures. Reason: {reason}. Please check your configuration." await self._hass.services.async_call( "persistent_notification", "create", { "title": title, "message": message, "notification_id": f"autotpi_learning_stopped_{self._unique_id}", }, blocking=False, ) except Exception as e: _LOGGER.error("%s - Auto TPI: Error sending persistent notification: %s", self._name, e) else: self.state.consecutive_failures = 0 @property def saturation_threshold(self) -> float: """The saturation power threshold (default 1.0, 100%).""" # This property is expected to be overridden by the mixing/main component. # Defaulting to 1.0 for self-contained use if not overridden. return 1.0 def calculate_power(self, setpoint: float, temp_in: float, temp_out: float, state_str: str) -> float: """Calculate TPI power, using aggressive coefficients during bootstrap.""" # Bootstrap logic: aggressive coefficients in_bootstrap = ( state_str == "heat" and (self.state.max_capacity_heat == 0 or self.state.capacity_heat_learn_count < 3) ) saved_kint = self.state.coeff_indoor_heat saved_kext = self.state.coeff_outdoor_heat if in_bootstrap: self.state.coeff_indoor_heat = 1.0 self.state.coeff_outdoor_heat = 0.1 try: return self._calculate_power_tpi(setpoint, temp_in, temp_out, state_str) finally: if in_bootstrap: self.state.coeff_indoor_heat = saved_kint self.state.coeff_outdoor_heat = saved_kext def _calculate_power_tpi(self, setpoint: float, temp_in: float, temp_out: float, state_str: str) -> float: """Normal TPI proportional control.""" if temp_out is None: return 0.0 direction = 1 if state_str == "heat" else -1 delta_in = setpoint - temp_in delta_out = setpoint - temp_out if state_str == "cool": coeff_int = self.state.coeff_indoor_cool coeff_ext = self.state.coeff_outdoor_cool else: coeff_int = self.state.coeff_indoor_heat coeff_ext = self.state.coeff_outdoor_heat offset = self.state.offset power = (direction * delta_in * coeff_int) + (direction * delta_out * coeff_ext) + offset return max(0.0, min(1.0, power)) @staticmethod def _remove_outliers_iqr(values: list[float]) -> list[float]: """ Remove outliers using Interquartile Range (IQR) method. Keeps values within [Q1 - 1.5*IQR, Q3 + 1.5*IQR]. """ if len(values) < 4: return values sorted_values = sorted(values) n = len(sorted_values) q1_idx = n // 4 q3_idx = (3 * n) // 4 q1 = sorted_values[q1_idx] q3 = sorted_values[q3_idx] iqr = q3 - q1 lower_bound = q1 - 1.5 * iqr upper_bound = q3 + 1.5 * iqr return [v for v in values if lower_bound <= v <= upper_bound] def _get_power_at_time_sample_hold(self, target_dt: datetime, sorted_power: list, start_idx: int = 0) -> tuple[Optional[float], int]: """ Get the power value at a specific time using sample-and-hold logic. For event-driven sensors, returns the last known power value before or at target_dt. This is more appropriate for capacity calibration where power may stay stable for long periods without new history entries. Args: target_dt: The datetime to find power for sorted_power: Power history sorted by time (ascending) start_idx: Index to start searching from (optimization) Returns: Tuple of (power value in percent, next index to continue from) """ if not sorted_power: return None, 0 last_valid_power = None last_valid_idx = start_idx # Find the last power entry that is <= target_dt for i in range(start_idx, len(sorted_power)): state = sorted_power[i] try: state_dt = state.last_changed if state_dt > target_dt: # We've passed the target time, use the last valid power break # This entry is at or before target_dt state_value = getattr(state, "state", None) if state_value not in ["unknown", "unavailable", None]: try: last_valid_power = float(state_value) last_valid_idx = i except (ValueError, TypeError): pass except (AttributeError, TypeError): continue return last_valid_power, last_valid_idx async def calculate_capacity_from_slope_sensor( self, slope_history: list, power_history: list, min_power_threshold: float = 0.95, kext_coeff: float = 0.0, current_indoor_temp: Optional[float] = None, current_outdoor_temp: Optional[float] = None, ) -> dict: """ Calculate ADIABATIC capacity using temperature_slope and power_percent sensor histories. ALGORITHM: 1. Match slope points with power values at the same time 2. Keep points where power >= threshold AND slope direction is correct 3. Remove outliers using IQR method 4. Use 75th percentile (biases toward higher/adiabatic values) 5. Add Kext compensation: capacity = percentile_75 + Kext × avg_delta_T Args: slope_history: History of temperature_slope sensor power_history: History of power_percent sensor min_power_threshold: Minimum power (0.0-1.0) to consider. Default 0.95 (95%) kext_coeff: Current Kext coefficient for adiabatic correction current_indoor_temp: Current indoor temperature for delta_T estimation current_outdoor_temp: Current outdoor temperature for delta_T estimation Returns: Dictionary with adiabatic capacity result and metrics """ # Always True now is_heat_mode = True power_threshold_percent = min_power_threshold * 100.0 _LOGGER.debug( "%s - Capacity Calibration: Analyzing %d slope points and %d power points (threshold=%.0f%%)", self._name, len(slope_history) if slope_history else 0, len(power_history) if power_history else 0, power_threshold_percent, ) if not slope_history: return {"success": False, "error": "No temperature slope history found", "samples_used": 0} if not power_history: return {"success": False, "error": "No power percent history found", "samples_used": 0} # Collect valid slope values raw_slopes = [] rejected_low_power = 0 rejected_wrong_direction = 0 rejected_invalid = 0 # Sort histories by time to enable O(N+M) matching sorted_slopes = sorted(slope_history, key=lambda s: s.last_changed) sorted_power = sorted(power_history, key=lambda s: s.last_changed) power_idx = 0 for slope_state in sorted_slopes: try: slope_dt = slope_state.last_changed slope_str = getattr(slope_state, "state", None) if slope_str in ["unknown", "unavailable", None]: rejected_invalid += 1 continue slope_value = float(slope_str) # Find power value using sample-and-hold logic (handles event-driven sensors) power, power_idx = self._get_power_at_time_sample_hold(slope_dt, sorted_power, start_idx=power_idx) if power is None: rejected_invalid += 1 continue # Check power threshold if power < power_threshold_percent: rejected_low_power += 1 continue # Check slope direction (always heating check now) if slope_value <= 0: rejected_wrong_direction += 1 continue raw_slopes.append(slope_value) except (ValueError, TypeError, AttributeError) as e: _LOGGER.debug("%s - Capacity Calibration: Invalid slope state: %s", self._name, e) rejected_invalid += 1 continue _LOGGER.info( "%s - Capacity Calibration: Found %d valid samples (rejected: %d low-power, %d wrong-direction, %d invalid)", self._name, len(raw_slopes), rejected_low_power, rejected_wrong_direction, rejected_invalid, ) if len(raw_slopes) < 2: return { "success": False, "error": f"Not enough valid samples ({len(raw_slopes)} found, minimum 2 required)", "samples_used": len(raw_slopes), "rejection_stats": {"low_power": rejected_low_power, "wrong_direction": rejected_wrong_direction, "invalid": rejected_invalid}, } # Remove outliers filtered_slopes = self._remove_outliers_iqr(raw_slopes) outliers_removed = len(raw_slopes) - len(filtered_slopes) _LOGGER.debug("%s - Capacity Calibration: Removed %d outliers, %d samples remaining", self._name, outliers_removed, len(filtered_slopes)) if len(filtered_slopes) < 2: return { "success": False, "error": f"Not enough samples after outlier removal ({len(filtered_slopes)} remaining)", "samples_used": len(filtered_slopes), "samples_before_filter": len(raw_slopes), } # Calculate 75th percentile (biases toward adiabatic - higher values) # Higher slopes = less heat loss = closer to adiabatic sorted_slopes = sorted(filtered_slopes) n = len(sorted_slopes) # 75th percentile index p75_idx = int(0.75 * (n - 1)) observed_capacity = sorted_slopes[p75_idx] # Estimate average delta_T for Kext compensation # When power is at 100%, we typically have a significant delta_T # Use current temperatures if available, otherwise use typical value if current_indoor_temp is not None and current_outdoor_temp is not None: avg_delta_t = abs(current_indoor_temp - current_outdoor_temp) else: # Typical delta_T when heating at high power (rough estimate: 10-15°C) avg_delta_t = 12.0 # Conservative estimate # Apply Kext compensation for adiabatic capacity # Capacity_adiabatic = Slope_observed + Kext × delta_T kext_compensation = kext_coeff * avg_delta_t capacity = observed_capacity + kext_compensation _LOGGER.debug( "%s - Capacity Calibration: 75th percentile=%.3f, Kext=%.4f, delta_T=%.1f, compensation=%.3f, final=%.3f", self._name, observed_capacity, kext_coeff, avg_delta_t, kext_compensation, capacity, ) # Ensure capacity is positive if capacity <= 0.0: _LOGGER.warning("%s - Capacity Calibration: Calculated capacity (%.3f) is not positive. Setting to 0.01.", self._name, capacity) capacity = 0.01 # Calculate reliability based on sample count and variance mean_slope = sum(filtered_slopes) / len(filtered_slopes) variance = sum((s - mean_slope) ** 2 for s in filtered_slopes) / len(filtered_slopes) std_dev = math.sqrt(variance) if variance > 0 else 0.0 cv = std_dev / mean_slope if mean_slope > 0 else 0.0 # Coefficient of variation # Reliability: higher with more samples and lower variance sample_factor = min(1.0, len(filtered_slopes) / 20.0) # Max at 20 samples variance_factor = max(0.0, 1.0 - (cv / 2.0)) # Lower if high variance reliability = 100.0 * sample_factor * variance_factor # Period calculation (in days) period_days = 0.0 if slope_history: try: timestamps = [s.last_changed for s in slope_history] if timestamps: start_date = min(timestamps) end_date = max(timestamps) period_days = (end_date - start_date).total_seconds() / 86400.0 except (AttributeError, TypeError): pass _LOGGER.info( "%s - Capacity Calibration: Adiabatic Capacity=%.3f °C/h (observed=%.3f + Kext×ΔT=%.3f), Reliability=%.1f%%, Samples=%d", self._name, capacity, observed_capacity, kext_compensation, reliability, len(filtered_slopes), ) return { "success": True, "capacity": round(capacity, 3), "observed_capacity": round(observed_capacity, 3), "kext_compensation": round(kext_compensation, 3), "avg_delta_t": round(avg_delta_t, 1), "samples_used": len(filtered_slopes), "samples_before_filter": len(raw_slopes), "outliers_removed": outliers_removed, "reliability": round(reliability, 1), "min_power_threshold": min_power_threshold, "period": round(period_days, 1), } async def service_calibrate_capacity( self, thermostat_entity_id: str, ext_temp_entity_id: str, save_to_config: bool, min_power_threshold: float, start_date: datetime | str | None = None, end_date: datetime | str | None = None, ) -> dict: """ Orchestrates the capacity calibration service using temperature_slope and power_percent sensor histories. NEW ALGORITHM: 1. Derives slope and power sensor entity IDs from thermostat entity ID 2. Fetches history for both sensors 3. Matches points where power >= threshold and slope direction is correct 4. Removes outliers and calculates median as Capacity Args: thermostat_entity_id: The climate entity ID (e.g., "climate.thermostat_salon") ext_temp_entity_id: External temperature sensor (unused in new algorithm but kept for API compatibility) save_to_config: Whether to save the result to config start_date: Start of history period (default: 30 days ago) end_date: End of history period (default: now) min_power_threshold: Minimum power percentage (0.0-1.0) to consider a sample. Default is 1.0 (100%). Lower values (e.g., 0.90) include more samples. """ # 1. Derive sensor entity IDs from thermostat entity ID # climate.thermostat_salon -> sensor.thermostat_salon_temperature_slope # climate.thermostat_salon -> sensor.thermostat_salon_power_percent if thermostat_entity_id.startswith("climate."): base_name = thermostat_entity_id.replace("climate.", "") else: base_name = thermostat_entity_id.split(".")[-1] slope_sensor_id = f"sensor.{base_name}_temperature_slope" power_sensor_id = f"sensor.{base_name}_power_percent" _LOGGER.info("%s - Capacity calibration: Using slope sensor '%s' and power sensor '%s'", self._name, slope_sensor_id, power_sensor_id) # 2. Convert start_date and end_date to datetime objects if isinstance(start_date, str): _date = dt_util.parse_date(start_date) start_date = dt_util.start_of_local_day(_date) if _date else None if isinstance(end_date, str): _date = dt_util.parse_date(end_date) _end_day_start = dt_util.start_of_local_day(_date) if _date else None end_date = _end_day_start + timedelta(days=1) if _end_day_start else None # 3. Determine History Time Range now = dt_util.now() start_time = dt_util.as_utc(start_date) if start_date is not None else now - timedelta(days=30) end_time = dt_util.as_utc(end_date) if end_date is not None else now _LOGGER.info("%s - Calibrating capacity using history from %s to %s", self._name, start_time, end_time) # Handle percentage value for min_power_threshold (e.g. 95 -> 0.95) if min_power_threshold > 1.0: _LOGGER.debug("%s - Converting min_power_threshold from %.1f to %.2f", self._name, min_power_threshold, min_power_threshold / 100.0) min_power_threshold = min_power_threshold / 100.0 # 4. Fetch sensor histories in chunks to avoid timeouts and cope with gaps entity_ids = [slope_sensor_id, power_sensor_id] slope_history = [] power_history = [] # We use 2-day chunks for robustness chunk_delta = timedelta(days=2) current_start = start_time while current_start < end_time: current_end = min(current_start + chunk_delta, end_time) _LOGGER.debug("%s - Fetching history chunk from %s to %s", self._name, current_start, current_end) try: chunk_states = await get_instance(self._hass).async_add_executor_job( partial( history.get_significant_states, self._hass, current_start, end_time=current_end, entity_ids=entity_ids, significant_changes_only=False, ) ) if chunk_states: slope_history.extend(chunk_states.get(slope_sensor_id, [])) power_history.extend(chunk_states.get(power_sensor_id, [])) except Exception as e: _LOGGER.warning("%s - Error fetching history chunk %s to %s: %s", self._name, current_start, current_end, e) current_start = current_end _LOGGER.debug("%s - Fetched %d slope sensor states and %d power sensor states for capacity calibration.", self._name, len(slope_history), len(power_history)) # Check if sensors exist if not slope_history: _LOGGER.warning("%s - No history found for slope sensor '%s'. " "Make sure the sensor exists and has history enabled in recorder.", self._name, slope_sensor_id) if not power_history: _LOGGER.warning("%s - No history found for power sensor '%s'. " "Make sure the sensor exists and has history enabled in recorder.", self._name, power_sensor_id) # 5. Get Kext from HA config (not learned value) for adiabatic correction kext_coeff = self._default_coef_ext # Get current temperatures from thermostat for delta_T estimation current_indoor_temp = None current_outdoor_temp = None # Try to get current thermostat state for indoor temp thermostat_state = self._hass.states.get(thermostat_entity_id) if thermostat_state: try: current_indoor_temp = float(thermostat_state.attributes.get("current_temperature", 0)) except (ValueError, TypeError): pass # Try to get outdoor temp from the external sensor if ext_temp_entity_id: outdoor_state = self._hass.states.get(ext_temp_entity_id) if outdoor_state and outdoor_state.state not in ["unknown", "unavailable"]: try: current_outdoor_temp = float(outdoor_state.state) except (ValueError, TypeError): pass _LOGGER.debug( "%s - Adiabatic correction params: Kext_config=%.4f, T_indoor=%.1f, T_outdoor=%.1f", self._name, kext_coeff, current_indoor_temp if current_indoor_temp else 0, current_outdoor_temp if current_outdoor_temp else 0, ) # 6. Call calculation method with adiabatic correction result = await self.calculate_capacity_from_slope_sensor( slope_history, power_history, min_power_threshold=min_power_threshold, kext_coeff=kext_coeff, current_indoor_temp=current_indoor_temp, current_outdoor_temp=current_outdoor_temp, ) _LOGGER.info("%s - Capacity calibration result: %s", self._name, result) # 6. Save to config if requested if result and isinstance(result, dict) and result.get("success"): max_capacity = result.get("capacity") if max_capacity is not None: # Rename capacity to max_capacity in result if "capacity" in result: del result["capacity"] result["max_capacity"] = max_capacity if save_to_config: # Always heat mode is_heat_mode = True await self.async_update_learning_data(capacity=max_capacity, is_heat_mode=is_heat_mode) _LOGGER.info( "%s - Heating capacity calibrated and saved: %.3f °C/h", self._name, max_capacity ) return result async def _try_pre_bootstrap_calibration(self) -> float | None: """ Try to calibrate capacity from historical data before starting bootstrap. Calls the calibration service internally with min_power_threshold=80%. If reliability >= MIN_PRE_BOOTSTRAP_CALIBRATION_RELIABILITY, returns max_capacity. Otherwise, returns None to trigger bootstrap. """ try: # Use the stored entity_id if available, otherwise fall back to unique_id if self._entity_id: thermostat_entity_id = self._entity_id else: thermostat_entity_id = f"climate.{self._unique_id}" _LOGGER.warning( "%s - Auto TPI: entity_id not set, falling back to unique_id-based entity_id: %s", self._name, thermostat_entity_id ) # Get external temperature entity from thermostat state if available ext_temp_entity_id = "" thermostat_state = self._hass.states.get(thermostat_entity_id) if thermostat_state: ext_temp_entity_id = thermostat_state.attributes.get("ext_current_temperature_entity_id", "") _LOGGER.debug( "%s - Auto TPI: Attempting pre-bootstrap calibration with min_power_threshold=80%%", self._name ) result = await self.service_calibrate_capacity( thermostat_entity_id=thermostat_entity_id, ext_temp_entity_id=ext_temp_entity_id, save_to_config=False, # Do not save yet, just check min_power_threshold=0.80, # 80% power threshold for more samples ) if not result or not result.get("success"): error = result.get("error", "unknown error") if result else "no result" _LOGGER.debug( "%s - Auto TPI: Pre-bootstrap calibration failed: %s", self._name, error ) return None reliability = result.get("reliability", 0.0) max_capacity = result.get("max_capacity", 0.0) if reliability >= MIN_PRE_BOOTSTRAP_CALIBRATION_RELIABILITY and max_capacity > 0: _LOGGER.info( "%s - Auto TPI: Pre-bootstrap calibration returned reliability=%.1f%% (>= %.1f%%), capacity=%.2f °C/h", self._name, reliability, MIN_PRE_BOOTSTRAP_CALIBRATION_RELIABILITY, max_capacity ) return max_capacity else: _LOGGER.debug( "%s - Auto TPI: Pre-bootstrap calibration reliability too low (%.1f%% < %.1f%%) or capacity invalid (%.2f)", self._name, reliability, MIN_PRE_BOOTSTRAP_CALIBRATION_RELIABILITY, max_capacity ) return None except Exception as e: _LOGGER.warning( "%s - Auto TPI: Pre-bootstrap calibration error: %s", self._name, e ) return None @callback def _capture_end_of_on_temp(self, _): """Capture the temperature at the end of the ON pulse.""" self.state.last_on_temp_in = self._current_temp_in _LOGGER.debug("%s - Auto TPI: Captured end of ON temp: %.1f", self._name, self.state.last_on_temp_in) self._timer_capture_remove_callback = None def update_realized_power(self, realized_percent: float): """Update the power actually applied to the underlyings. Called by the handler when the realized power differs from the requested on_percent. Sources of difference: - timing constraints (min_activation_delay, min_deactivation_delay) - max_on_percent clamping - safety mode override This ensures learning uses the actual applied power, not the requested one. """ if self.state.cycle_active: old = self.state.last_power self.state.last_power = realized_percent if abs(old - realized_percent) > 0.001: _LOGGER.debug( "%s - Auto TPI: Realized power updated: %.1f%% -> %.1f%%", self._name, old * 100, realized_percent * 100 ) async def on_cycle_started(self, on_time_sec: float, off_time_sec: float, on_percent: float, hvac_mode: str): """Called when a TPI cycle starts.""" # Detect if previous cycle was interrupted is_expected_interruption = self._learning_just_completed self._learning_just_completed = False # Reset the flag after check if self.state.cycle_active and not is_expected_interruption: _LOGGER.info("%s - Auto TPI: Previous cycle was interrupted (not completed). Discarding it.", self._name) # You could add specific logic here if needed (stats, etc) # Cancel any pending capture timer if self._timer_capture_remove_callback: self._timer_capture_remove_callback() self._timer_capture_remove_callback = None self.state.cycle_active = True _LOGGER.debug("%s - Auto TPI: Cycle started. On: %.0fs, Off: %.0fs (%.1f%%), Mode: %s", self._name, on_time_sec, off_time_sec, on_percent * 100, hvac_mode) now = dt_util.now() # Snapshot current state for learning at the end of the cycle self.state.last_temp_in = self._current_temp_in self.state.last_temp_out = self._current_temp_out self.state.last_order = self._current_target_temp self.state.last_power = on_percent if on_percent is not None else 0.0 self.state.last_on_temp_in = 0.0 # Reset # Save previous state before updating last_state (for first cycle detection) self.state.previous_state = self.state.last_state # Map VThermHvacMode/HVACMode to internal state string # hvac_mode is expected to be VThermHvacMode or string representation mode_str = str(hvac_mode) if mode_str == "heat" or mode_str == "heating": self.state.last_state = "heat" elif mode_str == "cool" or mode_str == "cooling": self.state.last_state = "cool" else: self.state.last_state = "stop" self.state.cycle_start_date = now self.state.last_update_date = now # Store current cycle params so on_cycle_completed() can read them without params self.state.current_cycle_params = { "on_time_sec": on_time_sec, "off_time_sec": off_time_sec, "on_percent": on_percent, "hvac_mode": str(hvac_mode), } # Schedule capture of temperature at the end of the ON pulse if on_time_sec > 0: self._timer_capture_remove_callback = async_call_later(self._hass, on_time_sec, self._capture_end_of_on_temp) # Calculate cold factor for this cycle self.state.current_cycle_cold_factor = 0.0 if self._heater_cooling_time > 0 and self.state.last_heater_stop_time: # Ensure both datetimes are timezone-aware (handles legacy data) last_stop = self.state.last_heater_stop_time if last_stop.tzinfo is None: last_stop = dt_util.as_local(last_stop) elapsed_off = (now - last_stop).total_seconds() / 60.0 if elapsed_off >= 0: self.state.current_cycle_cold_factor = min(1.0, max(0.0, elapsed_off / self._heater_cooling_time)) _LOGGER.debug( "%s - Auto TPI: Cold factor calc: elapsed_off=%.1f min, cooling_time=%.1f min, factor=%.2f", self._name, elapsed_off, self._heater_cooling_time, self.state.current_cycle_cold_factor, ) await self.async_save_data() def _should_learn_continuous_kext(self) -> bool: """Check if we should proceed with continuous Kext learning.""" if not self._continuous_kext: return False # Must be bootstrapped (at least 1 outdoor sample) # We check both modes as we don't know the future mode yet, # but technically we should check the count for the CURRENT mode in _learn_kext_continuous. # Here we just check if it's generally possible (any learning done). # However, to be strict, we can defer the check to _learn_kext_continuous. if self.state.coeff_outdoor_autolearn == 0 and self.state.coeff_outdoor_cool_autolearn == 0: return False # Standard exclusions adapted from _should_learn saturation_threshold = self.saturation_threshold if not (0 < self.state.last_power < saturation_threshold): return False if self._current_cycle_interrupted: return False if self._central_boiler_off: return False if self._current_is_heating_failure: return False if self.state.consecutive_failures >= 3: return False if self.state.previous_state == "stop": return False if self.state.last_order == 0: return False # Significant outdoor delta (> 1.0) delta_out = self.state.last_order - self._current_temp_out if abs(delta_out) < 1.0: return False return True async def _learn_kext_continuous(self, current_temp_in: float, current_temp_out: float): """Perform continuous Kext learning.""" if not self._should_learn_continuous_kext(): return is_heat = self.state.last_state == "heat" is_cool = self.state.last_state == "cool" if not (is_heat or is_cool): return # Check bootstrap for specific mode count = self.state.coeff_outdoor_autolearn if is_heat else self.state.coeff_outdoor_cool_autolearn if count == 0: _LOGGER.debug("%s - Continuous Kext: Not bootstrapped for %s mode", self._name, "heat" if is_heat else "cool") self.state.last_learning_status = "continuous_kext_not_bootstrapped" return # Check setpoint change if abs(self._current_target_temp - self.state.last_order) > 0.1: _LOGGER.debug("%s - Continuous Kext: Setpoint changed", self._name) self.state.last_learning_status = "continuous_kext_setpoint_changed" return target_temp = self.state.last_order gap_in = target_temp - current_temp_in gap_out = target_temp - current_temp_out # Avoid division by zero or small deltas if abs(gap_out) < 1.0: # Already checked in _should_learn but good to be safe return current_indoor = self.state.coeff_indoor_heat if is_heat else self.state.coeff_indoor_cool current_outdoor = self.state.coeff_outdoor_heat if is_heat else self.state.coeff_outdoor_cool # Formula: correction = Kint * (Gap_In / Gap_Out) correction = current_indoor * (gap_in / gap_out) target_outdoor = current_outdoor + correction # Validations if not math.isfinite(target_outdoor) or target_outdoor <= 0: _LOGGER.warning("%s - Continuous Kext: Invalid target Kext %.4f", self._name, target_outdoor) self.state.last_learning_status = "continuous_kext_invalid" return MAX_KEXT = 1.2 if target_outdoor > MAX_KEXT: target_outdoor = MAX_KEXT # EMA alpha = self._continuous_kext_alpha new_kext = (current_outdoor * (1.0 - alpha)) + (target_outdoor * alpha) # Update state in memory if is_heat: self.state.coeff_outdoor_heat = new_kext else: self.state.coeff_outdoor_cool = new_kext self.state.last_learning_status = f"continuous_kext_learned_{'cool' if is_cool else 'heat'}" self._learning_just_completed = True _LOGGER.info( "%s - Continuous Kext Learning (%s): Old=%.4f, Target=%.4f, New=%.4f (Alpha=%.3f, GapIn=%.2f, GapOut=%.2f)", self._name, "heat" if is_heat else "cool", current_outdoor, target_outdoor, new_kext, alpha, gap_in, gap_out ) # Persist immediately to config entry if Kext changed significantly (threshold: 0.001). # This avoids having to rely on a startup sync, and keeps the config entry up to date. if abs(new_kext - current_outdoor) > 0.001: await self.async_update_learning_data( coef_ext=new_kext, is_heat_mode=is_heat ) async def on_cycle_completed(self, e_eff: float = None, **_kw) -> None: """Called when a TPI cycle completes.""" # Validation logic (moved from old _tick) now = dt_util.now() prev_params = self.state.current_cycle_params or {} if self.state.cycle_start_date is not None and self.state.current_cycle_params is not None: # Ensure cycle_start_date is timezone-aware cycle_start = self.state.cycle_start_date if cycle_start.tzinfo is None: cycle_start = dt_util.as_local(cycle_start) elapsed_minutes = (now - cycle_start).total_seconds() / 60 expected_duration = self._cycle_min tolerance = max(expected_duration * 0.1, 1.0) duration_diff = elapsed_minutes - expected_duration # Case 1: Cycle too short (likely forced restart due to preset/temp change or restart) if duration_diff < -tolerance: _LOGGER.debug( "%s - Cycle too short: duration=%.1fmin (expected=%.1fmin). Likely forced restart. Skipping learning.", self._name, elapsed_minutes, expected_duration, ) self.state.last_learning_status = "cycle_too_short" # We return here because a short cycle shouldn't count towards total_cycles or update stop time # (it was interrupted actively) self.state.cycle_active = False return # Case 2: Cycle too long (Gap/Silence detected) if duration_diff > tolerance: _LOGGER.debug( "%s - Cycle gap detected: duration=%.1fmin (expected=%.1fmin, tolerance=%.1fmin). Resetting cycle but skipping learning.", self._name, elapsed_minutes, expected_duration, tolerance, ) # We do NOT return here. We allow update of total_cycles and last_heater_stop_time self.state.last_learning_status = "cycle_gap_detected" else: # No start date or params, nothing to do return on_time_sec = prev_params.get("on_time_sec", 0) off_time_sec = prev_params.get("off_time_sec", 0) if not self.state.cycle_active: _LOGGER.debug("%s - Auto TPI: Cycle completed but no cycle active. Ignoring.", self._name) return self.state.cycle_active = False if e_eff is not None: self.state.last_power = e_eff elapsed_minutes = (on_time_sec + off_time_sec) / 60 on_time_minutes = on_time_sec / 60.0 self.state.total_cycles += 1 # Update last_heater_stop_time if we were heating if self.state.last_state == "heat": self.state.last_heater_stop_time = dt_util.now() # Calculate Power Efficiency based on Heater Warm-up Time and Cold Factor # heater_heating_time is the time for the heater to warm up when fully cold. # effective_warm_up_time is the actual warm-up time in this cycle, adjusted by the cold_factor. # This part of the ON time is considered 'ineffective' for room temperature rise. self._last_cycle_power_efficiency = 1.0 # effective_warm_up_time is the portion of the ON time used to heat up the radiator itself effective_warm_up_time = self._heater_heating_time * self.state.current_cycle_cold_factor if effective_warm_up_time > 0 and on_time_minutes > 0: # effective_time is the time remaining after the radiator is warmed up effective_time = max(0.0, on_time_minutes - effective_warm_up_time) self._last_cycle_power_efficiency = effective_time / on_time_minutes _LOGGER.debug( "%s - Auto TPI: Power Efficiency calc: on_time=%.1f min, warm_up_time=%.1f, cold_factor=%.2f, eff_warm_up_time=%.1f, eff=%.2f", self._name, on_time_minutes, self._heater_heating_time, self.state.current_cycle_cold_factor, effective_warm_up_time, self._last_cycle_power_efficiency, ) if self.learning_active: _LOGGER.info( "%s - Auto TPI: Cycle #%d completed after %.1f minutes (efficiency: %.2f)", self._name, self.state.total_cycles, elapsed_minutes, self._last_cycle_power_efficiency ) else: _LOGGER.debug( "%s - Auto TPI: Cycle #%d completed after %.1f minutes (efficiency: %.2f)", self._name, self.state.total_cycles, elapsed_minutes, self._last_cycle_power_efficiency ) # Attempt learning # Determine if in bootstrap in_bootstrap = ( self._current_hvac_mode == "heat" and (self.state.max_capacity_heat == 0 or self.state.capacity_heat_learn_count < 3) ) # Check if cycle is significant enough for learning # Significant if we had some effective heating time (efficiency > 0) is_significant_cycle = self._last_cycle_power_efficiency > 0.0 # PHASE 1: Capacity Learning (independent of saturation check) # Capacity learning needs high power cycles (>=80%), which may be saturated (100%) # This must run independently of _should_learn() which rejects saturated power real_rise = self._current_temp_in - self.state.last_temp_in efficiency = self._last_cycle_power_efficiency # Check if cycle was flagged as invalid (e.g. gap detected) if self.state.last_learning_status == "cycle_gap_detected": _LOGGER.debug("%s - Auto TPI: Skipping capacity learning due to invalid cycle (gap detected)", self._name) elif self._should_learn_capacity(): await self._learn_capacity( power=self.state.last_power, delta_t=self._current_temp_in - self._current_temp_out, rise=real_rise, efficiency=efficiency, k_ext=self.state.coeff_outdoor_heat ) if in_bootstrap: _LOGGER.info( "%s - Bootstrap cycle %d/%d completed, capacity: %.2f°C/h", self._name, self.state.capacity_heat_learn_count, 3, # Total bootstrap cycles self.state.max_capacity_heat ) # PHASE 2: Kint/Kext Learning (requires non-saturated power) # Skip during bootstrap (learn only capacity first) if in_bootstrap: _LOGGER.debug("%s - Auto TPI: In bootstrap mode, skipping Kint/Kext learning", self._name) elif self._should_learn() and is_significant_cycle: _LOGGER.info("%s - Auto TPI: Attempting to learn Kint/Kext from cycle data", self._name) await self._perform_learning(self._current_temp_in, self._current_temp_out) elif self._continuous_kext and is_significant_cycle and self._should_learn_continuous_kext(): _LOGGER.info("%s - Continuous Kext: Learning active...", self._name) await self._learn_kext_continuous(self._current_temp_in, self._current_temp_out) else: reason = self._get_no_learn_reason() if not is_significant_cycle and reason == "unknown": reason = "on_time_too_short_vs_heating_time" _LOGGER.debug("%s - Auto TPI: Not learning Kint/Kext this cycle: %s", self._name, reason) # Only update status if it wasn't already set to "cycle_gap_detected" or other critical error if self.state.last_learning_status != "cycle_gap_detected": self.state.last_learning_status = reason # Check for failures await self._detect_failures(self._current_temp_in) # Centralized persistence: Check if learning is finished and persist if needed if self.learning_active: await self.process_learning_completion() await self.async_save_data() def get_calculated_params(self) -> dict: return self._calculated_params @property def is_in_bootstrap(self) -> bool: """Return True if the algorithm is in bootstrap mode (learning capacity).""" return ( self.state.max_capacity_heat == 0 or self.state.capacity_heat_learn_count < 3 ) @property def learning_active(self) -> bool: return self.state.autolearn_enabled @property def int_cycles(self) -> int: """Number of ACTUAL learning cycles completed for internal coefficient""" is_cool_mode = self._current_hvac_mode == "cool" if is_cool_mode: return max(0, self.state.coeff_indoor_cool_autolearn - self._avg_initial_weight) return max(0, self.state.coeff_indoor_autolearn - self._avg_initial_weight) @property def ext_cycles(self) -> int: """Number of learning cycles completed for external coefficient""" is_cool_mode = self._current_hvac_mode == "cool" if is_cool_mode: return self.state.coeff_outdoor_cool_autolearn return self.state.coeff_outdoor_autolearn @property def heating_cycles_count(self) -> int: """Number of total TPI cycles""" return self.state.total_cycles @property def time_constant(self) -> float: """Thermal time constant in hours""" if self.state.coeff_indoor_heat > 0: return round(1.0 / self.state.coeff_indoor_heat, 2) return 0.0 @property def confidence(self) -> float: """Confidence level in the learned model (0.0 to 1.0)""" # We consider stability reached when both coefficients have 50 cycles int_cycles = self.int_cycles ext_cycles = self.ext_cycles if int_cycles == 0 and ext_cycles == 0: return 0.0 # Average of progress for both confidence_int = min(int_cycles / 50.0, 1.0) confidence_ext = min(ext_cycles, 50) / 50.0 cycle_confidence = (confidence_int + confidence_ext) / 2.0 if self.state.consecutive_failures > 0: failure_penalty = min(self.state.consecutive_failures * 0.15, 0.6) cycle_confidence = max(0.2, cycle_confidence - failure_penalty) return round(cycle_confidence, 2) async def start_learning( self, coef_int: float = None, coef_ext: float = None, reset_data: bool = True, allow_kint_boost: bool = True, allow_kext_overshoot: bool = False, ): """Start learning, optionally resetting coefficients and learning data. Args: coef_int: Target internal coefficient (defaults to configured value) coef_ext: Target external coefficient (defaults to configured value) reset_data: If True, reset all learning data; if False, resume with existing data allow_kint_boost: Enable Kint boost on stagnation allow_kext_overshoot: Enable Kext compensation on overshoot """ # Update optional flags immediately (even if not resetting data) self.state.allow_kint_boost = allow_kint_boost self.state.allow_kext_overshoot = allow_kext_overshoot _LOGGER.info( "%s - Auto TPI: Optional parameters set: allow_kint_boost=%s, allow_kext_overshoot=%s", self._name, allow_kint_boost, allow_kext_overshoot ) # Use provided values, or fallback to default (configured) values target_int = coef_int if coef_int is not None else self._default_coef_int target_ext = coef_ext if coef_ext is not None else self._default_coef_ext if reset_data: _LOGGER.info("%s - Auto TPI: Starting learning with coef_int=%.3f, coef_ext=%.3f (resetting all data)", self._name, target_int, target_ext) # Reset coefficients to target values self.state.coeff_indoor_heat = target_int self.state.coeff_indoor_cool = target_int self.state.coeff_outdoor_heat = target_ext self.state.coeff_outdoor_cool = target_ext # Reset all counters self.state.coeff_indoor_autolearn = self._avg_initial_weight self.state.coeff_outdoor_autolearn = 0 self.state.coeff_indoor_cool_autolearn = self._avg_initial_weight self.state.coeff_outdoor_cool_autolearn = 0 # Reset all learning data for fresh start self.state.last_power = 0.0 self.state.last_order = 0.0 self.state.last_temp_in = 0.0 self.state.last_temp_out = 0.0 self.state.last_state = "stop" self.state.last_update_date = None self.state.last_heater_stop_time = None self.state.total_cycles = 0 self.state.consecutive_failures = 0 self.state.last_learning_status = "learning_started" self.state.cycle_start_date = dt_util.now() self.state.cycle_active = False self.state.current_cycle_params = None # Ensure first tick starts fresh # Reset capacity if configured heat_rate is 0 (user wants to re-learn capacity) if self._heating_rate == 0.0: _LOGGER.info( "%s - Auto TPI: Configured heat_rate is 0, resetting capacity for bootstrap", self._name ) self.state.max_capacity_heat = 0.0 self.state.capacity_heat_learn_count = 0 self.state.bootstrap_failure_count = 0 else: # If start_learning is called with explicit target values that differ from defaults, # apply them as an update to the current state, even without a full reset. if coef_int is not None and abs(coef_int - self._default_coef_int) > 0.001: _LOGGER.info("%s - Auto TPI: Updating Kint to %.3f (Manual override in resume)", self._name, target_int) self.state.coeff_indoor_heat = target_int self.state.coeff_indoor_cool = target_int if coef_ext is not None and abs(coef_ext - self._default_coef_ext) > 0.001: _LOGGER.info("%s - Auto TPI: Updating Kext to %.3f (Manual override in resume)", self._name, target_ext) self.state.coeff_outdoor_heat = target_ext self.state.coeff_outdoor_cool = target_ext _LOGGER.info( "%s - Auto TPI: Resuming learning with existing data (coef_int=%.3f, coef_ext=%.3f, cycles=%d)", self._name, self.state.coeff_indoor_heat, self.state.coeff_outdoor_heat, self.state.total_cycles, ) # Update status to indicate learning has resumed self.state.last_learning_status = "learning_resumed" # Always enable learning when activating self.state.autolearn_enabled = True # Set start date only if it's a new session (reset) or if it wasn't set (first start) if reset_data or self.state.learning_start_date is None: self.state.learning_start_date = dt_util.now() # ===== BOOTSTRAP PHASE LOGIC ===== # Determine bootstrap strategy (3 modes) manual_capacity = self._heating_rate # From config (CONF_AUTO_TPI_HEATING_POWER) if manual_capacity > 0: # Mode 1: Manual capacity provided - skip bootstrap self.state.max_capacity_heat = manual_capacity self.state.capacity_heat_learn_count = 3 # Mark as learned _LOGGER.info( "%s - Auto TPI: Using manual capacity %.2f °C/h, skipping bootstrap", self._name, manual_capacity ) elif self.state.max_capacity_heat > 0 and not reset_data: # Capacity already learned from previous session - skip bootstrap _LOGGER.info( "%s - Auto TPI: Capacity already known (%.2f °C/h), resuming in TPI mode", self._name, self.state.max_capacity_heat ) else: # Mode 2: No manual capacity - Try pre-bootstrap calibration first calibration_result = await self._try_pre_bootstrap_calibration() if calibration_result: # Pre-calibration succeeded, skip bootstrap self.state.max_capacity_heat = calibration_result self.state.capacity_heat_learn_count = 3 # Mark as learned _LOGGER.info( "%s - Auto TPI: Pre-bootstrap calibration succeeded (capacity=%.2f °C/h), skipping bootstrap", self._name, calibration_result ) else: # Pre-calibration failed or insufficient reliability, proceed with bootstrap # Bootstrap will automatically activate (capacity_heat_learn_count < 3) # High coefficients will be used during first 3 cycles _LOGGER.info( "%s - Auto TPI: Pre-bootstrap calibration failed or insufficient reliability, starting capacity bootstrap (TPI aggressive mode)", self._name ) # Ensure max_capacity fallback for TPI mode (unchanged) if self.state.max_capacity_heat == 0.0: self.state.max_capacity_heat = 1.0 if self.state.max_capacity_cool == 0.0: self.state.max_capacity_cool = 1.0 await self.async_save_data() async def stop_learning(self, save_capacity: bool = True): _LOGGER.info("%s - Auto TPI: Stopping learning", self._name) self.state.autolearn_enabled = False # Do not clear learning_start_date to allow resuming or display in history # self.state.learning_start_date = None self.state.last_learning_status = "learning_stopped" await self.async_save_data() # If we have learned enough, save capacity to config # ONLY if save_capacity is True (avoid double reload if caller handles it) if save_capacity and self.state.capacity_heat_learn_count >= 3: # Check if value has changed before saving to avoid useless reload current_capacity = self._config_entry.data.get(CONF_AUTO_TPI_HEATING_POWER) if current_capacity is None or abs(current_capacity - self.state.max_capacity_heat) > 0.01: if self._hass and self._hass.loop and not self._hass.loop.is_closed(): await self.async_update_learning_data(capacity=self.state.max_capacity_heat, is_heat_mode=True) async def reset_learning_data(self): _LOGGER.info("%s - Auto TPI: Resetting all learning data", self._name) self.state = AutoTpiState() self.state.cycle_active = False await self.async_save_data() async def reset_capacities(self): """Reset max heat/cool capacities to default (1.0).""" _LOGGER.info("%s - Auto TPI: Resetting max heat/cool capacities to default (1.0)", self._name) self.state.max_capacity_heat = 1.0 self.state.max_capacity_cool = 1.0 await self.async_save_data()