Skip to content

Commit b52aa68

Browse files
xchillxxclaude
andcommitted
Grid charging as one block, no stop-and-go (v3.1.15)
10.10. live: cheap charging started at 11:45 (17.16 ct); the cheapest single quarter hours would have stopped it at 12:00 (17.40) and started it again at 12:30 - every stop/start is a billed car command and stresses the charging hardware. Cheap and obligation grid charging now take the cheapest block of consecutive slots (planner._best_block; cheap: earlier on ties, obligation: later), and a running grid charge (last decision or car near max power) continues while the price stays within 0.5 ct of the best block's mean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 374aa40 commit b52aa68

5 files changed

Lines changed: 100 additions & 28 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ help), and once there is just enough time left it charges regardless.
5959
(default: the cheapest 10 % of the last 8 days) the car is topped up from the
6060
grid up to a target (default 80 %) — but only by the energy the PV forecast
6161
won't bring before the next departure anyway, so a sunny tomorrow isn't
62-
wasted. It charges in the cheapest *published* slots before the departure.
62+
wasted. It charges in the cheapest *block* of consecutive published slots before
63+
the departure (one block, no stop-and-go).
6364
If your home battery discharges into the car (option *Home battery
6465
discharges into the car*, default on), only slots without PV count and only
6566
while the battery is at its minimum SoC — otherwise the battery would just

‎custom_components/surplus_pilot/coordinator.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,8 @@ def inputs(pv_kw: float, base_kw: float, car_fixed: float | None) -> P.Inputs:
649649
forecast=self._forecast, prices=self._prices, departures=deps, car=car_in, devices=dev_inputs,
650650
allow_grid_for_car=bool(self.car_cfg.get(CONF_CAR_ALLOW_GRID, True)) if self.car_cfg else False,
651651
battery_feeds_car=feeds_car,
652+
grid_running=bool(self.car and ((self.car.decision is not None and self.car.decision.grid)
653+
or car_kw >= 0.9 * self.car.max_kw)),
652654
car_fixed_kw=car_fixed, price_profile=ps["profile"], price_now=ps["now"],
653655
cheap_threshold=ps["threshold"], cheap_target_soc=cheap_target)
654656

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

‎custom_components/surplus_pilot/planner.py‎

Lines changed: 62 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
MIN_LIFT_PV_SHARE = 0.5 # obligation at minimum current only while PV carries half of it ...
4949
MIN_LIFT_PV_SHARE_KEEP = 0.4 # ... (0.4 to keep an already charging car going)
5050
CHEAP_BATTERY_MARGIN = 3.0 # cheap grid top-up only with the home battery at most this far above its min SoC
51+
GRID_BLOCK_TOLERANCE = 0.005 # a running grid block continues while the price is within 0.5 ct of the block's mean
5152
FEED_CAR_MIN_KW = 1.0 # battery-feeds-car detection: car must draw at least this ...
5253
FEED_DEFICIT_KW = 0.5 # ... with at least this much missing beyond the PV ...
5354
FEED_SOC_MARGIN = 5.0 # ... and the battery this far above its min SoC (it could discharge)
@@ -124,6 +125,9 @@ class Inputs:
124125
# False: the home battery is set not to discharge into the car (e.g. in
125126
# the inverter app) - grid charging then leaves it alone, also at midday
126127
battery_feeds_car: bool = True
128+
# the car is grid charging right now (last decision, or drawing near its
129+
# maximum): a started grid block runs through instead of stop-and-go
130+
grid_running: bool = False
127131
# Price knowledge for grid charging: published prices (`prices`), an
128132
# estimate per hour of day for the not yet published ones (median of the
129133
# last days), the current price and the "cheap" threshold (percentile of
@@ -433,13 +437,47 @@ def _floor_slot(t: datetime) -> datetime:
433437
return t.replace(minute=t.minute - t.minute % 15, second=0, microsecond=0)
434438

435439

440+
def _best_block(slots: list[tuple[datetime, float | None]], n: int, prefer_late: bool) -> tuple[datetime, int, float] | None:
441+
"""Cheapest run of n consecutive 15-min slots (None = not usable, it
442+
breaks a run): (start, length, mean price). Charging is one block, not
443+
scattered quarter hours - every stop and start costs a (possibly
444+
billed) car command and stresses the charging hardware. When no run is
445+
n long, the longest possible one counts."""
446+
n = max(1, min(n, len(slots)))
447+
while n >= 1:
448+
best = None
449+
for i in range(len(slots) - n + 1):
450+
win = slots[i:i + n]
451+
if any(p is None for _, p in win) or any(win[k + 1][0] - win[k][0] != SLOT for k in range(n - 1)):
452+
continue
453+
cost = sum(p for _, p in win)
454+
if (best is None or cost < best[2] * n - 1e-9
455+
or (prefer_late and abs(cost - best[2] * n) <= 1e-9)):
456+
best = (win[0][0], n, cost / n)
457+
if best is not None:
458+
return best
459+
n -= 1
460+
return None
461+
462+
463+
def _in_block(inp: Inputs, block: tuple[datetime, int, float] | None) -> bool:
464+
if block is None:
465+
return False
466+
start, n, mean = block
467+
if start <= inp.now < start + n * SLOT:
468+
return True
469+
# a running block goes on while the price stays close to the best block
470+
price = inp.price_now if inp.price_now is not None else _price_at(inp, _floor_slot(inp.now))[0]
471+
return inp.grid_running and price is not None and price <= mean + GRID_BLOCK_TOLERANCE
472+
473+
436474
def _grid_slot_now(inp: Inputs, dep: Departure, uncovered_kwh: float, max_kw: float) -> bool:
437475
"""Grid fallback for a departure target the PV forecast can't reach:
438-
charge in the cheapest 15-min slots between now and the departure
439-
(unpublished prices estimated per hour of day — so a cheap midday today
440-
can win over a night whose prices aren't out yet). Ties go to the later
441-
slot (a sunnier hour than forecast may still help). Once there is just
442-
enough time left, charge regardless of price."""
476+
charge in the cheapest block of consecutive 15-min slots between now and
477+
the departure (unpublished prices estimated per hour of day — so a cheap
478+
midday today can win over a night whose prices aren't out yet). Ties go
479+
to the later block (a sunnier hour than forecast may still help). Once
480+
there is just enough time left, charge regardless of price."""
443481
need_slots = max(1, math.ceil(uncovered_kwh / max(max_kw, 0.1) / 0.25))
444482
now_slot = _floor_slot(inp.now)
445483
slots: list[tuple[datetime, float | None]] = []
@@ -450,12 +488,10 @@ def _grid_slot_now(inp: Inputs, dep: Departure, uncovered_kwh: float, max_kw: fl
450488
left_h = (dep.start - inp.now).total_seconds() / 3600.0
451489
if len(slots) <= need_slots or left_h <= need_slots * 0.25 + GRID_SAFETY_H:
452490
return True
453-
priced = [x for x in slots if x[1] is not None]
454-
if not priced:
491+
if not any(x[1] is not None for x in slots):
455492
return False # no price idea at all: wait for the safety start above
456-
priced.sort(key=lambda x: (x[1], -x[0].timestamp()))
457-
chosen = {x[0] for x in priced[:need_slots]}
458-
return now_slot in chosen
493+
# one block, equally cheap -> the later one (a sunnier hour may still help)
494+
return _in_block(inp, _best_block(slots, need_slots, prefer_late=True))
459495

460496

461497
def _cheap_topup_now(inp: Inputs, dep: Departure | None, surplus: float) -> tuple[bool, float]:
@@ -493,29 +529,29 @@ def _cheap_topup_now(inp: Inputs, dep: Departure | None, surplus: float) -> tupl
493529

494530

495531
def _cheapest_free_slot_now(inp: Inputs, end: datetime, kwh: float, max_kw: float) -> bool:
496-
"""Is the current slot among the cheapest PUBLISHED slots until `end`
497-
that are free for grid charging? Equal prices go to the earlier slot -
498-
no point waiting for the same price. When the home battery feeds the
499-
car, slots with PV above the base load don't count (now: measured,
532+
"""Is now inside the cheapest block of consecutive PUBLISHED slots until
533+
`end` that are free for grid charging? Equal blocks go to the earlier
534+
one - no point waiting for the same price. When the home battery feeds
535+
the car, slots with PV above the base load don't count (now: measured,
500536
later: forecast): grid charging there would take the PV the battery
501537
needs, it only charges from export."""
502538
need_slots = max(1, math.ceil(kwh / max(max_kw, 0.1) / 0.25))
503539
now_slot = _floor_slot(inp.now)
504540
pv_limit = inp.base_kw + 0.3
505541
if inp.battery_feeds_car and inp.pv_kw > pv_limit:
506542
return False
507-
free = []
508-
for p in inp.prices:
509-
if p.end <= now_slot or p.start >= end:
510-
continue
511-
if inp.battery_feeds_car and p.start > now_slot:
512-
pv_kw = forecast_kwh(inp.forecast, p.start, p.end) / max(_h(p.start, p.end), 0.01)
513-
if pv_kw * CHEAP_FORECAST_TRUST > pv_limit:
514-
continue
515-
free.append((p.price, p.start))
516-
free.sort()
517-
chosen = {t for _, t in free[:need_slots]}
518-
return any(p.start <= inp.now < p.end and p.start in chosen for p in inp.prices)
543+
slots: list[tuple[datetime, float | None]] = []
544+
for p in sorted(inp.prices, key=lambda x: x.start): # hourly prices -> 15-min slots
545+
t = max(_floor_slot(p.start), now_slot)
546+
while t < p.end and t < end:
547+
price: float | None = p.price
548+
if inp.battery_feeds_car and t > now_slot:
549+
pv_kw = forecast_kwh(inp.forecast, t, t + SLOT) / 0.25
550+
if pv_kw * CHEAP_FORECAST_TRUST > pv_limit:
551+
price = None
552+
slots.append((t, price))
553+
t += SLOT
554+
return _in_block(inp, _best_block(slots, need_slots, prefer_late=False))
519555

520556

521557
# ---------------------------------------------------------------- the plan

‎tests/test_planner.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,3 +365,36 @@ def test_battery_feed_evidence():
365365
assert ev(3.0, 1.4, 3.8, -2.5, 18.0, 15.0) is None # battery at its reserve: says nothing
366366
assert ev(0.0, 0.0, 0.8, -0.8, 40.0, 15.0) is None # car not charging
367367
assert ev(3.0, 4.0, 3.8, -0.0, 40.0, 15.0) is None # PV covers it all
368+
369+
370+
def test_grid_charging_is_one_block_not_scattered_quarter_hours():
371+
"""10.10. live: 11:45 17.16 ct, 12:00 17.40, 12:15 17.19, from 12:30
372+
17.16 - the cheapest single quarter hours would stop the car at 12:00
373+
and start it again at 12:30. One block instead; a running one goes on."""
374+
now = datetime(2026, 10, 10, 11, 45, tzinfo=TZ)
375+
day0 = now.replace(hour=0)
376+
377+
def tariff(t):
378+
if (t.hour, t.minute) == (12, 0):
379+
return 0.174
380+
if (t.hour, t.minute) == (12, 15):
381+
return 0.1719
382+
return 0.1716 if day0.replace(hour=11, minute=45) <= t < day0.replace(hour=16, minute=30) else 0.20
383+
384+
prices = [P.PriceSlot(day0 + timedelta(minutes=15 * i), day0 + timedelta(minutes=15 * (i + 1)),
385+
tariff(day0 + timedelta(minutes=15 * i))) for i in range(96)]
386+
387+
def at(t, running):
388+
inp = _oct10(t, pv_kw=6.8, battery_soc=25.0, car_soc=44.0, prices=prices,
389+
price_now=next(p.price for p in prices if p.start <= t < p.end), threshold=0.189,
390+
cheap_target=80.0)
391+
inp.battery_feeds_car = False
392+
inp.grid_running = running
393+
return P.make_plan(inp)
394+
395+
assert not at(now, False).car_grid # best block starts 12:30
396+
assert at(now.replace(hour=12, minute=30), False).car_grid
397+
assert at(now.replace(hour=12, minute=0), True).car_grid # already running: no stop for 0.2 ct
398+
# the two cheapest single slots (0.1) are apart: the block 0.1+0.12+0.12 wins
399+
blk = P._best_block([(now + i * P.SLOT, p) for i, p in enumerate([0.1, 0.4, 0.1, 0.12, 0.12, 0.12])], 3, False)
400+
assert blk[0] == now + 2 * P.SLOT and blk[1] == 3

0 commit comments

Comments
 (0)