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
1 change: 1 addition & 0 deletions .cspell/custom-dictionary-workspace.txt
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,7 @@ snakeviz
snprintf
socb
socketloop
socmax
socs
sofar
SolarEdge
Expand Down
21 changes: 21 additions & 0 deletions apps/predbat/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1394,6 +1394,27 @@
"icon": "mdi:battery-charging-100",
"default": 100,
},
{
"name": "manual_soc_max",
"friendly_name": "Manual SOC maximum target",
"type": "select",
"options": ["off"],
"icon": "mdi:battery-arrow-down",
"default": "off",
"restore": False,
"manual_rate": True,
},
{
"name": "manual_soc_max_value",
"friendly_name": "Manual SOC maximum target value",
"type": "input_number",
"min": 0,
"max": 100,
"step": 1,
"unit": "%",
"icon": "mdi:battery-arrow-down-outline",
"default": 0,
},
{
"name": "manual_api",
"friendly_name": "Manual API controls",
Expand Down
3 changes: 3 additions & 0 deletions apps/predbat/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ def execute_plan(self):

in_alert = self.alert_active_keep.get(self.minutes_now, 0) > 0
in_manual_soc = self.manual_soc_keep.get(self.minutes_now, 0) > 0
in_manual_soc_max = self.manual_soc_max_keep.get(self.minutes_now, 0) > 0

# Safeguard for set_charge_freeze_only: the planner never selects a charge target above the
# reserve while the switch is on, but a plan computed before it was turned on can still be
Expand Down Expand Up @@ -780,6 +781,8 @@ def execute_plan(self):
status += " [Alert]"
if in_manual_soc:
status += " [Manual SoC]"
if in_manual_soc_max:
status += " [Manual SoC Max]"

return status, status_extra

Expand Down
20 changes: 20 additions & 0 deletions apps/predbat/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,25 @@ def fetch_sensor_data(self, save=True):
else:
self.all_active_keep[minute] = soc_value

# Manual SOC max is a ceiling rather than a floor - combine separately, taking the
# tightest (lowest) ceiling if more than one source ever applies to the same minute.
self.all_active_keep_max = {}
if self.manual_soc_max_keep:
for minute, soc_value in self.manual_soc_max_keep.items():
if minute in self.all_active_keep_max:
self.all_active_keep_max[minute] = min(self.all_active_keep_max[minute], soc_value)
else:
self.all_active_keep_max[minute] = soc_value

# A ceiling below the floor at the same minute is a contradiction (e.g. a leftover manual_soc
# override never cleared) - the floor wins as the safety-relevant constraint, so drop the
# conflicting ceiling rather than hand the optimiser two penalties pulling opposite ways.
for minute in list(self.all_active_keep_max.keys()):
floor_value = self.all_active_keep.get(minute, 0)
if floor_value > self.all_active_keep_max[minute]:
self.log("Warn: manual_soc_max target {}% at minute {} is below the manual_soc/alert floor {}% for the same minute - ignoring the ceiling there".format(self.all_active_keep_max[minute], minute, floor_value))
del self.all_active_keep_max[minute]

# iBoost load data
if "iboost_energy_today" in self.args:
self.iboost_energy_today, iboost_energy_age = self.minute_data_load(self.now_utc, "iboost_energy_today", self.max_days_previous, required_unit="kWh", load_scaling=1.0)
Expand Down Expand Up @@ -2985,6 +3004,7 @@ def fetch_config_options(self):
self.manual_export_rates = self.manual_rates("manual_export_rates", default_rate=self.get_arg("manual_export_value"))
self.manual_load_adjust = self.manual_rates("manual_load_adjust", default_rate=self.get_arg("manual_load_value"))
self.manual_soc_keep = self.manual_rates("manual_soc", default_rate=self.get_arg("manual_soc_value"))
self.manual_soc_max_keep = self.manual_rates("manual_soc_max", default_rate=self.get_arg("manual_soc_max_value"))

# Update list of config options to save/restore to
self.update_save_restore_list()
Expand Down
3 changes: 3 additions & 0 deletions apps/predbat/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -1179,6 +1179,7 @@ def import_rate_color(rate):

in_alert = self.alert_active_keep.get(minute, 0) > 0
in_manual_soc = self.manual_soc_keep.get(minute, 0) > 0
in_manual_soc_max = self.manual_soc_max_keep.get(minute, 0) > 0

pv_forecast = 0
load_forecast = 0
Expand Down Expand Up @@ -1449,6 +1450,8 @@ def import_rate_color(rate):
soc_sym = "⚠ " + soc_sym
if in_manual_soc:
soc_sym = "✎ " + soc_sym
if in_manual_soc_max:
soc_sym = "⬇ " + soc_sym

# Import and export rates -> to string
adjust_type = self.rate_import_replicated.get(minute, None)
Expand Down
2 changes: 2 additions & 0 deletions apps/predbat/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -3077,6 +3077,8 @@ def discard_unused_export_slots(self, export_limits_best, export_window_best):
and (export_limits_best[window_n] == new_enable[-1])
and (export_window_best[window_n]["start"] not in self.manual_all_times)
and (new_best[-1]["start"] not in self.manual_all_times)
and (export_window_best[window_n]["start"] not in self.all_active_keep_max)
and (new_best[-1]["start"] not in self.all_active_keep_max)
):
new_best[-1]["end"] = export_window_best[window_n]["end"]
new_best[-1]["target"] = export_window_best[window_n].get("target", export_limits_best[window_n])
Expand Down
2 changes: 2 additions & 0 deletions apps/predbat/predbat.py
Original file line number Diff line number Diff line change
Expand Up @@ -605,7 +605,9 @@ def reset(self):
self.alerts = []
self.alert_active_keep = {}
self.manual_soc_keep = {}
self.manual_soc_max_keep = {}
self.all_active_keep = {}
self.all_active_keep_max = {}
self.set_charge_low_power = False
self.set_export_low_power = False
self.config_root = "./"
Expand Down
13 changes: 13 additions & 0 deletions apps/predbat/prediction.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ def __init__(
self.load_minutes_step90 = load_minutes_step90 if load_minutes_step90 is not None else load_minutes_step
self.carbon_intensity = base.carbon_intensity
self.all_active_keep = base.all_active_keep
self.all_active_keep_max = base.all_active_keep_max
self.iboost_running = False
self.iboost_running_solar = False
self.iboost_running_full = False
Expand Down Expand Up @@ -623,6 +624,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
battery_loss_discharge = self.battery_loss_discharge
battery_temperature_prediction = self.battery_temperature_prediction
all_active_keep = self.all_active_keep
all_active_keep_max = self.all_active_keep_max
best_soc_keep_weight = self.best_soc_keep_weight
best_soc_keep_orig = self.best_soc_keep
debug_enable = self.debug_enable
Expand Down Expand Up @@ -692,6 +694,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi

# Alert?
alert_keep = all_active_keep.get(minute_absolute, 0)
alert_keep_max = all_active_keep_max.get(minute_absolute, 0)

# Project battery temperature
battery_temperature = battery_temperature_prediction.get(minute, self.battery_temperature)
Expand All @@ -710,6 +713,12 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
keep_minute_scaling = max(keep_minute_scaling, 10.0)
best_soc_keep = max(best_soc_keep, min(alert_keep / 100.0 * soc_max, soc_max))

# Soc max keep is a ceiling rather than a floor (e.g. manual_soc_max) - 0 means no ceiling
best_soc_max = 0
if alert_keep_max > 0:
keep_minute_scaling = max(keep_minute_scaling, 10.0)
best_soc_max = min(alert_keep_max / 100.0 * soc_max, soc_max)

# Find charge & discharge windows
charge_window_n = charge_window_optimised.get(minute_absolute, -1)
export_window_n = export_window_optimised.get(minute_absolute, -1)
Expand Down Expand Up @@ -1270,6 +1279,10 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
if best_soc_keep > 0 and soc <= best_soc_keep:
metric_keep += (best_soc_keep - soc) * import_rate * keep_minute_scaling * step / 60.0

# Metric keep max - pretend the excess above the ceiling should have been exported instead of held
if best_soc_max > 0 and soc >= best_soc_max:
metric_keep += (soc - best_soc_max) * export_rate * keep_minute_scaling * step / 60.0

if diff > 0:
# Import
# All imports must go to home (no inverter loss) or to the battery (inverter loss accounted before above)
Expand Down
22 changes: 20 additions & 2 deletions apps/predbat/prediction_kernel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
// 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 5
#define PK_PARITY_REVISION 10
#define PK_PARITY_REVISION 11
#define PK_MAX_CARS 8
#define PK_RUN_EVERY 5 // const.py RUN_EVERY
#define PK_EXPORT_LIMIT_FREEZE 99.0 // const.py EXPORT_LIMIT_FREEZE
Expand Down Expand Up @@ -145,6 +145,7 @@ struct PkContext {
const double *rate_import; // import rate per step
const double *rate_export; // export rate per step
const double *alert_keep; // alert keep value per step
const double *alert_keep_max; // soc ceiling keep value per step (manual_soc_max), 0 = no ceiling
const double *pv; // PV forecast kWh per step (central)
const double *load; // load kWh per step (central)
const double *pv10; // PV forecast kWh per step (PV10)
Expand Down Expand Up @@ -360,7 +361,7 @@ static PkScratch &thread_scratch()

// Deep-copied context storage so Python-side buffers can be freed after create
struct ContextStore {
std::vector<double> rate_import, rate_export, alert_keep;
std::vector<double> rate_import, rate_export, alert_keep, alert_keep_max;
std::vector<double> pv, load, pv10, load10, pv90, load90;
std::vector<double> temp_charge_cap, temp_discharge_cap;
std::vector<int32_t> io_flag;
Expand Down Expand Up @@ -550,6 +551,7 @@ int64_t pk_context_create(const PkContext *in)
store->rate_import.assign(in->rate_import, in->rate_import + n);
store->rate_export.assign(in->rate_export, in->rate_export + n);
store->alert_keep.assign(in->alert_keep, in->alert_keep + n);
store->alert_keep_max.assign(in->alert_keep_max, in->alert_keep_max + n);
store->pv.assign(in->pv, in->pv + n);
store->load.assign(in->load, in->load + n);
store->pv10.assign(in->pv10, in->pv10 + n);
Expand All @@ -573,6 +575,7 @@ int64_t pk_context_create(const PkContext *in)
store->ctx.rate_import = store->rate_import.data();
store->ctx.rate_export = store->rate_export.data();
store->ctx.alert_keep = store->alert_keep.data();
store->ctx.alert_keep_max = store->alert_keep_max.data();
store->ctx.pv = store->pv.data();
store->ctx.load = store->load.data();
store->ctx.pv10 = store->pv10.data();
Expand Down Expand Up @@ -740,6 +743,7 @@ static int32_t pk_run_one(const ContextStore *store, const PkScenario *s, PkResu

// Alert - prediction.py:583
const double alert_keep = c->alert_keep[k];
const double alert_keep_max = c->alert_keep_max[k];

// Four hour rule scaling - prediction.py:589-592
double keep_minute_scaling = four_hour_rule ? std::min(minute / 240.0, 1.0) * best_soc_keep_weight : best_soc_keep_weight;
Expand All @@ -751,6 +755,14 @@ static int32_t pk_run_one(const ContextStore *store, const PkScenario *s, PkResu
best_soc_keep = std::max(best_soc_keep, std::min(alert_keep / 100.0 * soc_max, soc_max));
}

// Soc max keep is a ceiling rather than a floor (manual_soc_max) - mirrors prediction.py's
// best_soc_max block right after the alert keep floor. 0 = no ceiling.
double best_soc_max = 0;
if (alert_keep_max > 0) {
keep_minute_scaling = std::max(keep_minute_scaling, 10.0);
best_soc_max = std::min(alert_keep_max / 100.0 * soc_max, soc_max);
}

// Find charge & discharge windows - prediction.py:602-607
const int32_t charge_window_n = charge_window_optimised[k];
const int32_t export_window_n = export_window_optimised[k];
Expand Down Expand Up @@ -1262,6 +1274,12 @@ static int32_t pk_run_one(const ContextStore *store, const PkScenario *s, PkResu
metric_keep += (best_soc_keep - soc) * import_rate * keep_minute_scaling * step / 60.0;
}

// Metric keep max - pretend the excess above the ceiling should have been exported instead
// of held - mirrors prediction.py's best_soc_max block right after the floor.
if (best_soc_max > 0 && soc >= best_soc_max) {
metric_keep += (soc - best_soc_max) * export_rate * keep_minute_scaling * step / 60.0;
}

// Import/export accounting - prediction.py:1104-1143
if (diff > 0) {
// Import
Expand Down
10 changes: 7 additions & 3 deletions apps/predbat/prediction_kernel.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@

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

# Maximum number of cars supported by the kernel (PK_MAX_CARS in prediction_kernel.cpp)
KERNEL_MAX_CARS = PREDBAT_MAX_CARS
Expand All @@ -53,6 +53,7 @@ class PkContext(ctypes.Structure):
("rate_import", ctypes.POINTER(ctypes.c_double)),
("rate_export", ctypes.POINTER(ctypes.c_double)),
("alert_keep", ctypes.POINTER(ctypes.c_double)),
("alert_keep_max", ctypes.POINTER(ctypes.c_double)),
("pv", ctypes.POINTER(ctypes.c_double)),
("load", ctypes.POINTER(ctypes.c_double)),
("pv10", ctypes.POINTER(ctypes.c_double)),
Expand Down Expand Up @@ -527,6 +528,7 @@ def build_static_context_arrays(pred, n_steps, minutes_now, num_cars):
rate_import = []
rate_export = []
alert_keep = []
alert_keep_max = []
io_flag = []
pv = []
pv10 = []
Expand All @@ -549,6 +551,7 @@ def build_static_context_arrays(pred, n_steps, minutes_now, num_cars):
rate_import.append(pred.rate_import.get(minute_absolute, 0))
rate_export.append(pred.rate_export.get(minute_absolute, 0))
alert_keep.append(pred.all_active_keep.get(minute_absolute, 0))
alert_keep_max.append(pred.all_active_keep_max.get(minute_absolute, 0))
io_flag.append(1 if pred.io_adjusted.get(minute_absolute, 0) else 0)
pv.append(pred.pv_forecast_minute_step[minute])
pv10.append(pred.pv_forecast_minute10_step[minute])
Expand Down Expand Up @@ -579,7 +582,7 @@ def build_static_context_arrays(pred, n_steps, minutes_now, num_cars):
charge_curve = [get_curve_value(pred.battery_charge_power_curve, percent, 1.0) for percent in range(101)]
discharge_curve = [get_curve_value(pred.battery_discharge_power_curve, percent, 1.0) for percent in range(101)]

return (rate_import, rate_export, alert_keep, io_flag, pv, pv10, pv90, temp_charge_cap, temp_discharge_cap, carbon, gas_rate, iboost_plan_load, car_load_flat, car_rate_flat, charge_curve, discharge_curve)
return (rate_import, rate_export, alert_keep, alert_keep_max, io_flag, pv, pv10, pv90, temp_charge_cap, temp_discharge_cap, carbon, gas_rate, iboost_plan_load, car_load_flat, car_rate_flat, charge_curve, discharge_curve)


def create_kernel_context(pred, static_cache=None):
Expand Down Expand Up @@ -628,12 +631,13 @@ def create_kernel_context(pred, static_cache=None):
static = (shape, build_static_context_arrays(pred, n_steps, minutes_now, num_cars))
if static_cache is not None:
static_cache["arrays"] = static
(rate_import, rate_export, alert_keep, io_flag, pv, pv10, pv90, temp_charge_cap, temp_discharge_cap, carbon, gas_rate, iboost_plan_load, car_load_flat, car_rate_flat, charge_curve, discharge_curve) = static[1]
(rate_import, rate_export, alert_keep, alert_keep_max, io_flag, pv, pv10, pv90, temp_charge_cap, temp_discharge_cap, carbon, gas_rate, iboost_plan_load, car_load_flat, car_rate_flat, charge_curve, discharge_curve) = static[1]

ctx = PkContext()
ctx.rate_import = double_array(rate_import)
ctx.rate_export = double_array(rate_export)
ctx.alert_keep = double_array(alert_keep)
ctx.alert_keep_max = double_array(alert_keep_max)
ctx.pv = double_array(pv)
ctx.load = double_array(load)
ctx.pv10 = double_array(pv10)
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.
23 changes: 23 additions & 0 deletions apps/predbat/tests/test_discard_unused_export_slots.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def run_discard_unused_export_slots_tests(my_predbat):
failed |= test_no_combine_non_contiguous(my_predbat)
failed |= test_no_combine_manual_times_start(my_predbat)
failed |= test_no_combine_manual_times_prev(my_predbat)
failed |= test_no_combine_active_keep_max(my_predbat)
failed |= test_mixed_slots(my_predbat)
failed |= test_all_disabled(my_predbat)
failed |= test_freeze_export_kept(my_predbat)
Expand All @@ -39,6 +40,7 @@ def setup(my_predbat):
reset_inverter(my_predbat)
my_predbat.debug_enable = False
my_predbat.manual_all_times = []
my_predbat.all_active_keep_max = {}


def test_discard_disabled(my_predbat):
Expand Down Expand Up @@ -192,6 +194,27 @@ def test_no_combine_manual_times_prev(my_predbat):
return failed


def test_no_combine_active_keep_max(my_predbat):
"""Slots should not combine across a manual_soc_max (all_active_keep_max) boundary - issue #1578"""
print("**** test_no_combine_export_active_keep_max ****")
failed = False
setup(my_predbat)
my_predbat.all_active_keep_max = {750: 4.0}

windows = [make_window(720, 750), make_window(750, 780)]
limits = [50.0, 50.0]

result_limits, result_windows = my_predbat.discard_unused_export_slots(limits, windows)

if len(result_limits) != 2:
print("ERROR: Expected 2 slots (all_active_keep_max boundary) but got {}".format(len(result_limits)))
failed = True

if not failed:
print("PASS")
return failed


def test_mixed_slots(my_predbat):
"""Mix of disabled, enabled, and combinable slots"""
print("**** test_discard_export_mixed_slots ****")
Expand Down
8 changes: 8 additions & 0 deletions apps/predbat/tests/test_kernel_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
"rate_export",
"io_adjusted",
"all_active_keep",
"all_active_keep_max",
"carbon_enable",
"carbon_intensity",
"carbon_today_sofar",
Expand Down Expand Up @@ -269,6 +270,13 @@ def apply_random_scenario(my_predbat, rng):
start = my_predbat.minutes_now + rng.randrange(0, my_predbat.forecast_minutes - 60, 5)
for minute in range(start, start + 120):
my_predbat.all_active_keep[minute] = rng.choice([20, 50, 100])
# Derived entirely from the floor block above (same activation, same window, value transformed
# from the already-drawn floor value) rather than new draws, so the seeded scenario stream for
# everything after this point is unchanged - see the "derived from an existing draw" note above.
my_predbat.all_active_keep_max = {}
if my_predbat.all_active_keep:
for minute, floor_value in my_predbat.all_active_keep.items():
my_predbat.all_active_keep_max[minute] = 100 - floor_value

# Carbon intensity
my_predbat.carbon_enable = rng.random() < 0.3
Expand Down
Loading
Loading