@@ -89,6 +89,7 @@ class SiteConfig:
8989 import_cap_kw : float
9090 export_cap_kw : Optional [float ] = None
9191 load_port_cap_kw : Optional [float ] = None
92+ charge_import_cap_kw : Optional [float ] = None
9293
9394
9495@dataclass (frozen = True )
@@ -112,6 +113,7 @@ class PlannerConfig:
112113 soc_bucket_kwh : float = 0.05
113114 allow_limited_discharge : bool = False
114115 allowed_battery_modes : Tuple [str , ...] = BATTERY_MODES
116+ economic_diversity : bool = False
115117
116118
117119@dataclass (frozen = True )
@@ -269,6 +271,8 @@ def _validate(slots, ev, battery, site, planner):
269271 if ev .availability is not None and (not isinstance (ev .availability , tuple ) or any (type (i ) is not int or not 0 <= i < len (slots ) for i in ev .availability )):
270272 raise PlannerInputError ("Availability must contain original slot indices" )
271273 _require_finite ("import_cap_kw" , site .import_cap_kw , 0 )
274+ if site .charge_import_cap_kw is not None :
275+ _require_finite ("charge_import_cap_kw" , site .charge_import_cap_kw , 1e-6 , site .import_cap_kw )
272276 for field in ("export_cap_kw" , "load_port_cap_kw" ):
273277 if getattr (site , field ) is not None :
274278 _require_finite (field , getattr (site , field ), 0 )
@@ -314,6 +318,14 @@ def _segment(duration, house_kw, ev_kw, pv_kw, soc, mode, battery, ev, site):
314318 charge = discharge = 0.0
315319 if soc < battery .capacity_kwh - EPSILON :
316320 charge = battery .max_charge_kw if mode == BATTERY_CHARGE else min (max (pv_kw - demand , 0 ), battery .max_charge_kw )
321+ if mode == BATTERY_CHARGE and site .charge_import_cap_kw is not None :
322+ # A grid-charge REQUEST need not mean physical battery charging:
323+ # house/EV loads take priority. Use headroom to charge, or supply
324+ # the grid deficit from the battery. The site cap below still
325+ # rejects insufficient support, including reserve exhaustion.
326+ charge = min (charge , max (site .charge_import_cap_kw - demand + pv_kw , 0 ))
327+ if soc > battery .reserve_kwh + EPSILON :
328+ discharge = min (max (demand - pv_kw - site .charge_import_cap_kw , 0 ), battery .max_discharge_kw )
317329 if mode == BATTERY_DEMAND and soc > battery .reserve_kwh + EPSILON :
318330 discharge = min (max (demand - pv_kw , 0 ), battery .max_discharge_kw )
319331 net = demand + charge - pv_kw - discharge
@@ -405,21 +417,48 @@ def _resolve_action(slot, ev_on, mode, node, battery, ev, site):
405417 )
406418
407419
408- def _prune (nodes , width , bucket , terminal_value , preserve_battery = False ):
409- """Preserve EV progress and optionally stored-energy resilience within the same beam bound."""
420+ def _prune (nodes , width , bucket , terminal_value , preserve_battery = False , economic_diversity = False ):
421+ """Preserve EV progress and optionally stored-energy resilience within the same beam bound.
422+
423+ The fixed beam budget often causes a small number of distinct EV-progress
424+ buckets to share it with many near-duplicate cost-ranked candidates. With no
425+ hold action, the cheapest label of a bucket may sit on a stored-energy path
426+ that prematurely imports during the current band and forecloses a cheap
427+ later pre-charge. Promoting only the highest-energy neighbour (the
428+ previous behaviour) keeps one extreme but can still drop the intermediate
429+ stored-energy trajectory that completes the cheaper plan.
430+
431+ When ``preserve_battery`` is true, the cost leader stays at position 0 of
432+ its bucket (so the layer-by-layer budget picks it first across buckets).
433+ With opt-in ``economic_diversity``, the positions up to ``width`` are re-sorted by stored
434+ energy descending, so each per-bucket layer picks a different SoC level
435+ instead of `width`-many near-duplicates. Beyond the head, ranking order
436+ resumes unchanged. The frontier size and the per-slot evaluation budget
437+ stay within ``width``; this is a within-bucket ordering change, never an
438+ addition of new candidates. This remains bounded search, not an optimality proof.
439+ """
410440 groups = {}
411441 for node in nodes :
412442 groups .setdefault (_bucket (node .ev_kwh , bucket ), []).append (node )
413443 for group in groups .values ():
414444 group .sort (key = lambda n : (n .cost - terminal_value * n .battery_kwh , n .switches ))
415- if preserve_battery :
416- # With no hold action, the cheapest label can have too little stored
417- # energy to serve a later fixed-rate EV start. Keep the cost leader
418- # and highest-energy label before filling the remaining cost layers.
445+ if preserve_battery and len (group ) > 2 :
446+ # Cost leader stays at position 0; the next ``width - 1``
447+ # candidates are re-ordered by stored energy descending so layer
448+ # picks span SoC tiers instead of cost-ranked duplicates. Past
449+ # the head, ranking order resumes so all cheap-end behaviour is
450+ # unchanged.
451+ leader = group [0 ]
419452 high = max (group , key = lambda n : n .battery_kwh )
420- if high is not group [ 0 ] :
453+ if high is not leader :
421454 group .remove (high )
422455 group .insert (1 , high )
456+ if economic_diversity :
457+ head_rest = list (group [1 : max (1 , width )])
458+ tail = list (group [max (1 , width ) :])
459+ head_rest .sort (key = lambda n : - n .battery_kwh )
460+ group .clear ()
461+ group .extend ([leader ] + head_rest + tail )
423462 ordered = [groups [key ] for key in sorted (groups , reverse = True )]
424463 kept = []
425464 layer = 0
@@ -516,7 +555,7 @@ def plan(slots: Sequence[Slot], ev: EVConfig, battery: BatteryConfig, site: Site
516555 return _bounded_no_plan (deadline_idx , transitions , max_frontier , "no_complete_transition_at_slot_{}" .format (i ))
517556 frontier = list (next_states .values ())
518557 if planner .beam_width is not None and len (frontier ) > planner .beam_width :
519- frontier = _prune (frontier , planner .beam_width , planner .soc_bucket_kwh , battery .terminal_value_per_kwh , preserve_battery = BATTERY_HOLD not in base_modes )
558+ frontier = _prune (frontier , planner .beam_width , planner .soc_bucket_kwh , battery .terminal_value_per_kwh , preserve_battery = BATTERY_HOLD not in base_modes , economic_diversity = planner . economic_diversity )
520559 pruned = True
521560 max_frontier = max (max_frontier , len (frontier ))
522561 best = min (frontier , key = lambda n : (n .cost - battery .terminal_value_per_kwh * n .battery_kwh , n .switches ))
0 commit comments