Skip to content

Commit c14e51c

Browse files
authored
mpc loss (#1877)
* mpc loss learning in steady state * lint * new sensors * persist stats * 1h EMA sensor * bugfix * bugfix * ema 1h precision * mpc regime change and heat boost
1 parent 579c862 commit c14e51c

5 files changed

Lines changed: 587 additions & 15 deletions

File tree

custom_components/better_thermostat/climate.py

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@
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,
@@ -105,7 +107,9 @@
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

Comments
 (0)