Skip to content

Commit 8ddf0af

Browse files
xchillxxclaude
andcommitted
Load-profile learner: drop Zuhause, skip wallbox, median, heat pump per temperature band (v2.40.0)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent a37dc29 commit 8ddf0af

8 files changed

Lines changed: 214 additions & 16 deletions

File tree

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ cloud passes over or another appliance briefly kicks in.
7676
until a given combination has enough daily samples of its own.
7777
Diagnostic only for now — visible on the "Lastprofil Wochentag/Modus"
7878
sensor's attributes — doesn't yet feed into any switching decision.
79+
Since v2.40.0: the daytime "Zuhause" mode is no longer learned (spike-
80+
dominated, no pattern); samples are skipped while the wallbox charges
81+
and for 10 minutes afterwards; per-hour and cross-day values use the
82+
median. Optionally add a heat-pump power sensor and an outdoor
83+
temperature sensor: the heat pump is then subtracted from the profile
84+
and learned separately per temperature band (mean, since it cycles).
7985
- **Spike-resistant** — the battery-margin projection uses a 20-minute
8086
rolling median of the discharge rate, so a stove or kettle running for a
8187
few minutes doesn't get projected forward as if it continued all night.

‎custom_components/surplus_load_switch/config_flow.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
CONF_DEVICE_WINDOW_START,
3232
CONF_EXPORT_POWER_SENSOR,
3333
CONF_HAUSMODUS_ENTITY,
34+
CONF_HEATPUMP_POWER_SENSOR,
35+
CONF_OUTDOOR_TEMP_SENSOR,
3436
CONF_LOAD_SENSOR,
3537
CONF_MIN_SOC,
3638
CONF_SOC_SENSOR,
@@ -80,6 +82,16 @@ def _global_settings_schema(defaults: dict | None = None) -> vol.Schema:
8082
): selector.EntitySelector(
8183
selector.EntitySelectorConfig(domain=["input_select", "select"])
8284
),
85+
vol.Optional(
86+
CONF_HEATPUMP_POWER_SENSOR, **_default(d, CONF_HEATPUMP_POWER_SENSOR)
87+
): selector.EntitySelector(
88+
selector.EntitySelectorConfig(domain="sensor", device_class="power")
89+
),
90+
vol.Optional(
91+
CONF_OUTDOOR_TEMP_SENSOR, **_default(d, CONF_OUTDOOR_TEMP_SENSOR)
92+
): selector.EntitySelector(
93+
selector.EntitySelectorConfig(domain="sensor", device_class="temperature")
94+
),
8395
vol.Optional(
8496
CONF_SOLAR_FORECAST_REMAINING_ENTITY, **_default(d, CONF_SOLAR_FORECAST_REMAINING_ENTITY)
8597
): selector.EntitySelector(

‎custom_components/surplus_load_switch/const.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ def _minutes_to_cycles(minutes: float) -> int:
3333
# weekday/hour/mode load-profile learner (see load_profile.py). Left
3434
# unset, the learner and its diagnostic sensor simply don't run.
3535
CONF_HAUSMODUS_ENTITY = "hausmodus_entity"
36+
# Optional, load-profile learner only: the heat pump's electrical power
37+
# (subtracted from the learned house base and recorded separately per
38+
# outdoor-temperature band) and an outdoor temperature sensor.
39+
CONF_HEATPUMP_POWER_SENSOR = "heatpump_power_sensor"
40+
CONF_OUTDOOR_TEMP_SENSOR = "outdoor_temp_sensor"
3641
# Optional: a sensor reporting the forecast kWh still expected to arrive
3742
# today (e.g. Forecast.Solar's "Geschätzte Energieerzeugung – Resttag
3843
# heute" / sensor.energy_production_today_remaining) — lets
@@ -401,6 +406,15 @@ def _minutes_to_cycles(minutes: float) -> int:
401406
# load_profile.py's fallback hierarchy) — an average built from a single
402407
# day is just that one day, not a real pattern yet.
403408
LOAD_PROFILE_MIN_SAMPLES = 2
409+
# Modes whose load is not learned at all (and whose already-stored data is
410+
# dropped on load): daytime "Zuhause" is dominated by unpredictable spikes.
411+
LOAD_PROFILE_EXCLUDED_MODES = ("Zuhause",)
412+
# Wallbox draw above this (kW) keeps the sample out of the profile, and for
413+
# this many minutes afterwards (cross-source cloud polling lag).
414+
LOAD_PROFILE_WALLBOX_MIN_KW = 0.05
415+
LOAD_PROFILE_WALLBOX_COOLDOWN_MIN = 10
416+
# Outdoor-temperature band edges (°C) for the heat-pump profile.
417+
LOAD_PROFILE_TEMP_BANDS = (0.0, 5.0, 10.0, 15.0)
404418

405419
# How long to keep using the pre-transition managed-power figure for
406420
# base_load AND battery-discharge attribution after a managed device's

‎custom_components/surplus_load_switch/coordinator.py‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@
5353
CONF_DEVICE_WINDOW_START,
5454
CONF_EXPORT_POWER_SENSOR,
5555
CONF_HAUSMODUS_ENTITY,
56+
CONF_HEATPUMP_POWER_SENSOR,
57+
CONF_OUTDOOR_TEMP_SENSOR,
5658
CONF_LOAD_SENSOR,
5759
CONF_MIN_SOC,
5860
CONF_SOC_SENSOR,
@@ -2740,7 +2742,26 @@ async def _evaluate_devices(self, data: CoordinatorData) -> None:
27402742
if hausmodus_state is not None and hausmodus_state.state not in ("unavailable", "unknown")
27412743
else None
27422744
)
2743-
self._load_profile_learner.record(base_load, hausmodus)
2745+
# base_load_excl_wallbox, not base_load: the latter adds the
2746+
# wallbox's draw back in while it is "starved", which would put
2747+
# charging sessions into the profile.
2748+
heatpump_entity = self._config.get(CONF_HEATPUMP_POWER_SENSOR)
2749+
heatpump_state = self.hass.states.get(heatpump_entity) if heatpump_entity else None
2750+
heatpump_kw = (
2751+
self._get_power_kw(heatpump_entity)
2752+
if heatpump_state is not None
2753+
and heatpump_state.state not in ("unavailable", "unknown", "")
2754+
else None
2755+
)
2756+
temp_entity = self._config.get(CONF_OUTDOOR_TEMP_SENSOR)
2757+
temp_state = self.hass.states.get(temp_entity) if temp_entity else None
2758+
try:
2759+
outdoor_temp_c = float(temp_state.state) if temp_state is not None else None
2760+
except ValueError:
2761+
outdoor_temp_c = None
2762+
self._load_profile_learner.record(
2763+
base_load_excl_wallbox, hausmodus, wallbox_power_kw, heatpump_kw, outdoor_temp_c
2764+
)
27442765
data.load_profile = self._load_profile_learner.diagnostics
27452766

27462767
# Computed once per cycle, not per dependent device — several

‎custom_components/surplus_load_switch/load_profile.py‎

Lines changed: 151 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,25 @@
11
"""Learns typical Grundlast (base_load) by weekday, hour of day, and house
2-
mode (e.g. Zuhause/Abwesend/Schlafen/Urlaub) — diagnostic only for now,
3-
does not feed any switching decision.
2+
mode (e.g. Abwesend/Schlafen/Urlaub) — diagnostic only for now, does not
3+
feed any switching decision.
4+
5+
What gets learned, and what deliberately doesn't:
6+
- Modes listed in LOAD_PROFILE_EXCLUDED_MODES (the daytime "Zuhause"
7+
mode) are neither recorded nor kept: their load is dominated by
8+
unpredictable spikes (wallbox, oven, ...) and carries no pattern.
9+
- Wallbox charging is kept out entirely: samples are skipped while the
10+
wallbox draws power and for LOAD_PROFILE_WALLBOX_COOLDOWN_MIN afterwards
11+
(the wallbox reading and the house-load reading come from different
12+
cloud-polled integrations and don't refresh in lockstep, so the
13+
subtraction alone is noisy right at the ramp edges).
14+
- The heat pump is learned separately. If a heat-pump power sensor is
15+
configured, its draw is subtracted from the profile above (which then
16+
stays a stable, weather-independent house base) and instead recorded per
17+
outdoor-temperature band and hour. Without that sensor the heat pump's
18+
draw stays inside the profile, unseparated.
19+
- The house base uses the median per hour and across days (robust against
20+
one-off spikes); the heat pump uses the mean, because it cycles on and
21+
off within an hour and a median would report ~0 for a unit that really
22+
averages a few hundred watts.
423
524
Self-sampling from the coordinator's own cycles, not the recorder's
625
history: this install's Grundlast entity_id isn't known generically (see
@@ -26,15 +45,20 @@
2645
from __future__ import annotations
2746

2847
import logging
29-
from datetime import date
48+
import statistics
49+
from datetime import date, datetime, timedelta
3050

3151
from homeassistant.core import HomeAssistant
3252
from homeassistant.helpers.storage import Store
3353
from homeassistant.util import dt as dt_util
3454

3555
from .const import (
56+
LOAD_PROFILE_EXCLUDED_MODES,
3657
LOAD_PROFILE_MIN_SAMPLES,
58+
LOAD_PROFILE_TEMP_BANDS,
3759
LOAD_PROFILE_TRAILING_SAMPLES,
60+
LOAD_PROFILE_WALLBOX_COOLDOWN_MIN,
61+
LOAD_PROFILE_WALLBOX_MIN_KW,
3862
STORAGE_VERSION,
3963
)
4064

@@ -47,6 +71,20 @@ def _bucket_key(weekday: str, hour: int, mode: str) -> str:
4771
return f"{weekday}_{hour}_{mode}"
4872

4973

74+
def _temp_band(temp_c: float) -> str:
75+
"""Label of the LOAD_PROFILE_TEMP_BANDS interval temp_c falls into."""
76+
lower = None
77+
for edge in LOAD_PROFILE_TEMP_BANDS:
78+
if temp_c < edge:
79+
return f"<{edge:g}" if lower is None else f"{lower:g}..{edge:g}"
80+
lower = edge
81+
return f">={lower:g}"
82+
83+
84+
def _wp_key(band: str, hour: int) -> str:
85+
return f"{band}_{hour}"
86+
87+
5088
class WeekdayLoadProfileLearner:
5189
"""Tracks, per (weekday, hour, house mode), a rolling window of daily
5290
average base_load readings."""
@@ -65,6 +103,16 @@ def __init__(self, hass: HomeAssistant, entry_id: str) -> None:
65103
# or a new day starts).
66104
self._current_key: tuple[str, int, str, date] | None = None
67105
self._current_samples: list[float] = []
106+
# Heat-pump draw per (outdoor-temperature band, hour), same
107+
# rolling-window shape as _buckets. Its own in-progress
108+
# accumulator, since the band can change independently of the
109+
# weekday/mode key above.
110+
self._wp_buckets: dict[str, list[float]] = {}
111+
self._wp_current_key: tuple[str, int, date] | None = None
112+
self._wp_current_samples: list[float] = []
113+
# No sample is taken before this moment (wallbox cooldown).
114+
self._skip_until: datetime | None = None
115+
self._wp_separated = False
68116
self._dirty = False
69117

70118
async def async_load(self) -> None:
@@ -74,41 +122,107 @@ async def async_load(self) -> None:
74122
self._buckets = {
75123
k: list(v) for k, v in data.get("buckets", {}).items()
76124
}
125+
self._wp_buckets = {
126+
k: list(v) for k, v in data.get("wp_buckets", {}).items()
127+
}
128+
# Drop whatever an earlier version already learned for modes that
129+
# are no longer tracked.
130+
stale = [
131+
k for k in self._buckets
132+
if k.split("_", 2)[-1] in LOAD_PROFILE_EXCLUDED_MODES
133+
]
134+
for k in stale:
135+
del self._buckets[k]
136+
if stale:
137+
self._dirty = True
138+
self._store.async_delay_save(self._payload, 60)
77139

78140
async def async_save_now(self) -> None:
79141
"""Force an immediate write — see coordinator.PVSurplusCoordinator.
80142
async_flush_stores for why this matters on unload/reload."""
81143
self._finalize_current(force=True)
144+
self._wp_finalize_current(force=True)
82145
if self._dirty:
83-
await self._store.async_save({"buckets": self._buckets})
146+
await self._store.async_save(self._payload())
84147
self._dirty = False
85148

86-
def record(self, base_load_kw: float, mode: str | None) -> None:
87-
"""Call once per coordinator cycle with the current base_load and
88-
house mode (None if the configured entity is unavailable — that
89-
cycle is simply not sampled, nothing is finalized or lost)."""
149+
def _payload(self) -> dict:
150+
return {"buckets": self._buckets, "wp_buckets": self._wp_buckets}
151+
152+
def record(
153+
self,
154+
base_load_kw: float,
155+
mode: str | None,
156+
wallbox_kw: float = 0.0,
157+
heatpump_kw: float | None = None,
158+
outdoor_temp_c: float | None = None,
159+
) -> None:
160+
"""Call once per coordinator cycle. base_load_kw must already be
161+
free of the wallbox and every managed device (the coordinator's
162+
base_load_excl_wallbox). heatpump_kw / outdoor_temp_c are None
163+
when the corresponding optional sensor isn't configured or
164+
unavailable. mode is None if the house-mode entity is unavailable
165+
— that cycle is simply not sampled, nothing is finalized or lost."""
90166
if mode is None:
91167
return
92168
now = dt_util.now()
169+
170+
# Wallbox: never sample while it charges or shortly afterwards.
171+
if wallbox_kw > LOAD_PROFILE_WALLBOX_MIN_KW:
172+
self._skip_until = now + timedelta(minutes=LOAD_PROFILE_WALLBOX_COOLDOWN_MIN)
173+
if self._skip_until is not None and now < self._skip_until:
174+
return
175+
176+
if heatpump_kw is not None:
177+
self._wp_separated = True
178+
if outdoor_temp_c is not None:
179+
self._record_heatpump(now, heatpump_kw, outdoor_temp_c)
180+
base_load_kw = max(base_load_kw - heatpump_kw, 0.0)
181+
else:
182+
self._wp_separated = False
183+
184+
if mode in LOAD_PROFILE_EXCLUDED_MODES:
185+
return
93186
key = (WEEKDAYS[now.weekday()], now.hour, mode, now.date())
94187
if key != self._current_key:
95188
self._finalize_current()
96189
self._current_key = key
97190
self._current_samples = []
98191
self._current_samples.append(base_load_kw)
99192

193+
def _record_heatpump(self, now: datetime, heatpump_kw: float, temp_c: float) -> None:
194+
key = (_temp_band(temp_c), now.hour, now.date())
195+
if key != self._wp_current_key:
196+
self._wp_finalize_current()
197+
self._wp_current_key = key
198+
self._wp_current_samples = []
199+
self._wp_current_samples.append(heatpump_kw)
200+
201+
def _wp_finalize_current(self, force: bool = False) -> None:
202+
if self._wp_current_key is None or not self._wp_current_samples:
203+
return
204+
band, hour, _day = self._wp_current_key
205+
# Mean, not median: the unit cycles on/off within the hour.
206+
avg = sum(self._wp_current_samples) / len(self._wp_current_samples)
207+
history = self._wp_buckets.setdefault(_wp_key(band, hour), [])
208+
history.append(round(avg, 3))
209+
del history[:-LOAD_PROFILE_TRAILING_SAMPLES]
210+
self._dirty = True
211+
if not force:
212+
self._store.async_delay_save(self._payload, 60)
213+
100214
def _finalize_current(self, force: bool = False) -> None:
101215
if self._current_key is None or not self._current_samples:
102216
return
103217
weekday, hour, mode, _day = self._current_key
104-
avg = sum(self._current_samples) / len(self._current_samples)
218+
typical = statistics.median(self._current_samples)
105219
bucket_key = _bucket_key(weekday, hour, mode)
106220
history = self._buckets.setdefault(bucket_key, [])
107-
history.append(round(avg, 3))
221+
history.append(round(typical, 3))
108222
del history[:-LOAD_PROFILE_TRAILING_SAMPLES]
109223
self._dirty = True
110224
if not force:
111-
self._store.async_delay_save(lambda: {"buckets": self._buckets}, 60)
225+
self._store.async_delay_save(self._payload, 60)
112226

113227
def effective_average(self, weekday: str, hour: int, mode: str) -> tuple[float | None, str]:
114228
"""The learned average for (weekday, hour, mode), falling back to
@@ -118,7 +232,7 @@ def effective_average(self, weekday: str, hour: int, mode: str) -> tuple[float |
118232
hour, any weekday/mode), or "keine daten" (value is None)."""
119233
exact = self._buckets.get(_bucket_key(weekday, hour, mode), [])
120234
if len(exact) >= LOAD_PROFILE_MIN_SAMPLES:
121-
return sum(exact) / len(exact), "genau"
235+
return statistics.median(exact), "genau"
122236

123237
by_hour_mode: list[float] = []
124238
by_hour: list[float] = []
@@ -127,7 +241,7 @@ def effective_average(self, weekday: str, hour: int, mode: str) -> tuple[float |
127241
if same_hour_mode:
128242
by_hour_mode.extend(same_hour_mode)
129243
if len(by_hour_mode) >= LOAD_PROFILE_MIN_SAMPLES:
130-
return sum(by_hour_mode) / len(by_hour_mode), "stunde+modus"
244+
return statistics.median(by_hour_mode), "stunde+modus"
131245

132246
for key, values in self._buckets.items():
133247
# bucket_key format is "<weekday>_<hour>_<mode>" — the mode
@@ -137,7 +251,7 @@ def effective_average(self, weekday: str, hour: int, mode: str) -> tuple[float |
137251
if len(parts) == 3 and parts[1] == str(hour):
138252
by_hour.extend(values)
139253
if len(by_hour) >= LOAD_PROFILE_MIN_SAMPLES:
140-
return sum(by_hour) / len(by_hour), "stunde"
254+
return statistics.median(by_hour), "stunde"
141255

142256
return None, "keine daten"
143257

@@ -148,4 +262,27 @@ def diagnostics(self) -> dict:
148262
"erfasste_kombinationen": len(self._buckets),
149263
"gesamte_tages_messwerte": total_days,
150264
"profil": dict(self._buckets),
265+
"nicht_erfasste_modi": list(LOAD_PROFILE_EXCLUDED_MODES),
266+
"waermepumpe_herausgerechnet": self._wp_separated,
267+
"waermepumpe_kw_je_temperaturband": self._wp_band_averages(),
268+
"waermepumpe_profil": dict(self._wp_buckets),
269+
}
270+
271+
def _wp_band_averages(self) -> dict[str, dict[str, float]]:
272+
"""Per temperature band: the mean heat-pump draw over all learned
273+
hours ("alle_stunden") and over the night hours 0-6 ("nacht") —
274+
the figure that matters for the overnight battery estimate."""
275+
per_band: dict[str, dict[str, list[float]]] = {}
276+
for key, values in self._wp_buckets.items():
277+
band, _, hour = key.rpartition("_")
278+
slot = per_band.setdefault(band, {"alle_stunden": [], "nacht": []})
279+
slot["alle_stunden"].extend(values)
280+
if hour.isdigit() and int(hour) <= 6:
281+
slot["nacht"].extend(values)
282+
return {
283+
band: {
284+
name: round(sum(v) / len(v), 3)
285+
for name, v in slots.items() if v
286+
}
287+
for band, slots in per_band.items()
151288
}

‎custom_components/surplus_load_switch/manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@
99
"iot_class": "local_polling",
1010
"issue_tracker": "https://github.com/xchillxx/surplus-load-switch/issues",
1111
"requirements": [],
12-
"version": "2.39.0"
12+
"version": "2.40.0"
1313
}

‎custom_components/surplus_load_switch/translations/de.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
"battery_capacity_kwh": "Akku-Kapazität (kWh)",
1313
"min_soc": "Mindest-SOC (%)",
1414
"hausmodus_entity": "Hausmodus-Helfer (optional, z.B. ein input_select) — aktiviert den Wochentag/Stunde/Modus-Lastprofil-Lerner (nur Diagnose, beeinflusst noch keine Schaltentscheidung)",
15+
"heatpump_power_sensor": "Wärmepumpen-Leistungssensor (optional, W/kW) — nur Lastprofil-Lerner (Diagnose): die WP-Last wird aus der gelernten Haus-Grundlast herausgerechnet und separat je Außentemperatur erfasst",
16+
"outdoor_temp_sensor": "Außentemperatur-Sensor (optional, °C) — nur Lastprofil-Lerner: Temperaturbänder für das Wärmepumpen-Profil",
1517
"solar_forecast_remaining_entity": "PV-Prognose Resttag heute (optional, kWh, z.B. Forecast.Solar) — lässt die Wallbox-Reservierung dem tatsächlichen Tagesverlauf der Sonne folgen statt einem flachen Zeitdurchschnitt",
1618
"export_power_sensor": "Einspeise-Leistungssensor (optional, W/kW, z.B. Tibber Pulse) — der Akku-Pfad (Gerät läuft nur weil der Hausakku es sich leisten könnte) bleibt dann nur offen, solange wirklich ins Netz eingespeist wird"
1719
}
@@ -154,6 +156,8 @@
154156
"battery_capacity_kwh": "Akku-Kapazität (kWh)",
155157
"min_soc": "Mindest-SOC (%)",
156158
"hausmodus_entity": "Hausmodus-Helfer (optional, z.B. ein input_select) — aktiviert den Wochentag/Stunde/Modus-Lastprofil-Lerner (nur Diagnose, beeinflusst noch keine Schaltentscheidung)",
159+
"heatpump_power_sensor": "Wärmepumpen-Leistungssensor (optional, W/kW) — nur Lastprofil-Lerner (Diagnose): die WP-Last wird aus der gelernten Haus-Grundlast herausgerechnet und separat je Außentemperatur erfasst",
160+
"outdoor_temp_sensor": "Außentemperatur-Sensor (optional, °C) — nur Lastprofil-Lerner: Temperaturbänder für das Wärmepumpen-Profil",
157161
"solar_forecast_remaining_entity": "PV-Prognose Resttag heute (optional, kWh, z.B. Forecast.Solar) — lässt die Wallbox-Reservierung dem tatsächlichen Tagesverlauf der Sonne folgen statt einem flachen Zeitdurchschnitt",
158162
"export_power_sensor": "Einspeise-Leistungssensor (optional, W/kW, z.B. Tibber Pulse) — der Akku-Pfad (Gerät läuft nur weil der Hausakku es sich leisten könnte) bleibt dann nur offen, solange wirklich ins Netz eingespeist wird"
159163
}

0 commit comments

Comments
 (0)