Skip to content

Commit 997fff3

Browse files
xchillxxclaude
andcommitted
Stop the low-priority cascade flapping on broken-cloud days (v2.37.0)
Confirmed live 2026-09-10, a heavy broken-cloud day with an EV home far below target: the miner, pool pump and boiler switched fully on and off every 10-20 minutes all afternoon. Two amplifiers, fixed here: 1. Fictional modelled surplus on turn-on. "Überschuss" briefly read up to +7 kW while the Tibber meter showed ~0.5 kW real feed-in — the wallbox reservation was lagging a gross-PV upswing and the car was ramping up to take the rest. The whole low-priority cascade switched on chasing surplus that wasn't leaving the house, then shed a few cycles later. Fix: a device may only be *switched on* via the modelled-surplus path (`remaining_surplus > need`) while the export meter also shows a real feed-in covering its own draw, minus what devices already turned on this cycle have claimed from the same reading (cumulative_export_ committed). Only while an export sensor is configured and the sun is up; an already-on device is never shed by this (its draw is already in the meter), it only blocks a fresh turn-on. New decision-log reason "Wartet — keine echte Einspeisung". Const EXPORT_CORROBORATION_MARGIN_KW. 2. The reservation's gross-PV gate releasing on brief cloud gaps. When gross PV dipped under WALLBOX_MIN_PV_FOR_RESERVATION_KW (1.7 kW) the whole ~deadline-pace wallbox reservation dropped to 0 the same cycle, handing it to the cascade, then snapped back to full when the sun returned — "Grundlast" toggling 0.3 <-> 10 kW. Fix: once armed, the gate only releases after gross PV has stayed below the floor for WALLBOX_PV_GATE_RELEASE_CYCLES (15 min) in a row. Arming stays instant. New per-wallbox counter _wallbox_pv_gate_below_cycles. Both are no-ops without an export sensor / a wallbox configured. The underlying base_load desync spike (a fresh wallbox-power reading subtracted from a stale house-load reading briefly inflating the honest base load) is a separate, known issue left for a later pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 46664a9 commit 997fff3

3 files changed

Lines changed: 107 additions & 9 deletions

File tree

‎custom_components/surplus_load_switch/const.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,18 @@ def _minutes_to_cycles(minutes: float) -> int:
203203
# the full reservation arms at 2.0 kW gross PV and drops again below
204204
# 1.7 kW.
205205
WALLBOX_MIN_PV_HYSTERESIS_KW = 0.3
206+
# v2.37: once the reservation's gross-PV gate has armed, keep it armed
207+
# until gross PV has stayed below the floor for this many *consecutive*
208+
# cycles — not the instant it dips once. A car that's home and below
209+
# target still needs its full day's charge; a passing cloud that drops
210+
# gross PV under 1.7 kW for a few minutes shouldn't release the entire
211+
# ~deadline-pace reservation to the device cascade and then yank it back
212+
# when the sun returns. Confirmed live 2026-09-10 (broken-cloud day):
213+
# "Grundlast" toggling 0.3 <-> 10 kW every ~10-15 min, miner + pool pump
214+
# + boiler switching fully on and off with each swing. Arming stays
215+
# instant (PV genuinely there -> reserve at once); only the release is
216+
# held.
217+
WALLBOX_PV_GATE_RELEASE_CYCLES = _minutes_to_cycles(15)
206218
# How far a wallbox's real measured draw may exceed what was actually
207219
# reserved for it before that alone counts as "starved" (see
208220
# wallbox_starved in coordinator.py), independent of whether the
@@ -504,6 +516,21 @@ def _minutes_to_cycles(minutes: float) -> int:
504516
EXPORT_GATE_MEDIAN_WINDOW = 3
505517
EXPORT_GATE_SOC_OVERRIDE = 99.0
506518

519+
# v2.37: a device may only be *switched on* via the modelled-surplus path
520+
# (`remaining_surplus > need`) while the meter also shows the household is
521+
# really exporting at least that device's own draw (minus what devices
522+
# already granted this cycle have claimed from the same reading). The
523+
# modelled surplus can briefly read strongly positive when the wallbox
524+
# reservation lags a gross-PV upswing — confirmed live 2026-09-10, a
525+
# broken-cloud day: "Überschuss" spiking to +7 kW while the Tibber meter
526+
# showed ~0.5 kW real feed-in (the car was ramping up and taking the
527+
# rest), the whole low-priority cascade switching fully on, then off
528+
# again a few minutes later. Only enforced while battery_full_projection_
529+
# applies (daytime) and an export sensor is configured; an already-on
530+
# device is never shed by this (its own draw is already in the meter
531+
# reading) — it only blocks a fresh turn-on.
532+
EXPORT_CORROBORATION_MARGIN_KW = 0.0
533+
507534
# Default monthly solar offsets (hours after sunrise until PV is useful)
508535
DEFAULT_SOLAR_OFFSETS = [3.5, 3.0, 2.5, 2.0, 2.0, 2.2, 2.2, 2.0, 2.5, 3.0, 3.5, 4.0]
509536

‎custom_components/surplus_load_switch/coordinator.py‎

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
DEFAULT_SOLAR_OFFSETS,
7373
DISCHARGE_SMOOTHING_SAMPLES,
7474
DOMAIN,
75+
EXPORT_CORROBORATION_MARGIN_KW,
7576
EXPORT_GATE_MEDIAN_WINDOW,
7677
EXPORT_GATE_MIN_KW,
7778
EXPORT_GATE_RELEASE_KW,
@@ -99,6 +100,7 @@
99100
WALLBOX_MIN_PV_FOR_RESERVATION_KW,
100101
WALLBOX_MIN_PV_HYSTERESIS_KW,
101102
WALLBOX_OVERDRAW_MARGIN_KW,
103+
WALLBOX_PV_GATE_RELEASE_CYCLES,
102104
WALLBOX_RELIEF_CYCLES,
103105
WALLBOX_STARVED_RESERVED_RATIO,
104106
WALLBOX_TARGET_MIN_HOURS,
@@ -491,6 +493,12 @@ def __init__(self, hass: HomeAssistant, config: dict[str, Any], entry_id: str) -
491493
# every cycle. Starts closed (below floor) — a genuine morning
492494
# ramp opens it within a cycle or two.
493495
self._wallbox_pv_gate_open: dict[str, bool] = {}
496+
# Per-wallbox count of consecutive cycles gross PV has been below
497+
# the release floor while the gate is armed — the gate only
498+
# actually releases once this reaches WALLBOX_PV_GATE_RELEASE_CYCLES,
499+
# so a brief cloud gap doesn't drop the whole reservation. Reset to
500+
# 0 on any cycle PV is back above the floor. See _wallbox_reserved_kw.
501+
self._wallbox_pv_gate_below_cycles: dict[str, int] = {}
494502
# Cached price-optimized-forcing slot selection per device (see
495503
# _price_optimized_force_active/price_source.py) — avoids
496504
# calling tibber.get_prices every 60-second cycle. Keyed by
@@ -1275,15 +1283,31 @@ def _wallbox_reserved_kw(
12751283
# Below WALLBOX_MIN_PV_FOR_RESERVATION_KW of gross production the
12761284
# car's charger can't sustain a charge no matter how the house-load
12771285
# math works out, so reserving surplus for it only keeps managed
1278-
# devices off for a car that isn't charging. Hysteresis via a latch
1279-
# (needs +WALLBOX_MIN_PV_HYSTERESIS_KW to re-arm) so a PV reading
1280-
# sitting on the threshold doesn't flip the reservation, and every
1281-
# lower-priority device's on/off state with it, every cycle.
1286+
# devices off for a car that isn't charging. Magnitude hysteresis
1287+
# via a latch (needs +WALLBOX_MIN_PV_HYSTERESIS_KW to arm) so a PV
1288+
# reading sitting on the threshold doesn't flip the reservation.
1289+
# On top of that, once armed the gate only *releases* after gross
1290+
# PV has stayed below the floor for WALLBOX_PV_GATE_RELEASE_CYCLES
1291+
# in a row: a car that's home and below target still needs its
1292+
# whole day's charge, and letting a passing cloud drop the entire
1293+
# ~deadline-pace reservation to 0 — then snap it back when the sun
1294+
# returns — flaps every lower-priority device on and off with it
1295+
# (confirmed live 2026-09-10, broken-cloud day). Arming stays
1296+
# instant.
12821297
gate_open = self._wallbox_pv_gate_open.get(wallbox_id, False)
1283-
threshold = WALLBOX_MIN_PV_FOR_RESERVATION_KW + (
1284-
0.0 if gate_open else WALLBOX_MIN_PV_HYSTERESIS_KW
1285-
)
1286-
gate_open = solar_kw >= threshold
1298+
if not gate_open:
1299+
gate_open = solar_kw >= (
1300+
WALLBOX_MIN_PV_FOR_RESERVATION_KW + WALLBOX_MIN_PV_HYSTERESIS_KW
1301+
)
1302+
if gate_open:
1303+
self._wallbox_pv_gate_below_cycles[wallbox_id] = 0
1304+
elif solar_kw < WALLBOX_MIN_PV_FOR_RESERVATION_KW:
1305+
below = self._wallbox_pv_gate_below_cycles.get(wallbox_id, 0) + 1
1306+
self._wallbox_pv_gate_below_cycles[wallbox_id] = below
1307+
if below >= WALLBOX_PV_GATE_RELEASE_CYCLES:
1308+
gate_open = False
1309+
else:
1310+
self._wallbox_pv_gate_below_cycles[wallbox_id] = 0
12871311
self._wallbox_pv_gate_open[wallbox_id] = gate_open
12881312
if not gate_open:
12891313
self._wallbox_target_rate_kw[wallbox_id] = 0.0
@@ -2813,6 +2837,13 @@ async def _evaluate_devices(self, data: CoordinatorData) -> None:
28132837
# already covered by the reactive battery_eligible_ids gate, and
28142838
# force_runtime is exempt everywhere).
28152839
cumulative_battery_competing_draw = 0.0
2840+
# v2.37: predicted draw of every device turned on via the modelled-
2841+
# surplus path *this cycle that wasn't already on* — subtracted
2842+
# from the export-meter reading before the next device's turn-on
2843+
# is corroborated against it, so a single positive meter reading
2844+
# isn't spent by several devices at once. Already-on devices are
2845+
# left out: their draw is already reflected in the meter.
2846+
cumulative_export_committed = 0.0
28162847
now_dt = dt_util.utcnow()
28172848
horizon_end = now_dt + timedelta(hours=data.effective_h_to_solar + BATT_OK_BUFFER_H)
28182849

@@ -3335,11 +3366,33 @@ async def _evaluate_devices(self, data: CoordinatorData) -> None:
33353366
would_delay_battery_full = not self._battery_would_still_reach_full(
33363367
data, cumulative_battery_competing_draw + predicted_power
33373368
)
3369+
# v2.37: while an export sensor is configured and the sun is up,
3370+
# a device may only be *switched on* via the modelled-surplus
3371+
# path if the meter also shows a real feed-in that covers its
3372+
# own draw (minus what devices turned on earlier this cycle
3373+
# have already claimed from the same reading). The modelled
3374+
# `remaining_surplus` can read strongly positive for a few
3375+
# cycles when the wallbox reservation lags a gross-PV upswing,
3376+
# while almost nothing is actually leaving the house — see
3377+
# EXPORT_CORROBORATION_MARGIN_KW. Never sheds an already-on
3378+
# device (its draw is already in the meter reading); only
3379+
# blocks a fresh turn-on. At night / no export sensor this is
3380+
# always False.
3381+
export_uncorroborated = (
3382+
data.export_sensor_configured
3383+
and data.battery_full_projection_applies
3384+
and not is_on
3385+
and (
3386+
data.export_smoothed_kw - cumulative_export_committed
3387+
< predicted_power + EXPORT_CORROBORATION_MARGIN_KW
3388+
)
3389+
)
33383390
should_on = (
33393391
force_runtime
33403392
or (
33413393
remaining_surplus > predicted_power + SURPLUS_ON_THRESHOLD
33423394
and not would_delay_battery_full
3395+
and not export_uncorroborated
33433396
)
33443397
or battery_would_last
33453398
)
@@ -3424,6 +3477,17 @@ async def _evaluate_devices(self, data: CoordinatorData) -> None:
34243477
f"{predicted_power:.2f} kW benötigt, Akku würde nicht bis Solar-Start reichen)"
34253478
)
34263479
await self._log_decision(dev, False, decision_titel, decision_reason)
3480+
elif export_uncorroborated and remaining_surplus > predicted_power + SURPLUS_ON_THRESHOLD:
3481+
# Modelled surplus would switch this on, but the meter
3482+
# doesn't back it up yet — held, not switched, so the log
3483+
# doesn't read as a plain hysteresis hold.
3484+
await self._log_decision(
3485+
dev, False, "Wartet — keine echte Einspeisung",
3486+
f"rechnerischer Überschuss reichte ({remaining_surplus:.2f} kW), aber es "
3487+
f"wird nur {max(data.export_smoothed_kw - cumulative_export_committed, 0.0):.2f} "
3488+
f"kW wirklich eingespeist ({predicted_power:.2f} kW nötig) — noch nicht "
3489+
f"eingeschaltet",
3490+
)
34273491
else:
34283492
stable_titel = "Bleibt an — Hysterese" if is_on else "Bleibt aus — Hysterese"
34293493
decision_reason = (
@@ -3449,6 +3513,13 @@ async def _evaluate_devices(self, data: CoordinatorData) -> None:
34493513
# what's already been committed ahead of it, not just its
34503514
# own isolated draw in a vacuum.
34513515
cumulative_battery_competing_draw += predicted_power
3516+
# A device turned on this cycle that wasn't already on will
3517+
# start drawing from real export the moment it switches —
3518+
# so the next device's export-corroboration check must see
3519+
# that reduced headroom, not the raw meter reading (which
3520+
# still reflects a moment before this one came on).
3521+
if not is_on:
3522+
cumulative_export_committed += predicted_power
34523523

34533524
if should_on and not is_on:
34543525
# Capped rather than incremented without bound: once

‎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.36.0"
12+
"version": "2.37.0"
1313
}

0 commit comments

Comments
 (0)