Skip to content

Commit 8d9e9ba

Browse files
xchillxxclaude
andcommitted
Fix device starvation by the car top-up, react to plug-in at once (v3.1.1)
- Car priority "no PV chance tomorrow": tomorrow's forecast is taken at face value (it is only about the optional top-up). With x0.7 an ordinary 25 kWh autumn day counted as "no chance" and the top-up pushed the pool pump off. Replay of 30 days: pump +2 h, pool heat pump +2 h, 4 kWh less export, same grid use, no departure below target. - Device plan: the car's top-up is no longer a fixed load. Devices rank above it and may claim it; the car follows at its next decision. Before, a car started on a sunnier 30-min average kept the boiler off. - Daytime off reason is "no surplus left" instead of "battery below reserve" (the battery path is closed in daylight without export anyway). - Plug-in / charging changes trigger a cycle immediately: a car that starts charging by itself is stopped within seconds instead of after up to a minute. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent be44b26 commit 8d9e9ba

7 files changed

Lines changed: 107 additions & 9 deletions

File tree

‎README.md‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,13 @@ and splits the surplus in this order:
2828
3. **Devices** — by priority, each with its own time window, minimum daily
2929
runtime, battery reserve and dependency (e.g. a pool heat pump that needs
3030
the pump).
31-
4. **Car top-up** — whatever is left, up to the car's charge limit.
31+
4. **Car top-up** — whatever is left, up to the car's charge limit. Between
32+
two car decisions a device may claim power the top-up is using; the car
33+
follows at its next decision.
3234
*Exception:* when the car won't get a PV chance tomorrow (it is away during
33-
tomorrow's PV window, or the forecast is too weak), it charges **before**
34-
the devices today.
35+
most of tomorrow's PV window, or tomorrow's forecast — after the base load
36+
and half a battery — covers less than half of what the car is missing), it
37+
charges **before** the devices today.
3538
5. The rest is exported.
3639

3740
If the forecast says PV can't reach a departure target, the car charges the
@@ -54,7 +57,8 @@ solar start (+1 h) with all planned loads, and only above each device's
5457
battery reserve.
5558

5659
The car is re-planned every 15 minutes on a 30-minute average (or immediately
57-
on plug-in); devices are re-planned every minute on a 5-minute median and
60+
on plug-in, which is acted on within seconds — a car that starts charging by
61+
itself is stopped right away); devices are re-planned every minute on a 5-minute median and
5862
switch only after the decision held for 10 minutes (20 for thermostats).
5963

6064
### Validated on recorded data

‎custom_components/surplus_pilot/__init__.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
1717
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
1818
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
1919
entry.async_on_unload(entry.add_update_listener(_reload))
20+
unsub = coordinator.async_watch_car()
21+
if unsub:
22+
entry.async_on_unload(unsub)
2023
return True
2124

2225

‎custom_components/surplus_pilot/coordinator.py‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
from datetime import datetime, time as dtime, timedelta
1010

1111
from homeassistant.config_entries import ConfigEntry
12-
from homeassistant.core import HomeAssistant
12+
from homeassistant.core import Event, HomeAssistant, callback
13+
from homeassistant.helpers.event import async_track_state_change_event
1314
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
1415
from homeassistant.util import dt as dt_util
1516

@@ -166,6 +167,24 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
166167
self.car_plan: P.Plan | None = None
167168
self.status: dict = {}
168169

170+
def async_watch_car(self):
171+
"""Plug-in and charge start run a cycle right away instead of at the
172+
next full minute: a car that starts charging by itself is stopped
173+
within seconds (the home battery pays for every minute)."""
174+
c = self.car_cfg or {}
175+
ents = [e for e in (c.get(CONF_CAR_PLUGGED_SENSOR), c.get(CONF_CAR_PLUGGED_FALLBACK),
176+
c.get(CONF_CAR_CHARGING_SENSOR)) if e]
177+
if not ents:
178+
return None
179+
180+
@callback
181+
def _changed(event: Event) -> None:
182+
old, new = event.data.get("old_state"), event.data.get("new_state")
183+
if old is not None and new is not None and old.state != new.state:
184+
self.hass.async_create_task(self.async_request_refresh())
185+
186+
return async_track_state_change_event(self.hass, ents, _changed)
187+
169188
async def async_setup(self) -> None:
170189
await self.store.async_load()
171190
await self.calibrator.async_load()
@@ -554,7 +573,13 @@ def inputs(pv_kw: float, base_kw: float, car_fixed: float | None) -> P.Inputs:
554573
pv5 = statistics.median(s[1] for s in win5)
555574
base5 = statistics.median(s[2] for s in win5)
556575
car5 = statistics.median(s[3] for s in win5)
557-
self.device_plan = P.make_plan(inputs(pv5, base5, car5))
576+
car_fixed = car5
577+
cp = self.car_plan
578+
if cp is not None and not cp.car_first and not cp.car_grid and cp.car_reason != "pflicht_minimum":
579+
# the top-up ranks below the devices: they may claim it, the car
580+
# follows at its next decision (only the obligation stays fixed)
581+
car_fixed = min(car5, cp.car_must_kw)
582+
self.device_plan = P.make_plan(inputs(pv5, base5, car_fixed))
558583
await self._apply_devices(now, dev_on)
559584

560585
self.status = self._build_status(now, pv, load, base, soc, export, car_in, car_kw, charging, sun,

‎custom_components/surplus_pilot/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-pilot/issues",
1111
"requirements": [],
12-
"version": "3.1.0"
12+
"version": "3.1.1"
1313
}

‎custom_components/surplus_pilot/planner.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
PV_HOUR_MARGIN_KW = 1.0 # an hour counts as "PV hour" when forecast > base + 1 kW
4444
CHEAP_FORECAST_TRUST = 1.0 # optional cheap top-up: trust the forecast fully (no guarantee needed)
4545
TOMORROW_SHARE = 0.5 # tomorrow must cover this share of the car's missing energy
46+
TOMORROW_FORECAST_TRUST = 1.0 # the top-up is optional: no extra safety factor (x0.7 put it before the devices too often)
4647

4748

4849
@dataclass
@@ -240,7 +241,7 @@ def car_pv_chance_tomorrow(inp: Inputs, car_missing_kwh: float) -> bool:
240241
return False
241242
if not inp.forecast:
242243
return True
243-
surplus, _ = forecast_surplus_kwh(inp.forecast, start, end, inp.night_base_kw)
244+
surplus, _ = forecast_surplus_kwh(inp.forecast, start, end, inp.night_base_kw, TOMORROW_FORECAST_TRUST)
244245
if surplus <= 0 and forecast_kwh(inp.forecast, start, end) == 0:
245246
return True # no forecast published for tomorrow yet -> don't panic
246247
battery_missing = 0.0 # battery is usually refilled first tomorrow
@@ -460,7 +461,9 @@ def _devices(inp: Inputs, remaining: float) -> dict[str, DeviceDecision]:
460461
out[d.id] = DeviceDecision(True, "akku_reicht", "akku")
461462
planned[d.id] = True
462463
continue
463-
if inp.battery_soc is not None and inp.battery_soc < d.soc_reserve:
464+
# name the rule that actually blocked it: in daylight without export
465+
# the battery path is closed anyway, the reserve is not the reason
466+
if gate and inp.battery_soc is not None and inp.battery_soc < d.soc_reserve:
464467
reason = "akku_reserve"
465468
elif day:
466469
reason = "kein_ueberschuss"

‎tests/test_integration.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,39 @@ async def test_unknown_plug_state_means_not_present(hass: HomeAssistant, freezer
142142
hass.states.async_set("sensor.car_cable", "disconnected")
143143
await co.async_refresh()
144144
assert co.status["auto_da"] is False
145+
146+
147+
async def test_plug_in_autostart_stopped_without_waiting_for_the_minute(hass: HomeAssistant, freezer):
148+
"""06:07 live: plugged in at night, the car started by itself and ran a
149+
full minute until the next cycle. A plug/charging change runs a cycle now."""
150+
set_world(hass, pv=0.0, load=0.6, soc=60, car_soc=52, plugged="off")
151+
hass.states.async_set("switch.car_charge", "off")
152+
for e in ("switch.miner", "switch.pump", "climate.pool"):
153+
hass.states.async_set(e, "off")
154+
data = {**DATA, "car": {**DATA["car"], "charging_sensor": "binary_sensor.car_charging"}}
155+
hass.states.async_set("binary_sensor.car_charging", "off")
156+
entry = MockConfigEntry(domain=DOMAIN, data=data)
157+
entry.add_to_hass(hass)
158+
assert await hass.config_entries.async_setup(entry.entry_id)
159+
await hass.async_block_till_done()
160+
calls = []
161+
162+
async def fake(call):
163+
calls.append((call.domain, call.service, dict(call.data)))
164+
if call.domain == "switch":
165+
hass.states.async_set(call.data["entity_id"], "on" if call.service == "turn_on" else "off")
166+
167+
for dom, srv in (("switch", "turn_on"), ("switch", "turn_off"), ("number", "set_value")):
168+
hass.services.async_register(dom, srv, fake)
169+
freezer.tick(timedelta(seconds=15))
170+
hass.states.async_set("binary_sensor.car_plugged", "on")
171+
await hass.async_block_till_done()
172+
freezer.tick(timedelta(seconds=15))
173+
hass.states.async_set("switch.car_charge", "on")
174+
hass.states.async_set("sensor.car_power", 4.0, {"unit_of_measurement": "kW"})
175+
hass.states.async_set("binary_sensor.car_charging", "on")
176+
await hass.async_block_till_done()
177+
freezer.tick(timedelta(seconds=11)) # request_refresh cooldown, still far below the 60 s cycle
178+
async_fire_time_changed(hass, dt_util.utcnow())
179+
await hass.async_block_till_done()
180+
assert ("switch", "turn_off", {"entity_id": "switch.car_charge"}) in calls

‎tests/test_planner.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,30 @@ def test_cheap_topup_only_when_pv_wont_do_it():
124124
inp.car.soc = 55.0
125125
plan = P.make_plan(inp)
126126
assert not plan.car_grid
127+
128+
129+
def test_ordinary_autumn_tomorrow_keeps_devices_before_car_topup():
130+
"""07.10.2026 live: car 52 % (limit 80), tomorrow ~25 kWh forecast, car
131+
home all day. The x0.7 forecast made it 'no PV chance tomorrow' and the
132+
optional top-up pushed the pool pump off."""
133+
now = datetime(2026, 10, 7, 10, 30, tzinfo=TZ)
134+
car = P.CarInput(present=True, soc=52.0, limit_soc=80.0, capacity_kwh=72.89, efficiency=0.9, min_kw=4.14,
135+
max_kw=11.04)
136+
tomorrow = now.replace(hour=0, minute=0) + timedelta(days=1)
137+
kwh = {10: 1.5, 11: 2.8, 12: 3.8, 13: 4.3, 14: 4.3, 15: 3.8, 16: 2.9, 17: 1.6, 18: 0.6}
138+
fc = [P.ForecastHour(end=tomorrow + timedelta(hours=h), kwh=v) for h, v in kwh.items()]
139+
pump = P.DeviceInput(id="pump", name="Poolpumpe", priority=3, decision_kw=1.34, is_on=True, soc_reserve=85.0)
140+
plan = P.make_plan(base_inputs(now, pv_kw=6.0, base_kw=1.0, battery_soc=70.0, battery_capacity_kwh=13.8,
141+
car=car, forecast=fc, devices=[miner(True), pump]))
142+
assert not plan.car_first
143+
assert plan.devices["pump"].on
144+
145+
146+
def test_daytime_off_reason_is_missing_surplus_not_reserve():
147+
now = datetime(2026, 10, 7, 13, 0, tzinfo=TZ)
148+
boiler = P.DeviceInput(id="boiler", name="Boiler", priority=5, decision_kw=2.0, is_on=True, soc_reserve=95.0)
149+
plan = P.make_plan(base_inputs(now, pv_kw=2.0, base_kw=1.0, battery_soc=83.0, export_kw=0.0, devices=[boiler]))
150+
assert plan.devices["boiler"].reason == "kein_ueberschuss"
151+
night = now.replace(hour=22)
152+
plan = P.make_plan(base_inputs(night, battery_soc=83.0, devices=[boiler]))
153+
assert plan.devices["boiler"].reason == "akku_reserve"

0 commit comments

Comments
 (0)