Skip to content
Open
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
4 changes: 4 additions & 0 deletions apps/predbat/predbat.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,10 @@ def reset(self):
self.battery_loss = 1.0
self.battery_loss_discharge = 1.0
self.inverter_loss = 1.0
# Internal battery discharge rate (W) observed while Freeze Export is active.
# Prediction-only: this does not alter inverter control or house/grid energy.
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
15 changes: 13 additions & 2 deletions 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 @@ -713,6 +715,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
charge_window_active = charge_window_n >= 0
export_window_active = export_window_n >= 0
export_limit_now = export_limits[export_window_n] if export_window_active else 100.0
freeze_export_active = set_export_freeze and export_window_active and export_limit_now < 100.0 and (export_limit_now == 99.0 or set_export_freeze_only)

# Find charge limit
charge_limit_n = 0
Expand Down Expand Up @@ -1204,6 +1207,14 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
else:
soc = min(soc - battery_draw * battery_loss, soc_max)

# Some inverters continue to discharge the battery internally while Freeze Export
# blocks normal external battery flow. Model that measured battery-side discharge
# directly in SoC without creating fictitious house load or grid energy.
freeze_export_discharge_step = 0.0
if freeze_export_active and inverter_freeze_export_discharge_rate > 0:
freeze_export_discharge_step = min(inverter_freeze_export_discharge_rate * step / 60000.0, max(soc - reserve_expected, 0))
soc -= freeze_export_discharge_step

# Iboost finally count
if self.iboost_enable:
# iBoost Solar diversion model
Expand Down Expand Up @@ -1232,8 +1243,8 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
if iboost_next > self.iboost_today:
iboost_running = True

# Count battery cycles
battery_cycle = battery_cycle + abs(battery_draw)
# Count battery cycles, including internal Freeze Export battery discharge
battery_cycle = battery_cycle + abs(battery_draw) + freeze_export_discharge_step

# Work out left over energy after battery adjustment
diff = get_diff(battery_draw, pv_dc, pv_ac, load_yesterday, inverter_loss, inverter_loss_recp)
Expand Down
17 changes: 14 additions & 3 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 8
#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, internal battery discharge while Freeze Export is active
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 @@ -752,6 +754,7 @@ static int32_t pk_run_one(const ContextStore *store, const PkScenario *s, PkResu
const bool charge_window_active = charge_window_n >= 0;
const bool export_window_active = export_window_n >= 0;
const double export_limit_now = export_window_active ? s->export_limits[export_window_n] : 100.0;
const bool freeze_export_active = c->set_export_freeze && export_window_active && export_limit_now < 100.0 && (export_limit_now == 99.0 || c->set_export_freeze_only);

// Find charge limit - prediction.py:609-620
double charge_limit_n = 0;
Expand Down Expand Up @@ -1188,6 +1191,14 @@ static int32_t pk_run_one(const ContextStore *store, const PkScenario *s, PkResu
soc = std::min(soc - battery_draw * battery_loss, soc_max);
}

// Internal, directional battery discharge while Freeze Export is active.
// This mirrors prediction.py and deliberately does not alter battery_draw/grid energy.
double freeze_export_discharge_step = 0.0;
if (freeze_export_active && inverter_freeze_export_discharge_rate > 0) {
freeze_export_discharge_step = std::min(inverter_freeze_export_discharge_rate * step / 60000.0, std::max(soc - reserve_expected, 0.0));
soc -= freeze_export_discharge_step;
}

// iBoost final count - prediction.py:1066-1092
if (c->iboost_enable) {
// iBoost solar excess diversion model - prediction.py:1068-1078 (uses the pre-clip diff)
Expand Down Expand Up @@ -1225,7 +1236,7 @@ static int32_t pk_run_one(const ContextStore *store, const PkScenario *s, PkResu
}

// Count battery cycles - prediction.py:1094-1095
battery_cycle = battery_cycle + std::fabs(battery_draw);
battery_cycle = battery_cycle + std::fabs(battery_draw) + freeze_export_discharge_step;

// Work out left over energy after battery adjustment - prediction.py:1097-1098
diff = get_diff(battery_draw, pv_dc, pv_ac, load_yesterday, inverter_loss, inverter_loss_recp);
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 = 8

# 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 @@ -586,6 +586,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 @@ -608,6 +609,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 @@ -696,6 +698,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 @@ -852,6 +855,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
62 changes: 62 additions & 0 deletions apps/predbat/tests/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,68 @@ def run_model_tests(my_predbat, prediction_kernel=False):
reset_rates(my_predbat, import_rate, export_rate)

failed = False
# Freeze Export internal battery discharge regression coverage. 240 W for one hour
# is 0.24 kWh. It applies only in Freeze Export, respects reserve, and counts as cycling.
failed |= simple_scenario(
"freeze_export_discharge_rate_default_zero",
my_predbat,
0,
0,
assert_final_metric=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,
assert_battery_cycle=0.0,
)
failed |= simple_scenario(
"freeze_export_discharge_rate_240w_one_hour",
my_predbat,
0,
0,
assert_final_metric=0,
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,
assert_battery_cycle=0.24,
)
failed |= simple_scenario(
"freeze_export_discharge_rate_not_outside_freeze",
my_predbat,
0,
0,
assert_final_metric=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,
assert_battery_cycle=0.0,
)
failed |= simple_scenario(
"freeze_export_discharge_rate_reserve_floor",
my_predbat,
0,
0,
assert_final_metric=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,
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