1010proof of global optimality. Expected availability never establishes connection.
1111"""
1212
13- from dataclasses import dataclass
13+ from dataclasses import dataclass , replace
1414import math
1515from typing import Optional , Sequence , Tuple
1616
1717STATUS_FEASIBLE = "feasible"
1818STATUS_BOUNDED_NO_PLAN = "bounded_no_plan"
1919BATTERY_DEMAND , BATTERY_HOLD , BATTERY_CHARGE = "demand" , "hold" , "charge"
2020BATTERY_MODES = (BATTERY_DEMAND , BATTERY_HOLD , BATTERY_CHARGE )
21+ BATTERY_LIMITED_DEMAND = "limited_demand"
2122EV_OFF , EV_ON = "off" , "on"
2223EPSILON = 1e-9
2324
@@ -101,6 +102,7 @@ class PlannerConfig:
101102 time_step_minutes : int = 5
102103 beam_width : Optional [int ] = 128
103104 soc_bucket_kwh : float = 0.05
105+ allow_limited_discharge : bool = False
104106
105107
106108@dataclass (frozen = True )
@@ -147,6 +149,7 @@ class SlotAction:
147149 slot_cost : float
148150 ev_on_minutes : float
149151 segments : Tuple [FlowSegment , ...]
152+ discharge_limit_kw : Optional [float ] = None
150153
151154
152155@dataclass (frozen = True )
@@ -201,6 +204,8 @@ def _validate(slots, ev, battery, site, planner):
201204 raise PlannerInputError ("Unsupported planning step" )
202205 if planner .beam_width is not None and (type (planner .beam_width ) is not int or not 1 <= planner .beam_width <= 4096 ):
203206 raise PlannerInputError ("beam_width must be an integer in 1..4096 or None" )
207+ if type (planner .allow_limited_discharge ) is not bool :
208+ raise PlannerInputError ("allow_limited_discharge must be boolean" )
204209 _require_finite ("soc_bucket_kwh" , planner .soc_bucket_kwh , 1e-6 )
205210 if not slots or len (slots ) > 576 or (planner .beam_width is None and len (slots ) > 12 ):
206211 raise PlannerInputError ("Horizon must have 1..576 slots (at most 12 for exhaustive search)" )
@@ -312,18 +317,28 @@ def _segment(duration, house_kw, ev_kw, pv_kw, soc, mode, battery, ev, site):
312317def _resolve_action (slot , ev_on , mode , node , battery , ev , site ):
313318 """Compute a complete physical step, never a partially served household/EV load."""
314319 on_minutes = 0.0
320+ discharge_limit = None
315321 if ev_on :
316322 if not _ev_is_available (ev , slot .index ) or slot .start_min >= ev .deadline_min or node .ev_kwh >= ev .stop_soc_kwh - EPSILON :
317323 return None
318324 if not node .on and (node .off_minutes + EPSILON < ev .min_off_minutes or node .battery_kwh + EPSILON < battery .capacity_kwh * ev .restart_battery_percent / 100 ):
319325 return None
320326 on_minutes = min (slot .duration_min , ev .deadline_min - slot .start_min , (ev .stop_soc_kwh - node .ev_kwh ) / ev .efficiency / ev .rate_kw * 60 )
327+ flow_mode = mode
328+ if mode == BATTERY_LIMITED_DEMAND :
329+ # Still battery-first, but under a reproducible per-period power ceiling.
330+ # The fixed ceiling remains in place after a partly completed EV step;
331+ # it is not a fictitious continuously modulating grid-first controller.
332+ net_kw = (slot .ac_load_kwh - slot .pv_kwh ) * 60 / slot .duration_min + (ev .rate_kw if on_minutes else 0 )
333+ discharge_limit = min (battery .max_discharge_kw , max (0 , math .ceil ((net_kw - site .import_cap_kw ) * 1000 + 2 ) / 1000 ))
334+ battery = replace (battery , max_discharge_kw = discharge_limit )
335+ flow_mode = BATTERY_DEMAND
321336 segments = []
322337 soc = node .battery_kwh
323338 for duration , ev_kw in ((on_minutes , ev .rate_kw ), (slot .duration_min - on_minutes , 0 )):
324339 if duration <= 1e-10 :
325340 continue
326- result = _segment (duration , slot .ac_load_kwh * 60 / slot .duration_min , ev_kw , slot .pv_kwh * 60 / slot .duration_min , soc , mode , battery , ev , site )
341+ result = _segment (duration , slot .ac_load_kwh * 60 / slot .duration_min , ev_kw , slot .pv_kwh * 60 / slot .duration_min , soc , flow_mode , battery , ev , site )
327342 if result is None :
328343 return None
329344 parts , soc = result
@@ -363,6 +378,7 @@ def _resolve_action(slot, ev_on, mode, node, battery, ev, site):
363378 cost ,
364379 on_minutes ,
365380 tuple (segments ),
381+ discharge_limit ,
366382 )
367383
368384
@@ -396,6 +412,25 @@ def _bounded_no_plan(deadline_idx, transitions, max_frontier, reason):
396412 return PlanResult (STATUS_BOUNDED_NO_PLAN , None , (), (), (), (), (), (), diagnostic , deadline_idx )
397413
398414
415+ def _reachable_floor_envelope (slots , battery , site ):
416+ """Optimistic charging bound that preserves time-local floors during pruning.
417+
418+ Assume the EV is off and any grid/PV headroom can charge the battery, even
419+ where an actual discrete action could not use all that headroom. This only
420+ rejects states unable to reach a future floor under the most favourable
421+ charging assumption; it does not claim a feasible continuation exists.
422+ """
423+ required_end = [0.0 ] * len (slots )
424+ required = battery .end_min_kwh
425+ for slot in reversed (slots ):
426+ required = max (required , slot .battery_min_kwh , battery .reserve_kwh )
427+ required_end [slot .index ] = required
428+ headroom = max (0 , site .import_cap_kw + (slot .pv_kwh - slot .ac_load_kwh ) * 60 / slot .duration_min )
429+ gain = min (battery .max_charge_kw , headroom ) * slot .duration_min / 60 * battery .charge_efficiency
430+ required = max (slot .battery_min_kwh , battery .reserve_kwh , required - gain )
431+ return required_end
432+
433+
399434def plan (slots : Sequence [Slot ], ev : EVConfig , battery : BatteryConfig , site : SiteConfig , planner : Optional [PlannerConfig ] = None ) -> PlanResult :
400435 """Jointly search all eligible prices, EV decisions and executable-mode physics.
401436
@@ -405,6 +440,8 @@ def plan(slots: Sequence[Slot], ev: EVConfig, battery: BatteryConfig, site: Site
405440 """
406441 planner = planner if planner is not None else PlannerConfig ()
407442 _validate (slots , ev , battery , site , planner )
443+ modes = BATTERY_MODES + (BATTERY_LIMITED_DEMAND ,) if planner .allow_limited_discharge else BATTERY_MODES
444+ required_end = _reachable_floor_envelope (slots , battery , site )
408445 deadline_idx = _deadline_slot_index (slots , ev .deadline_min )
409446 future = [0.0 ] * (len (slots ) + 1 )
410447 for i in range (len (slots ) - 1 , - 1 , - 1 ):
@@ -420,12 +457,14 @@ def plan(slots: Sequence[Slot], ev: EVConfig, battery: BatteryConfig, site: Site
420457 for i , slot in enumerate (slots ):
421458 next_states = {}
422459 for node in frontier :
423- for mode in BATTERY_MODES :
460+ for mode in modes :
424461 for on in (False , True ):
425462 transitions += 1
426463 action = _resolve_action (slot , on , mode , node , battery , ev , site )
427464 if action is None :
428465 continue
466+ if action .battery_soc_kwh_end < required_end [i ] - EPSILON :
467+ continue
429468 if action .ev_soc_kwh_end + future [i + 1 ] < ev .stop_soc_kwh - EPSILON :
430469 continue
431470 if i == len (slots ) - 1 and action .battery_soc_kwh_end < battery .end_min_kwh - EPSILON :
@@ -464,7 +503,7 @@ def plan(slots: Sequence[Slot], ev: EVConfig, battery: BatteryConfig, site: Site
464503 ev_stop_target_met = True ,
465504 battery_end_min_met = True ,
466505 ev_on_slot_count = sum (a .ev_action == EV_ON for a in actions ),
467- mode_counts = tuple ((mode , sum (a .battery_mode == mode for a in actions )) for mode in BATTERY_MODES ),
506+ mode_counts = tuple ((mode , sum (a .battery_mode == mode for a in actions )) for mode in modes ),
468507 notes = notes ,
469508 transitions_evaluated = transitions ,
470509 max_frontier_size = max_frontier ,
0 commit comments