Skip to content

Commit c540447

Browse files
xchillxxclaude
andcommitted
Cheap charging with PV below the battery value when the forecast fills it anyway (v3.1.18)
User 10.10.: the forecast still fills the battery in the afternoon, the house draws little while the car charges. Below 'Cheap charging with PV from home battery' PV slots are open when the forecast x0.7 after the car's block until the PV end covers the battery gap plus the house (base + running devices) until then; a running block doesn't stop for the battery while it does. 10.10. (13:03, 34 %): 12.7 kWh vs 19.7 kWh needed -> still blocked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 6108119 commit c540447

5 files changed

Lines changed: 62 additions & 10 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,9 @@ it needs. With the option off (battery set not to discharge into the
7272
wallbox in the inverter app) every slot up to the departure counts, midday
7373
included, and the car charges PV plus grid there — slots with PV only while
7474
the home battery is at least *Cheap charging with PV from home battery* full
75-
(default 50 %, 0 = no limit). Many inverters (e.g. Huawei) give the PV to the
75+
(default 50 %, 0 = no limit) — or below it when the forecast (×0.7) after the
76+
car's block until the PV end still fills the battery and carries the house
77+
(measured base load + running devices) meanwhile. Many inverters (e.g. Huawei) give the PV to the
7678
wallbox first and let the battery carry the house, so a midday block still
7779
drains it; a running block goes on down to 5 % below that value, then stops
7880
and stays off in PV hours for the rest of the day. With a *home battery

‎custom_components/surplus_pilot/coordinator.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -905,7 +905,8 @@ def explain(self) -> list[str]:
905905
txt += " — lädt aus dem Netz: günstigster Zeitraum bis zur Abfahrt"
906906
if not cp.car_grid and getattr(self, "_cheap_pv_waits", False):
907907
txt += (f" — Strom billig, aber mit PV erst ab Hausakku "
908-
f"{float(self.store.data['cheap_pv_battery_soc']):.0f} %"
908+
f"{float(self.store.data['cheap_pv_battery_soc']):.0f} % "
909+
f"oder wenn die Prognose ihn danach trotzdem füllt"
909910
+ (" (heute schon einmal abgebrochen)"
910911
if self.store.data.get("cheap_pv_lock") == dt_util.now().date().isoformat() else ""))
911912
if cp.car_block and not cp.car_grid and cp.car_block[1] > dt_util.now():

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

‎custom_components/surplus_pilot/planner.py‎

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -561,23 +561,43 @@ def _cheap_topup_now(inp: Inputs, dep: Departure | None,
561561

562562

563563
def cheap_pv_battery_ok(inp: Inputs) -> bool:
564-
"""May cheap grid charging use slots with PV? Only with the home battery at
565-
least `cheap_pv_battery_soc` full - a running block goes on down to
566-
CHEAP_PV_SOC_HYSTERESIS below it, and once stopped for that it stays off
567-
in PV for the day (no stop-and-go while PV refills the battery)."""
564+
"""May cheap grid charging use slots with PV? With the home battery at
565+
least `cheap_pv_battery_soc` full, or below that if the cautious forecast
566+
still fills it by the PV end anyway (`battery_fills_anyway`). A running
567+
block goes on down to CHEAP_PV_SOC_HYSTERESIS below the value; once
568+
stopped for the battery it stays off in PV for the day (no stop-and-go
569+
while PV refills the battery)."""
568570
if inp.cheap_pv_battery_soc is None or inp.battery_soc is None:
569571
return True
570572
if inp.cheap_pv_locked:
571573
return False
572574
floor = inp.cheap_pv_battery_soc - (CHEAP_PV_SOC_HYSTERESIS if inp.grid_running else 0.0)
573-
return inp.battery_soc >= floor
575+
return inp.battery_soc >= floor or battery_fills_anyway(inp)
574576

575577

576578
def cheap_pv_battery_stop(inp: Inputs) -> bool:
577579
"""A running cheap block with PV must stop for the battery (-> lock for today)."""
578580
return (inp.grid_running and inp.cheap_pv_battery_soc is not None and inp.battery_soc is not None
579581
and not inp.battery_feeds_car and inp.pv_kw > inp.base_kw + 0.3
580-
and inp.battery_soc < inp.cheap_pv_battery_soc - CHEAP_PV_SOC_HYSTERESIS)
582+
and inp.battery_soc < inp.cheap_pv_battery_soc - CHEAP_PV_SOC_HYSTERESIS
583+
and not battery_fills_anyway(inp))
584+
585+
586+
def battery_fills_anyway(inp: Inputs) -> bool:
587+
"""Below the battery value cheap charging with PV is still fine when the
588+
forecast (x0.7) after the car's block until the PV end fills the battery
589+
and carries the house the whole time (during the block the inverter gives
590+
the PV to the car and the battery carries the house)."""
591+
car = inp.car
592+
if (inp.battery_soc is None or car is None or car.soc is None or inp.cheap_target_soc is None
593+
or inp.pv_end.date() != inp.now.date() or inp.pv_end <= inp.now):
594+
return False
595+
car_kwh = max(0.0, min(inp.cheap_target_soc, car.limit_soc) - car.soc) / 100.0 * car.capacity_kwh / car.efficiency
596+
block_end = inp.now + timedelta(hours=car_kwh / max(car.max_kw, 0.1))
597+
pv = forecast_kwh(inp.forecast, block_end, inp.pv_end) * FORECAST_SAFETY
598+
house_kw = inp.base_kw + sum(d.decision_kw for d in inp.devices if d.is_on)
599+
need = (100.0 - inp.battery_soc) / 100.0 * inp.battery_capacity_kwh + house_kw * _h(inp.now, inp.pv_end)
600+
return pv >= need
581601

582602

583603
def _cheapest_free_slot_now(inp: Inputs, end: datetime, kwh: float,

‎tests/test_planner.py‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -440,7 +440,7 @@ def test_cheap_topup_with_pv_needs_a_full_enough_home_battery():
440440
def inp_at(t, pv, battery, running=False, locked=False):
441441
inp = _oct10(t, pv_kw=pv, battery_soc=battery, car_soc=53.0, prices=prices,
442442
price_now=next(p.price for p in prices if p.start <= t < p.end), threshold=0.1904,
443-
cheap_target=80.0)
443+
cheap_target=80.0, scale=0.5) # dull afternoon: the forecast won't fill the battery
444444
inp.battery_feeds_car = False
445445
inp.cheap_pv_battery_soc = 50.0
446446
inp.grid_running, inp.cheap_pv_locked = running, locked
@@ -460,3 +460,32 @@ def inp_at(t, pv, battery, running=False, locked=False):
460460
free = inp_at(now, 4.5, 34.0)
461461
free.cheap_pv_battery_soc = None
462462
assert P.make_plan(free).car_grid
463+
464+
465+
def test_cheap_topup_with_pv_below_the_battery_value_when_the_forecast_fills_it_anyway():
466+
"""User 10.10.: 'the battery gets nearly full by the forecast anyway'. Below
467+
50 % cheap charging with PV is fine when the forecast x0.7 after the car's
468+
block until the PV end fills the battery and carries the house meanwhile.
469+
10.10. 13:03 (34 %, Forecast.Solar 18 kWh after the block, house ~2.2 kW)
470+
that's not enough; on a sunny day it is."""
471+
now = datetime(2026, 10, 10, 13, 0, tzinfo=TZ)
472+
prices = [P.PriceSlot(now.replace(hour=0) + timedelta(minutes=15 * i),
473+
now.replace(hour=0) + timedelta(minutes=15 * (i + 1)), 0.1716) for i in range(96)]
474+
475+
def plan(scale, battery=34.0):
476+
inp = _oct10(now, pv_kw=4.5, battery_soc=battery, car_soc=53.0, prices=prices, price_now=0.1716,
477+
threshold=0.1904, cheap_target=80.0, scale=scale)
478+
inp.base_kw = 0.9
479+
inp.devices = [P.DeviceInput(id="pump", name="Pumpe", priority=3, decision_kw=1.34, is_on=True)]
480+
inp.battery_feeds_car = False
481+
inp.cheap_pv_battery_soc = 50.0
482+
inp.pv_end = now.replace(hour=17, minute=46)
483+
return inp, P.make_plan(inp)
484+
485+
inp, p = plan(1.0) # 10.10.: ~9 kWh x0.7 after the block, ~19 kWh needed
486+
assert not P.battery_fills_anyway(inp) and not p.car_grid
487+
inp, p = plan(3.0) # sunny afternoon: PV slots open (the PV then brings the car anyway)
488+
assert P.battery_fills_anyway(inp) and P.cheap_pv_battery_ok(inp)
489+
inp, p = plan(3.0, battery=20.0) # running block below 45 %: no stop while the forecast covers it
490+
inp.grid_running = True
491+
assert not P.cheap_pv_battery_stop(inp)

0 commit comments

Comments
 (0)