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
35 changes: 29 additions & 6 deletions apps/predbat/prediction.py
Original file line number Diff line number Diff line change
Expand Up @@ -816,23 +816,39 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
over_limit = abs(diff) - export_limit
reduce_by = over_limit

if reduce_by > battery_draw:
# Compare the AC over-export against the battery's AC export contribution (battery_draw is DC,
# so that is battery_draw * inverter_loss). If the surplus is larger then even stopping the
# battery leaves PV over the limit, so we must charge to absorb it rather than clip the solar.
if reduce_by > battery_draw * inverter_loss:
if self.inverter_can_charge_during_export:
reduce_by = reduce_by - battery_draw
# Stopping the battery only removes its AC export contribution (battery_draw is DC, so
# that is battery_draw * inverter_loss). Whatever AC export is still over the limit has
# to be absorbed by charging the battery.
reduce_by = reduce_by - battery_draw * inverter_loss

if inverter_hybrid:
charge_rate_now_curve_dc = (
get_charge_rate_curve_cached(soc, battery_rate_max_charge_dc, soc_max, battery_rate_max_charge_dc, battery_charge_power_curve_tuple, battery_rate_min, battery_temperature, battery_temperature_charge_curve_tuple)
* battery_rate_max_scaling
)
charge_rate_now_curve_dc_step = charge_rate_now_curve_dc * step
battery_draw = max(-reduce_by * inverter_loss, -battery_to_min, -charge_rate_now_curve_dc_step)
# Hybrid charges from PV on the DC side (see pv_dc below), so the AC surplus maps
# back to DC through the loss reciprocal. Clamp by battery_to_max (remaining charge
# headroom), not battery_to_min, otherwise a near-full battery is asked to absorb
# more than it can hold and the surplus is mis-accounted instead of clipped.
battery_draw = max(-reduce_by * inverter_loss_recp, -battery_to_max, -charge_rate_now_curve_dc_step)
else:
battery_draw = max(-reduce_by * inverter_loss, -battery_to_min, -charge_rate_now_curve_step)
# Non-hybrid charges from the grid (AC), so the DC charge is the AC surplus * loss.
battery_draw = max(-reduce_by * inverter_loss, -battery_to_max, -charge_rate_now_curve_step)
else:
battery_draw = 0
else:
battery_draw = battery_draw - reduce_by
# reduce_by is an AC over-export figure but battery_draw is DC and exports through
# the inverter, so scale by the loss reciprocal to remove the right amount of grid
# export. Subtracting the raw AC figure under-reduces the battery and leaves a small
# residual that gets clipped off the solar later. Clamp at zero so we never flip to
# charging here (that case is handled by the inverter_can_charge_during_export branch).
battery_draw = max(battery_draw - reduce_by * inverter_loss_recp, 0)

if inverter_hybrid and battery_draw < 0:
pv_dc = min(abs(battery_draw), pv_now)
Expand All @@ -853,7 +869,14 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
* battery_rate_max_scaling
)
charge_rate_now_curve_dc_step = charge_rate_now_curve_dc * step
battery_draw = max(-reduce_by * inverter_loss, -battery_to_min, -charge_rate_now_curve_dc_step)
# reduce_by here is in the same DC-equivalent throughput units as total_inverted
# (get_total_inverted counts the battery and the PV diverted to DC 1:1), so the
# battery must charge by reduce_by directly to bring total_inverted onto the
# inverter limit - no inverter_loss factor. Multiplying by inverter_loss under-
# charges and leaves PV to be clipped that the battery could have absorbed. Clamp
# by battery_to_max (remaining charge headroom) so a near-full battery is not
# asked to absorb more than it can hold.
battery_draw = max(-reduce_by, -battery_to_max, -charge_rate_now_curve_dc_step)
else:
battery_draw = battery_draw - reduce_by

Expand Down
120 changes: 118 additions & 2 deletions apps/predbat/tests/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,118 @@ def run_model_tests(my_predbat):
failed |= simple_scenario("load_discharge_reserve", my_predbat, 1, 0, assert_final_metric=import_rate * 15, assert_final_soc=1, battery_soc=10.0, with_battery=True, reserve=1.0)
failed |= simple_scenario("load_discharge_reserve2", my_predbat, 1, 0, assert_final_metric=import_rate * 20, assert_final_soc=2, battery_soc=10.0, with_battery=True, reserve=2.0, battery_loss=0.5)
failed |= simple_scenario("load_discharge_loss", my_predbat, 1, 0, assert_final_metric=import_rate * 19, assert_final_soc=0, battery_soc=10.0, with_battery=True, battery_loss=0.5)
# Forced export with PV on a lossy hybrid inverter. The battery exports through the inverter (DC->AC) so
# when battery + solar would exceed the export limit the battery discharge must be scaled back by the loss
# reciprocal to bring grid export down to the limit. Otherwise a small residual is left above the limit and
# gets clipped off the solar. Regression test: with the scale-back correct, no solar should be clipped.
# battery_draw(DC) = (export_limit - pv_ac) / inverter_loss = (3 - 2*0.8) / 0.8 = 1.75 kW, over 24h = 42 kWh.
failed |= simple_scenario(
"export_pv_clip_loss",
my_predbat,
0,
2,
assert_final_metric=-export_rate * 24 * 3,
assert_final_soc=100 - 42,
battery_soc=100.0,
with_battery=True,
hybrid=True,
inverter_loss=0.8,
export_limit=3.0,
inverter_limit=10.0,
battery_rate_max_charge=5.0,
discharge=0,
assert_clipped=0,
)
# Forced export with PV so large that even stopping the battery leaves the solar over the export limit. With
# inverter_can_charge_during_export the battery should charge from the surplus PV (DC side) to keep grid export
# at the limit, rather than clipping the solar. Regression test for the AC/DC unit mismatch in that charge branch.
# remaining_ac = pv_ac - export_limit = 2*0.8 - 1 = 0.6; hybrid DC charge = 0.6 / 0.8 = 0.75 kW, over 24h = 18 kWh.
failed |= simple_scenario(
"export_pv_charge_clip_loss",
my_predbat,
0,
2,
assert_final_metric=-export_rate * 24,
assert_final_soc=40 + 18,
battery_soc=40.0,
with_battery=True,
hybrid=True,
inverter_loss=0.8,
export_limit=1.0,
inverter_limit=10.0,
battery_rate_max_charge=1.0,
discharge=0,
inverter_can_charge_during_export=True,
assert_clipped=0,
)
# Band case for the scale-back vs charge decision. The AC over-export (1.5kW) is larger than the battery's
# AC contribution (battery_draw 2kW DC * inverter_loss 0.5 = 1kW) but smaller than the raw DC discharge (2kW).
# The branch pivot must use the AC contribution: even after stopping the battery the 1kW AC PV is still over
# the 0.5kW export limit, so the battery should charge to absorb the 0.5kW surplus (0.25kW DC, 6kWh over 24h)
# instead of clipping it. Comparing against the raw DC value sends this to the scale-back path which just
# stops the battery and clips the solar.
failed |= simple_scenario(
"export_pv_charge_band_loss",
my_predbat,
0,
1,
assert_final_metric=-export_rate * 24 * 0.5,
assert_final_soc=50 + 0.25 * 24,
battery_soc=50.0,
with_battery=True,
inverter_loss=0.5,
export_limit=0.5,
inverter_limit=10.0,
battery_rate_max_charge=2.0,
discharge=0,
inverter_can_charge_during_export=True,
assert_clipped=0,
)
# Full battery during a high-PV forced export. PV alone (2kW) exceeds the 0.5kW export limit so the charge
# path is entered, but the battery is already at 100% so it has no headroom to absorb anything. The charge
# must be clamped by battery_to_max (0 here) so all 1.5kW AC surplus is clipped. Clamping by battery_to_min
# instead would let the model "charge" a full battery and under-report the clipping (clip 12 instead of 36).
failed |= simple_scenario(
"export_pv_charge_full_battery",
my_predbat,
0,
2,
assert_final_metric=-export_rate * 24 * 0.5,
assert_final_soc=100,
battery_soc=100.0,
with_battery=True,
export_limit=0.5,
inverter_limit=10.0,
battery_rate_max_charge=1.0,
discharge=0,
inverter_can_charge_during_export=True,
assert_clipped=24 * 1.5,
)
# Hybrid forced export where PV (4kW DC) exceeds the inverter limit (2kW) but the grid export limit is not
# binding, so the inverter-limit charge branch absorbs the surplus PV into the battery. total_inverted counts
# the battery and the DC-diverted PV 1:1, so the battery must charge by reduce_by = pv - inverter_limit = 2kW
# (not reduce_by * inverter_loss). Charging the full 2kW DC keeps total_inverted exactly on the 2kW limit with
# no clipping; charging only 1.6kW (the under-charge bug) leaves total_inverted at 2.4kW and clips 0.4kW of PV.
failed |= simple_scenario(
"export_pv_inverter_limit_charge",
my_predbat,
0,
4,
assert_final_metric=-export_rate * 1.6 * 24,
assert_final_soc=100 + 2.0 * 24,
battery_soc=100.0,
battery_size=200.0,
with_battery=True,
hybrid=True,
inverter_loss=0.8,
export_limit=100.0,
inverter_limit=2.0,
battery_rate_max_charge=1.0,
battery_rate_max_charge_dc=10.0,
discharge=0,
inverter_can_charge_during_export=True,
assert_clipped=0,
)
failed |= simple_scenario("load_pv", my_predbat, 1, 1, assert_final_metric=0, assert_final_soc=0, with_battery=False)
failed |= simple_scenario("pv_only", my_predbat, 0, 1, assert_final_metric=-export_rate * 24, assert_final_soc=0, with_battery=False)
failed |= simple_scenario("pv10_only", my_predbat, 0, 1, assert_final_metric=-export_rate * 24, assert_final_soc=0, with_battery=False, pv10=True)
Expand Down Expand Up @@ -1147,14 +1259,18 @@ def run_model_tests(my_predbat):
0,
2,
assert_final_metric=-export_rate * 24 * 0.5,
assert_final_soc=50 + 1.0 * 24 * 0.5,
# 1.5kW AC PV surplus (2kW - 0.5kW export limit). AC-coupled charging stores AC * inverter_loss as DC,
# so absorbing all 1.5kW only needs 1.5 * 0.5 = 0.75kW DC, which is within the 1kW charge rate. The
# battery therefore soaks up all the surplus and nothing is clipped (was previously under-charging at
# 0.5kW DC and clipping the rest due to an AC/DC unit mismatch in the export-limit charge branch).
assert_final_soc=50 + 0.75 * 24,
with_battery=True,
discharge=0,
battery_soc=50,
export_limit=0.5,
inverter_limit=2.0,
inverter_loss=0.5,
assert_clipped=24 * 0.5,
assert_clipped=0,
)
failed |= simple_scenario(
"battery_discharge_export_limit_ac_pv5",
Expand Down
Loading