Skip to content
Closed
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
6 changes: 6 additions & 0 deletions apps/predbat/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
21 changes: 16 additions & 5 deletions apps/predbat/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Comment thread
chalfontchubby marked this conversation as resolved.
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))
Expand Down Expand Up @@ -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:
Expand Down
41 changes: 41 additions & 0 deletions apps/predbat/tests/test_clip_export_slots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 ****")
Expand Down
60 changes: 60 additions & 0 deletions apps/predbat/tests/test_optimise_export_copy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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.

Expand Down
Loading