diff --git a/apps/predbat/const.py b/apps/predbat/const.py index 824e2ef23..ac9f3ad37 100644 --- a/apps/predbat/const.py +++ b/apps/predbat/const.py @@ -112,6 +112,12 @@ # revision bump and a rebuild of all platform binaries. EXPORT_LIMIT_FREEZE = 99.0 # Hold SoC, export only genuine PV surplus - no forced discharge EXPORT_LIMIT_IDLE = 100.0 # Export window disabled entirely +# Highest SoC target an export window may aim at. The limit packs the target in the integer part and +# the export power in the fraction, so reserving 99.0 for freeze actually consumes all of +# [99.0, 100.0): a 99% target at reduced power packs to 99.3/99.5/99.7, which reads as neither a +# freeze (not == 99.0) nor a forced export (not < 99.0) and leaves the window doing nothing at all +# (GH#4914). Targets are clamped here instead; 98% and 99% are operationally the same request. +EXPORT_TARGET_MAX_PERCENT = 98 # Create an array of times in the day in 5-minute intervals BASE_TIME = datetime.strptime("00:00:00", "%H:%M:%S") diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index 4e53cb6af..4fcb9d758 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -20,7 +20,7 @@ from datetime import datetime, timedelta from multiprocessing import cpu_count -from const import CLOUD_FACTOR_PV10, CLOUD_WINDOW_MINUTES, PREDICT_STEP, PV_SCENARIO_NOMINAL, PV_SCENARIO_PV10, PV_SCENARIO_PV90, TIME_FORMAT, MINUTE_WATT, EXPORT_LIMIT_FREEZE, EXPORT_LIMIT_IDLE +from const import CLOUD_FACTOR_PV10, CLOUD_WINDOW_MINUTES, PREDICT_STEP, PV_SCENARIO_NOMINAL, PV_SCENARIO_PV10, PV_SCENARIO_PV90, TIME_FORMAT, MINUTE_WATT, EXPORT_LIMIT_FREEZE, EXPORT_LIMIT_IDLE, EXPORT_TARGET_MAX_PERCENT from utils import calc_percent_limit, clone_windows, dp0, dp1, dp2, dp3, dp4, remove_intersecting_windows, in_car_slot from prediction import Prediction @@ -2353,9 +2353,15 @@ def optimise_export(self, window_n, record_charge_windows, try_charge_limit, cha if (this_export_limit in [EXPORT_LIMIT_IDLE, EXPORT_LIMIT_FREEZE]) and (start != window["start"]): continue - # Never go below the minimum level - this_export_limit = max(calc_percent_limit(self.best_soc_min, self.soc_max), int(this_export_limit)) - this_export_limit = this_export_limit + loop_limit - int(loop_limit) + # Never go below the minimum level. The floor is also capped below the reserved + # range: the fraction carries the export power, so a target of 99 would pack into + # [99.0, 100.0) and read as neither a freeze nor a forced export, silently disabling + # the window (GH#4914). Only real targets are capped - the idle and freeze rungs are + # the reserved values themselves and must pass through untouched. + if this_export_limit not in [EXPORT_LIMIT_IDLE, EXPORT_LIMIT_FREEZE]: + soc_floor_percent = min(calc_percent_limit(self.best_soc_min, self.soc_max), EXPORT_TARGET_MAX_PERCENT) + this_export_limit = max(soc_floor_percent, int(this_export_limit)) + this_export_limit = this_export_limit + loop_limit - int(loop_limit) try_options.append([start, this_export_limit]) results.append(self.launch_run_prediction_export(this_export_limit, start, window_n, try_charge_limit, charge_window, try_export_window, try_export, PV_SCENARIO_NOMINAL, all_n, end_record)) @@ -3076,7 +3082,12 @@ def clip_export_slots(self, minutes_now, predict_soc, export_window_best, export target_soc = max(limit_soc, soc_min) limit_soc = max(limit_soc, soc_min - 10 * self.battery_rate_max_discharge * self.battery_rate_max_scaling_discharge) window["target"] = calc_percent_limit(target_soc, self.soc_max) - export_limits_best[window_n] = calc_percent_limit(limit_soc, self.soc_max) + (limit - int(limit)) + # Cap below the reserved range before re-attaching the power fraction, or a + # clip up to 99% would pack into [99.0, 100.0) and read as neither a freeze + # nor a forced export, silently disabling the window (GH#4914). Unlike the + # ladder's floor this needs no config to reach - it is wherever the + # simulation says the battery actually got to. + export_limits_best[window_n] = min(calc_percent_limit(limit_soc, self.soc_max), EXPORT_TARGET_MAX_PERCENT) + (limit - int(limit)) if limit != export_limits_best[window_n] and self.debug_enable: self.log("Clip up export window {} from {} - {} from limit {} to new limit {} target set to {}".format(window_n, window_start, window_end, limit, export_limits_best[window_n], window["target"])) else: diff --git a/apps/predbat/tests/test_clip_export_slots.py b/apps/predbat/tests/test_clip_export_slots.py index 5b3560edf..56c8c2a7b 100644 --- a/apps/predbat/tests/test_clip_export_slots.py +++ b/apps/predbat/tests/test_clip_export_slots.py @@ -21,6 +21,7 @@ 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_up_when_soc_above_reserve_with_zero_limit(my_predbat) failed |= test_normal_export_clipped_up_when_soc_flat_above_limit(my_predbat) + failed |= test_clip_up_never_lands_in_the_reserved_range(my_predbat) failed |= test_disabled_window_ignored(my_predbat) failed |= test_passed_window_clipped(my_predbat) failed |= test_multiple_windows_mixed(my_predbat) @@ -204,6 +205,46 @@ def test_normal_export_clipped_up_when_soc_flat_above_limit(my_predbat): return failed +def test_clip_up_never_lands_in_the_reserved_range(my_predbat): + """A clip up towards a nearly-full battery must not produce a limit in [99.0, 100.0). + + The limit packs the target SoC in the integer part and the export power in the fraction, so a + target of 99 with a low-power rung packs to 99.3/99.5/99.7 - which reads as neither a freeze + (not == 99.0) nor a forced export (not < 99.0), and the window silently does nothing (GH#4914). + Reaching it needs no unusual config here: limit_soc is wherever the simulation says the battery + actually got to, so any barely-discharging window with low power export on can land on 99. + """ + print("**** test_clip_up_never_lands_in_the_reserved_range ****") + failed = False + setup(my_predbat) + + minutes_now = 720 + windows = [make_window(720, 750)] + # A 20% target at 70% export power - the fraction is what makes the packed value ambiguous + limits = [20.3] + # A modest discharge rate (600W) keeps the 10 minute clip margin small, so a nearly-full + # battery clips up to 99% rather than being pulled well clear of the reserved range + my_predbat.battery_rate_max_discharge = 0.01 + my_predbat.battery_rate_max_scaling_discharge = 1.0 + # The battery barely moves and stays essentially full, so the clip up aims at ~99% + predict_soc = make_predict_soc_falling(minutes_now, 9.99, 9.98, 60) + + result_windows, result_limits = my_predbat.clip_export_slots(minutes_now, predict_soc, windows, limits, 1, 5) + + # Pin the exact packed result rather than just excluding the reserved range: a loose check would + # also pass if the clip incorrectly collapsed to the freeze (99.0) or idle (100.0) sentinel, + # which loses the requested forced low-power export just as silently as landing in [99.0, 100.0). + limit = result_limits[0] + expected_limit = 98.3 + if abs(limit - expected_limit) > 0.001: + print("ERROR: clip up produced {}, expected {} (98% target clamp, .3 power fraction preserved) (GH#4914)".format(limit, expected_limit)) + 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 ****") diff --git a/apps/predbat/tests/test_optimise_export_copy.py b/apps/predbat/tests/test_optimise_export_copy.py index d6fb06045..cddcff2c6 100644 --- a/apps/predbat/tests/test_optimise_export_copy.py +++ b/apps/predbat/tests/test_optimise_export_copy.py @@ -42,6 +42,7 @@ def run_optimise_export_copy_tests(my_predbat): failed = False failed |= test_optimise_export_does_not_deepcopy_the_windows(my_predbat) failed |= test_optimise_export_leaves_the_callers_windows_untouched(my_predbat) + failed |= test_optimise_export_floor_clamped_below_reserved_range(my_predbat) return failed @@ -75,6 +76,65 @@ def test_optimise_export_does_not_deepcopy_the_windows(my_predbat): return failed +def test_optimise_export_floor_clamped_below_reserved_range(my_predbat): + """optimise_export's own floor clamp (plan.py, GH#4914) must never hand a candidate of 99 to + the simulation. + + calc_percent_limit(best_soc_min, soc_max) rounding to 99 would otherwise raise the floor to 99, + which packs with a low-power rung's fraction into [99.0, 100.0) - a value that reads as neither + a freeze (not == 99.0) nor a forced export (not < 99.0), silently disabling the window. This + hits the ladder's floor rather than clip_export_slots' post-simulation clamp (already covered in + test_clip_export_slots.py), so it needs its own case. + + launch_run_prediction_export is monkeypatched to record every this_export_limit it is asked to + simulate, rather than asserting on the optimiser's eventual winner - the winner is a metric-based + choice among many candidates, but every candidate that reaches simulation must already be clamped. + """ + print("**** test_optimise_export_floor_clamped_below_reserved_range ****") + failed = False + + windows, record_export_windows, end_record = build_windows(my_predbat) + my_predbat.export_window_best = windows + my_predbat.charge_window_best = [] + my_predbat.charge_limit_best = [] + my_predbat.set_export_freeze = True + my_predbat.set_export_freeze_only = False + my_predbat.set_export_low_power = True + # 9.9 on the fixture's 10.0 kWh battery rounds to calc_percent_limit -> 99, the boundary case + # the clamp exists for. + my_predbat.best_soc_min = 9.9 + + simulated_limits = [] + real_launch = my_predbat.launch_run_prediction_export + + def fake_launch(this_export_limit, *args, **kwargs): + simulated_limits.append(this_export_limit) + return real_launch(this_export_limit, *args, **kwargs) + + my_predbat.launch_run_prediction_export = fake_launch + try: + my_predbat.optimise_export(0, record_export_windows, [], [], windows, [0.0], end_record=end_record) + finally: + my_predbat.launch_run_prediction_export = real_launch + + reserved = [limit for limit in simulated_limits if 99.0 < limit < 100.0] + if reserved: + print("ERROR: optimise_export simulated candidate(s) in the reserved range: {}".format(sorted(set(reserved)))) + failed = True + + # The clamp exists to be reached, not just avoided: confirm a real candidate was actually + # floored to 98 rather than the whole 99-floor branch going untested. + if not any(98.0 <= limit < 99.0 for limit in simulated_limits): + print("ERROR: no candidate reached the 98% clamped floor - test setup does not exercise the fix, got {}".format(sorted(set(simulated_limits)))) + failed = True + + my_predbat.export_window_best = [] + my_predbat.best_soc_min = 0 + if not failed: + print("PASS") + return failed + + def test_optimise_export_leaves_the_callers_windows_untouched(my_predbat): """The caller's window dicts must come back exactly as they went in.