Skip to content

Commit fcf5464

Browse files
committed
feat: preserve household control around gated EV sessions
1 parent da9b736 commit fcf5464

27 files changed

Lines changed: 6333 additions & 76 deletions

‎.cspell/custom-dictionary-workspace.txt‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ deadbanding
140140
deadbeefdeadbeef
141141
dedup
142142
dedupe
143+
deepcopied
143144
dend
144145
denorm
145146
derate
@@ -282,6 +283,7 @@ hainterface
282283
halfhourly
283284
hanchu
284285
hanchuess
286+
handback
285287
hanres
286288
HAOS
287289
harvi
@@ -442,6 +444,8 @@ NODETAIL
442444
nodischarge
443445
nohup
444446
nonboolean
447+
nonfeasible
448+
nonfinal
445449
nonfinite
446450
noopener
447451
nord
@@ -563,6 +567,7 @@ replayer
563567
resetmidnight
564568
resultid
565569
resultmid
570+
retrievalstatus
566571
retryable
567572
reweight
568573
reweighted
@@ -712,6 +717,7 @@ unflushed
712717
unindexed
713718
unnormalised
714719
unparseable
720+
unpassed
715721
unpickled
716722
unpickles
717723
unpushed

‎apps/predbat/joint_site.py‎

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)