Skip to content

Commit a37dc29

Browse files
xchillxxclaude
andcommitted
Replace real-export corroboration with a wallbox-aware battery projection (v2.39.0)
v2.37 required the grid meter to show a real feed-in before a device could switch on via the modelled-surplus path, to catch cases where remaining_ surplus read positive only because the wallbox reservation was modelled too low relative to a gross-PV upswing. In practice it also blocked genuinely safe starts: confirmed live 2026-09-17, the pool pump's window opened at 09:00 and modelled surplus cleared its need by ~09:20-09:30 on a clear, EV-away morning, but the real-export gate held it off until a forced price slot rescued it at 10:48 - the house battery (32% SOC at sunrise) was legitimately absorbing the "surplus" the model saw, and the gate had no way to know that was fine given how much margin the battery still had before its own deadline. _battery_would_still_reach_full already answers the question the export gate was approximating, more precisely: it explicitly models the worst case (the device's whole draw comes straight out of the battery's charge rate, not some separate "free" pool) and re-evaluates every cycle against the battery's own reach-full-by-deadline math. The one gap: it never accounted for the wallbox's reservation, only for other managed devices' committed draw. Fixed by seeding cumulative_battery_competing_ draw with wallbox_reserved_kw, so every device's projection treats a car that still wants power as a prior claim on the battery's charge rate - whether or not the wallbox is drawing that reservation yet. With the wallbox now counted, would_delay_battery_full is a strictly more precise substitute for what the export gate was a blunt proxy for, so v2.37's export_uncorroborated check (and its EXPORT_CORROBORATION_ MARGIN_KW / cumulative_export_committed bookkeeping) is removed outright rather than kept alongside it. The separate battery-affordability path (battery_would_last / export_gate_open, v2.35/36) is untouched. Backtested against real sensor history from both 2026-09-16 (EV home, wallbox reserving) and 2026-09-17 (EV away): on the EV-home day the new rule's first qualifying cycle for the pool pump landed at 11:15, the same 10-minute window the forced price slot actually used that day - no earlier, because the wallbox's reservation correctly held it back through three prior hours of weak/contested surplus. On the EV-away day it would have started ~75 minutes earlier than the forced fallback that actually fired. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent e874829 commit a37dc29

3 files changed

Lines changed: 26 additions & 72 deletions

File tree

‎custom_components/surplus_load_switch/const.py‎

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -525,21 +525,6 @@ def _minutes_to_cycles(minutes: float) -> int:
525525
EXPORT_GATE_MEDIAN_WINDOW = 3
526526
EXPORT_GATE_SOC_OVERRIDE = 99.0
527527

528-
# v2.37: a device may only be *switched on* via the modelled-surplus path
529-
# (`remaining_surplus > need`) while the meter also shows the household is
530-
# really exporting at least that device's own draw (minus what devices
531-
# already granted this cycle have claimed from the same reading). The
532-
# modelled surplus can briefly read strongly positive when the wallbox
533-
# reservation lags a gross-PV upswing — confirmed live 2026-09-10, a
534-
# broken-cloud day: "Überschuss" spiking to +7 kW while the Tibber meter
535-
# showed ~0.5 kW real feed-in (the car was ramping up and taking the
536-
# rest), the whole low-priority cascade switching fully on, then off
537-
# again a few minutes later. Only enforced while battery_full_projection_
538-
# applies (daytime) and an export sensor is configured; an already-on
539-
# device is never shed by this (its own draw is already in the meter
540-
# reading) — it only blocks a fresh turn-on.
541-
EXPORT_CORROBORATION_MARGIN_KW = 0.0
542-
543528
# Default monthly solar offsets (hours after sunrise until PV is useful)
544529
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]
545530

‎custom_components/surplus_load_switch/coordinator.py‎

Lines changed: 25 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@
7272
DEFAULT_SOLAR_OFFSETS,
7373
DISCHARGE_SMOOTHING_SAMPLES,
7474
DOMAIN,
75-
EXPORT_CORROBORATION_MARGIN_KW,
7675
EXPORT_GATE_MEDIAN_WINDOW,
7776
EXPORT_GATE_MIN_KW,
7877
EXPORT_GATE_RELEASE_KW,
@@ -2836,15 +2835,13 @@ async def _evaluate_devices(self, data: CoordinatorData) -> None:
28362835
# cumulative_committed above; kept separate since this one only
28372836
# ever grows from surplus-path grants (battery_would_last is
28382837
# already covered by the reactive battery_eligible_ids gate, and
2839-
# force_runtime is exempt everywhere).
2840-
cumulative_battery_competing_draw = 0.0
2841-
# v2.37: predicted draw of every device turned on via the modelled-
2842-
# surplus path *this cycle that wasn't already on* — subtracted
2843-
# from the export-meter reading before the next device's turn-on
2844-
# is corroborated against it, so a single positive meter reading
2845-
# isn't spent by several devices at once. Already-on devices are
2846-
# left out: their draw is already reflected in the meter.
2847-
cumulative_export_committed = 0.0
2838+
# force_runtime is exempt everywhere). Seeded with the wallbox's
2839+
# own reservation (v2.39), not 0 — a car that still wants power is
2840+
# a prior claim on the battery's charge rate too, worst-case, the
2841+
# same way _battery_would_still_reach_full already treats every
2842+
# device's draw: whether or not the wallbox is drawing that
2843+
# reservation *yet*, the projection assumes it will.
2844+
cumulative_battery_competing_draw = data.wallbox_reserved_kw
28482845
now_dt = dt_util.utcnow()
28492846
horizon_end = now_dt + timedelta(hours=data.effective_h_to_solar + BATT_OK_BUFFER_H)
28502847

@@ -3371,42 +3368,32 @@ async def _evaluate_devices(self, data: CoordinatorData) -> None:
33713368
# surplus path only (see should_on/should_off below) —
33723369
# battery_would_last is already fully covered by the
33733370
# reactive gate.
3371+
# Worst-case, not an exact simulation (see
3372+
# _battery_would_still_reach_full): assumes this device's whole
3373+
# draw, on top of the wallbox's reservation and whatever
3374+
# higher-priority devices already claimed this cycle
3375+
# (cumulative_battery_competing_draw, seeded with
3376+
# wallbox_reserved_kw above), comes straight out of the
3377+
# battery's own charge rate — never out of some separate pool
3378+
# of "free" surplus that doesn't affect the battery either way.
3379+
# That makes this the sole gate the surplus path needs. v2.37
3380+
# had additionally required the grid meter to show a real
3381+
# feed-in before a fresh turn-on, to catch cases where
3382+
# `remaining_surplus` read positive only because the wallbox's
3383+
# reservation was modelled too low, not because power was
3384+
# genuinely spare — v2.39 removes that on top of this check:
3385+
# with the wallbox's reservation now counted here directly, a
3386+
# device that passes this check isn't taking anything the
3387+
# wallbox needed or the battery couldn't spare, independent of
3388+
# what the grid meter shows at this exact moment.
33743389
would_delay_battery_full = not self._battery_would_still_reach_full(
33753390
data, cumulative_battery_competing_draw + predicted_power
33763391
)
3377-
# v2.37: while an export sensor is configured and the sun is up,
3378-
# a device may only be *switched on* via the modelled-surplus
3379-
# path if the meter also shows a real feed-in that covers its
3380-
# own draw (minus what devices turned on earlier this cycle
3381-
# have already claimed from the same reading). The modelled
3382-
# `remaining_surplus` can read strongly positive for a few
3383-
# cycles when the wallbox reservation lags a gross-PV upswing,
3384-
# while almost nothing is actually leaving the house — see
3385-
# EXPORT_CORROBORATION_MARGIN_KW. Never sheds an already-on
3386-
# device (its draw is already in the meter reading); only
3387-
# blocks a fresh turn-on. At night / no export sensor this is
3388-
# always False. Same SOC override as the battery-path export
3389-
# gate above (EXPORT_GATE_SOC_OVERRIDE): once the house battery
3390-
# is effectively full it can't be the thing silently absorbing
3391-
# the modelled surplus, so the meter reading and the model can
3392-
# no longer disagree for that reason — the corroboration
3393-
# requirement has nothing left to protect against.
3394-
export_uncorroborated = (
3395-
data.export_sensor_configured
3396-
and data.battery_full_projection_applies
3397-
and data.soc < EXPORT_GATE_SOC_OVERRIDE
3398-
and not is_on
3399-
and (
3400-
data.export_smoothed_kw - cumulative_export_committed
3401-
< predicted_power + EXPORT_CORROBORATION_MARGIN_KW
3402-
)
3403-
)
34043392
should_on = (
34053393
force_runtime
34063394
or (
34073395
remaining_surplus > predicted_power + SURPLUS_ON_THRESHOLD
34083396
and not would_delay_battery_full
3409-
and not export_uncorroborated
34103397
)
34113398
or battery_would_last
34123399
)
@@ -3491,17 +3478,6 @@ async def _evaluate_devices(self, data: CoordinatorData) -> None:
34913478
f"{predicted_power:.2f} kW benötigt, Akku würde nicht bis Solar-Start reichen)"
34923479
)
34933480
await self._log_decision(dev, False, decision_titel, decision_reason)
3494-
elif export_uncorroborated and remaining_surplus > predicted_power + SURPLUS_ON_THRESHOLD:
3495-
# Modelled surplus would switch this on, but the meter
3496-
# doesn't back it up yet — held, not switched, so the log
3497-
# doesn't read as a plain hysteresis hold.
3498-
await self._log_decision(
3499-
dev, False, "Wartet — keine echte Einspeisung",
3500-
f"rechnerischer Überschuss reichte ({remaining_surplus:.2f} kW), aber es "
3501-
f"wird nur {max(data.export_smoothed_kw - cumulative_export_committed, 0.0):.2f} "
3502-
f"kW wirklich eingespeist ({predicted_power:.2f} kW nötig) — noch nicht "
3503-
f"eingeschaltet",
3504-
)
35053481
else:
35063482
stable_titel = "Bleibt an — Hysterese" if is_on else "Bleibt aus — Hysterese"
35073483
decision_reason = (
@@ -3527,13 +3503,6 @@ async def _evaluate_devices(self, data: CoordinatorData) -> None:
35273503
# what's already been committed ahead of it, not just its
35283504
# own isolated draw in a vacuum.
35293505
cumulative_battery_competing_draw += predicted_power
3530-
# A device turned on this cycle that wasn't already on will
3531-
# start drawing from real export the moment it switches —
3532-
# so the next device's export-corroboration check must see
3533-
# that reduced headroom, not the raw meter reading (which
3534-
# still reflects a moment before this one came on).
3535-
if not is_on:
3536-
cumulative_export_committed += predicted_power
35373506

35383507
if should_on and not is_on:
35393508
# 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.38.0"
12+
"version": "2.39.0"
1313
}

0 commit comments

Comments
 (0)