Skip to content

Commit 24fde5a

Browse files
feat(plan): model-based clipping — prune plan slots that are dead in the central forecast (#4491)
* 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> * refactor(plan): drop the clip removal branches now subsumed by the prune prune_dead_plan_slots decides whether a slot achieves anything by asking the model, which makes the trajectory-based removal heuristics in clip_charge_slots and clip_export_slots redundant. Instrumenting every branch across the 20 scenario benchmark shows the removal branches fire 31 times with the prune disabled and 0 times with it enabled, while both clip-up branches keep firing unchanged (111 fires). The same holds on the real captures from #4453/#4478. Removed: freeze-export-at-100%, no-SoC-above-reserve (#4171/#4434), phantom export (#4453/#4487), export target-unreachable, and charge never-reaches-limit. What remains in both functions is limit adjustment only - narrowing a requested limit to what the window can actually achieve, so the target sent to the inverter matches the simulated plan and adjacent windows merge. The charge freeze-to-charge-at-100% conversion is kept: it rewrites a limit rather than removing a window, and covers windows the prune skips. The prune deliberately does not trial a window covering the current minute (#4402), so an in-progress phantom is no longer converted in place; it is re-planned on the next cycle from real inverter state instead. Random benchmark over 20 scenarios before vs after this removal: plan metric and cost identical 20/20, runtime unchanged. Debug cases pass unchanged - the expected files regenerated for the prune itself needed no further update, which is independent evidence the branches were dead. Tests that asserted the removed behaviour are rewritten to assert the new contract (clipping adjusts limits and never removes); the removal behaviour they covered is now exercised by the prune tests and the debug case regression. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plan): let the prune trial in-progress slots too The prune skipped any window covering the current minute, on the grounds that #4402 forbids cancelling an in-progress export. That over-applied the guard: #4402's regression came from writing back a change scored on optimise_export's adjusted metric (commitment bonus plus tie-break weightings) with no check that the whole plan improved, and its fix was to gate on the unadjusted whole-plan metric - exactly what the prune trial already uses. An in-progress export worth anything fails that gate and is kept. Skipping it also left the worst gap: the in-progress window is the slot being executed right now, so a dead one there is precisely the spurious command that reaches the inverter - and since the clip removal branches were dropped in favour of the prune, nothing covered it at all. Probing the 20 scenario benchmark, all 15 in-progress slots are worth real money to keep (+0.41p to +25.91p if removed), so none are pruned and the plans are identical 20/20 on metric and cost. Runtime +0.03s average for the extra trials. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(random): start scenarios part way through a plan slot Every scenario ran from the template's minutes_now of 08:00, exactly on a 30 minute boundary, so a window covering the current minute always had its full length remaining. Partially-elapsed slots - the shape that matters for in-progress pruning and for anything that reasons about the remainder of the window being executed - were never generated. Scenarios now carry a clock offset sampled from 0/5/10/15/20/25 minutes (5 minutes being predbat's run cadence) and start that far into the slot. The offset is drawn last in the generator's random sequence, so re-generating an existing seed leaves every other parameter unchanged, and it is applied from the slot boundary rather than the current clock so that applying scenarios in a loop cannot accumulate offsets. Scenario files written before this carry no "clock" entry and keep the template's own minutes_now: re-running the committed cases/random_scenarios.yaml gives metric and cost identical to before on all 20, so previously recorded benchmark results stay comparable. A freshly generated set has 16 of 20 scenarios starting mid-slot and produces in-progress windows with 5, 15 and 25 minutes remaining (previously always 30). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(random): randomise scenario start time, and cover the whole PV horizon Replaces the within-slot clock offset with a random minute-of-day (still on predbat's 5 minute cadence), so scenarios start at every point of the tariff and solar day - overnight cheap windows, the evening peak, mid-generation - rather than all at the template's 08:00. Start times in the regenerated set span 00:10 to 22:10 with 15 of 20 landing off a 30 minute boundary, so partially-elapsed slots are now generated as a matter of course. Fixes a harness bug the randomisation would otherwise have made much worse: step_data_history reads forward series at (minute + minutes_now), but expand_pv_forecast only generated 0..forecast_minutes, so the tail of every horizon silently had no PV at all - 8 hours' worth even at the fixed 08:00 start, and nearly the whole horizon for a scenario starting late in the day. It now runs to minutes_now + forecast_minutes. cases/random_scenarios.yaml is regenerated, which resets the benchmark baseline: the scenarios are substantially richer (mean optimise time 1.3s -> 12.9s, because the horizon now actually contains solar), so results are not comparable with runs recorded before this commit. Re-measured on the new set, prune vs the pre-prune base: nominal cost better by 3.36p on average (best -43.21p, worst +0.06p, which is within the accumulated per-trial epsilon), pv-weighted metric +0.25p on average as pure pessimistic-branch insurance is stripped, optimise time +0.8s (6%). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 4bb7841 commit 24fde5a

9 files changed

Lines changed: 25187 additions & 24817 deletions

apps/predbat/plan.py

Lines changed: 106 additions & 71 deletions
Large diffs are not rendered by default.

apps/predbat/tests/test_clip_charge_slots.py

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ def run_clip_charge_slots_tests(my_predbat):
1717
failed = False
1818
failed |= test_disabled_window_ignored(my_predbat)
1919
failed |= test_passed_window_clipped(my_predbat)
20-
failed |= test_clip_off_soc_above_limit(my_predbat)
20+
failed |= test_clip_left_alone_soc_above_limit(my_predbat)
2121
failed |= test_clip_off_preserves_reserve(my_predbat)
2222
failed |= test_clip_up_soc_max_below_limit(my_predbat)
2323
failed |= test_clip_up_soc_max_within_margin_of_limit(my_predbat)
@@ -122,25 +122,23 @@ def test_passed_window_clipped(my_predbat):
122122
return failed
123123

124124

125-
def test_clip_off_soc_above_limit(my_predbat):
126-
"""Charge window should be clipped off when soc_min is well above the charge limit"""
127-
print("**** test_clip_charge_off_soc_above_limit ****")
125+
def test_clip_left_alone_soc_above_limit(my_predbat):
126+
"""Clipping no longer removes a charge window whose SoC is already above the limit - deciding a slot
127+
achieves nothing is prune_dead_plan_slots' job, which asks the model instead of the SoC trace"""
128+
print("**** test_clip_charge_left_alone_soc_above_limit ****")
128129
failed = False
129130
setup(my_predbat)
130131

131132
minutes_now = 720
132133
windows = [make_window(720, 750)]
133134
limits = [3.0] # Charge to 3 kWh (30% of 10)
134-
# SoC at 8 kWh (80%) which is well above 30% + 1%
135+
# SoC at 8 kWh (80%) which is well above the limit - nothing to charge
135136
predict_soc = make_predict_soc(minutes_now, 8.0, 60)
136137

137138
result_windows, result_limits = my_predbat.clip_charge_slots(minutes_now, predict_soc, windows, limits, 1, 5)
138139

139-
if result_limits[0] != 0:
140-
print("ERROR: Expected charge window clipped off (0) but got {}".format(result_limits[0]))
141-
failed = True
142-
if result_windows[0]["target"] != 0:
143-
print("ERROR: Expected target 0 but got {}".format(result_windows[0]["target"]))
140+
if result_limits[0] != 3.0:
141+
print("ERROR: Charge limit was modified by clipping, expected 3.0 but got {}".format(result_limits[0]))
144142
failed = True
145143

146144
if not failed:
@@ -457,14 +455,14 @@ def test_multiple_windows_mixed(my_predbat):
457455

458456
minutes_now = 720
459457
windows = [
460-
make_window(720, 750), # Window 0: SoC well above limit -> clip off
458+
make_window(720, 750), # Window 0: SoC well above limit -> left alone (prune removes it)
461459
make_window(780, 810), # Window 1: freeze charge at 100% SoC -> convert to full charge
462460
make_window(840, 870), # Window 2: SoC below limit -> clip up
463461
]
464462
limits = [3.0, my_predbat.reserve, 8.0]
465463

466464
predict_soc = {}
467-
# Window 0: relative minutes 0-30, SoC at 8.0, limit is 3.0 (30%) -> clip off
465+
# Window 0: relative minutes 0-30, SoC at 8.0, limit is 3.0 (30%) -> left alone
468466
for minute in range(0, 35, 5):
469467
predict_soc[minute] = 8.0
470468
# Window 1: relative minutes 60-90, SoC at soc_max -> freeze charge to full charge
@@ -476,9 +474,9 @@ def test_multiple_windows_mixed(my_predbat):
476474

477475
result_windows, result_limits = my_predbat.clip_charge_slots(minutes_now, predict_soc, windows, limits, 3, 5)
478476

479-
# Window 0: clipped off
480-
if result_limits[0] != 0:
481-
print("ERROR: Window 0 expected clipped off (0) but got {}".format(result_limits[0]))
477+
# Window 0: SoC already above the limit - clipping leaves it alone now (removal is the prune's job)
478+
if result_limits[0] != 3.0:
479+
print("ERROR: Window 0 expected left alone at 3.0 but got {}".format(result_limits[0]))
482480
failed = True
483481

484482
# Window 1: freeze charge at 100% -> converted to soc_max

apps/predbat/tests/test_clip_export_slots.py

Lines changed: 34 additions & 186 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,12 @@ def run_clip_export_slots_tests(my_predbat):
1515
Tests for clip_export_slots method
1616
"""
1717
failed = False
18-
failed |= test_freeze_export_clipped_at_100_soc(my_predbat)
19-
failed |= test_freeze_export_manual_preserved_at_100_soc(my_predbat)
18+
failed |= test_freeze_export_left_alone_at_100_soc(my_predbat)
2019
failed |= test_freeze_export_kept_when_soc_below_max(my_predbat)
21-
failed |= test_normal_export_clipped_off_when_soc_below_limit(my_predbat)
20+
failed |= test_normal_export_left_alone_when_soc_below_limit(my_predbat)
2221
failed |= test_normal_export_clipped_up_when_soc_above_limit(my_predbat)
23-
failed |= test_normal_export_clipped_off_when_soc_never_above_reserve(my_predbat)
2422
failed |= test_normal_export_clipped_up_when_soc_above_reserve_with_zero_limit(my_predbat)
25-
failed |= test_normal_export_clipped_to_freeze_when_soc_flat_above_limit(my_predbat)
26-
failed |= test_normal_export_clipped_off_when_soc_flat_at_full(my_predbat)
27-
failed |= test_normal_export_clipped_off_when_soc_flat_and_freeze_unsupported(my_predbat)
28-
failed |= test_manual_export_preserved_when_soc_flat_above_limit(my_predbat)
23+
failed |= test_normal_export_clipped_up_when_soc_flat_above_limit(my_predbat)
2924
failed |= test_disabled_window_ignored(my_predbat)
3025
failed |= test_passed_window_clipped(my_predbat)
3126
failed |= test_multiple_windows_mixed(my_predbat)
@@ -64,9 +59,10 @@ def setup(my_predbat):
6459
my_predbat.manual_export_times = []
6560

6661

67-
def test_freeze_export_clipped_at_100_soc(my_predbat):
68-
"""Freeze export (limit=99) should be clipped to 100 when SoC is at soc_max throughout the window"""
69-
print("**** test_freeze_export_clipped_at_100_soc ****")
62+
def test_freeze_export_left_alone_at_100_soc(my_predbat):
63+
"""Clipping no longer removes a freeze export that sits at 100% SoC - deciding a slot achieves
64+
nothing is prune_dead_plan_slots' job now, so clipping must leave the limit untouched"""
65+
print("**** test_freeze_export_left_alone_at_100_soc ****")
7066
failed = False
7167
setup(my_predbat)
7268

@@ -77,40 +73,8 @@ def test_freeze_export_clipped_at_100_soc(my_predbat):
7773

7874
result_windows, result_limits = my_predbat.clip_export_slots(minutes_now, predict_soc, windows, limits, 1, 5)
7975

80-
if result_limits[0] != 100.0:
81-
print("ERROR: Expected limit 100.0 but got {}".format(result_limits[0]))
82-
failed = True
83-
if result_windows[0]["target"] != 100:
84-
print("ERROR: Expected target 100 but got {}".format(result_windows[0]["target"]))
85-
failed = True
86-
87-
if not failed:
88-
print("PASS")
89-
return failed
90-
91-
92-
def test_freeze_export_manual_preserved_at_100_soc(my_predbat):
93-
"""Manual freeze export (limit=99) should NOT be clipped even when SoC is at soc_max (bug fix for #3657)"""
94-
print("**** test_freeze_export_manual_preserved_at_100_soc ****")
95-
failed = False
96-
setup(my_predbat)
97-
98-
minutes_now = 720
99-
window_start = 720
100-
windows = [make_window(window_start, 750)]
101-
limits = [99.0]
102-
predict_soc = make_predict_soc(minutes_now, my_predbat.soc_max, 60)
103-
104-
# Mark this window as a manual freeze export
105-
my_predbat.manual_freeze_export_times = [window_start]
106-
107-
result_windows, result_limits = my_predbat.clip_export_slots(minutes_now, predict_soc, windows, limits, 1, 5)
108-
10976
if result_limits[0] != 99.0:
110-
print("ERROR: Manual freeze export was clipped! Expected limit 99.0 but got {}".format(result_limits[0]))
111-
failed = True
112-
if result_windows[0]["target"] != 99.0:
113-
print("ERROR: Manual freeze export target changed! Expected 99.0 but got {}".format(result_windows[0]["target"]))
77+
print("ERROR: Freeze export was modified by clipping, expected 99.0 but got {}".format(result_limits[0]))
11478
failed = True
11579

11680
if not failed:
@@ -141,25 +105,23 @@ def test_freeze_export_kept_when_soc_below_max(my_predbat):
141105
return failed
142106

143107

144-
def test_normal_export_clipped_off_when_soc_below_limit(my_predbat):
145-
"""Normal export window should be clipped off (set to 100) when soc_max < limit_soc"""
146-
print("**** test_normal_export_clipped_off_when_soc_below_limit ****")
108+
def test_normal_export_left_alone_when_soc_below_limit(my_predbat):
109+
"""A window whose SoC never reaches its export target is no longer clipped off here - removal is
110+
prune_dead_plan_slots' job. Clipping only narrows limits towards what is achievable, and there is
111+
nothing to narrow when the battery is below the target."""
112+
print("**** test_normal_export_left_alone_when_soc_below_limit ****")
147113
failed = False
148114
setup(my_predbat)
149115

150116
minutes_now = 720
151117
windows = [make_window(720, 750)]
152-
limits = [50.0] # Export at 50%
153-
# SoC well below the 50% export limit (limit_soc = 10 * 50/100 = 5.0, soc_max in window = 2.0)
154-
predict_soc = make_predict_soc(minutes_now, 2.0, 60)
118+
limits = [50.0] # Export at 50% (limit_soc = 5.0)
119+
predict_soc = make_predict_soc(minutes_now, 2.0, 60) # SoC well below the target
155120

156121
result_windows, result_limits = my_predbat.clip_export_slots(minutes_now, predict_soc, windows, limits, 1, 5)
157122

158-
if result_limits[0] != 100.0:
159-
print("ERROR: Expected export clipped off (100.0) but got {}".format(result_limits[0]))
160-
failed = True
161-
if result_windows[0]["target"] != 100.0:
162-
print("ERROR: Expected target 100.0 but got {}".format(result_windows[0]["target"]))
123+
if result_limits[0] != 50.0:
124+
print("ERROR: Export limit was modified by clipping, expected 50.0 but got {}".format(result_limits[0]))
163125
failed = True
164126

165127
if not failed:
@@ -191,34 +153,6 @@ def test_normal_export_clipped_up_when_soc_above_limit(my_predbat):
191153
return failed
192154

193155

194-
def test_normal_export_clipped_off_when_soc_never_above_reserve(my_predbat):
195-
"""A window whose SoC never rises above reserve has nothing exportable and should be clipped off,
196-
even when the requested limit is 0% (drain to empty) - regression test for #4171"""
197-
print("**** test_normal_export_clipped_off_when_soc_never_above_reserve ****")
198-
failed = False
199-
setup(my_predbat)
200-
my_predbat.reserve = 0.5
201-
202-
minutes_now = 720
203-
windows = [make_window(720, 750)]
204-
limits = [0.0] # Export to 0% (drain to empty)
205-
# SoC flat at reserve throughout the window - nothing above reserve to export
206-
predict_soc = make_predict_soc(minutes_now, 0.5, 60)
207-
208-
result_windows, result_limits = my_predbat.clip_export_slots(minutes_now, predict_soc, windows, limits, 1, 5)
209-
210-
if result_limits[0] != 100.0:
211-
print("ERROR: Expected export clipped off (100.0) but got {}".format(result_limits[0]))
212-
failed = True
213-
if result_windows[0]["target"] != 100.0:
214-
print("ERROR: Expected target 100.0 but got {}".format(result_windows[0]["target"]))
215-
failed = True
216-
217-
if not failed:
218-
print("PASS")
219-
return failed
220-
221-
222156
def test_normal_export_clipped_up_when_soc_above_reserve_with_zero_limit(my_predbat):
223157
"""A 0% export request should still be clipped up (not off) when SoC genuinely rises above reserve,
224158
so the reserve-floor check doesn't swallow the existing clip-up behaviour"""
@@ -244,111 +178,25 @@ def test_normal_export_clipped_up_when_soc_above_reserve_with_zero_limit(my_pred
244178
return failed
245179

246180

247-
def test_normal_export_clipped_to_freeze_when_soc_flat_above_limit(my_predbat):
248-
"""A forced export window whose predicted SoC stays flat above the requested limit moved no energy in
249-
the simulation (e.g. PV already saturates the export limit) - a phantom export. In the simulation the
250-
window pinned SoC (blocking charge like a freeze does), so convert it to freeze export (99) to keep
251-
the executed plan faithful to what was scored, while removing the force-export command that would
252-
dump to grid if PV dips - regression test for the lone-export artifact in #4453.
253-
If conditions later worsen (the PV10 case), a genuine export re-appears on a subsequent plan."""
254-
print("**** test_normal_export_clipped_to_freeze_when_soc_flat_above_limit ****")
255-
failed = False
256-
setup(my_predbat)
257-
my_predbat.set_export_freeze = True
258-
259-
minutes_now = 720
260-
windows = [make_window(720, 750)]
261-
limits = [50.0] # Export to 50% (limit_soc = 5.0)
262-
# SoC flat at 8.0 for the whole window: the commanded drain to 5.0 never happened
263-
predict_soc = make_predict_soc(minutes_now, 8.0, 60)
264-
265-
result_windows, result_limits = my_predbat.clip_export_slots(minutes_now, predict_soc, windows, limits, 1, 5)
266-
267-
if result_limits[0] != 99.0:
268-
print("ERROR: Expected phantom export converted to freeze (99.0) but got {}".format(result_limits[0]))
269-
failed = True
270-
if result_windows[0]["target"] != 99.0:
271-
print("ERROR: Expected target 99.0 but got {}".format(result_windows[0]["target"]))
272-
failed = True
273-
274-
if not failed:
275-
print("PASS")
276-
return failed
277-
278-
279-
def test_normal_export_clipped_off_when_soc_flat_at_full(my_predbat):
280-
"""A phantom export whose flat SoC is at soc_max must be clipped off (100), not converted to freeze:
281-
the native freeze rule clips a 99 window off when SoC is pinned at 100%, and the conversion must not
282-
produce a freeze window that rule would have removed (review finding on #4487)"""
283-
print("**** test_normal_export_clipped_off_when_soc_flat_at_full ****")
284-
failed = False
285-
setup(my_predbat)
286-
my_predbat.set_export_freeze = True
287-
288-
minutes_now = 720
289-
windows = [make_window(720, 750)]
290-
limits = [50.0]
291-
# SoC pinned at full for the whole window: the drain never happened AND a freeze would be pointless
292-
predict_soc = make_predict_soc(minutes_now, my_predbat.soc_max, 60)
293-
294-
result_windows, result_limits = my_predbat.clip_export_slots(minutes_now, predict_soc, windows, limits, 1, 5)
295-
296-
if result_limits[0] != 100.0:
297-
print("ERROR: Expected phantom export at full SoC clipped off (100.0) but got {}".format(result_limits[0]))
298-
failed = True
299-
if result_windows[0]["target"] != 100.0:
300-
print("ERROR: Expected target 100.0 but got {}".format(result_windows[0]["target"]))
301-
failed = True
302-
303-
if not failed:
304-
print("PASS")
305-
return failed
306-
307-
308-
def test_normal_export_clipped_off_when_soc_flat_and_freeze_unsupported(my_predbat):
309-
"""The phantom-export conversion falls back to off (100) when set_export_freeze is disabled, since a
310-
freeze window cannot be executed on such systems"""
311-
print("**** test_normal_export_clipped_off_when_soc_flat_and_freeze_unsupported ****")
181+
def test_normal_export_clipped_up_when_soc_flat_above_limit(my_predbat):
182+
"""A phantom-shaped window (SoC flat, above the requested limit) now only has its limit narrowed by
183+
clipping - removing it is prune_dead_plan_slots' job, which asks the model rather than the SoC trace"""
184+
print("**** test_normal_export_clipped_up_when_soc_flat_above_limit ****")
312185
failed = False
313186
setup(my_predbat)
314-
my_predbat.set_export_freeze = False
315187

316188
minutes_now = 720
317189
windows = [make_window(720, 750)]
318-
limits = [50.0]
319-
predict_soc = make_predict_soc(minutes_now, 8.0, 60)
190+
limits = [50.0] # limit_soc = 5.0
191+
predict_soc = make_predict_soc(minutes_now, 8.0, 60) # flat, above the target
320192

321193
result_windows, result_limits = my_predbat.clip_export_slots(minutes_now, predict_soc, windows, limits, 1, 5)
322194

323-
if result_limits[0] != 100.0:
324-
print("ERROR: Expected phantom export clipped off (100.0) without freeze support but got {}".format(result_limits[0]))
195+
if result_limits[0] in (99.0, 100.0):
196+
print("ERROR: Clipping removed/converted the window instead of narrowing it, got {}".format(result_limits[0]))
325197
failed = True
326-
327-
if not failed:
328-
print("PASS")
329-
return failed
330-
331-
332-
def test_manual_export_preserved_when_soc_flat_above_limit(my_predbat):
333-
"""A manually forced export window must not be clipped off by the phantom-export prune even when the
334-
simulation shows no energy moving - the user asked for it explicitly"""
335-
print("**** test_manual_export_preserved_when_soc_flat_above_limit ****")
336-
failed = False
337-
setup(my_predbat)
338-
339-
minutes_now = 720
340-
window_start = 720
341-
windows = [make_window(window_start, 750)]
342-
limits = [50.0]
343-
predict_soc = make_predict_soc(minutes_now, 8.0, 60)
344-
345-
# Mark this window as a manual export
346-
my_predbat.manual_export_times = [window_start]
347-
348-
result_windows, result_limits = my_predbat.clip_export_slots(minutes_now, predict_soc, windows, limits, 1, 5)
349-
350-
if result_limits[0] == 100.0:
351-
print("ERROR: Manual export was clipped off by the phantom-export prune")
198+
if result_limits[0] <= 50.0:
199+
print("ERROR: Expected the limit to be clipped up from 50.0, got {}".format(result_limits[0]))
352200
failed = True
353201

354202
if not failed:
@@ -411,9 +259,9 @@ def test_multiple_windows_mixed(my_predbat):
411259

412260
minutes_now = 720
413261
windows = [
414-
make_window(720, 750), # Window 0: freeze export at 100% SoC (should be clipped)
415-
make_window(750, 780), # Window 1: manual freeze export at 100% SoC (should be preserved)
416-
make_window(780, 810), # Window 2: normal export (should remain)
262+
make_window(720, 750), # Window 0: freeze export at 100% SoC (left alone)
263+
make_window(750, 780), # Window 1: manual freeze export at 100% SoC (left alone)
264+
make_window(780, 810), # Window 2: normal export (limit clipped up)
417265
]
418266
limits = [99.0, 99.0, 50.0]
419267

@@ -427,12 +275,12 @@ def test_multiple_windows_mixed(my_predbat):
427275

428276
result_windows, result_limits = my_predbat.clip_export_slots(minutes_now, predict_soc, windows, limits, 3, 5)
429277

430-
# Window 0: auto freeze export at 100% SoC -> clipped to 100
431-
if result_limits[0] != 100.0:
432-
print("ERROR: Window 0 (auto freeze at 100% SoC) expected 100.0 but got {}".format(result_limits[0]))
278+
# Window 0: freeze export is never modified by clipping now
279+
if result_limits[0] != 99.0:
280+
print("ERROR: Window 0 (freeze at 100% SoC) expected 99.0 but got {}".format(result_limits[0]))
433281
failed = True
434282

435-
# Window 1: manual freeze export at 100% SoC -> preserved at 99
283+
# Window 1: manual freeze export likewise untouched
436284
if result_limits[1] != 99.0:
437285
print("ERROR: Window 1 (manual freeze at 100% SoC) expected 99.0 but got {}".format(result_limits[1]))
438286
failed = True

0 commit comments

Comments
 (0)