Skip to content

Commit 295f805

Browse files
feat: add manual_soc_max, a discharge-direction ceiling sibling to manual_soc (#1578)
manual_soc already lets an automation set a minimum SoC floor for a specific time - Predbat plans charging to reach it. There was no equivalent for the opposite direction: forcing the battery down to a ceiling by a deadline, which is what #1578 actually needs (a periodic calibration discharge finishing right before a known cheap import slot, e.g. an Octopus Intelligent Go midnight slot). manual_soc_max mirrors manual_soc's existing mechanism exactly, just as a ceiling: it merges into a new all_active_keep_max per-minute dict (fetch.py), and the optimiser treats being above the ceiling as a cost penalty priced at the export rate (prediction.py) - the same "keep" scoring approach already used for the floor, just inverted. Because it's a real cost signal integrated into the normal search rather than a fixed-duration forced export, the optimiser is free to use the energy against load or export depending on what's actually cheapest at each moment, which should make better use of the energy than a blunt forced discharge automation. A ceiling below the floor at the same minute is a contradiction (most likely a stale override left over from a previous automation run) - the floor wins as the safety-relevant constraint, and the conflicting ceiling is dropped with a warning rather than handing the optimiser two penalties pulling opposite ways. This touches the hot loop that has both a pure-Python and a C++ kernel implementation (prediction_kernel.cpp) for performance - mirrored the new ceiling logic there too and bumped both parity revisions per the kernel's own documented rule, so a stale prebuilt kernel binary falls back to the Python engine instead of silently disagreeing with it. Verified locally: rebuilt the kernel and the full parity suite passes, including a new randomized ceiling scenario added to its existing seeded sweep (constructed to consume no additional RNG draws, so the existing seeded scenarios are unaffected). Also covered: the web plan page's SOC dropdown gained a second "Maximum SOC" control (web.py/web_helper.py), plan-status markers mirroring the existing floor indicator (execute.py/output.py), and the export-window merge guard already applied to the floor is now applied to the ceiling too (plan.py) so two export windows can't be silently combined across a ceiling deadline. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent d48cb22 commit 295f805

18 files changed

Lines changed: 302 additions & 15 deletions

‎.cspell/custom-dictionary-workspace.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,7 @@ snakeviz
568568
snprintf
569569
socb
570570
socketloop
571+
socmax
571572
socs
572573
sofar
573574
SolarEdge

‎apps/predbat/config.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1394,6 +1394,27 @@
13941394
"icon": "mdi:battery-charging-100",
13951395
"default": 100,
13961396
},
1397+
{
1398+
"name": "manual_soc_max",
1399+
"friendly_name": "Manual SOC maximum target",
1400+
"type": "select",
1401+
"options": ["off"],
1402+
"icon": "mdi:battery-arrow-down",
1403+
"default": "off",
1404+
"restore": False,
1405+
"manual_rate": True,
1406+
},
1407+
{
1408+
"name": "manual_soc_max_value",
1409+
"friendly_name": "Manual SOC maximum target value",
1410+
"type": "input_number",
1411+
"min": 0,
1412+
"max": 100,
1413+
"step": 1,
1414+
"unit": "%",
1415+
"icon": "mdi:battery-arrow-down-outline",
1416+
"default": 0,
1417+
},
13971418
{
13981419
"name": "manual_api",
13991420
"friendly_name": "Manual API controls",

‎apps/predbat/execute.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ def execute_plan(self):
137137

138138
in_alert = self.alert_active_keep.get(self.minutes_now, 0) > 0
139139
in_manual_soc = self.manual_soc_keep.get(self.minutes_now, 0) > 0
140+
in_manual_soc_max = self.manual_soc_max_keep.get(self.minutes_now, 0) > 0
140141

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

780783
return status, status_extra
781784

‎apps/predbat/fetch.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -961,6 +961,25 @@ def fetch_sensor_data(self, save=True):
961961
else:
962962
self.all_active_keep[minute] = soc_value
963963

964+
# Manual SOC max is a ceiling rather than a floor - combine separately, taking the
965+
# tightest (lowest) ceiling if more than one source ever applies to the same minute.
966+
self.all_active_keep_max = {}
967+
if self.manual_soc_max_keep:
968+
for minute, soc_value in self.manual_soc_max_keep.items():
969+
if minute in self.all_active_keep_max:
970+
self.all_active_keep_max[minute] = min(self.all_active_keep_max[minute], soc_value)
971+
else:
972+
self.all_active_keep_max[minute] = soc_value
973+
974+
# A ceiling below the floor at the same minute is a contradiction (e.g. a leftover manual_soc
975+
# override never cleared) - the floor wins as the safety-relevant constraint, so drop the
976+
# conflicting ceiling rather than hand the optimiser two penalties pulling opposite ways.
977+
for minute in list(self.all_active_keep_max.keys()):
978+
floor_value = self.all_active_keep.get(minute, 0)
979+
if floor_value > self.all_active_keep_max[minute]:
980+
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))
981+
del self.all_active_keep_max[minute]
982+
964983
# iBoost load data
965984
if "iboost_energy_today" in self.args:
966985
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)
@@ -3216,6 +3235,7 @@ def fetch_config_options(self):
32163235
self.manual_export_rates = self.manual_rates("manual_export_rates", default_rate=self.get_arg("manual_export_value"))
32173236
self.manual_load_adjust = self.manual_rates("manual_load_adjust", default_rate=self.get_arg("manual_load_value"))
32183237
self.manual_soc_keep = self.manual_rates("manual_soc", default_rate=self.get_arg("manual_soc_value"))
3238+
self.manual_soc_max_keep = self.manual_rates("manual_soc_max", default_rate=self.get_arg("manual_soc_max_value"))
32193239

32203240
# Update list of config options to save/restore to
32213241
self.update_save_restore_list()

‎apps/predbat/output.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1232,6 +1232,7 @@ def import_rate_color(rate):
12321232

12331233
in_alert = self.alert_active_keep.get(minute, 0) > 0
12341234
in_manual_soc = self.manual_soc_keep.get(minute, 0) > 0
1235+
in_manual_soc_max = self.manual_soc_max_keep.get(minute, 0) > 0
12351236

12361237
pv_forecast = 0
12371238
load_forecast = 0
@@ -1519,6 +1520,8 @@ def import_rate_color(rate):
15191520
soc_sym = "&#9888; " + soc_sym
15201521
if in_manual_soc:
15211522
soc_sym = "&#9998; " + soc_sym
1523+
if in_manual_soc_max:
1524+
soc_sym = "&#11015; " + soc_sym
15221525

15231526
# Import and export rates -> to string
15241527
adjust_type = self.rate_import_replicated.get(minute, None)

‎apps/predbat/plan.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3099,6 +3099,8 @@ def discard_unused_export_slots(self, export_limits_best, export_window_best):
30993099
and (export_limits_best[window_n] == new_enable[-1])
31003100
and (export_window_best[window_n]["start"] not in self.manual_all_times)
31013101
and (new_best[-1]["start"] not in self.manual_all_times)
3102+
and (export_window_best[window_n]["start"] not in self.all_active_keep_max)
3103+
and (new_best[-1]["start"] not in self.all_active_keep_max)
31023104
):
31033105
new_best[-1]["end"] = export_window_best[window_n]["end"]
31043106
new_best[-1]["target"] = export_window_best[window_n].get("target", export_limits_best[window_n])

‎apps/predbat/predbat.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,7 +615,9 @@ def reset(self):
615615
self.alerts = []
616616
self.alert_active_keep = {}
617617
self.manual_soc_keep = {}
618+
self.manual_soc_max_keep = {}
618619
self.all_active_keep = {}
620+
self.all_active_keep_max = {}
619621
self.set_charge_low_power = False
620622
self.set_export_low_power = False
621623
self.config_root = "./"

‎apps/predbat/prediction.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ def __init__(
169169
self.load_minutes_step90 = load_minutes_step90 if load_minutes_step90 is not None else load_minutes_step
170170
self.carbon_intensity = base.carbon_intensity
171171
self.all_active_keep = base.all_active_keep
172+
self.all_active_keep_max = base.all_active_keep_max
172173
self.iboost_running = False
173174
self.iboost_running_solar = False
174175
self.iboost_running_full = False
@@ -628,6 +629,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
628629
battery_loss_discharge = self.battery_loss_discharge
629630
battery_temperature_prediction = self.battery_temperature_prediction
630631
all_active_keep = self.all_active_keep
632+
all_active_keep_max = self.all_active_keep_max
631633
best_soc_keep_weight = self.best_soc_keep_weight
632634
best_soc_keep_orig = self.best_soc_keep
633635
debug_enable = self.debug_enable
@@ -697,6 +699,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
697699

698700
# Alert?
699701
alert_keep = all_active_keep.get(minute_absolute, 0)
702+
alert_keep_max = all_active_keep_max.get(minute_absolute, 0)
700703

701704
# Project battery temperature
702705
battery_temperature = battery_temperature_prediction.get(minute, self.battery_temperature)
@@ -715,6 +718,12 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
715718
keep_minute_scaling = max(keep_minute_scaling, 10.0)
716719
best_soc_keep = max(best_soc_keep, min(alert_keep / 100.0 * soc_max, soc_max))
717720

721+
# Soc max keep is a ceiling rather than a floor (e.g. manual_soc_max) - 0 means no ceiling
722+
best_soc_max = 0
723+
if alert_keep_max > 0:
724+
keep_minute_scaling = max(keep_minute_scaling, 10.0)
725+
best_soc_max = min(alert_keep_max / 100.0 * soc_max, soc_max)
726+
718727
# Find charge & discharge windows
719728
charge_window_n = charge_window_optimised.get(minute_absolute, -1)
720729
export_window_n = export_window_optimised.get(minute_absolute, -1)
@@ -1275,6 +1284,10 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
12751284
if best_soc_keep > 0 and soc <= best_soc_keep:
12761285
metric_keep += (best_soc_keep - soc) * import_rate * keep_minute_scaling * step / 60.0
12771286

1287+
# Metric keep max - pretend the excess above the ceiling should have been exported instead of held
1288+
if best_soc_max > 0 and soc >= best_soc_max:
1289+
metric_keep += (soc - best_soc_max) * export_rate * keep_minute_scaling * step / 60.0
1290+
12781291
if diff > 0:
12791292
# Import
12801293
# All imports must go to home (no inverter loss) or to the battery (inverter loss accounted before above)

‎apps/predbat/prediction_kernel.cpp‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
// falling back. Bumping makes the loader reject it and use the Python engine, which is the whole
4444
// point of the check.
4545
#define PK_ABI_VERSION 5
46-
#define PK_PARITY_REVISION 10
46+
#define PK_PARITY_REVISION 11
4747
#define PK_MAX_CARS 8
4848
#define PK_RUN_EVERY 5 // const.py RUN_EVERY
4949
#define PK_EXPORT_LIMIT_FREEZE 99.0 // const.py EXPORT_LIMIT_FREEZE
@@ -145,6 +145,7 @@ struct PkContext {
145145
const double *rate_import; // import rate per step
146146
const double *rate_export; // export rate per step
147147
const double *alert_keep; // alert keep value per step
148+
const double *alert_keep_max; // soc ceiling keep value per step (manual_soc_max), 0 = no ceiling
148149
const double *pv; // PV forecast kWh per step (central)
149150
const double *load; // load kWh per step (central)
150151
const double *pv10; // PV forecast kWh per step (PV10)
@@ -360,7 +361,7 @@ static PkScratch &thread_scratch()
360361

361362
// Deep-copied context storage so Python-side buffers can be freed after create
362363
struct ContextStore {
363-
std::vector<double> rate_import, rate_export, alert_keep;
364+
std::vector<double> rate_import, rate_export, alert_keep, alert_keep_max;
364365
std::vector<double> pv, load, pv10, load10, pv90, load90;
365366
std::vector<double> temp_charge_cap, temp_discharge_cap;
366367
std::vector<int32_t> io_flag;
@@ -550,6 +551,7 @@ int64_t pk_context_create(const PkContext *in)
550551
store->rate_import.assign(in->rate_import, in->rate_import + n);
551552
store->rate_export.assign(in->rate_export, in->rate_export + n);
552553
store->alert_keep.assign(in->alert_keep, in->alert_keep + n);
554+
store->alert_keep_max.assign(in->alert_keep_max, in->alert_keep_max + n);
553555
store->pv.assign(in->pv, in->pv + n);
554556
store->load.assign(in->load, in->load + n);
555557
store->pv10.assign(in->pv10, in->pv10 + n);
@@ -573,6 +575,7 @@ int64_t pk_context_create(const PkContext *in)
573575
store->ctx.rate_import = store->rate_import.data();
574576
store->ctx.rate_export = store->rate_export.data();
575577
store->ctx.alert_keep = store->alert_keep.data();
578+
store->ctx.alert_keep_max = store->alert_keep_max.data();
576579
store->ctx.pv = store->pv.data();
577580
store->ctx.load = store->load.data();
578581
store->ctx.pv10 = store->pv10.data();
@@ -740,6 +743,7 @@ static int32_t pk_run_one(const ContextStore *store, const PkScenario *s, PkResu
740743

741744
// Alert - prediction.py:583
742745
const double alert_keep = c->alert_keep[k];
746+
const double alert_keep_max = c->alert_keep_max[k];
743747

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

758+
// Soc max keep is a ceiling rather than a floor (manual_soc_max) - mirrors prediction.py's
759+
// best_soc_max block right after the alert keep floor. 0 = no ceiling.
760+
double best_soc_max = 0;
761+
if (alert_keep_max > 0) {
762+
keep_minute_scaling = std::max(keep_minute_scaling, 10.0);
763+
best_soc_max = std::min(alert_keep_max / 100.0 * soc_max, soc_max);
764+
}
765+
754766
// Find charge & discharge windows - prediction.py:602-607
755767
const int32_t charge_window_n = charge_window_optimised[k];
756768
const int32_t export_window_n = export_window_optimised[k];
@@ -1262,6 +1274,12 @@ static int32_t pk_run_one(const ContextStore *store, const PkScenario *s, PkResu
12621274
metric_keep += (best_soc_keep - soc) * import_rate * keep_minute_scaling * step / 60.0;
12631275
}
12641276

1277+
// Metric keep max - pretend the excess above the ceiling should have been exported instead
1278+
// of held - mirrors prediction.py's best_soc_max block right after the floor.
1279+
if (best_soc_max > 0 && soc >= best_soc_max) {
1280+
metric_keep += (soc - best_soc_max) * export_rate * keep_minute_scaling * step / 60.0;
1281+
}
1282+
12651283
// Import/export accounting - prediction.py:1104-1143
12661284
if (diff > 0) {
12671285
// Import

‎apps/predbat/prediction_kernel.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232

3333
# Expected ABI/parity revisions of the shared library (see prediction_kernel.cpp)
3434
KERNEL_ABI_VERSION = 5
35-
KERNEL_PARITY_REVISION = 10
35+
KERNEL_PARITY_REVISION = 11
3636

3737
# Maximum number of cars supported by the kernel (PK_MAX_CARS in prediction_kernel.cpp)
3838
KERNEL_MAX_CARS = PREDBAT_MAX_CARS
@@ -53,6 +53,7 @@ class PkContext(ctypes.Structure):
5353
("rate_import", ctypes.POINTER(ctypes.c_double)),
5454
("rate_export", ctypes.POINTER(ctypes.c_double)),
5555
("alert_keep", ctypes.POINTER(ctypes.c_double)),
56+
("alert_keep_max", ctypes.POINTER(ctypes.c_double)),
5657
("pv", ctypes.POINTER(ctypes.c_double)),
5758
("load", ctypes.POINTER(ctypes.c_double)),
5859
("pv10", ctypes.POINTER(ctypes.c_double)),
@@ -527,6 +528,7 @@ def build_static_context_arrays(pred, n_steps, minutes_now, num_cars):
527528
rate_import = []
528529
rate_export = []
529530
alert_keep = []
531+
alert_keep_max = []
530532
io_flag = []
531533
pv = []
532534
pv10 = []
@@ -549,6 +551,7 @@ def build_static_context_arrays(pred, n_steps, minutes_now, num_cars):
549551
rate_import.append(pred.rate_import.get(minute_absolute, 0))
550552
rate_export.append(pred.rate_export.get(minute_absolute, 0))
551553
alert_keep.append(pred.all_active_keep.get(minute_absolute, 0))
554+
alert_keep_max.append(pred.all_active_keep_max.get(minute_absolute, 0))
552555
io_flag.append(1 if pred.io_adjusted.get(minute_absolute, 0) else 0)
553556
pv.append(pred.pv_forecast_minute_step[minute])
554557
pv10.append(pred.pv_forecast_minute10_step[minute])
@@ -579,7 +582,7 @@ def build_static_context_arrays(pred, n_steps, minutes_now, num_cars):
579582
charge_curve = [get_curve_value(pred.battery_charge_power_curve, percent, 1.0) for percent in range(101)]
580583
discharge_curve = [get_curve_value(pred.battery_discharge_power_curve, percent, 1.0) for percent in range(101)]
581584

582-
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)
585+
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)
583586

584587

585588
def create_kernel_context(pred, static_cache=None):
@@ -628,12 +631,13 @@ def create_kernel_context(pred, static_cache=None):
628631
static = (shape, build_static_context_arrays(pred, n_steps, minutes_now, num_cars))
629632
if static_cache is not None:
630633
static_cache["arrays"] = static
631-
(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]
634+
(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]
632635

633636
ctx = PkContext()
634637
ctx.rate_import = double_array(rate_import)
635638
ctx.rate_export = double_array(rate_export)
636639
ctx.alert_keep = double_array(alert_keep)
640+
ctx.alert_keep_max = double_array(alert_keep_max)
637641
ctx.pv = double_array(pv)
638642
ctx.load = double_array(load)
639643
ctx.pv10 = double_array(pv10)

0 commit comments

Comments
 (0)