Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ coverage/*.txt
coverage/*.db
coverage/*.html
coverage/*.prof
coverage/*.py
coverage/cache/
coverage/debug/
coverage/predbat_standalone/
Expand Down
16 changes: 16 additions & 0 deletions apps/predbat/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -2478,6 +2478,22 @@ def clip_export_slots(self, minutes_now, predict_soc, export_window_best, export
export_limits_best[window_n] = 100.0
if self.debug_enable:
self.log("Clip off export window {} from {} - {} from limit {} to new limit {} - no SoC above reserve in this window".format(window_n, window_start, window_end, limit, export_limits_best[window_n]))
elif dp2(soc_min) == dp2(soc_max) and soc_min > limit_soc and window["start"] not in self.manual_export_times:
# SoC is above the requested limit yet completely flat across the window: the commanded
# export moved no energy in the simulation (e.g. PV already saturates the export limit),
# so this is a phantom export. In the simulation it pinned SoC - the same behaviour as a
# freeze - so convert it to freeze export to keep the executed plan faithful to what was
# scored, while dropping the force-export command that would dump to grid if PV dips
# below the export limit. Falls back to off when freeze is unsupported, and when SoC is
# pinned at 100% the freeze would be pointless, so clip off - the same rule the limit==99
# branch above applies to native freeze windows. If conditions worsen (the PV10 case) a
# genuine export re-appears on a later plan recompute. Manual exports are preserved.
# See #4453 (lone exports on flat rates).
new_limit = 99.0 if (self.set_export_freeze and dp1(soc_min) != dp1(self.soc_max)) else 100.0
window["target"] = new_limit
export_limits_best[window_n] = new_limit
if self.debug_enable:
self.log("Clip phantom export window {} from {} - {} from limit {} to {} - SoC flat at {} above limit, no energy moved".format(window_n, window_start, window_end, limit, new_limit, dp2(soc_min)))
elif soc_min > limit_soc:
# Give it 10 minute margin
target_soc = max(limit_soc, soc_min)
Expand Down
139 changes: 134 additions & 5 deletions apps/predbat/tests/test_clip_export_slots.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ def run_clip_export_slots_tests(my_predbat):
failed |= test_normal_export_clipped_up_when_soc_above_limit(my_predbat)
failed |= test_normal_export_clipped_off_when_soc_never_above_reserve(my_predbat)
failed |= test_normal_export_clipped_up_when_soc_above_reserve_with_zero_limit(my_predbat)
failed |= test_normal_export_clipped_to_freeze_when_soc_flat_above_limit(my_predbat)
failed |= test_normal_export_clipped_off_when_soc_flat_at_full(my_predbat)
failed |= test_normal_export_clipped_off_when_soc_flat_and_freeze_unsupported(my_predbat)
failed |= test_manual_export_preserved_when_soc_flat_above_limit(my_predbat)
failed |= test_disabled_window_ignored(my_predbat)
failed |= test_passed_window_clipped(my_predbat)
failed |= test_multiple_windows_mixed(my_predbat)
Expand All @@ -40,13 +44,24 @@ def make_predict_soc(minutes_now, soc_value, duration_minutes=60):
return predict_soc


def make_predict_soc_falling(minutes_now, soc_start, soc_end, duration_minutes=60):
"""Build a predict_soc dict with SoC falling linearly from soc_start to soc_end over duration_minutes,
the physical signature of an export that is genuinely moving energy"""
predict_soc = {}
steps = duration_minutes // 5
for i, minute in enumerate(range(0, duration_minutes + 5, 5)):
predict_soc[minute] = soc_start + (soc_end - soc_start) * min(i, steps) / steps
return predict_soc


def setup(my_predbat):
reset_inverter(my_predbat)
my_predbat.soc_max = 10.0
my_predbat.debug_enable = False
my_predbat.battery_rate_max_discharge = 1 / 60.0
my_predbat.battery_rate_max_scaling_discharge = 1.0
my_predbat.manual_freeze_export_times = []
my_predbat.manual_export_times = []


def test_freeze_export_clipped_at_100_soc(my_predbat):
Expand Down Expand Up @@ -161,8 +176,8 @@ def test_normal_export_clipped_up_when_soc_above_limit(my_predbat):
minutes_now = 720
windows = [make_window(720, 750)]
limits = [20.0] # Export at 20% (limit_soc = 10 * 20/100 = 2.0)
# SoC at 8.0 which is > limit_soc of 2.0
predict_soc = make_predict_soc(minutes_now, 8.0, 60)
# SoC falling from 8.0 to 6.0 (a genuine export in progress), soc_min 6.0 > limit_soc 2.0
predict_soc = make_predict_soc_falling(minutes_now, 8.0, 6.0, 60)

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

Expand Down Expand Up @@ -215,8 +230,8 @@ def test_normal_export_clipped_up_when_soc_above_reserve_with_zero_limit(my_pred
minutes_now = 720
windows = [make_window(720, 750)]
limits = [0.0] # Export to 0% (drain to empty)
# SoC well above reserve - there is real energy available to export
predict_soc = make_predict_soc(minutes_now, 8.0, 60)
# SoC well above reserve and falling - there is real energy available and it is moving
predict_soc = make_predict_soc_falling(minutes_now, 8.0, 6.0, 60)

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

Expand All @@ -229,6 +244,118 @@ def test_normal_export_clipped_up_when_soc_above_reserve_with_zero_limit(my_pred
return failed


def test_normal_export_clipped_to_freeze_when_soc_flat_above_limit(my_predbat):
"""A forced export window whose predicted SoC stays flat above the requested limit moved no energy in
the simulation (e.g. PV already saturates the export limit) - a phantom export. In the simulation the
window pinned SoC (blocking charge like a freeze does), so convert it to freeze export (99) to keep
the executed plan faithful to what was scored, while removing the force-export command that would
dump to grid if PV dips - regression test for the lone-export artifact in #4453.
If conditions later worsen (the PV10 case), a genuine export re-appears on a subsequent plan."""
print("**** test_normal_export_clipped_to_freeze_when_soc_flat_above_limit ****")
failed = False
setup(my_predbat)
my_predbat.set_export_freeze = True

minutes_now = 720
windows = [make_window(720, 750)]
limits = [50.0] # Export to 50% (limit_soc = 5.0)
# SoC flat at 8.0 for the whole window: the commanded drain to 5.0 never happened
predict_soc = make_predict_soc(minutes_now, 8.0, 60)

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

if result_limits[0] != 99.0:
print("ERROR: Expected phantom export converted to freeze (99.0) but got {}".format(result_limits[0]))
failed = True
if result_windows[0]["target"] != 99.0:
print("ERROR: Expected target 99.0 but got {}".format(result_windows[0]["target"]))
failed = True

if not failed:
print("PASS")
return failed


def test_normal_export_clipped_off_when_soc_flat_at_full(my_predbat):
"""A phantom export whose flat SoC is at soc_max must be clipped off (100), not converted to freeze:
the native freeze rule clips a 99 window off when SoC is pinned at 100%, and the conversion must not
produce a freeze window that rule would have removed (review finding on #4487)"""
print("**** test_normal_export_clipped_off_when_soc_flat_at_full ****")
failed = False
setup(my_predbat)
my_predbat.set_export_freeze = True

minutes_now = 720
windows = [make_window(720, 750)]
limits = [50.0]
# SoC pinned at full for the whole window: the drain never happened AND a freeze would be pointless
predict_soc = make_predict_soc(minutes_now, my_predbat.soc_max, 60)

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

if result_limits[0] != 100.0:
print("ERROR: Expected phantom export at full SoC clipped off (100.0) but got {}".format(result_limits[0]))
failed = True
if result_windows[0]["target"] != 100.0:
print("ERROR: Expected target 100.0 but got {}".format(result_windows[0]["target"]))
failed = True

if not failed:
print("PASS")
return failed


def test_normal_export_clipped_off_when_soc_flat_and_freeze_unsupported(my_predbat):
"""The phantom-export conversion falls back to off (100) when set_export_freeze is disabled, since a
freeze window cannot be executed on such systems"""
print("**** test_normal_export_clipped_off_when_soc_flat_and_freeze_unsupported ****")
failed = False
setup(my_predbat)
my_predbat.set_export_freeze = False

minutes_now = 720
windows = [make_window(720, 750)]
limits = [50.0]
predict_soc = make_predict_soc(minutes_now, 8.0, 60)

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

if result_limits[0] != 100.0:
print("ERROR: Expected phantom export clipped off (100.0) without freeze support but got {}".format(result_limits[0]))
failed = True

if not failed:
print("PASS")
return failed


def test_manual_export_preserved_when_soc_flat_above_limit(my_predbat):
"""A manually forced export window must not be clipped off by the phantom-export prune even when the
simulation shows no energy moving - the user asked for it explicitly"""
print("**** test_manual_export_preserved_when_soc_flat_above_limit ****")
failed = False
setup(my_predbat)

minutes_now = 720
window_start = 720
windows = [make_window(window_start, 750)]
limits = [50.0]
predict_soc = make_predict_soc(minutes_now, 8.0, 60)

# Mark this window as a manual export
my_predbat.manual_export_times = [window_start]

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

if result_limits[0] == 100.0:
print("ERROR: Manual export was clipped off by the phantom-export prune")
failed = True

if not failed:
print("PASS")
return failed


def test_disabled_window_ignored(my_predbat):
"""Windows with limit=100 should be ignored and left unchanged"""
print("**** test_disabled_window_ignored ****")
Expand Down Expand Up @@ -293,8 +420,10 @@ def test_multiple_windows_mixed(my_predbat):
# Mark window 1 as manual
my_predbat.manual_freeze_export_times = [750]

# SoC at max throughout
# SoC at max through windows 0 and 1, then falling through window 2 (a genuine export in progress)
predict_soc = make_predict_soc(minutes_now, my_predbat.soc_max, 120)
for i, minute in enumerate(range(60, 95, 5)):
predict_soc[minute] = my_predbat.soc_max - 2.0 * i / 6

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

Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"charge_limit_best": [3.02, 0.38, 0.38, 9.52, 9.52, 9.52, 8.27, 0.38, 9.52, 9.52], "charge_window_best": [{"start": 1020, "end": 1050, "average": 25.58, "target": 3.02}, {"start": 1050, "end": 1080, "average": 25.58, "target": 0.38}, {"start": 1140, "end": 1260, "average": 25.58, "target": 0.38}, {"start": 1410, "end": 1680, "average": 7.0, "target": 9.52}, {"start": 1710, "end": 1770, "average": 7.0, "target": 9.509}, {"start": 1770, "end": 1980, "average": 25.58, "target": 9.52}, {"start": 2040, "end": 2220, "average": 25.58, "target": 8.27}, {"start": 2700, "end": 2820, "average": 25.58, "target": 0.38}, {"start": 2850, "end": 3210, "average": 7.0, "target": 9.52}, {"start": 3210, "end": 3900, "average": 25.58, "target": 9.52}], "export_window_best": [{"average": 75.0, "end": 1140, "start": 1080, "set": 69.8, "start_orig": 1080, "target": 12}, {"average": 15.0, "end": 1410, "start": 1320, "set": 14.0, "target": 4}, {"average": 15.0, "end": 1710, "start": 1680, "set": 14.0, "target": 89}], "export_limits_best": [7.0, 0, 85]}
{"charge_limit_best": [3.02, 0.38, 0.38, 9.52, 9.52, 9.52, 8.27, 0.38, 9.52, 9.52], "charge_window_best": [{"start": 1020, "end": 1050, "average": 25.58, "target": 3.02}, {"start": 1050, "end": 1080, "average": 25.58, "target": 0.38}, {"start": 1140, "end": 1260, "average": 25.58, "target": 0.38}, {"start": 1410, "end": 1680, "average": 7.0, "target": 9.52}, {"start": 1710, "end": 1770, "average": 7.0, "target": 9.509}, {"start": 1770, "end": 1980, "average": 25.58, "target": 9.52}, {"start": 2040, "end": 2220, "average": 25.58, "target": 8.27}, {"start": 2700, "end": 2820, "average": 25.58, "target": 0.38}, {"start": 2850, "end": 3210, "average": 7.0, "target": 9.52}, {"start": 3210, "end": 3900, "average": 25.58, "target": 9.52}], "export_window_best": [{"average": 75.0, "end": 1140, "start": 1080, "set": 69.8, "start_orig": 1080, "target": 12}, {"average": 15.0, "end": 1710, "start": 1680, "set": 14.0, "target": 89}], "export_limits_best": [7.0, 85]}
Loading