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
9 changes: 7 additions & 2 deletions apps/predbat/prediction.py
Original file line number Diff line number Diff line change
Expand Up @@ -993,8 +993,11 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
total_inverted = get_total_inverted(battery_draw, pv_dc, pv_ac, inverter_loss, inverter_hybrid)
if total_inverted > inverter_limit:
over_limit = total_inverted - inverter_limit
clipped_today += over_limit
pv_ac_before = pv_ac
pv_ac = max(pv_ac - over_limit * inverter_loss, 0)
pv_ac_no_loss = max(pv_ac_before - over_limit, 0)
clipped_today += pv_ac_before - pv_ac_no_loss
Comment on lines +998 to +999
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the hybrid inverter-limit clipping path, over_limit is in the same units as total_inverted (it includes PV as pv_ac / inverter_loss). The current clipped_today calculation mixes units by subtracting over_limit directly from pv_ac_before (AC energy), which will over/under-count clipping when inverter_loss != 1. Consider calculating clipped energy consistently from the actual PV reduction (e.g., based on pv_ac_before - pv_ac), or otherwise convert over_limit into the same units as pv_ac before using it.

Suggested change
pv_ac_no_loss = max(pv_ac_before - over_limit, 0)
clipped_today += pv_ac_before - pv_ac_no_loss
clipped_today += pv_ac_before - pv_ac

Copilot uses AI. Check for mistakes.
total_inverted = get_total_inverted(battery_draw, pv_dc, pv_ac, inverter_loss, inverter_hybrid)
else:
total_inverted = get_total_inverted(battery_draw, pv_dc, pv_ac, inverter_loss, inverter_hybrid)
if total_inverted > inverter_limit:
Expand All @@ -1008,8 +1011,10 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
diff = get_diff(battery_draw, pv_dc, pv_ac, load_yesterday, inverter_loss, inverter_loss_recp)
if diff < 0 and abs(diff) > export_limit:
over_limit = abs(diff) - export_limit
clipped_today += over_limit
# Only solar PV is truly "clipped" (lost energy); excess battery discharge just gets limited
pv_ac_before = pv_ac
pv_ac = max(pv_ac - over_limit, 0)
clipped_today += pv_ac_before - pv_ac

# Adjust battery soc
if battery_draw > 0:
Expand Down
8 changes: 8 additions & 0 deletions apps/predbat/tests/test_infra.py
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,7 @@ def simple_scenario(
ignore_failed=False,
set_charge_freeze=True,
calculate_export_on_pv=True,
assert_clipped=0,
):
"""
No PV, No Load
Expand Down Expand Up @@ -818,6 +819,13 @@ def simple_scenario(
print("ERROR: iBoost running full should be {}".format(assert_iboost_running_full))
failed = True

if save != "none":
total_clipped = prediction.predict_clipped_best[max(prediction.predict_clipped_best.keys())] if prediction.predict_clipped_best else 0
if abs(total_clipped - assert_clipped) >= 0.9:
if not ignore_failed:
print("ERROR: Total clipped {} should be {}".format(total_clipped, assert_clipped))
failed = True

if failed and not ignore_failed:
(
metric,
Expand Down
49 changes: 42 additions & 7 deletions apps/predbat/tests/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ def run_model_tests(my_predbat):
battery_soc=50.0,
inverter_loss=0.8,
hybrid=True,
assert_clipped=2 * 24, # 1 for the battery on DC and 1 for the PV on AC
)
failed |= simple_scenario("load_carbon", my_predbat, 1, 0, assert_final_metric=import_rate * 24, assert_final_soc=0, with_battery=False, carbon=3, assert_final_carbon=3 * 24)
failed |= simple_scenario(
Expand Down Expand Up @@ -487,7 +488,7 @@ def run_model_tests(my_predbat):
failed |= simple_scenario("pv_only_bat_ac_clips2b", my_predbat, 0, 2, assert_final_metric=-export_rate * 24, assert_final_soc=24, with_battery=True, battery_rate_max_charge_dc=2.0)
failed |= simple_scenario("pv_only_bat_ac_clips2c", my_predbat, 0, 2, assert_final_metric=-export_rate * 24, assert_final_soc=24, with_battery=True, battery_rate_max_charge=2.0)
failed |= simple_scenario("pv_only_bat_ac_clips3", my_predbat, 0, 3, assert_final_metric=-export_rate * 48, assert_final_soc=24, with_battery=True)
failed |= simple_scenario("pv_only_bat_ac_export_limit", my_predbat, 0, 3, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=24, with_battery=True, export_limit=0.5)
failed |= simple_scenario("pv_only_bat_ac_export_limit", my_predbat, 0, 3, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=24, with_battery=True, export_limit=0.5, assert_clipped=24 * 1.5)
failed |= simple_scenario(
"pv_only_bat_ac_export_limit_loss",
my_predbat,
Expand All @@ -498,14 +499,15 @@ def run_model_tests(my_predbat):
with_battery=True,
export_limit=0.1,
inverter_loss=0.5,
assert_clipped=24 * 2.9,
)
failed |= simple_scenario("pv_only_bat_ac_export_limit_load", my_predbat, 0.5, 3, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=24, with_battery=True, export_limit=0.5)
failed |= simple_scenario("pv_only_bat_ac_export_limit_load", my_predbat, 0.5, 3, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=24, with_battery=True, export_limit=0.5, assert_clipped=24 * 1)
failed |= simple_scenario("pv_only_bat_dc_clips2", my_predbat, 0, 2, assert_final_metric=-export_rate * 24, assert_final_soc=24, with_battery=True, hybrid=True)
failed |= simple_scenario("pv_only_bat_dc_clips2dc", my_predbat, 0, 2, assert_final_metric=0, assert_final_soc=48, with_battery=True, hybrid=True, battery_rate_max_charge_dc=2.0)
failed |= simple_scenario("pv_only_bat_dc_clips2dch", my_predbat, 0, 2, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=36, with_battery=True, hybrid=True, battery_rate_max_charge_dc=1.5)
failed |= simple_scenario("pv_only_bat_dc_clips2l", my_predbat, 0, 2, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=24, with_battery=True, hybrid=True, inverter_loss=0.5)
failed |= simple_scenario("pv_only_bat_dc_clips3", my_predbat, 0, 3, assert_final_metric=-export_rate * 24, assert_final_soc=24, with_battery=True, hybrid=True)
failed |= simple_scenario("pv_only_bat_dc_clips3l", my_predbat, 0, 3, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=24, with_battery=True, hybrid=True, inverter_loss=0.5)
failed |= simple_scenario("pv_only_bat_dc_clips3", my_predbat, 0, 3, assert_final_metric=-export_rate * 24, assert_final_soc=24, with_battery=True, hybrid=True, assert_clipped=24 * 1)
failed |= simple_scenario("pv_only_bat_dc_clips3l", my_predbat, 0, 3, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=24, with_battery=True, hybrid=True, inverter_loss=0.5, assert_clipped=24 * 1)
failed |= simple_scenario(
"pv_only_bat_dc_clips3l2",
my_predbat,
Expand All @@ -518,7 +520,7 @@ def run_model_tests(my_predbat):
inverter_loss=0.5,
inverter_limit=2.0,
)
failed |= simple_scenario("pv_only_bat_dc_export_limit", my_predbat, 0, 3, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=24, with_battery=True, hybrid=True, export_limit=0.5)
failed |= simple_scenario("pv_only_bat_dc_export_limit", my_predbat, 0, 3, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=24, with_battery=True, hybrid=True, export_limit=0.5, assert_clipped=24 * 1.5)
failed |= simple_scenario(
"pv_only_bat_dc_export_limit_loss",
my_predbat,
Expand All @@ -530,8 +532,32 @@ def run_model_tests(my_predbat):
hybrid=True,
export_limit=0.1,
inverter_loss=0.5,
assert_clipped=24 * 1.9,
)
failed |= simple_scenario("pv_only_bat_dc_export_limit_load", my_predbat, 0.5, 3, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=24, with_battery=True, hybrid=True, export_limit=0.5)

# Export limit less than battery max discharge rate, no solar - battery should just be rate-limited, no clipping
failed_local, prediction = simple_scenario(
"export_limit_no_clip_no_solar",
my_predbat,
0,
0,
assert_final_metric=-export_rate * 24 * 0.5, # 0.5 kW * 24h = 12 kWh exported at 5p
assert_final_soc=100 - 12, # 12 kWh drained from 100 kWh battery
with_battery=True,
battery_soc=100.0,
battery_size=100,
battery_rate_max_charge=1.0, # 1 kW max discharge, higher than the export limit
export_limit=0.5, # 0.5 kW export limit - less than max battery discharge rate
discharge=0, # export all the way to empty
return_prediction_handle=True,
)
failed |= failed_local
total_clipped = max(prediction.predict_clipped_best.values()) if prediction.predict_clipped_best else 0
if total_clipped > 0:
print("ERROR: export_limit_no_clip_no_solar: clipping should be 0 but got {}".format(total_clipped))
failed = True
Comment on lines +555 to +558
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This scenario manually inspects prediction.predict_clipped_best and prints an error if any clipping occurred. Since simple_scenario now supports assert_clipped, this can be simplified by passing assert_clipped=0 and letting simple_scenario handle the assertion (and you can drop return_prediction_handle=True unless it's needed elsewhere). This reduces duplication and keeps assertion logic in one place.

Copilot uses AI. Check for mistakes.

failed |= simple_scenario("pv_only_bat_dc_export_limit_load", my_predbat, 0.5, 3, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=24, with_battery=True, hybrid=True, export_limit=0.5, assert_clipped=24 * 1)
failed |= simple_scenario("battery_charge", my_predbat, 0, 0, assert_final_metric=import_rate * 10, assert_final_soc=10, with_battery=True, charge=10, battery_size=10)

failed |= simple_scenario("battery_charge_low_off", my_predbat, 0, 0, assert_final_metric=import_rate * 10, assert_final_soc=10, with_battery=True, charge=10, battery_size=10, set_charge_low_power=False, keep=5, assert_keep=24.59)
Expand Down Expand Up @@ -675,6 +701,7 @@ def run_model_tests(my_predbat):
battery_rate_max_charge_dc=2.0,
hybrid=True,
export_limit=10.0,
assert_clipped=24 * 1,
)

failed |= simple_scenario(
Expand Down Expand Up @@ -703,6 +730,7 @@ def run_model_tests(my_predbat):
inverter_loss=0.5,
inverter_limit=2,
hybrid=True,
assert_clipped=24 * 1,
)
failed |= simple_scenario(
"battery_charge_pv_term_dc1",
Expand Down Expand Up @@ -995,7 +1023,7 @@ def run_model_tests(my_predbat):
)
failed |= simple_scenario("battery_discharge_pv2_hybrid", my_predbat, 0, 1.5, assert_final_metric=-export_rate * 24, assert_final_soc=22, with_battery=True, discharge=0, battery_soc=10, hybrid=True)
failed |= simple_scenario("battery_discharge_pv3_hybrid", my_predbat, 0, 2, assert_final_metric=-export_rate * 24, assert_final_soc=24, with_battery=True, discharge=0, battery_soc=0, hybrid=True)
failed |= simple_scenario("battery_discharge_pv3_hybrid2", my_predbat, 0, 3, assert_final_metric=-export_rate * 24, assert_final_soc=24, with_battery=True, discharge=0, battery_soc=0, hybrid=True)
failed |= simple_scenario("battery_discharge_pv3_hybrid2", my_predbat, 0, 3, assert_final_metric=-export_rate * 24, assert_final_soc=24, with_battery=True, discharge=0, battery_soc=0, hybrid=True, assert_clipped=24 * 1)
failed |= simple_scenario("battery_discharge_pv3_hybrid3", my_predbat, 0, 3, assert_final_metric=-export_rate * 24, assert_final_soc=48, with_battery=True, discharge=0, battery_soc=0, hybrid=True, battery_rate_max_charge_dc=2.0)
failed |= simple_scenario(
"battery_discharge_pv4_hybrid",
Expand All @@ -1010,6 +1038,7 @@ def run_model_tests(my_predbat):
hybrid=True,
inverter_limit=2,
inverter_loss=0.5,
assert_clipped=24 * 2,
)
failed |= simple_scenario("battery_discharge_freeze", my_predbat, 0, 0.5, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=10, with_battery=True, discharge=99, battery_soc=10)
failed |= simple_scenario("battery_discharge_freeze2", my_predbat, 0, 0.5, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=10, with_battery=True, discharge=99, battery_soc=10, set_export_freeze_only=True)
Expand Down Expand Up @@ -1083,6 +1112,7 @@ def run_model_tests(my_predbat):
battery_soc=50,
export_limit=0.5,
battery_rate_max_charge_dc=10.0,
assert_clipped=24 * 1.5,
)
failed |= simple_scenario(
"battery_discharge_export_limit_ac_pv2",
Expand All @@ -1109,6 +1139,7 @@ def run_model_tests(my_predbat):
battery_soc=50,
export_limit=0.5,
inverter_limit=2.0,
assert_clipped=24 * 0.5,
)
failed |= simple_scenario(
"battery_discharge_export_limit_ac_pv4",
Expand All @@ -1123,6 +1154,7 @@ def run_model_tests(my_predbat):
export_limit=0.5,
inverter_limit=2.0,
inverter_loss=0.5,
assert_clipped=24 * 0.5,
)
failed |= simple_scenario(
"battery_discharge_export_limit_ac_pv5",
Expand Down Expand Up @@ -1152,6 +1184,7 @@ def run_model_tests(my_predbat):
inverter_limit=2.0,
battery_rate_max_charge=1.0,
inverter_can_charge_during_export=False,
assert_clipped=24 * 1.5,
)
failed |= simple_scenario(
"battery_discharge_export_limit_hybrid",
Expand Down Expand Up @@ -1219,6 +1252,7 @@ def run_model_tests(my_predbat):
battery_soc=50,
export_limit=0.5,
hybrid=True,
assert_clipped=24 * 0.5,
)
failed |= simple_scenario(
"battery_discharge_export_limit_hybrid_pv5",
Expand All @@ -1233,6 +1267,7 @@ def run_model_tests(my_predbat):
export_limit=0.5,
hybrid=True,
battery_rate_max_charge_dc=2.0,
assert_clipped=24 * 0.5,
)
failed |= simple_scenario(
"battery_charge_ac_loss",
Expand Down
Loading