4848MIN_LIFT_PV_SHARE = 0.5 # obligation at minimum current only while PV carries half of it ...
4949MIN_LIFT_PV_SHARE_KEEP = 0.4 # ... (0.4 to keep an already charging car going)
5050CHEAP_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
5152FEED_CAR_MIN_KW = 1.0 # battery-feeds-car detection: car must draw at least this ...
5253FEED_DEFICIT_KW = 0.5 # ... with at least this much missing beyond the PV ...
5354FEED_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+
436474def _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
461497def _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
495531def _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
0 commit comments