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
3 changes: 3 additions & 0 deletions apps/predbat/predbat.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,9 @@ def reset(self):
self.battery_loss = 1.0
self.battery_loss_discharge = 1.0
self.inverter_loss = 1.0
# Battery-side discharge (W) that may continue to supply house load during Freeze Export.
self.inverter_freeze_export_discharge_rate = max(float(self.args.get("inverter_freeze_export_discharge_rate", 0)), 0.0)
self.log("Freeze Export discharge rate configured: {:.0f} W".format(self.inverter_freeze_export_discharge_rate))
self.inverter_hybrid = True
self.pv_ac_limit = 0
self.inverter_soc_reset = False
Expand Down
22 changes: 21 additions & 1 deletion apps/predbat/prediction.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ def __init__(
self.iboost_rate_threshold_export = base.iboost_rate_threshold_export
self.rate_gas = base.rate_gas
self.inverter_loss = base.inverter_loss
self.inverter_freeze_export_discharge_rate = base.inverter_freeze_export_discharge_rate
self.inverter_hybrid = base.inverter_hybrid
self.inverter_limit = base.inverter_limit
self.export_limit = base.export_limit
Expand Down Expand Up @@ -634,6 +635,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
battery_rate_max_discharge = self.battery_rate_max_discharge
battery_rate_max_export = self.battery_rate_max_export
battery_rate_min = self.battery_rate_min
inverter_freeze_export_discharge_rate = self.inverter_freeze_export_discharge_rate
carbon_intensity = self.carbon_intensity
set_discharge_during_charge = self.set_discharge_during_charge
battery_charge_power_curve_tuple = charge_curve_to_tuple(self.battery_charge_power_curve)
Expand Down Expand Up @@ -1089,7 +1091,25 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
pv_dc = min(abs(battery_draw), pv_now)
pv_ac = (pv_now - pv_dc) * inverter_loss_ac

battery_state = "fz+" if battery_draw < 0 else "fz~"
# Some inverters (observed on AlphaESS) continue a small residual battery
# discharge during Freeze Export. Treat the configured value as battery-side
# power and feed it into the normal AC balance: house load consumes it first and
# any surplus may reach the grid. Respect the battery reserve and physical export
# limit rather than capping the discharge at house demand.
if inverter_freeze_export_discharge_rate > 0 and battery_draw >= 0:
freeze_soc_available = min(inverter_freeze_export_discharge_rate * step / 60000.0, max(soc - reserve_expected, 0))
freeze_draw = freeze_soc_available * battery_loss_discharge
freeze_diff = get_diff(freeze_draw, pv_dc, pv_ac, load_yesterday, inverter_loss, inverter_loss_recp)
if freeze_diff < 0 and abs(freeze_diff) > export_limit:
freeze_draw = max(freeze_draw - (abs(freeze_diff) - export_limit) * inverter_loss_recp, 0)
battery_draw = freeze_draw

if battery_draw < 0:
battery_state = "fz+"
elif battery_draw > 0:
battery_state = "fz-"
else:
battery_state = "fz~"
else:
# ECO Mode
pv_ac = pv_now * inverter_loss_ac
Expand Down
20 changes: 18 additions & 2 deletions apps/predbat/prediction_kernel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@
// unconditionally, so loading one against this Python segfaults on the first prediction rather than
// falling back. Bumping makes the loader reject it and use the Python engine, which is the whole
// point of the check.
#define PK_ABI_VERSION 4
#define PK_PARITY_REVISION 7
#define PK_ABI_VERSION 5
#define PK_PARITY_REVISION 9
#define PK_MAX_CARS 8
#define PK_RUN_EVERY 5 // const.py RUN_EVERY

Expand Down Expand Up @@ -169,6 +169,7 @@ struct PkContext {
double battery_loss;
double battery_loss_discharge;
double inverter_loss;
double inverter_freeze_export_discharge_rate; // W, residual battery-side discharge entering the AC balance during Freeze Export
double inverter_limit; // per-minute rate (multiplied by step in the kernel)
double export_limit; // per-minute rate
double pv_ac_limit; // per-minute rate
Expand Down Expand Up @@ -714,6 +715,7 @@ static int32_t pk_run_one(const ContextStore *store, const PkScenario *s, PkResu
const double battery_rate_max_discharge = c->battery_rate_max_discharge;
const double battery_rate_max_export = c->battery_rate_max_export;
const double battery_rate_min = c->battery_rate_min;
const double inverter_freeze_export_discharge_rate = c->inverter_freeze_export_discharge_rate;
// PV10 de-rating of the charge rate - prediction.py:587-592. PV90 is the upside case, no de-rate.
const double battery_rate_max_scaling = is_pv10 ? c->battery_rate_max_scaling10 : c->battery_rate_max_scaling;
const double battery_rate_max_scaling_discharge = c->battery_rate_max_scaling_discharge;
Expand Down Expand Up @@ -1077,6 +1079,20 @@ static int32_t pk_run_one(const ContextStore *store, const PkScenario *s, PkResu
pv_ac = (pv_now - pv_dc) * inverter_loss_ac;
}
}

// Some inverters (observed on AlphaESS) continue a small residual battery
// discharge during Freeze Export. Feed the battery-side rate into the normal AC
// balance so load consumes it first and any surplus may export, while respecting
// the reserve and the physical grid export limit.
if (inverter_freeze_export_discharge_rate > 0 && battery_draw >= 0) {
const double freeze_soc_available = std::min(inverter_freeze_export_discharge_rate * step / 60000.0, std::max(soc - reserve_expected, 0.0));
double freeze_draw = freeze_soc_available * battery_loss_discharge;
const double freeze_diff = get_diff(freeze_draw, pv_dc, pv_ac, load_yesterday, inverter_loss, inverter_loss_recp);
if (freeze_diff < 0 && std::abs(freeze_diff) > export_limit) {
freeze_draw = std::max(freeze_draw - (std::abs(freeze_diff) - export_limit) * inverter_loss_recp, 0.0);
}
battery_draw = freeze_draw;
}
} else {
// ECO Mode - prediction.py:951-997
pv_ac = pv_now * inverter_loss_ac;
Expand Down
6 changes: 4 additions & 2 deletions apps/predbat/prediction_kernel.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@
from utils import get_curve_value, find_battery_temperature_cap, in_car_slot, in_iboost_slot

# Expected ABI/parity revisions of the shared library (see prediction_kernel.cpp)
KERNEL_ABI_VERSION = 4
KERNEL_PARITY_REVISION = 7
KERNEL_ABI_VERSION = 5
KERNEL_PARITY_REVISION = 9

# Maximum number of cars supported by the kernel (PK_MAX_CARS in prediction_kernel.cpp)
KERNEL_MAX_CARS = PREDBAT_MAX_CARS
Expand Down Expand Up @@ -78,6 +78,7 @@ class PkContext(ctypes.Structure):
("battery_loss", ctypes.c_double),
("battery_loss_discharge", ctypes.c_double),
("inverter_loss", ctypes.c_double),
("inverter_freeze_export_discharge_rate", ctypes.c_double),
("inverter_limit", ctypes.c_double),
("export_limit", ctypes.c_double),
("pv_ac_limit", ctypes.c_double),
Expand Down Expand Up @@ -658,6 +659,7 @@ def create_kernel_context(pred, static_cache=None):
ctx.battery_loss = pred.battery_loss
ctx.battery_loss_discharge = pred.battery_loss_discharge
ctx.inverter_loss = pred.inverter_loss
ctx.inverter_freeze_export_discharge_rate = pred.inverter_freeze_export_discharge_rate
ctx.inverter_limit = pred.inverter_limit
ctx.export_limit = pred.export_limit
ctx.pv_ac_limit = pred.pv_ac_limit
Expand Down
Binary file modified apps/predbat/prediction_kernel_lib_aarch64.so
Binary file not shown.
Binary file modified apps/predbat/prediction_kernel_lib_armv7l.so
Binary file not shown.
Binary file modified apps/predbat/prediction_kernel_lib_darwin_arm64.so
Binary file not shown.
Binary file modified apps/predbat/prediction_kernel_lib_darwin_x86_64.so
Binary file not shown.
Binary file modified apps/predbat/prediction_kernel_lib_i686.so
Binary file not shown.
Binary file modified apps/predbat/prediction_kernel_lib_x86_64.so
Binary file not shown.
7 changes: 7 additions & 0 deletions apps/predbat/tests/test_infra.py
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,7 @@ def simple_scenario(
charge_window_best=[],
charge_limit_best=None,
inverter_loss=1.0,
inverter_freeze_export_discharge_rate=0.0,
battery_rate_max_charge=1.0,
battery_rate_max_charge_dc=None,
charge_car=0,
Expand All @@ -612,6 +613,7 @@ def simple_scenario(
keep=0.0,
keep_weight=0.5,
assert_keep=0.0,
assert_battery_cycle=None,
save="best",
quiet=False,
iboost_rate_threshold=9999,
Expand Down Expand Up @@ -700,6 +702,7 @@ def simple_scenario(
my_predbat.pv_ac_limit = pv_ac_limit / 60.0
my_predbat.reserve = reserve
my_predbat.inverter_loss = inverter_loss
my_predbat.inverter_freeze_export_discharge_rate = inverter_freeze_export_discharge_rate
my_predbat.battery_rate_max_charge = battery_rate_max_charge / 60.0
my_predbat.battery_rate_max_charge_dc = battery_rate_max_charge_dc / 60.0
my_predbat.battery_rate_max_discharge = battery_rate_max_charge / 60.0
Expand Down Expand Up @@ -856,6 +859,10 @@ def simple_scenario(
if not ignore_failed:
print("ERROR: Final SOC {} should be {}".format(final_soc, assert_final_soc))
failed = True
if assert_battery_cycle is not None and abs(battery_cycle - assert_battery_cycle) >= 0.001:
if not ignore_failed:
print("ERROR: Battery cycle {} should be {}".format(battery_cycle, assert_battery_cycle))
failed = True
if abs(final_iboost - assert_final_iboost) >= 0.1:
if not ignore_failed:
print("ERROR: Final iBoost {} should be {}".format(final_iboost, assert_final_iboost))
Expand Down
3 changes: 3 additions & 0 deletions apps/predbat/tests/test_kernel_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
"battery_loss_discharge",
"inverter_hybrid",
"inverter_loss",
"inverter_freeze_export_discharge_rate",
"inverter_limit",
"export_limit",
"pv_ac_limit",
Expand Down Expand Up @@ -219,6 +220,8 @@ def apply_random_scenario(my_predbat, rng):
my_predbat.set_reserve_enable = rng.choice([True, False])
my_predbat.set_export_freeze = rng.choice([True, False])
my_predbat.set_export_freeze_only = rng.choice([True, False, False, False])
# Exercise a non-zero value without consuming another RNG draw, preserving seeded scenarios.
my_predbat.inverter_freeze_export_discharge_rate = 240.0 if my_predbat.set_export_freeze else 0.0
my_predbat.set_charge_window = rng.choice([True, True, False])
my_predbat.set_export_window = rng.choice([True, True, False])
my_predbat.set_discharge_during_charge = rng.choice([True, False])
Expand Down
115 changes: 115 additions & 0 deletions apps/predbat/tests/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,121 @@ def run_model_tests(my_predbat, prediction_kernel=False):
reset_rates(my_predbat, import_rate, export_rate)

failed = False
# Freeze Export residual discharge is real battery energy entering the AC balance.
# House load consumes it first; any excess is exported. Normal battery discharge is
# disabled here so only this configured path is under test.
failed |= simple_scenario(
"freeze_export_ac_flow_default_zero",
my_predbat,
1.0,
0,
assert_final_metric=10.0,
assert_final_soc=10.0,
battery_size=10.0,
battery_soc=10.0,
discharge=99,
end_record=60,
inverter_freeze_export_discharge_rate=0.0,
battery_rate_max_charge=0.0,
assert_battery_cycle=0.0,
)
failed |= simple_scenario(
"freeze_export_ac_flow_240w_one_hour",
my_predbat,
1.0,
0,
assert_final_metric=7.6,
assert_final_soc=9.76,
battery_size=10.0,
battery_soc=10.0,
discharge=99,
end_record=60,
inverter_freeze_export_discharge_rate=240.0,
battery_rate_max_charge=0.0,
assert_battery_cycle=0.24,
)
failed |= simple_scenario(
"freeze_export_ac_flow_respects_inverter_loss",
my_predbat,
1.0,
0,
assert_final_metric=8.08,
assert_final_soc=9.76,
battery_size=10.0,
battery_soc=10.0,
discharge=99,
end_record=60,
inverter_loss=0.8,
inverter_freeze_export_discharge_rate=240.0,
battery_rate_max_charge=0.0,
assert_battery_cycle=0.24,
)
failed |= simple_scenario(
"freeze_export_ac_flow_no_load_exports_residual",
my_predbat,
0,
0,
assert_final_metric=-1.2,
assert_final_soc=9.76,
battery_size=10.0,
battery_soc=10.0,
discharge=99,
end_record=60,
inverter_freeze_export_discharge_rate=240.0,
battery_rate_max_charge=0.0,
assert_battery_cycle=0.24,
)
# Live AlphaESS behaviour: PV nearly covers the house, but Freeze Export residual
# discharge continues and the surplus reaches grid (487 W load, 466 W PV, 269 W battery).
failed |= simple_scenario(
"freeze_export_ac_flow_surplus_reaches_grid",
my_predbat,
0.487,
0.466,
assert_final_metric=-1.24,
assert_final_soc=9.731,
battery_size=10.0,
battery_soc=10.0,
discharge=99,
end_record=60,
inverter_freeze_export_discharge_rate=269.0,
battery_rate_max_charge=0.0,
assert_battery_cycle=0.269,
)
failed |= simple_scenario(
"freeze_export_ac_flow_not_outside_freeze",
my_predbat,
1.0,
0,
assert_final_metric=10.0,
assert_final_soc=10.0,
battery_size=10.0,
battery_soc=10.0,
discharge=100,
end_record=60,
inverter_freeze_export_discharge_rate=240.0,
battery_rate_max_charge=0.0,
assert_battery_cycle=0.0,
)
failed |= simple_scenario(
"freeze_export_ac_flow_reserve_floor",
my_predbat,
1.0,
0,
assert_final_metric=9.0,
assert_final_soc=4.0,
battery_size=10.0,
battery_soc=4.1,
reserve=4.0,
discharge=99,
end_record=60,
inverter_freeze_export_discharge_rate=240.0,
battery_rate_max_charge=0.0,
assert_battery_cycle=0.1,
)
if failed:
return failed

failed |= simple_scenario("zero", my_predbat, 0, 0, 0, 0, with_battery=False)
failed |= simple_scenario("load_only", my_predbat, 1, 0, assert_final_metric=import_rate * 24, assert_final_soc=0, with_battery=False)
failed |= simple_scenario("load_bat_ac", my_predbat, 4, 0, assert_final_metric=import_rate * 24 * 3.2, assert_final_soc=100 - 24, with_battery=True, battery_soc=100.0, inverter_loss=0.8)
Expand Down
Loading