8080 ATTR_STATE_BATTERIES ,
8181 ATTR_STATE_CALL_FOR_HEAT ,
8282 ATTR_STATE_ERRORS ,
83+ ATTR_STATE_HEAT_LOSS ,
84+ ATTR_STATE_HEAT_LOSS_STATS ,
8385 ATTR_STATE_HEATING_POWER ,
8486 ATTR_STATE_HUMIDIY ,
8587 ATTR_STATE_LAST_CHANGE ,
105107 CONF_WEATHER ,
106108 CONF_WINDOW_TIMEOUT ,
107109 CONF_WINDOW_TIMEOUT_AFTER ,
110+ MAX_HEAT_LOSS ,
108111 MAX_HEATING_POWER ,
112+ MIN_HEAT_LOSS ,
109113 MIN_HEATING_POWER ,
110114 SERVICE_RESET_HEATING_POWER ,
111115 SERVICE_RESET_PID_LEARNINGS ,
@@ -467,6 +471,15 @@ def __init__(
467471 self .heating_start_timestamp = None
468472 self .heating_end_temp = None
469473 self .heating_end_timestamp = None
474+ # Heat loss tracking (idle cooling rate)
475+ self .loss_start_temp = None
476+ self .loss_start_timestamp = None
477+ self .loss_end_temp = None
478+ self .loss_end_timestamp = None
479+ self .heat_loss_rate = 0.01
480+ self .last_heat_loss_stats = deque (maxlen = 10 )
481+ self .loss_cycles = deque (maxlen = 50 )
482+ self ._loss_last_action = None
470483 self ._async_unsub_state_changed = None
471484 self .all_entities = []
472485 self .devices_states = {}
@@ -509,6 +522,9 @@ def __init__(
509522 # TPI adaptive state persistence
510523 self ._tpi_store = None
511524 self ._tpi_save_scheduled = False
525+ # Thermal stats persistence (heating_power / heat_loss)
526+ self ._thermal_store = None
527+ self ._thermal_save_scheduled = False
512528
513529 self .last_known_external_temp = None
514530 self ._slope_periodic_last_ts = None
@@ -686,6 +702,17 @@ def on_remove():
686702 e ,
687703 )
688704
705+ # Initialize persistent storage for thermal stats (heating_power / heat_loss)
706+ try :
707+ self ._thermal_store = Store (self .hass , 1 , f"{ DOMAIN } _thermal_stats" )
708+ await self ._load_thermal_stats ()
709+ except Exception as e :
710+ _LOGGER .debug (
711+ "better_thermostat %s: thermal stats storage init/load failed: %s" ,
712+ self .device_name ,
713+ e ,
714+ )
715+
689716 @callback
690717 def _async_startup (* _ ):
691718 """Init on startup.
@@ -1360,6 +1387,44 @@ async def startup(self):
13601387 bounded_power ,
13611388 )
13621389 self .heating_power = bounded_power
1390+ elif getattr (self , "_thermal_store" , None ) is not None :
1391+ # Fallback: restore heating_power from persistent thermal stats
1392+ try :
1393+ data = await self ._thermal_store .async_load ()
1394+ key = str (self ._config_entry_id )
1395+ if data and key in data and "heating_power" in data [key ]:
1396+ loaded_power = float (data [key ]["heating_power" ])
1397+ bounded_power = max (
1398+ MIN_HEATING_POWER , min (MAX_HEATING_POWER , loaded_power )
1399+ )
1400+ self .heating_power = bounded_power
1401+ except Exception :
1402+ pass
1403+
1404+ # Restore heat loss if available
1405+ if old_state .attributes .get (ATTR_STATE_HEAT_LOSS , None ) is not None :
1406+ try :
1407+ loaded_loss = float (
1408+ old_state .attributes .get (ATTR_STATE_HEAT_LOSS )
1409+ )
1410+ bounded_loss = max (
1411+ MIN_HEAT_LOSS , min (MAX_HEAT_LOSS , loaded_loss )
1412+ )
1413+ self .heat_loss_rate = bounded_loss
1414+ except (TypeError , ValueError ):
1415+ pass
1416+ elif getattr (self , "_thermal_store" , None ) is not None :
1417+ try :
1418+ data = await self ._thermal_store .async_load ()
1419+ key = str (self ._config_entry_id )
1420+ if data and key in data and "heat_loss" in data [key ]:
1421+ loaded_loss = float (data [key ]["heat_loss" ])
1422+ bounded_loss = max (
1423+ MIN_HEAT_LOSS , min (MAX_HEAT_LOSS , loaded_loss )
1424+ )
1425+ self .heat_loss_rate = bounded_loss
1426+ except Exception :
1427+ pass
13631428 if (
13641429 old_state .attributes .get (ATTR_STATE_PRESET_TEMPERATURE , None )
13651430 is not None
@@ -2299,6 +2364,68 @@ async def _delayed_save():
22992364
23002365 self .hass .async_create_task (_delayed_save ())
23012366
2367+ async def _load_thermal_stats (self ) -> None :
2368+ """Load persisted thermal stats (heating_power / heat_loss)."""
2369+
2370+ if self ._thermal_store is None :
2371+ return
2372+ data = await self ._thermal_store .async_load ()
2373+ if not data :
2374+ return
2375+
2376+ key = str (self ._config_entry_id )
2377+ payload = data .get (key )
2378+ if not isinstance (payload , dict ):
2379+ return
2380+
2381+ if "heating_power" in payload :
2382+ try :
2383+ loaded_power = float (payload ["heating_power" ])
2384+ self .heating_power = max (
2385+ MIN_HEATING_POWER , min (MAX_HEATING_POWER , loaded_power )
2386+ )
2387+ except (TypeError , ValueError ):
2388+ pass
2389+
2390+ if "heat_loss" in payload :
2391+ try :
2392+ loaded_loss = float (payload ["heat_loss" ])
2393+ self .heat_loss_rate = max (
2394+ MIN_HEAT_LOSS , min (MAX_HEAT_LOSS , loaded_loss )
2395+ )
2396+ except (TypeError , ValueError ):
2397+ pass
2398+
2399+ async def _save_thermal_stats (self ) -> None :
2400+ """Persist thermal stats to storage (debounced)."""
2401+
2402+ if self ._thermal_store is None :
2403+ return
2404+ key = str (self ._config_entry_id )
2405+ payload = {
2406+ "heating_power" : getattr (self , "heating_power" , None ),
2407+ "heat_loss" : getattr (self , "heat_loss_rate" , None ),
2408+ }
2409+ existing = await self ._thermal_store .async_load () or {}
2410+ existing [key ] = payload
2411+ await self ._thermal_store .async_save (existing )
2412+
2413+ def _schedule_save_thermal_stats (self , delay_s : float = 15.0 ) -> None :
2414+ """Debounced scheduling for persisting thermal stats."""
2415+
2416+ if self ._thermal_store is None or self ._thermal_save_scheduled :
2417+ return
2418+ self ._thermal_save_scheduled = True
2419+
2420+ async def _delayed_save ():
2421+ try :
2422+ await asyncio .sleep (delay_s )
2423+ await self ._save_thermal_stats ()
2424+ finally :
2425+ self ._thermal_save_scheduled = False
2426+
2427+ self .hass .async_create_task (_delayed_save ())
2428+
23022429 async def calculate_heating_power (self ):
23032430 """Learn effective heating power (°C/min) from completed heating cycles.
23042431
@@ -2551,8 +2678,140 @@ async def calculate_heating_power(self):
25512678 # Store normalized power if available
25522679 if normalized_power is not None :
25532680 self .heating_power_normalized = normalized_power
2681+ if heating_power_changed :
2682+ self ._schedule_save_thermal_stats ()
25542683 self .async_write_ha_state ()
25552684
2685+ async def calculate_heat_loss (self ):
2686+ """Learn effective heat loss (°C/min) during idle cooling periods.
2687+
2688+ Measures temperature decay when HVAC action is IDLE and the window is closed.
2689+ Similar to heating_power, but for passive cooling (loss rate).
2690+ """
2691+
2692+ if self .cur_temp is None :
2693+ return
2694+
2695+ now = dt_util .utcnow ()
2696+ current_action = self ._compute_hvac_action ()
2697+
2698+ # Do not learn when window is open
2699+ if self .window_open :
2700+ self .loss_start_temp = None
2701+ self .loss_start_timestamp = None
2702+ self .loss_end_temp = None
2703+ self .loss_end_timestamp = None
2704+ self ._loss_last_action = current_action
2705+ return
2706+
2707+ # Start tracking when we enter idle (not heating)
2708+ if current_action != HVACAction .HEATING :
2709+ if self .loss_start_temp is None :
2710+ self .loss_start_temp = self .cur_temp
2711+ self .loss_start_timestamp = now
2712+ self .loss_end_temp = self .cur_temp
2713+ self .loss_end_timestamp = now
2714+ elif self .loss_end_temp is None or self .cur_temp < self .loss_end_temp :
2715+ self .loss_end_temp = self .cur_temp
2716+ self .loss_end_timestamp = now
2717+
2718+ # Finalize when heating starts again
2719+ if current_action == HVACAction .HEATING and self .loss_start_temp is not None :
2720+ if self .loss_end_temp is not None and self .loss_start_timestamp is not None :
2721+ temp_drop = self .loss_start_temp - self .loss_end_temp
2722+ duration_min = (
2723+ (
2724+ self .loss_end_timestamp - self .loss_start_timestamp
2725+ ).total_seconds ()
2726+ / 60.0
2727+ if self .loss_end_timestamp and self .loss_start_timestamp
2728+ else 0
2729+ )
2730+
2731+ if duration_min >= 1.0 and temp_drop > 0 :
2732+ # Raw loss rate (°C/min)
2733+ loss_rate = round (temp_drop / duration_min , 5 )
2734+
2735+ # Adaptive smoothing
2736+ base_alpha = 0.10
2737+ alpha = max (0.02 , min (0.25 , base_alpha ))
2738+ old_loss = self .heat_loss_rate
2739+ unbounded = old_loss * (1 - alpha ) + loss_rate * alpha
2740+
2741+ clamped_loss = max (MIN_HEAT_LOSS , min (MAX_HEAT_LOSS , unbounded ))
2742+ if clamped_loss != unbounded :
2743+ bound_name = (
2744+ "MIN_HEAT_LOSS"
2745+ if clamped_loss <= MIN_HEAT_LOSS
2746+ else "MAX_HEAT_LOSS"
2747+ )
2748+ _LOGGER .debug (
2749+ "better_thermostat: heat_loss clamped from %.4f to %.4f at %s "
2750+ "(min=%.4f, max=%.4f)" ,
2751+ unbounded ,
2752+ clamped_loss ,
2753+ bound_name ,
2754+ MIN_HEAT_LOSS ,
2755+ MAX_HEAT_LOSS ,
2756+ )
2757+
2758+ self .heat_loss_rate = round (clamped_loss , 5 )
2759+ loss_changed = self .heat_loss_rate != old_loss
2760+
2761+ self .last_heat_loss_stats .append (
2762+ {
2763+ "dT" : round (temp_drop , 2 ),
2764+ "min" : round (duration_min , 1 ),
2765+ "rate" : loss_rate ,
2766+ "alpha" : round (alpha , 3 ),
2767+ "loss" : self .heat_loss_rate ,
2768+ }
2769+ )
2770+
2771+ try :
2772+ self .loss_cycles .append (
2773+ {
2774+ "start" : (
2775+ self .loss_start_timestamp .isoformat ()
2776+ if self .loss_start_timestamp
2777+ else None
2778+ ),
2779+ "end" : (
2780+ self .loss_end_timestamp .isoformat ()
2781+ if self .loss_end_timestamp
2782+ else None
2783+ ),
2784+ "temp_start" : (
2785+ round (self .loss_start_temp , 2 )
2786+ if self .loss_start_temp is not None
2787+ else None
2788+ ),
2789+ "temp_min" : (
2790+ round (self .loss_end_temp , 2 )
2791+ if self .loss_end_temp is not None
2792+ else None
2793+ ),
2794+ "rate" : loss_rate ,
2795+ }
2796+ )
2797+ except Exception :
2798+ _LOGGER .exception (
2799+ "better_thermostat %s: Error while storing heat loss cycle" ,
2800+ self .device_name ,
2801+ )
2802+
2803+ self .async_write_ha_state ()
2804+ if loss_changed :
2805+ self ._schedule_save_thermal_stats ()
2806+
2807+ # Reset after finalize
2808+ self .loss_start_temp = None
2809+ self .loss_start_timestamp = None
2810+ self .loss_end_temp = None
2811+ self .loss_end_timestamp = None
2812+
2813+ self ._loss_last_action = current_action
2814+
25562815 @property
25572816 def extra_state_attributes (self ) -> dict [str , Any ]:
25582817 """Return the device specific state attributes.
@@ -2574,6 +2833,7 @@ def extra_state_attributes(self) -> dict[str, Any]:
25742833 CONF_TOLERANCE : self .tolerance ,
25752834 CONF_TARGET_TEMP_STEP : self .bt_target_temp_step ,
25762835 ATTR_STATE_HEATING_POWER : self .heating_power ,
2836+ ATTR_STATE_HEAT_LOSS : getattr (self , "heat_loss_rate" , None ),
25772837 ATTR_STATE_ERRORS : json .dumps (self .devices_errors ),
25782838 ATTR_STATE_BATTERIES : json .dumps (self .devices_states ),
25792839 "external_temp_ema" : self .cur_temp_filtered ,
@@ -2615,6 +2875,20 @@ def extra_state_attributes(self) -> dict[str, Any]:
26152875 dev_specific ["heating_cycle_last" ] = json .dumps (last_cycle )
26162876 except Exception :
26172877 _LOGGER .exception ("Error while serializing heating cycle telemetry" )
2878+ if hasattr (self , "loss_cycles" ) and len (self .loss_cycles ) > 0 :
2879+ last_cycle = self .loss_cycles [- 1 ]
2880+ try :
2881+ dev_specific ["heat_loss_cycle_count" ] = len (self .loss_cycles )
2882+ dev_specific ["heat_loss_cycle_last" ] = json .dumps (last_cycle )
2883+ except Exception :
2884+ _LOGGER .exception ("Error while serializing heat loss telemetry" )
2885+ if hasattr (self , "last_heat_loss_stats" ) and self .last_heat_loss_stats :
2886+ try :
2887+ dev_specific [ATTR_STATE_HEAT_LOSS_STATS ] = json .dumps (
2888+ list (self .last_heat_loss_stats )
2889+ )
2890+ except Exception :
2891+ _LOGGER .exception ("Error while serializing heat loss stats" )
26182892 if hasattr (self , "heating_power_normalized" ):
26192893 dev_specific ["heating_power_norm" ] = getattr (
26202894 self , "heating_power_normalized" , None
0 commit comments