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
1 change: 1 addition & 0 deletions apps/predbat/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2443,6 +2443,7 @@
"pause_end_time": {"type": "sensor_list", "sensor_type": "none|string", "modify": True, "entries": "num_inverters"},
"inverter_limit": {"type": "sensor_list", "sensor_type": "float", "modify": False, "zero": False, "entries": "num_inverters"},
"inverter_can_charge_during_export": {"type": "boolean"},
"inverter_freeze_export_discharge_rate": {"type": "float", "zero": True},
"pv_ac_limit": {"type": "float", "zero": True},
"inverter_limit_charge": {"type": "sensor_list", "sensor_type": "integer", "modify": False, "zero": False, "entries": "num_inverters"},
"inverter_limit_charge_dc": {"type": "sensor_list", "sensor_type": "integer", "modify": False, "zero": False, "entries": "num_inverters"},
Expand Down
2 changes: 2 additions & 0 deletions apps/predbat/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -2645,6 +2645,8 @@ def fetch_config_options(self):
self.inverter_loss = 1.0 - self.get_arg("inverter_loss")
self.inverter_hybrid = self.get_arg("inverter_hybrid")
self.pv_ac_limit = self.get_arg("pv_ac_limit", 0.0) / MINUTE_WATT
self.inverter_freeze_export_discharge_rate = max(self.get_arg("inverter_freeze_export_discharge_rate", 0.0), 0.0) / MINUTE_WATT
self.log("Freeze Export discharge rate configured: {:.0f} W".format(self.inverter_freeze_export_discharge_rate * MINUTE_WATT))
Comment thread
springfall2008 marked this conversation as resolved.
self.base_load = self.get_arg("base_load", 100) / 1000.0

# Charge curve
Expand Down
1 change: 1 addition & 0 deletions apps/predbat/predbat.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,7 @@ def reset(self):
self.battery_loss = 1.0
self.battery_loss_discharge = 1.0
self.inverter_loss = 1.0
self.inverter_freeze_export_discharge_rate = 0.0
self.inverter_hybrid = True
self.pv_ac_limit = 0
self.inverter_soc_reset = False
Expand Down
21 changes: 20 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,24 @@ 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_draw = min(inverter_freeze_export_discharge_rate * step * battery_loss_discharge, battery_to_min)
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
19 changes: 17 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; // per-minute rate (multiplied by step in the kernel), 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,19 @@ 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) {
double freeze_draw = std::min(inverter_freeze_export_discharge_rate * step * battery_loss_discharge, battery_to_min);
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.
9 changes: 8 additions & 1 deletion apps/predbat/tests/test_infra.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
# pylint: disable=attribute-defined-outside-init

from datetime import datetime, timedelta
from const import PREDBAT_MAX_CARS
from const import PREDBAT_MAX_CARS, MINUTE_WATT
from prediction import Prediction
from matplotlib import pyplot as plt
import asyncio
Expand Down 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 / MINUTE_WATT
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
5 changes: 4 additions & 1 deletion apps/predbat/tests/test_kernel_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
import time

import prediction_kernel
from const import PV_SCENARIO_NOMINAL, PV_SCENARIO_PV10, PV_SCENARIO_PV90
from const import PV_SCENARIO_NOMINAL, PV_SCENARIO_PV10, PV_SCENARIO_PV90, MINUTE_WATT
from prediction import Prediction
from prediction_kernel import create_kernel_context, run_prediction_kernel, load_kernel
from utils import remove_intersecting_windows
Expand Down 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 / MINUTE_WATT) 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
14 changes: 14 additions & 0 deletions docs/apps-yaml.md
Original file line number Diff line number Diff line change
Expand Up @@ -1196,6 +1196,20 @@ If this setting is `false` then the inverter will not charge the battery and the

For Freeze Export specifically, this means the battery still holds its SoC flat while the export limit alone can absorb all the surplus solar - it only starts charging once solar genuinely exceeds what load and the export limit together can use, matching how many hybrid inverters actually behave (e.g. FoxESS's "Feed-in First" mode prioritises house load, then export, then the battery).

### **inverter_freeze_export_discharge_rate**

Global setting, defaults to `0` (disabled).

Controls the way Predbat models your inverter, this does not change the way it is controlled.

Some inverters (observed on AlphaESS) continue a small residual battery discharge during Freeze Export instead of holding the battery perfectly flat. If your inverter behaves this way, set this to the observed battery-side discharge rate in Watts so Predbat's prediction model matches reality.

```yaml
inverter_freeze_export_discharge_rate: 269
```

When set, Predbat feeds this rate into the normal AC balance during a Freeze Export period: house load consumes it first, and any surplus may reach the grid, subject to the battery reserve and the physical export limit. Leave this at `0` (the default) if your inverter holds the battery flat during Freeze Export.

## Controlling the Inverter

There are a few different ways to control your inverter:
Expand Down
Loading