Skip to content

Commit 0b59cbb

Browse files
feat(plan): model-based clipping - prune plan slots dead in the central forecast
Follow-up to #4487. The heuristic clip branches each target one specific failure shape (battery pinned empty, target unreachable, SoC flat above limit), and #4453/#4478 showed new shapes keep appearing: every gate in the pipeline is tuned for a different pathology, and dead slots thread between them. Replace the guessing with a direct question to the model: remove the slot, re-simulate, did the central forecast change? prune_dead_plan_slots trials each active charge/export slot inside the record window in turn - the slot is removed and the whole plan re-simulated in the nominal (50%) scenario only, one simulation per trial via a new run_prediction_metric(nominal_only=True) option (skips pv10 and pv90). The removal is kept when the nominal metric does not get worse, so slots whose value exists only in the pessimistic branches - or nowhere at all - are dropped. If the pv10/pv90 conditions materialise in reality, the next plan recompute re-creates a genuine slot from actual state. The pass runs after the pre-clip scoring snapshot, so plan selection still compares plans as optimised (#4403). In-progress windows are never trialled (the #4402 commitment - the clip_export_slots phantom branch from #4487 remains as the complementary catch for that case), manual windows are preserved, and each accepted removal updates the running baseline so one removal cannot make the next look free. Random benchmark over 20 scenarios against main: 114 slots pruned, nominal cost of the executed plan never worse than +0.012p (the per-trial epsilon) and dramatically better on two scenarios (-35.18p, -71.61p - plans were carrying slots that cost real money in the central forecast for pessimistic -branch insurance); the pv-weighted metric of the executed plan gives back +0.62p on average (max +5.05p) where pure insurance was stripped, which is the designed trade. Plan runtime unchanged (1.33s avg both sides). Debug case expected files regenerated: agile1's pruned plan is 1.31p cheaper nominally (metric-neutral), pre_saving1's is cost-identical with a +0.21p pv10 residual. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9dbbecd commit 0b59cbb

5 files changed

Lines changed: 446 additions & 24 deletions

File tree

apps/predbat/plan.py

Lines changed: 87 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -838,14 +838,19 @@ def hit_charge_window(self, charge_window, start, end):
838838
window_n += 1
839839
return -1
840840

841-
def run_prediction_metric(self, charge_limit_best, charge_window_best, export_window_best, export_limits_best, end_record=None, save=None):
841+
def run_prediction_metric(self, charge_limit_best, charge_window_best, export_window_best, export_limits_best, end_record=None, save=None, nominal_only=False):
842842
"""
843843
Run a single datapoint for PV and PV10 (and PV90, when enabled) and return the metric
844844
845845
Every optimiser comparison (optimise_charge_limit_price_threads, optimise_charge_limit,
846846
optimise_export) that this baseline is measured against now blends in a pv90 term whenever
847847
pv_metric90_weight > 0, so this baseline must too - otherwise the two sides of every
848848
`n_best_metric < best_metric` comparison sit on different scales.
849+
850+
With nominal_only=True only the nominal (50%) scenario is simulated - one simulation instead
851+
of two or three. The nominal results are mirrored into the pv10 slots so compute_metric's
852+
pv10 adjustment cancels to zero and pv90 is skipped, giving a pure central-forecast metric.
853+
Used by prune_dead_plan_slots, where each trial only asks whether the nominal outcome moved.
849854
"""
850855

851856
if end_record is None:
@@ -860,7 +865,7 @@ def run_prediction_metric(self, charge_limit_best, charge_window_best, export_wi
860865
soc90 = None
861866
cost90 = None
862867
final_iboost90 = 0.0
863-
if self.pv_metric90_weight > 0:
868+
if self.pv_metric90_weight > 0 and not nominal_only:
864869
(cost90, _, _, _, _, soc90, _, _, _, final_iboost90, _) = self.run_prediction(
865870
charge_limit_best,
866871
charge_window_best,
@@ -870,26 +875,27 @@ def run_prediction_metric(self, charge_limit_best, charge_window_best, export_wi
870875
end_record=end_record,
871876
)
872877

873-
(
874-
cost10,
875-
import_kwh_battery10,
876-
import_kwh_house10,
877-
export_kwh10,
878-
soc_min10,
879-
soc10,
880-
soc_min_minute10,
881-
battery_cycle10,
882-
metric_keep10,
883-
final_iboost10,
884-
final_carbon_g10,
885-
) = self.run_prediction(
886-
charge_limit_best,
887-
charge_window_best,
888-
export_window_best,
889-
export_limits_best,
890-
True,
891-
end_record=end_record,
892-
)
878+
if not nominal_only:
879+
(
880+
cost10,
881+
import_kwh_battery10,
882+
import_kwh_house10,
883+
export_kwh10,
884+
soc_min10,
885+
soc10,
886+
soc_min_minute10,
887+
battery_cycle10,
888+
metric_keep10,
889+
final_iboost10,
890+
final_carbon_g10,
891+
) = self.run_prediction(
892+
charge_limit_best,
893+
charge_window_best,
894+
export_window_best,
895+
export_limits_best,
896+
True,
897+
end_record=end_record,
898+
)
893899
# Run new plan
894900
(
895901
cost,
@@ -912,6 +918,11 @@ def run_prediction_metric(self, charge_limit_best, charge_window_best, export_wi
912918
end_record=end_record,
913919
save=save,
914920
)
921+
if nominal_only:
922+
# Mirror the nominal outputs into the pv10 slots so the pv10 adjustment cancels to zero
923+
cost10 = cost
924+
soc10 = soc
925+
final_iboost10 = final_iboost
915926
metric, battery_value = self.compute_metric(
916927
self.end_record, soc, soc10, cost, cost10, final_iboost, final_iboost10, battery_cycle, metric_keep, final_carbon_g, import_kwh_battery, import_kwh_house, export_kwh, soc90=soc90, cost90=cost90, final_iboost90=final_iboost90
917928
)
@@ -1346,6 +1357,10 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
13461357
# Snapshot the plan as optimised, before clipping adjusts the percentages for execution
13471358
preclip_new = (copy.deepcopy(self.charge_limit_best), copy.deepcopy(self.charge_window_best), copy.deepcopy(self.export_window_best), copy.deepcopy(self.export_limits_best))
13481359

1360+
# Model-based clipping: drop slots that do nothing in the central forecast. Runs after the
1361+
# scoring snapshot so plan selection still compares plans as optimised (#4403).
1362+
self.prune_dead_plan_slots()
1363+
13491364
# Filter out any unused export windows
13501365
if self.calculate_best_export and self.export_window_best:
13511366
# Filter out the windows we disabled
@@ -2623,6 +2638,56 @@ def find_spare_energy(self, predict_soc, predict_export, step, first_charge):
26232638
},
26242639
)
26252640

2641+
def prune_dead_plan_slots(self):
2642+
"""Model-based clipping: remove plan slots that do nothing in the central forecast.
2643+
2644+
Each active charge/export slot inside the record window is trialled in turn: the slot is
2645+
removed (export -> 100, charge -> 0) and the whole plan re-simulated in the nominal (50%)
2646+
scenario only - one simulation per trial via run_prediction_metric(nominal_only=True). The
2647+
removal is kept when the nominal metric does not get worse, so slots whose value exists only
2648+
in the pv10/pv90 branches (or nowhere at all - phantom exports, dead freezes) are dropped.
2649+
If the pessimistic scenario materialises in reality, the next plan recompute re-creates a
2650+
genuine slot from actual state, so nothing is permanently lost.
2651+
2652+
Runs after the pre-clip scoring snapshot (see calculate_plan), so plan selection still
2653+
compares plans as optimised (#4403). Windows covering the current minute are never trialled
2654+
(an in-progress export must not be cancelled mid-flight - the #4402 commitment) and manual
2655+
windows are preserved. Later removals are compared against the running baseline, so an
2656+
earlier accepted removal cannot make a later one look free.
2657+
"""
2658+
eps = 0.02
2659+
record_limit = self.end_record + self.minutes_now
2660+
baseline = None
2661+
pruned = 0
2662+
trials = 0
2663+
for typ, windows, limits, off_value in (("export", self.export_window_best, self.export_limits_best, 100.0), ("charge", self.charge_window_best, self.charge_limit_best, 0)):
2664+
for window_n, window in enumerate(windows):
2665+
limit = limits[window_n]
2666+
active = (limit < 100.0) if typ == "export" else (limit > 0)
2667+
if not active:
2668+
continue
2669+
if window["end"] <= self.minutes_now or window["start"] >= record_limit:
2670+
continue
2671+
if window["start"] <= self.minutes_now < window["end"]:
2672+
continue
2673+
if window["start"] in self.manual_all_times:
2674+
continue
2675+
if baseline is None:
2676+
baseline = self.run_prediction_metric(self.charge_limit_best, self.charge_window_best, self.export_window_best, self.export_limits_best, end_record=self.end_record, nominal_only=True)[0]
2677+
limits[window_n] = off_value
2678+
trial = self.run_prediction_metric(self.charge_limit_best, self.charge_window_best, self.export_window_best, self.export_limits_best, end_record=self.end_record, nominal_only=True)[0]
2679+
trials += 1
2680+
if trial <= baseline + eps:
2681+
if self.debug_enable:
2682+
self.log("Prune dead {} slot {} {}-{} limit {} - nominal metric {} vs baseline {}".format(typ, window_n, self.time_abs_str(window["start"]), self.time_abs_str(window["end"]), limit, dp2(trial), dp2(baseline)))
2683+
baseline = trial
2684+
pruned += 1
2685+
else:
2686+
limits[window_n] = limit
2687+
if pruned:
2688+
self.log("Pruned {} dead plan slot(s) in {} trial(s)".format(pruned, trials))
2689+
return pruned
2690+
26262691
def clip_charge_slots(self, minutes_now, predict_soc, charge_window_best, charge_limit_best, record_charge_windows, step):
26272692
"""
26282693
Clip charge slots that are useless as they don't charge at all

0 commit comments

Comments
 (0)