diff --git a/.gitignore b/.gitignore index 98c4ab8b3..17fdb4834 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ coverage/*.txt coverage/*.db coverage/*.html coverage/*.prof +coverage/*.py coverage/cache/ coverage/debug/ coverage/predbat_standalone/ diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index 33828765e..e4e401ee7 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -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) diff --git a/apps/predbat/tests/test_clip_export_slots.py b/apps/predbat/tests/test_clip_export_slots.py index 4526d452a..14be4344e 100644 --- a/apps/predbat/tests/test_clip_export_slots.py +++ b/apps/predbat/tests/test_clip_export_slots.py @@ -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) @@ -40,6 +44,16 @@ 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 @@ -47,6 +61,7 @@ def setup(my_predbat): 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): @@ -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) @@ -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) @@ -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 ****") @@ -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) diff --git a/coverage/cases/predbat_debug_pre_saving1.yaml.expected.json b/coverage/cases/predbat_debug_pre_saving1.yaml.expected.json index c56c818e0..5b678e77a 100644 --- a/coverage/cases/predbat_debug_pre_saving1.yaml.expected.json +++ b/coverage/cases/predbat_debug_pre_saving1.yaml.expected.json @@ -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]}