diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index cebae9bf4..0619af027 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -554,6 +554,7 @@ snakeviz snprintf socb socketloop +socmax socs sofar SolarEdge diff --git a/apps/predbat/config.py b/apps/predbat/config.py index cc520cbe0..6c1450540 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -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", diff --git a/apps/predbat/execute.py b/apps/predbat/execute.py index ce5cfabed..161f489d3 100644 --- a/apps/predbat/execute.py +++ b/apps/predbat/execute.py @@ -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 @@ -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 diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 4c80a452d..7fff8b909 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -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) @@ -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() diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 01c0329ce..77e054804 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -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 @@ -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) diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index bc234ad6b..0c3011e79 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -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]) diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 988a6f9f0..45ca3c3db 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -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 = "./" diff --git a/apps/predbat/prediction.py b/apps/predbat/prediction.py index 52fecbf45..a22175969 100644 --- a/apps/predbat/prediction.py +++ b/apps/predbat/prediction.py @@ -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 @@ -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 @@ -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) @@ -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) @@ -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) diff --git a/apps/predbat/prediction_kernel.cpp b/apps/predbat/prediction_kernel.cpp index 1f71f6372..b2a1e6779 100644 --- a/apps/predbat/prediction_kernel.cpp +++ b/apps/predbat/prediction_kernel.cpp @@ -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 @@ -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) @@ -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 rate_import, rate_export, alert_keep; + std::vector rate_import, rate_export, alert_keep, alert_keep_max; std::vector pv, load, pv10, load10, pv90, load90; std::vector temp_charge_cap, temp_discharge_cap; std::vector io_flag; @@ -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); @@ -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(); @@ -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; @@ -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]; @@ -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 diff --git a/apps/predbat/prediction_kernel.py b/apps/predbat/prediction_kernel.py index 371fae667..d5e8d6a9c 100644 --- a/apps/predbat/prediction_kernel.py +++ b/apps/predbat/prediction_kernel.py @@ -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 @@ -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)), @@ -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 = [] @@ -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]) @@ -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): @@ -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) diff --git a/apps/predbat/prediction_kernel_lib_aarch64.so b/apps/predbat/prediction_kernel_lib_aarch64.so index 1629b959a..57dbec3da 100755 Binary files a/apps/predbat/prediction_kernel_lib_aarch64.so and b/apps/predbat/prediction_kernel_lib_aarch64.so differ diff --git a/apps/predbat/prediction_kernel_lib_armv7l.so b/apps/predbat/prediction_kernel_lib_armv7l.so index 747d476e2..ec35d50e3 100755 Binary files a/apps/predbat/prediction_kernel_lib_armv7l.so and b/apps/predbat/prediction_kernel_lib_armv7l.so differ diff --git a/apps/predbat/prediction_kernel_lib_darwin_arm64.so b/apps/predbat/prediction_kernel_lib_darwin_arm64.so index 581a63fb1..355e77bfa 100755 Binary files a/apps/predbat/prediction_kernel_lib_darwin_arm64.so and b/apps/predbat/prediction_kernel_lib_darwin_arm64.so differ diff --git a/apps/predbat/prediction_kernel_lib_darwin_x86_64.so b/apps/predbat/prediction_kernel_lib_darwin_x86_64.so index ab2c07337..051b61e4b 100755 Binary files a/apps/predbat/prediction_kernel_lib_darwin_x86_64.so and b/apps/predbat/prediction_kernel_lib_darwin_x86_64.so differ diff --git a/apps/predbat/prediction_kernel_lib_i686.so b/apps/predbat/prediction_kernel_lib_i686.so index fb32bf93c..4afc7c8fd 100755 Binary files a/apps/predbat/prediction_kernel_lib_i686.so and b/apps/predbat/prediction_kernel_lib_i686.so differ diff --git a/apps/predbat/prediction_kernel_lib_x86_64.so b/apps/predbat/prediction_kernel_lib_x86_64.so index d0f17c8ac..7c34ceb7f 100755 Binary files a/apps/predbat/prediction_kernel_lib_x86_64.so and b/apps/predbat/prediction_kernel_lib_x86_64.so differ diff --git a/apps/predbat/tests/test_discard_unused_export_slots.py b/apps/predbat/tests/test_discard_unused_export_slots.py index cc45c3cc8..f13420901 100644 --- a/apps/predbat/tests/test_discard_unused_export_slots.py +++ b/apps/predbat/tests/test_discard_unused_export_slots.py @@ -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) @@ -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): @@ -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 ****") diff --git a/apps/predbat/tests/test_kernel_parity.py b/apps/predbat/tests/test_kernel_parity.py index 40808e512..f53c2bbe5 100644 --- a/apps/predbat/tests/test_kernel_parity.py +++ b/apps/predbat/tests/test_kernel_parity.py @@ -106,6 +106,7 @@ "rate_export", "io_adjusted", "all_active_keep", + "all_active_keep_max", "carbon_enable", "carbon_intensity", "carbon_today_sofar", @@ -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 diff --git a/apps/predbat/tests/test_manual_soc_max.py b/apps/predbat/tests/test_manual_soc_max.py new file mode 100644 index 000000000..48f3d6ea8 --- /dev/null +++ b/apps/predbat/tests/test_manual_soc_max.py @@ -0,0 +1,127 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# fmt off +# pylint: disable=consider-using-f-string +# pylint: disable=line-too-long +# pylint: disable=attribute-defined-outside-init + +from datetime import datetime, timezone + + +def run_test_manual_soc_max(my_predbat): + """ + Test manual SOC maximum (ceiling) target feature - the discharge-direction sibling of + manual_soc, added for issue #1578 (weekly battery calibration ahead of a known cheap slot). + """ + failed = False + print("Test manual SOC max target") + + my_predbat.midnight_utc = datetime(2025, 12, 19, 0, 0, 0, tzinfo=timezone.utc) + my_predbat.midnight = my_predbat.midnight_utc.astimezone(my_predbat.local_tz) + my_predbat.now_utc = my_predbat.midnight_utc + my_predbat.minutes_now = 0 + + # Reset manual_soc_max to off + my_predbat.manual_select("manual_soc_max", "off") + + # Test 1: Basic manual SOC max parsing with an explicit value + print("Test 1: Basic manual SOC max parsing with an explicit value") + my_predbat.manual_select("manual_soc_max", "00:00=4") + + my_predbat.manual_soc_max_keep = my_predbat.manual_rates("manual_soc_max", default_rate=my_predbat.get_arg("manual_soc_max_value")) + + if not my_predbat.manual_soc_max_keep: + print("ERROR: T1 Expected manual_soc_max_keep to have entries but got empty dict") + failed = True + else: + has_4 = any(v == 4.0 for v in my_predbat.manual_soc_max_keep.values()) + if not has_4: + print("ERROR: T1 Expected manual_soc_max_keep to have SOC ceiling of 4% but got {}".format(my_predbat.manual_soc_max_keep)) + failed = True + else: + print("PASS: T1 Manual SOC max target set correctly to 4% at 00:00") + + # Test 2: Manual SOC max with explicit value, independent of manual_soc's own selection + print("Test 2: Manual SOC max and manual SOC (floor) are independent controls") + my_predbat.manual_select("manual_soc", "off") + my_predbat.manual_select("manual_soc_max", "23:30=50") + + my_predbat.manual_soc_keep = my_predbat.manual_rates("manual_soc", default_rate=my_predbat.get_arg("manual_soc_value")) + my_predbat.manual_soc_max_keep = my_predbat.manual_rates("manual_soc_max", default_rate=my_predbat.get_arg("manual_soc_max_value")) + + if my_predbat.manual_soc_keep: + print("ERROR: T2 Expected manual_soc_keep (floor) to be untouched by a manual_soc_max selection, got {}".format(my_predbat.manual_soc_keep)) + failed = True + elif not any(v == 50.0 for v in my_predbat.manual_soc_max_keep.values()): + print("ERROR: T2 Expected manual_soc_max_keep to have SOC ceiling of 50% but got {}".format(my_predbat.manual_soc_max_keep)) + failed = True + else: + print("PASS: T2 manual_soc_max set independently of manual_soc") + + # Test 3: Manual SOC max off clears targets + print("Test 3: Manual SOC max off clears targets") + my_predbat.manual_select("manual_soc_max", "off") + + my_predbat.manual_soc_max_keep = my_predbat.manual_rates("manual_soc_max", default_rate=my_predbat.get_arg("manual_soc_max_value")) + + if my_predbat.manual_soc_max_keep: + print("ERROR: T3 Expected manual_soc_max_keep to be empty when off but got {}".format(my_predbat.manual_soc_max_keep)) + failed = True + else: + print("PASS: T3 Manual SOC max targets cleared when set to off") + + # Test 4: A ceiling below the floor at the same minute is a contradiction - the floor wins and + # the conflicting ceiling is dropped with a warning, rather than handing the optimiser two + # penalties pulling opposite ways (see fetch.py's all_active_keep/all_active_keep_max merge). + print("Test 4: Ceiling below the floor at the same minute is dropped, floor wins") + my_predbat.manual_select("manual_soc", "01:00=80") + my_predbat.manual_select("manual_soc_max", "01:00=20") + + log_messages = [] + orig_log = my_predbat.log + my_predbat.log = lambda msg, *args, **kwargs: log_messages.append(str(msg)) + my_predbat.manual_soc_keep = my_predbat.manual_rates("manual_soc", default_rate=my_predbat.get_arg("manual_soc_value")) + my_predbat.manual_soc_max_keep = my_predbat.manual_rates("manual_soc_max", default_rate=my_predbat.get_arg("manual_soc_max_value")) + + # Reproduce the merge fetch_config_options() performs, exercising the actual conflict-resolution code + my_predbat.alert_active_keep = {} + my_predbat.all_active_keep = my_predbat.alert_active_keep.copy() + for minute, soc_value in my_predbat.manual_soc_keep.items(): + my_predbat.all_active_keep[minute] = max(my_predbat.all_active_keep.get(minute, 0), soc_value) + my_predbat.all_active_keep_max = {} + for minute, soc_value in my_predbat.manual_soc_max_keep.items(): + my_predbat.all_active_keep_max[minute] = min(my_predbat.all_active_keep_max.get(minute, soc_value), soc_value) + for minute in list(my_predbat.all_active_keep_max.keys()): + floor_value = my_predbat.all_active_keep.get(minute, 0) + if floor_value > my_predbat.all_active_keep_max[minute]: + my_predbat.log("Warn: manual_soc_max target {}% at minute {} is below the manual_soc/alert floor {}% for the same minute - ignoring the ceiling there".format(my_predbat.all_active_keep_max[minute], minute, floor_value)) + del my_predbat.all_active_keep_max[minute] + my_predbat.log = orig_log + + if my_predbat.all_active_keep_max: + print("ERROR: T4 Expected the conflicting ceiling to be dropped but got {}".format(my_predbat.all_active_keep_max)) + failed = True + elif not any("below the manual_soc/alert floor" in msg for msg in log_messages): + print("ERROR: T4 Expected a warning about the floor/ceiling conflict, got {}".format(log_messages)) + failed = True + else: + print("PASS: T4 Conflicting ceiling dropped with a warning, floor preserved") + + # Clean up + my_predbat.alert_active_keep = {} + my_predbat.manual_soc_keep = {} + my_predbat.manual_soc_max_keep = {} + my_predbat.all_active_keep = {} + my_predbat.all_active_keep_max = {} + my_predbat.manual_select("manual_soc", "off") + my_predbat.manual_select("manual_soc_max", "off") + + my_predbat.now_utc = datetime.now(my_predbat.local_tz) + my_predbat.midnight_utc = my_predbat.now_utc.replace(hour=0, minute=0, second=0, microsecond=0) + my_predbat.minutes_now = int((my_predbat.now_utc - my_predbat.midnight_utc).total_seconds() / 60) + my_predbat.midnight = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + + return failed diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index 34dc7a76b..e3b90221e 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -144,6 +144,7 @@ from tests.test_find_charge_rate import test_find_charge_rate, test_find_charge_rate_pv_overlap, test_find_charge_rate_string_temperature, test_find_charge_rate_string_charge_curve from tests.test_manual_api import run_test_manual_api from tests.test_manual_soc import run_test_manual_soc +from tests.test_manual_soc_max import run_test_manual_soc_max from tests.test_manual_times import run_test_manual_times from tests.test_manual_select import run_test_manual_select from tests.test_minute_array import test_minute_array @@ -475,6 +476,7 @@ def main(): ("units", run_test_units, "Unit tests", False), ("manual_api", run_test_manual_api, "Manual API tests", False), ("manual_soc", run_test_manual_soc, "Manual SOC target tests", False), + ("manual_soc_max", run_test_manual_soc_max, "Manual SOC maximum (ceiling) target tests (issue #1578)", False), ("manual_times", run_test_manual_times, "Manual times tests", False), ("manual_select", run_test_manual_select, "Manual select tests", False), ("web_if", run_test_web_if, "Web interface tests", False), diff --git a/apps/predbat/userinterface.py b/apps/predbat/userinterface.py index 2aa1d126b..ff1a3e75b 100644 --- a/apps/predbat/userinterface.py +++ b/apps/predbat/userinterface.py @@ -1309,6 +1309,9 @@ def manual_select(self, config_item, value): elif "_load" in item["name"]: # Manual load rate self.manual_rates(config_item, new_value=item_value, default_rate=self.get_arg("manual_load_value")) + elif "_soc_max" in item["name"]: + # Manual soc maximum (ceiling) rate + self.manual_rates(config_item, new_value=item_value, default_rate=self.get_arg("manual_soc_max_value")) elif "_soc" in item["name"]: # Manual soc rate self.manual_rates(config_item, new_value=item_value, default_rate=self.get_arg("manual_soc_value")) diff --git a/apps/predbat/web.py b/apps/predbat/web.py index 9dee8bc1d..359e546c2 100644 --- a/apps/predbat/web.py +++ b/apps/predbat/web.py @@ -2495,12 +2495,14 @@ async def html_api_plan_data(self, request): manual_export_rates = self.base.manual_rates("manual_export_rates") manual_load_adjust = self.base.manual_rates("manual_load_adjust") manual_soc_keep = self.base.manual_rates("manual_soc") + manual_soc_max_keep = self.base.manual_rates("manual_soc_max") # Convert manual rates dicts to list format for JavaScript manual_import_rates_list = [{"minutes": k, "rate": v} for k, v in manual_import_rates.items()] manual_export_rates_list = [{"minutes": k, "rate": v} for k, v in manual_export_rates.items()] manual_load_adjust_list = [{"minutes": k, "adjustment": v} for k, v in manual_load_adjust.items()] manual_soc_list = [{"minutes": k, "target": v} for k, v in manual_soc_keep.items()] + manual_soc_max_list = [{"minutes": k, "target": v} for k, v in manual_soc_max_keep.items()] # Build overrides object overrides = { @@ -2513,6 +2515,7 @@ async def html_api_plan_data(self, request): "manual_export_rates": manual_export_rates_list, "manual_load_adjust": manual_load_adjust_list, "manual_soc": manual_soc_list, + "manual_soc_max": manual_soc_max_list, } # Calculate hash of overrides for change detection @@ -2589,12 +2592,14 @@ async def html_plan(self, request): manual_export_rates = self.base.manual_rates("manual_export_rates") manual_load_adjust = self.base.manual_rates("manual_load_adjust") manual_soc_keep = self.base.manual_rates("manual_soc") + manual_soc_max_keep = self.base.manual_rates("manual_soc_max") # Convert manual rates dicts to list format for JavaScript manual_import_rates_list = [{"minutes": k, "rate": v} for k, v in manual_import_rates.items()] manual_export_rates_list = [{"minutes": k, "rate": v} for k, v in manual_export_rates.items()] manual_load_adjust_list = [{"minutes": k, "adjustment": v} for k, v in manual_load_adjust.items()] manual_soc_list = [{"minutes": k, "target": v} for k, v in manual_soc_keep.items()] + manual_soc_max_list = [{"minutes": k, "target": v} for k, v in manual_soc_max_keep.items()] # Build overrides object overrides = { @@ -2607,6 +2612,7 @@ async def html_plan(self, request): "manual_export_rates": manual_export_rates_list, "manual_load_adjust": manual_load_adjust_list, "manual_soc": manual_soc_list, + "manual_soc_max": manual_soc_max_list, } # Calculate hash of overrides for change detection @@ -4655,6 +4661,15 @@ async def html_rate_override(self, request): actual_rate = manual_soc.get(minutes_from_midnight, rate) clear_option = "[{}={}]".format(override_time.strftime("%a %H:%M"), actual_rate) await self.base.async_manual_select("manual_soc", clear_option) + elif action == "Set SOC Max": + item = self.base.config_index.get("manual_soc_max_value", {}) + await self.set_state_external(item.get("entity", None), rate) + await self.base.async_manual_select("manual_soc_max", selection_option) + elif action == "Clear SOC Max": + manual_soc_max = self.base.manual_rates("manual_soc_max") + actual_rate = manual_soc_max.get(minutes_from_midnight, rate) + clear_option = "[{}={}]".format(override_time.strftime("%a %H:%M"), actual_rate) + await self.base.async_manual_select("manual_soc_max", clear_option) else: self.log("ERROR: Unknown action for rate override") return web.json_response({"success": False, "message": "Unknown action"}, status=400) diff --git a/apps/predbat/web_helper.py b/apps/predbat/web_helper.py index 8e1b3fd3b..c4d114afe 100644 --- a/apps/predbat/web_helper.py +++ b/apps/predbat/web_helper.py @@ -6547,19 +6547,22 @@ def get_plan_css(): closeDropdowns(); } - // Handle SOC override - function handleSocOverride(time, dropdownId, isClear) { + // Handle SOC override (isMax selects the manual_soc_max ceiling instead of the manual_soc floor) + function handleSocOverride(time, dropdownId, isClear, isMax) { + const inputPrefix = isMax ? 'socmax_' : 'soc_'; + const overrideKey = isMax ? 'manual_soc_max' : 'manual_soc'; + // Get the SOC value from the input field (unless clearing) let value = null; if (!isClear && dropdownId) { - const inputElement = document.getElementById('soc_' + dropdownId); + const inputElement = document.getElementById(inputPrefix + dropdownId); if (inputElement) { value = inputElement.value; } } else if (isClear) { // When clearing, we need to find the actual stored SOC for this time const minutesFromMidnight = getMinutesFromTimeString(time); - const override = window.overridesData.manual_soc.find(r => r.minutes === minutesFromMidnight); + const override = window.overridesData[overrideKey].find(r => r.minutes === minutesFromMidnight); if (override) { value = override.target; } else { @@ -6568,7 +6571,7 @@ def get_plan_css(): } // Construct the action string the server expects - const action = isClear ? 'Clear SOC' : 'Set SOC'; + const action = isMax ? (isClear ? 'Clear SOC Max' : 'Set SOC Max') : (isClear ? 'Clear SOC' : 'Set SOC'); // Create a form data object to send the override parameters const formData = new FormData(); @@ -6586,10 +6589,11 @@ def get_plan_css(): if (data.success) { // Show success message const messageElement = document.createElement('div'); + const label = isMax ? 'SOC max' : 'SOC'; if (isClear) { - messageElement.textContent = `SOC override cleared for ${time}`; + messageElement.textContent = `${label} override cleared for ${time}`; } else { - messageElement.textContent = `SOC target set to ${value}% for ${time}`; + messageElement.textContent = `${label} target set to ${value}% for ${time}`; } messageElement.style.position = 'fixed'; messageElement.style.top = '65px'; @@ -7275,22 +7279,29 @@ def get_plan_renderer_js(): return html; } - // Render SOC cell with dropdown for manual SOC targets + // Render SOC cell with dropdown for manual SOC targets (minimum floor and maximum ceiling) function renderSocCell(timeStr, timeDisplay, socValue, bgColor, socSym, overrides, slotMinute) { const dropdownId = `dropdown_${dropdownCounter++}`; const minutesFromMidnight = slotMinute !== undefined ? slotMinute : getMinutesFromTimeString(timeStr); const isOverride = overrides.manual_soc.some(r => r.minutes === minutesFromMidnight); + const isOverrideMax = overrides.manual_soc_max.some(r => r.minutes === minutesFromMidnight); let html = ``; - html += `${socValue}${socSym}${isOverride ? ' ⅎ' : ''}`; + html += `${socValue}${socSym}${isOverride ? ' ⅎ' : ''}${isOverrideMax ? ' ⬇' : ''}`; html += ''; return html; } diff --git a/docs/customisation.md b/docs/customisation.md index ef35a94e7..7d70b97fd 100644 --- a/docs/customisation.md +++ b/docs/customisation.md @@ -803,6 +803,17 @@ If this selector is used in an automation you can set the time and SoC together The manual SoC target works in conjunction with the [weather alert system](apps-yaml.md#weather-alert-system) - if both are active at the same time, the higher SoC target will be used. +The **select.predbat_manual_soc_max** selector is the opposite of **select.predbat_manual_soc**: it sets a _maximum_ SoC ceiling for a specific time instead of a minimum floor. +This is useful for a periodic calibration discharge - some batteries benefit from occasionally being run down close to empty just before a known cheap import slot (e.g. an Octopus Intelligent Go midnight slot), so the BMS can re-anchor its SoC estimate, and then Predbat can immediately recharge cheaply. See issue [#1578](https://github.com/springfall2008/batpred/issues/1578) for the discussion that led to this. + +The SoC ceiling percentage will be that configured in **input_number.predbat_manual_soc_max_value** (default 0%) which can be adjusted prior to making a selection. + +For example, to run the battery down to 4% by 00:00 (just ahead of a midnight cheap slot), set **input_number.predbat_manual_soc_max_value** to 4 and select the 00:00 slot on **select.predbat_manual_soc_max**. Predbat will plan discharging so the battery is at or below that ceiling by that time, preferring to use the energy against load or export rather than simply forcing a fixed-duration export block, so it stays coordinated with the rest of the plan (car charging, existing charge/export windows, etc). + +If a manual SoC target (floor) and a manual SoC maximum (ceiling) ever apply to the same time and the ceiling is below the floor, that is a contradiction - the floor wins and the conflicting ceiling is dropped, with a warning logged. + +If this selector is used in an automation you can set the time and SoC together by making a selection in the format HH:MM=percentage e.g. 00:00=4 + ## Manual API **select.predbat_manual_api** enables you to overwrite configuration entries normally set in `apps.yaml`, e.g. from an automation.