Skip to content

Commit 091716c

Browse files
committed
fix: plan bounded battery sharing and preserve future SOC targets
Add limited-discharge actions, future-floor reachability, native segmented replay and a bounded forecast-envelope retry. Keep stale-SOC scenarios explicitly conditional and non-executing.
1 parent 5653cc0 commit 091716c

7 files changed

Lines changed: 401 additions & 22 deletions

File tree

‎apps/predbat/joint_site.py‎

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@
1010
proof of global optimality. Expected availability never establishes connection.
1111
"""
1212

13-
from dataclasses import dataclass
13+
from dataclasses import dataclass, replace
1414
import math
1515
from typing import Optional, Sequence, Tuple
1616

1717
STATUS_FEASIBLE = "feasible"
1818
STATUS_BOUNDED_NO_PLAN = "bounded_no_plan"
1919
BATTERY_DEMAND, BATTERY_HOLD, BATTERY_CHARGE = "demand", "hold", "charge"
2020
BATTERY_MODES = (BATTERY_DEMAND, BATTERY_HOLD, BATTERY_CHARGE)
21+
BATTERY_LIMITED_DEMAND = "limited_demand"
2122
EV_OFF, EV_ON = "off", "on"
2223
EPSILON = 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):
312317
def _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+
399434
def 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

Comments
 (0)