diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index 9a355bb8f..e7449c9ce 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -514,6 +514,7 @@ predheat preseed preseeded prevs +prosumer protobuf pseudonymisation pseudonymise diff --git a/apps/predbat/annual.py b/apps/predbat/annual.py index 57db475da..b0f2d7b1a 100644 --- a/apps/predbat/annual.py +++ b/apps/predbat/annual.py @@ -750,6 +750,7 @@ def reset_sample_state(predbat): predbat.soc_kw = 0.0 predbat.cost_today_sofar = 0 + predbat.export_budget_seed = None predbat.carbon_today_sofar = 0 predbat.iboost_today = 0 predbat.import_today_now = 0 diff --git a/apps/predbat/config.py b/apps/predbat/config.py index 9bfa89841..c7a9244ce 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -2604,6 +2604,8 @@ "db_primary": {"type": "boolean"}, "threads": {"type": "string|integer", "allowed": ["auto", 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]}, "prediction_kernel_enable": {"type": "boolean"}, + "export_budget_mode": {"type": "string|boolean", "allowed": ["off", "pv_origin", "pv_equivalent"]}, + "export_budget_cap_kwh": {"type": "float", "zero": True}, "log_count": {"type": "integer", "min": 2, "max": 100}, "ha_url": {"type": "string", "empty": False}, "ha_key": {"type": "string", "empty": False}, diff --git a/apps/predbat/export_budget.py b/apps/predbat/export_budget.py new file mode 100644 index 000000000..c004bb23a --- /dev/null +++ b/apps/predbat/export_budget.py @@ -0,0 +1,211 @@ +# ----------------------------------------------------------------------------- +# 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 +"""Export energy budget (export_budget_mode). + +Limits battery-to-grid export to energy derived from the household's own PV: + +- pv_origin: stored energy is split into a PV part and a grid part. Battery output up to the whole + on-site load counts as the house's own use and draws the grid part first, even while PV is exported. + Only battery output beyond the load is export, and only the PV part may be exported. +- pv_equivalent: a bucket filled by PV production and drained by all export, capped (default the + battery size); battery export is allowed while the bucket has energy. + +The step functions are the reference the prediction engines follow. prediction_kernel.cpp mirrors +them inline, so the arithmetic must not change without a kernel parity revision bump. +""" + +import math + +EXPORT_BUDGET_OFF = 0 +EXPORT_BUDGET_PV_ORIGIN = 1 +EXPORT_BUDGET_PV_EQUIVALENT = 2 +EXPORT_BUDGET_MODE_NAMES = {"off": EXPORT_BUDGET_OFF, "pv_origin": EXPORT_BUDGET_PV_ORIGIN, "pv_equivalent": EXPORT_BUDGET_PV_EQUIVALENT} +EXPORT_BUDGET_REPLAY_DAYS = 7 +# A 5 minute meter delta above this (240 kW) is a counter glitch, not energy +EXPORT_BUDGET_MAX_STEP_KWH = 20.0 + + +def parse_export_budget_mode(value): + """Return the mode constant for an apps.yaml value, or None when it is not a known mode""" + if value is None or value is False: + return EXPORT_BUDGET_OFF + if not isinstance(value, str): + return None + return EXPORT_BUDGET_MODE_NAMES.get(value.strip().lower()) + + +def export_budget_house_from_battery(mode, battery_out, load, pv): + """Battery output (AC) that counts as serving on-site load rather than as export. + + pv_origin counts the whole load, so stored grid energy may run the house while PV is exported; + pv_equivalent counts PV as covering the load first, so only what PV leaves uncovered. + """ + if mode == EXPORT_BUDGET_PV_ORIGIN: + return min(battery_out, max(load, 0.0)) + return min(battery_out, max(load - pv, 0.0)) + + +def export_budget_battery_export_allowed(mode, bucket, pv_part, grid_part, pv_now, pv_direct_export, house_from_battery_ac, stored_to_ac): + """AC energy the battery may export to the grid in one forced export step. + + pv_equivalent: total export <= bucket + pv_now, so the battery may add what is left after the PV + exported directly. pv_origin: on-site load is served from the grid part first and export only from + the PV part that remains; stored_to_ac converts stored kWh to AC at the meter. + """ + if mode == EXPORT_BUDGET_PV_EQUIVALENT: + return max(bucket + pv_now - pv_direct_export, 0.0) + house_stored = house_from_battery_ac / stored_to_ac if stored_to_ac > 0 else 0.0 + pv_left = max(pv_part - max(house_stored - grid_part, 0.0), 0.0) + return pv_left * stored_to_ac + + +def export_budget_update(mode, bucket, pv_part, grid_part, soc_before, soc_after, charge_kwh, pv_now, house_frac, export_kwh, cap): + """Advance the budget state over one step, returns (bucket, pv_part, grid_part). + + charge_kwh is the DC energy drawn into the battery this step (0 when not charging), house_frac the + share of this step's battery output that served on-site load, export_kwh the energy that reached the + meter. pv_origin parts are renormalised to soc_after so they always sum to the SoC. + """ + if mode == EXPORT_BUDGET_PV_EQUIVALENT: + bucket = min(max(bucket + pv_now - export_kwh, 0.0), cap) + return bucket, pv_part, grid_part + delta = soc_after - soc_before + if delta > 0: + pv_frac = min(charge_kwh, pv_now) / charge_kwh if charge_kwh > 0 else 0.0 + pv_part += delta * pv_frac + grid_part += delta * (1.0 - pv_frac) + elif delta < 0: + removed = -delta + removed_house = removed * house_frac + removed_export = removed - removed_house + from_grid = min(grid_part, removed_house) + grid_part -= from_grid + pv_part = max(pv_part - (removed_house - from_grid), 0.0) + from_pv = min(pv_part, removed_export) + pv_part -= from_pv + grid_part = max(grid_part - (removed_export - from_pv), 0.0) + total = pv_part + grid_part + if total > 0: + scale = soc_after / total + pv_part *= scale + grid_part *= scale + else: + pv_part = 0.0 + grid_part = soc_after + return bucket, pv_part, grid_part + + +def export_budget_floor_percent(soc, exportable, soc_max): + """Lowest export target (whole percent, rounded up) that cannot sell more than `exportable` stored kWh from `soc`""" + if soc_max <= 0: + return 0 + return min(max(int(math.ceil((soc - exportable) * 100.0 / soc_max - 1e-6)), 0), 100) + + +def _replay_delta(data, ago, step): + """Energy in the step ending `ago` minutes ago, None for a counter glitch""" + value = data.get(ago, 0.0) - data.get(ago + step, 0.0) + if value < -0.05 or value > EXPORT_BUDGET_MAX_STEP_KWH: + return None + return max(value, 0.0) + + +def _replay_soc_delta(soc_history, ago, step): + """Measured change of stored energy in the step ending `ago` minutes ago, None when unknown or a glitch""" + if ago not in soc_history or (ago + step) not in soc_history: + return None + value = soc_history[ago] - soc_history[ago + step] + if abs(value) > EXPORT_BUDGET_MAX_STEP_KWH: + return None + return value + + +def export_budget_replay(mode, pv_today, export_today, import_today, load_minutes, soc_max, cap, minutes=EXPORT_BUDGET_REPLAY_DAYS * 24 * 60, step=5, soc_history=None): + """Replay measured history (cumulative series keyed by minutes ago) through the budget rules. + + Returns (seed, noncompliant_kwh, steps). seed is (bucket, pv_share), or None when there is no usable + history. noncompliant_kwh is battery export the rules did not allow. + + pv_origin labels battery flow per step. With soc_history (stored kWh keyed by minutes ago) the flow is the + measured SoC change, starting from the stored energy at the start of the window counted as grid; a drop the + house and the meter do not explain (losses, BMS resync) comes off the PV part first. Without it the flow is + estimated as PV + import - export - load, which reads conversion losses as charge, so it is only a fallback. + Only the PV share of the stored energy is kept; the prediction applies it to the real SoC. + """ + series = [pv_today, export_today] + use_soc = False + if mode == EXPORT_BUDGET_PV_ORIGIN: + series += [import_today, load_minutes] + use_soc = bool(soc_history) + if use_soc: + series.append(soc_history) + if any(not data for data in series): + return None, 0.0, 0 + available = min([minutes] + [max(data.keys()) for data in series]) + available = (available // step) * step + bucket = 0.0 + pv_part = 0.0 + grid_part = soc_history.get(available, 0.0) if use_soc else 0.0 + noncompliant = 0.0 + steps = 0 + for ago in range(available - step, -1, -step): + pv = _replay_delta(pv_today, ago, step) + exported = _replay_delta(export_today, ago, step) + if pv is None or exported is None: + continue + if mode == EXPORT_BUDGET_PV_EQUIVALENT: + noncompliant += max(exported - (bucket + pv), 0.0) + bucket = min(max(bucket + pv - exported, 0.0), cap) + else: + imported = _replay_delta(import_today, ago, step) + load = _replay_delta(load_minutes, ago, step) + if imported is None or load is None: + continue + if use_soc: + flow = _replay_soc_delta(soc_history, ago, step) + if flow is None: + continue + else: + flow = pv + imported - exported - load + if flow > 0: + pv_in = min(flow, pv) + pv_part += pv_in + grid_part += flow - pv_in + elif flow < 0: + out = -flow + house = export_budget_house_from_battery(mode, out, load, pv) + to_grid = out - house + unexplained = 0.0 + if use_soc: + # The meter bounds what can have left for the grid; the rest is loss or a SoC resync + unexplained = max(to_grid - exported, 0.0) + to_grid -= unexplained + from_grid = min(grid_part, house) + grid_part -= from_grid + pv_part = max(pv_part - (house - from_grid), 0.0) + from_pv = min(pv_part, to_grid) + pv_part -= from_pv + excess = to_grid - from_pv + noncompliant += excess + grid_part = max(grid_part - excess, 0.0) + from_pv = min(pv_part, unexplained) + pv_part -= from_pv + grid_part = max(grid_part - (unexplained - from_pv), 0.0) + total = pv_part + grid_part + if soc_max > 0 and total > soc_max: + pv_part *= soc_max / total + grid_part *= soc_max / total + steps += 1 + if steps == 0: + return None, 0.0, 0 + if mode == EXPORT_BUDGET_PV_EQUIVALENT: + return (bucket, 0.0), noncompliant, steps + total = pv_part + grid_part + return (0.0, pv_part / total if total > 0 else 0.0), noncompliant, steps diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index dfef97b0e..be7ee8f00 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -40,6 +40,7 @@ from predbat_metrics import metrics from futurerate import FutureRate from axle import fetch_axle_sessions, load_axle_slot, fetch_axle_active +from export_budget import EXPORT_BUDGET_OFF, EXPORT_BUDGET_PV_ORIGIN, EXPORT_BUDGET_MODE_NAMES, EXPORT_BUDGET_REPLAY_DAYS, parse_export_budget_mode, export_budget_replay import ast import copy @@ -1259,6 +1260,9 @@ def fetch_sensor_data(self, save=True): self.high_export_rates = [] self.octopus_slots = [[] for _ in range(self.num_cars)] self.cost_today_sofar = 0 + self.export_budget_seed = None + self.export_budget_noncompliant_kwh = 0.0 + self.export_budget_replay_steps = 0 self.carbon_today_sofar = 0 self.import_today = {} self.export_today = {} @@ -1700,6 +1704,69 @@ def fetch_sensor_data(self, save=True): force_replan = True return force_replan + def update_export_budget_seed(self): + """Replay the measured history to find where the export energy budget stands now. + + Runs after fetch_inverter_data() because the budget cap and the pv_origin parts need soc_max. + """ + self.export_budget_seed = None + self.export_budget_noncompliant_kwh = 0.0 + self.export_budget_replay_steps = 0 + if not self.export_budget_mode: + return + cap = self.export_budget_cap_kwh if self.export_budget_cap_kwh > 0 else self.soc_max + soc_history = {} + if self.export_budget_mode == EXPORT_BUDGET_PV_ORIGIN: + soc_history = self.fetch_export_budget_soc_history() + if not soc_history: + self.log("Warn: Export energy budget has no battery SoC history - estimating the battery flow from pv/import/export/load, which counts conversion losses as PV charge") + seed, noncompliant, steps = export_budget_replay(self.export_budget_mode, self.pv_today, self.export_today, self.import_today, self.load_minutes, self.soc_max, cap, soc_history=soc_history) + if seed is None: + self.log("Warn: Export energy budget has no usable pv/export/import/load history - treating nothing as exportable") + self.export_budget_seed = seed + self.export_budget_noncompliant_kwh = noncompliant + self.export_budget_replay_steps = steps + + def fetch_export_budget_soc_history(self): + """Stored kWh keyed by minutes ago over the export budget replay window, summed across inverters. + + Read from each inverter's soc_percent (or soc_kw) sensor history. Returns {} when any inverter has none, so the + replay falls back to estimating the battery flow. + """ + days = EXPORT_BUDGET_REPLAY_DAYS + 1 + total = {} + counts = {} + for inverter in self.inverters: + soc_max = getattr(inverter, "soc_max", 0) or 0 + percent_sensor = self.get_arg("soc_percent", indirect=False, index=inverter.id) + kw_sensor = self.get_arg("soc_kw", indirect=False, index=inverter.id) + sensor = percent_sensor or kw_sensor + if not isinstance(sensor, str) or not sensor or (percent_sensor and soc_max <= 0): + return {} + history = self.get_history_wrapper(entity_id=sensor, days=days, required=False) + if not history or not history[0]: + return {} + data, _ = minute_data( + history[0], + days, + self.now_utc, + "state", + "last_updated", + backwards=True, + clean_increment=False, + smoothing=False, + divide_by=1.0, + # soc_kw is scaled by battery_scaling, as the live SoC is (Inverter.update_status) + scale=soc_max / 100.0 if percent_sensor else (getattr(inverter, "battery_scaling", 1.0) or 1.0), + required_unit="%" if percent_sensor else "kWh", + ) + if not data: + return {} + for minute, value in data.items(): + total[minute] = total.get(minute, 0.0) + value + counts[minute] = counts.get(minute, 0) + 1 + return {minute: value for minute, value in total.items() if counts[minute] == len(self.inverters)} + def fetch_sensor_data_car_planning(self): # Work out car plan? # Determine which cars are configured with Octopus Intelligent to avoid calling plan_car_charging for them @@ -3852,6 +3919,24 @@ def fetch_config_options(self): self.calculate_export_on_pv = self.get_arg("calculate_export_on_pv") self.calculate_second_pass = self.get_arg("calculate_second_pass") self.prediction_kernel_enable = self.get_arg("prediction_kernel_enable", True) + budget_arg = self.get_arg("export_budget_mode", "off") + budget_mode = parse_export_budget_mode(budget_arg) + if budget_mode is None: + self.log("Warn: export_budget_mode {} must be one of {} - export energy budget disabled".format(budget_arg, ", ".join(EXPORT_BUDGET_MODE_NAMES))) + budget_mode = EXPORT_BUDGET_OFF + self.export_budget_mode = budget_mode + cap_arg = self.get_arg("export_budget_cap_kwh", 0.0) or 0.0 + try: + budget_cap = float(cap_arg) + except (TypeError, ValueError): + budget_cap = -1.0 + if budget_cap < 0: + self.log("Warn: export_budget_cap_kwh {} must be a positive number of kWh - using the battery size".format(cap_arg)) + budget_cap = 0.0 + self.export_budget_cap_kwh = budget_cap + if budget_mode: + mode_name = [name for name, value in EXPORT_BUDGET_MODE_NAMES.items() if value == budget_mode][0] + self.log("Export energy budget enabled in {} mode".format(mode_name)) self.calculate_inday_adjustment = self.get_arg("calculate_inday_adjustment") self.calculate_regions = True self.calculate_import_low_export = self.get_arg("calculate_import_low_export") diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 9ab84a2ad..22bd68db1 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -23,6 +23,7 @@ from predbat import THIS_VERSION_DISPLAY from const import TIME_FORMAT, PREDICT_STEP, RUN_EVERY, EXPORT_LIMIT_IDLE, MINUTE_WATT, FULL_EXPORT_POWER, EXPORT_MODE_TARGET, EXPORT_MODE_FREEZE, EXPORT_MODE_IDLE, CHARGE_STATE_PRECEDENCE, EXPORT_STATE_PRECEDENCE from utils import dp0, dp1, dp2, dp3, calc_percent_limit, minute_data, minute_data_state, find_charge_rate, export_mode_of, export_target_of, export_power_of, export_limit_sort_key, pack_export_limit, export_limit_from_stored +from export_budget import EXPORT_BUDGET_PV_EQUIVALENT, EXPORT_BUDGET_MODE_NAMES from prediction import Prediction # Per-slot plan "why" reason templates. Keyed by a stable reason code, each template is @@ -2363,6 +2364,47 @@ def today_cost(self, import_today, export_today, car_today, load_today, save=Tru ) return day_cost, carbon_g + def publish_export_budget(self): + """Publish .export_budget: battery energy the export energy budget lets Predbat export now, with the plan's forecast in results""" + if not self.export_budget_mode: + return + mode_name = [name for name, value in EXPORT_BUDGET_MODE_NAMES.items() if value == self.export_budget_mode][0] + cap = self.export_budget_cap_kwh if self.export_budget_cap_kwh > 0 else self.soc_max + seed = self.export_budget_seed + attributes = { + "friendly_name": "Export energy budget", + "state_class": "measurement", + "unit_of_measurement": "kWh", + "icon": "mdi:transmission-tower-export", + "mode": mode_name, + "cap": dp2(cap), + "planned_battery_export_kwh": dp2(self.export_budget_planned_battery_export), + "noncompliant_export_kwh": dp2(self.export_budget_noncompliant_kwh), + "history_steps": self.export_budget_replay_steps, + } + if self.export_budget_mode == EXPORT_BUDGET_PV_EQUIVALENT: + state = seed[0] if seed else 0.0 + else: + pv_part = self.soc_kw * (seed[1] if seed else 0.0) + state = pv_part + attributes["pv_part_kwh"] = dp2(pv_part) + attributes["grid_part_kwh"] = dp2(self.soc_kw - pv_part) + # Forecast from the best plan: the budget now, then the budget at the end of each step + forecast = {(self.midnight_utc + timedelta(minutes=self.minutes_now)).strftime(TIME_FORMAT): dp2(state)} + for minute, value in self.predict_export_budget.items(): + forecast[(self.midnight_utc + timedelta(minutes=self.minutes_now + minute + PREDICT_STEP)).strftime(TIME_FORMAT)] = dp2(value) + attributes["results"] = self.filtered_times(forecast) + self.dashboard_item(self.prefix + ".export_budget", state=dp2(state), attributes=attributes) + self.log( + "Export energy budget ({}): {} kWh exportable now, planned battery export {} kWh, non-compliant export in the last {} days {} kWh".format( + mode_name, dp2(state), dp2(self.export_budget_planned_battery_export), dp1(self.export_budget_replay_steps * 5 / (24 * 60)), dp2(self.export_budget_noncompliant_kwh) + ) + ) + capped = set(self.export_budget_capped_minutes) + for window in self.export_window_best: + if window["start"] in self.manual_export_times and any(window["start"] <= minute < window["end"] for minute in capped): + self.log("Export energy budget capped manual export {} - {}: the budget does not allow the full export".format(self.time_abs_str(window["start"]), self.time_abs_str(window["end"]))) + def publish_export_limit(self, export_window, export_limits, best): """ Create entity to chart export limit @@ -3386,6 +3428,7 @@ def calculate_yesterday(self): midnight_utc = self.midnight_utc forecast_minutes = self.forecast_minutes cost_today_sofar = self.cost_today_sofar + export_budget_seed = self.export_budget_seed import_today_now = self.import_today_now export_today_now = self.export_today_now pv_today_now = self.pv_today_now @@ -3408,6 +3451,7 @@ def calculate_yesterday(self): # Fake to yesterday state self.minutes_now = 0 self.cost_today_sofar = 0 + self.export_budget_seed = None self.import_today_now = 0 self.export_today_now = 0 self.carbon_today_sofar = 0 @@ -3785,6 +3829,7 @@ def calculate_yesterday(self): self.midnight_utc = midnight_utc self.forecast_minutes = forecast_minutes self.cost_today_sofar = cost_today_sofar + self.export_budget_seed = export_budget_seed self.import_today_now = import_today_now self.export_today_now = export_today_now self.carbon_today_sofar = carbon_today_sofar diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index 57332b6ef..44ab06b98 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -39,7 +39,9 @@ EXPORT_MODE_TARGET, EXPORT_MODE_FREEZE, EXPORT_MODE_IDLE, + RUN_EVERY, ) +from export_budget import EXPORT_BUDGET_PV_EQUIVALENT, export_budget_floor_percent from utils import ( calc_percent_limit, @@ -2209,13 +2211,18 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True): self.export_limits_best, False, end_record=self.end_record, + save="clip" if self.export_budget_mode else None, ) # Work out record windows record_export_windows = max(self.max_charge_windows(self.end_record + self.minutes_now, self.export_window_best), 1) - # Export slot clipping - self.export_window_best, self.export_limits_best = self.clip_export_slots(self.minutes_now, self.predict_soc, self.export_window_best, self.export_limits_best, record_export_windows, PREDICT_STEP) + # Export slot clipping. With the export energy budget on, the run above was a saving (Python) run + # so clipping can see what the budget allows at the start of each window + budget_clip = {} + if self.export_budget_mode: + budget_clip = {"budget_exportable": self.prediction.predict_export_budget_start} + self.export_window_best, self.export_limits_best = self.clip_export_slots(self.minutes_now, self.predict_soc, self.export_window_best, self.export_limits_best, record_export_windows, PREDICT_STEP, **budget_clip) # Filter out the windows we disabled during clipping self.export_limits_best, self.export_window_best = self.discard_unused_export_slots(self.export_limits_best, self.export_window_best) @@ -2310,6 +2317,10 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True): self.plan_last_updated = self.now_utc self.plan_last_updated_minutes = self.minutes_now + # Export energy budget: re-check the export running now against the measured state every cycle, so a kept + # plan or a plan clipped from an earlier forecast can never command more export than the budget allows + self.apply_export_budget_floor() + # Final simulation of base metric, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g = self.run_prediction( self.charge_limit, self.charge_window, self.export_window, self.export_limits, False, save="base" if publish else None, end_record=self.end_record @@ -2394,6 +2405,7 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True): if publish: self.publish_charge_limit(self.charge_limit_best, self.charge_window_best, best=True, soc=self.predict_soc_best) self.publish_export_limit(self.export_window_best, self.export_limits_best, best=True) + self.publish_export_budget() # Compute marginal energy cost matrix (what-if extra load scenarios) self.calculate_marginal_costs() @@ -3663,9 +3675,35 @@ def clip_charge_slots(self, minutes_now, predict_soc, charge_window_best, charge window["target"] = 0 return charge_window_best, charge_limit_best - def clip_export_slots(self, minutes_now, predict_soc, export_window_best, export_limits_best, record_export_windows, step): + def apply_export_budget_floor(self): + """Hold export windows running now, or starting before the next cycle, at or above the measured SoC minus the + energy the export budget allows now (seeded from history every cycle). The inverter force exports at full power + down to the target while the battery also covers the house, so a lower target could sell more than the budget. + """ + if not self.export_budget_mode: + return + seed = self.export_budget_seed + if self.export_budget_mode == EXPORT_BUDGET_PV_EQUIVALENT: + stored_to_ac = self.battery_loss_discharge * self.inverter_loss + exportable = seed[0] / stored_to_ac if seed and stored_to_ac > 0 else 0.0 + else: + exportable = self.soc_kw * seed[1] if seed else 0.0 + floor_percent = export_budget_floor_percent(self.soc_kw, exportable, self.soc_max) + for window_n, window in enumerate(self.export_window_best): + limit = self.export_limits_best[window_n] + if window["start"] >= self.minutes_now + RUN_EVERY or window["end"] <= self.minutes_now or export_mode_of(limit) != EXPORT_MODE_TARGET: + continue + if (export_target_of(limit) or 0) < floor_percent: + self.export_limits_best[window_n] = pack_export_limit(EXPORT_MODE_TARGET, floor_percent, export_power_of(limit)) + window["target"] = max(window.get("target", 0), float(floor_percent)) + self.log("Export energy budget: export {} - {} target raised to {}% (SoC {} kWh, exportable {} kWh)".format(self.time_abs_str(window["start"]), self.time_abs_str(window["end"]), floor_percent, dp2(self.soc_kw), dp2(exportable))) + + def clip_export_slots(self, minutes_now, predict_soc, export_window_best, export_limits_best, record_export_windows, step, budget_exportable=None): """ Clip export slots to the right length + + budget_exportable ({relative minute: stored kWh the export energy budget allows from the start of that step}) + comes from a saving prediction run when the budget is on, see the budget floor below. """ for window_n in range(min(record_export_windows, len(export_window_best))): window = export_window_best[window_n] @@ -3720,6 +3758,17 @@ def clip_export_slots(self, minutes_now, predict_soc, export_window_best, export export_limits_best[window_n] = pack_export_limit(EXPORT_MODE_TARGET, calc_percent_limit(limit_soc, self.soc_max), export_power_of(limit)) if limit != export_limits_best[window_n] and self.debug_enable: self.log("Clip up export window {} from {} - {} from limit {} to new limit {} target set to {}".format(window_n, window_start, window_end, limit, export_limits_best[window_n], window["target"])) + + # Export energy budget: the inverter force exports at full power down to the target, and the battery + # also covers the house meanwhile, so a target below the start SoC minus the exportable budget could + # sell more than the budget allows. Never command below that floor; rounded up so it never allows more. + if self.export_budget_mode and budget_exportable is not None and export_mode_of(limit) == EXPORT_MODE_TARGET and predict_minute_start in budget_exportable: + floor_percent = export_budget_floor_percent(predict_soc[predict_minute_start], budget_exportable[predict_minute_start], self.soc_max) + current = export_limits_best[window_n] + if floor_percent > (export_target_of(current) or 0): + export_limits_best[window_n] = pack_export_limit(EXPORT_MODE_TARGET, floor_percent, export_power_of(current)) + window["target"] = max(window["target"], float(floor_percent)) + self.log("Export energy budget: export window {} - {} target raised to {}% so the inverter only exports what the budget allows".format(self.time_abs_str(window["start"]), self.time_abs_str(window["end"]), floor_percent)) else: self.log("Warn: Clip export window {} as it's already passed".format(window_n)) export_limits_best[window_n] = pack_export_limit(EXPORT_MODE_IDLE) @@ -5178,6 +5227,9 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi self.predict_carbon_best = pred.predict_carbon_best self.predict_clipped_best = pred.predict_clipped_best self.predict_car_hold_best = pred.predict_car_hold_best + self.predict_export_budget = pred.predict_export_budget + self.export_budget_capped_minutes = pred.export_budget_capped_minutes + self.export_budget_planned_battery_export = pred.export_budget_planned_battery_export if save: self.log( diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index c81c57028..0b9793e0e 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -708,6 +708,14 @@ def reset(self): self.github_url_cache_loaded = False self.load_forecast_history = False self.prediction_kernel_enable = False + self.export_budget_mode = 0 + self.export_budget_cap_kwh = 0.0 + self.export_budget_seed = None + self.export_budget_noncompliant_kwh = 0.0 + self.export_budget_replay_steps = 0 + self.export_budget_planned_battery_export = 0.0 + self.export_budget_capped_minutes = [] + self.predict_export_budget = {} for root in CONFIG_ROOTS: if os.path.exists(root): @@ -1128,6 +1136,7 @@ def update_pred(self, scheduled=True): self.record_status("Error: Failed to fetch inverter data, not able to compute a plan", had_errors=True) return self.check_export_more_solar_effective() + self.update_export_budget_seed() self.log_replay_state() self.log_replay_inverter() diff --git a/apps/predbat/prediction.py b/apps/predbat/prediction.py index d5310117b..fd3b54260 100644 --- a/apps/predbat/prediction.py +++ b/apps/predbat/prediction.py @@ -34,6 +34,7 @@ export_target_of, pack_export_limit, ) +from export_budget import EXPORT_BUDGET_OFF, EXPORT_BUDGET_PV_EQUIVALENT, export_budget_battery_export_allowed, export_budget_house_from_battery, export_budget_update from prediction_batch import PredictionBatch, prediction_cache_key from prediction_kernel import create_kernel_context, kernel_supported, run_prediction_kernel @@ -124,6 +125,9 @@ def __init__( self.car_energy_reported_load = base.car_energy_reported_load self.reserve = base.reserve self.metric_standing_charge = base.metric_standing_charge + self.export_budget_mode = getattr(base, "export_budget_mode", EXPORT_BUDGET_OFF) + self.export_budget_cap_kwh = getattr(base, "export_budget_cap_kwh", 0.0) + self.export_budget_seed = getattr(base, "export_budget_seed", None) self.set_charge_freeze = base.set_charge_freeze self.set_reserve_enable = base.set_reserve_enable self.set_export_freeze = base.set_export_freeze @@ -728,12 +732,28 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi pv_remaining += pv_forecast_minute_step_flat.get(minute_step, 0.0) pv_remaining_kwh[minute_step] = pv_remaining + # Export energy budget (export_budget_mode, see export_budget.py): pv_equivalent tracks a bucket of + # exportable energy, pv_origin splits the stored energy into a PV part and a grid part. Starts from + # the measured history (Fetch.update_export_budget_seed); without a seed nothing is exportable. + budget_mode = self.export_budget_mode + budget_cap = self.export_budget_cap_kwh if self.export_budget_cap_kwh > 0 else self.soc_max + budget_seed = self.export_budget_seed if budget_mode else None + budget_bucket = budget_seed[0] if budget_seed else 0.0 + budget_pv_part = soc * budget_seed[1] if budget_seed else 0.0 + budget_grid_part = soc - budget_pv_part + budget_stored_to_ac = battery_loss_discharge * inverter_loss + predict_export_budget = {} + predict_export_budget_start = {} + export_budget_capped_minutes = [] + planned_battery_export = 0.0 + # Simulate each forward minute minute = 0 while minute < self.forecast_minutes: # Minute yesterday can wrap if days_previous is only 1 minute_absolute = minute + self.minutes_now prev_soc = soc + budget_forced = False reserve_expected = reserve import_rate = rate_import.get(minute_absolute, 0) dispatch_rate = import_rate @@ -988,6 +1008,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi discharge_min = max(soc_max * export_limit_percent / 100.0, reserve, self.best_soc_min) if not set_export_freeze_only and export_window_active and export_mode_now == EXPORT_MODE_TARGET and (soc > discharge_min): + budget_forced = True # Discharge enable, capped at export limit if self.set_export_low_power: export_rate_adjust = export_power_of(export_limit_now) @@ -1318,6 +1339,31 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi pv_ac = max(pv_ac - over_limit, 0) clipped_today += pv_ac_before - pv_ac + # Export energy budget - in a forced export step the battery may only export what the budget allows; + # whatever serves on-site load is never capped + budget_house_frac = 1.0 + if budget_mode and save: + # Stored energy the battery may export from the start of this step (clip_export_slots' target floor) + predict_export_budget_start[minute] = budget_pv_part if budget_mode != EXPORT_BUDGET_PV_EQUIVALENT else (budget_bucket / budget_stored_to_ac if budget_stored_to_ac > 0 else 0.0) + if budget_mode and battery_draw > 0: + battery_ac = battery_draw * inverter_loss + house_from_battery_ac = export_budget_house_from_battery(budget_mode, battery_ac, load_yesterday, pv_ac) + battery_export_ac = battery_ac - house_from_battery_ac + if budget_forced and battery_export_ac > 0: + allowed = export_budget_battery_export_allowed(budget_mode, budget_bucket, budget_pv_part, budget_grid_part, pv_now, max(pv_ac - load_yesterday, 0.0), house_from_battery_ac, budget_stored_to_ac) + if battery_export_ac > allowed: + battery_draw -= (battery_export_ac - allowed) / inverter_loss + battery_ac = battery_draw * inverter_loss + battery_export_ac = allowed + # The iBoost solar excess below must only see the export left after the cap + diff = get_diff(battery_draw, pv_dc, pv_ac, load_yesterday, inverter_loss, inverter_loss_recp) + if save: + export_budget_capped_minutes.append(minute_absolute) + budget_house_frac = house_from_battery_ac / battery_ac if battery_ac > 0 else 1.0 + if record: + planned_battery_export += battery_export_ac + budget_soc_before = soc + # Adjust battery soc if battery_draw > 0: soc = max(soc - battery_draw / battery_loss_discharge, reserve_expected) @@ -1361,6 +1407,14 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi # The car in a dispatch that has gone away is met by the grid (or PV surplus), never the battery diff += car_gone_kwh + if budget_mode: + budget_export = 0.0 + if diff < 0: + budget_export = max(0.0, -diff - car_load_energy_bypass) if not car_energy_reported_load else -diff + budget_bucket, budget_pv_part, budget_grid_part = export_budget_update(budget_mode, budget_bucket, budget_pv_part, budget_grid_part, budget_soc_before, soc, max(-battery_draw, 0.0), pv_now, budget_house_frac, budget_export, budget_cap) + if save: + predict_export_budget[minute] = round(budget_bucket if budget_mode == EXPORT_BUDGET_PV_EQUIVALENT else budget_pv_part, 4) + # Metric keep - pretend the battery is empty and you have to import instead of using the battery if best_soc_keep > 0 and soc <= best_soc_keep: metric_keep += (best_soc_keep - soc) * import_rate * keep_minute_scaling * step / 60.0 @@ -1507,6 +1561,10 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi self.record_time = record_time self.predict_battery_cycle = predict_battery_cycle self.predict_soc = predict_soc + self.predict_export_budget = predict_export_budget + self.predict_export_budget_start = predict_export_budget_start + self.export_budget_capped_minutes = export_budget_capped_minutes + self.export_budget_planned_battery_export = round(planned_battery_export, 4) self.pv_kwh_h0 = round(pv_kwh_h0, 4) self.import_kwh_h0 = round(import_kwh_h0, 4) self.export_kwh_h0 = round(export_kwh_h0, 4) diff --git a/apps/predbat/prediction_kernel.cpp b/apps/predbat/prediction_kernel.cpp index 9c78828e9..fc807e52b 100644 --- a/apps/predbat/prediction_kernel.cpp +++ b/apps/predbat/prediction_kernel.cpp @@ -50,7 +50,8 @@ // ABI 7: export limits are packed as PkExportLimit structs (array-of-structs) rather than those // three arrays. Same three fields, but one buffer and one stride, with the (99.0, 100.0) gap gone // because mode is explicit (GH#4914). -#define PK_ABI_VERSION 7 +// ABI 8: PkContext gained export_budget_mode, export_budget_cap and the export budget seed at the end. +#define PK_ABI_VERSION 8 // Parity 14: the explicit (mode, target, power) fields replace the packed double throughout the hot // loop (see the ABI 6 note); the floor a target exports to now reads the target field, matching // prediction.py. @@ -61,7 +62,9 @@ // across cars, which counted the first car once per car (GH#5313). // Parity 17: a dispatch assumed gone pays the greater of rate_max (now the tariff's own highest import // rate, without saving session or Axle rewards) and the slot's own rate (#5163). -#define PK_PARITY_REVISION 17 +// Parity 18: optional export energy budget (export_budget_mode) caps forced battery export, mirroring +// export_budget.py; a capped step recomputes the grid balance before the iBoost solar excess. +#define PK_PARITY_REVISION 18 #define PK_MAX_CARS 8 #define PK_RUN_EVERY 5 // const.py RUN_EVERY #define PK_EXPORT_MODE_TARGET 0 // const.py EXPORT_MODE_TARGET @@ -83,6 +86,61 @@ struct PkExportLimit { }; inline bool pk_export_is_idle(int32_t mode) { return mode == PK_EXPORT_MODE_IDLE; } + +// Export energy budget - mirror of export_budget.py (export_budget_battery_export_allowed / export_budget_update) +#define PK_EXPORT_BUDGET_PV_ORIGIN 1 +#define PK_EXPORT_BUDGET_PV_EQUIVALENT 2 + +inline double pk_export_budget_house_from_battery(int32_t mode, double battery_out, double load, double pv) +{ + if (mode == PK_EXPORT_BUDGET_PV_ORIGIN) { + return std::min(battery_out, std::max(load, 0.0)); + } + return std::min(battery_out, std::max(load - pv, 0.0)); +} + +inline double pk_export_budget_allowed(int32_t mode, double bucket, double pv_part, double grid_part, double pv_now, double pv_direct_export, double house_from_battery_ac, double stored_to_ac) +{ + if (mode == PK_EXPORT_BUDGET_PV_EQUIVALENT) { + return std::max(bucket + pv_now - pv_direct_export, 0.0); + } + const double house_stored = stored_to_ac > 0 ? house_from_battery_ac / stored_to_ac : 0.0; + const double pv_left = std::max(pv_part - std::max(house_stored - grid_part, 0.0), 0.0); + return pv_left * stored_to_ac; +} + +inline void pk_export_budget_update(int32_t mode, double &bucket, double &pv_part, double &grid_part, double soc_before, double soc_after, double charge_kwh, double pv_now, double house_frac, double export_kwh, double cap) +{ + if (mode == PK_EXPORT_BUDGET_PV_EQUIVALENT) { + bucket = std::min(std::max(bucket + pv_now - export_kwh, 0.0), cap); + return; + } + const double delta = soc_after - soc_before; + if (delta > 0) { + const double pv_frac = charge_kwh > 0 ? std::min(charge_kwh, pv_now) / charge_kwh : 0.0; + pv_part += delta * pv_frac; + grid_part += delta * (1.0 - pv_frac); + } else if (delta < 0) { + const double removed = -delta; + const double removed_house = removed * house_frac; + const double removed_export = removed - removed_house; + const double from_grid = std::min(grid_part, removed_house); + grid_part -= from_grid; + pv_part = std::max(pv_part - (removed_house - from_grid), 0.0); + const double from_pv = std::min(pv_part, removed_export); + pv_part -= from_pv; + grid_part = std::max(grid_part - (removed_export - from_pv), 0.0); + } + const double total = pv_part + grid_part; + if (total > 0) { + const double scale = soc_after / total; + pv_part *= scale; + grid_part *= scale; + } else { + pv_part = 0.0; + grid_part = soc_after; + } +} inline bool pk_export_is_freeze(int32_t mode) { return mode == PK_EXPORT_MODE_FREEZE; } @@ -265,6 +323,10 @@ struct PkContext { int32_t iboost_on_export; int32_t has_rate_gas; int32_t has_iboost_plan; + int32_t export_budget_mode; // 0 off, 1 pv_origin, 2 pv_equivalent + double export_budget_cap; // kWh, resolved (soc_max when not configured) + double export_budget_seed_bucket; // pv_equivalent bucket at minutes_now + double export_budget_seed_pv_share; // pv_origin share of the stored energy that is PV }; // Per-scenario inputs; field order MUST match the ctypes Structure in prediction_kernel.py. @@ -765,11 +827,21 @@ static int32_t pk_run_one(const ContextStore *store, const PkScenario *s, PkResu const double *pv_step = is_pv10 ? c->pv10 : (is_pv90 ? c->pv90 : c->pv); const double *load_step = is_pv10 ? c->load10 : (is_pv90 ? c->load90 : c->load); + + // Export energy budget state - prediction.py run_prediction budget block + const int32_t budget_mode = c->export_budget_mode; + const double budget_cap = c->export_budget_cap; + double budget_bucket = budget_mode ? c->export_budget_seed_bucket : 0.0; + double budget_pv_part = budget_mode ? soc * c->export_budget_seed_pv_share : 0.0; + double budget_grid_part = soc - budget_pv_part; + const double budget_stored_to_ac = battery_loss_discharge * inverter_loss; + // Simulate each forward step - prediction.py:570-1200 for (int32_t k = 0; k < n_steps; k++) { const int32_t minute = k * step; const int32_t minute_absolute = minute + c->minutes_now; double reserve_expected = reserve; + bool budget_forced = false; // Rates - prediction.py:577-580 double import_rate = c->rate_import[k]; @@ -1026,6 +1098,7 @@ static int32_t pk_run_one(const ContextStore *store, const PkScenario *s, PkResu // engine already asks the mode here (prediction.py:918), so this also ends a real divergence // between the two: the comment claiming bit-parity on this line was already out of date. if (!c->set_export_freeze_only && export_window_active && export_mode_now == PK_EXPORT_MODE_TARGET && (soc > discharge_min)) { + budget_forced = true; // Force export - prediction.py:795-902 double export_rate_adjust = 1.0; if (c->set_export_low_power) { @@ -1291,6 +1364,25 @@ static int32_t pk_run_one(const ContextStore *store, const PkScenario *s, PkResu pv_ac = std::max(pv_ac - over_limit, 0.0); } + // Export energy budget - cap forced battery export (prediction.py) + double budget_house_frac = 1.0; + if (budget_mode && battery_draw > 0) { + double battery_ac = battery_draw * inverter_loss; + const double house_from_battery_ac = pk_export_budget_house_from_battery(budget_mode, battery_ac, load_yesterday, pv_ac); + const double battery_export_ac = battery_ac - house_from_battery_ac; + if (budget_forced && battery_export_ac > 0) { + const double allowed = pk_export_budget_allowed(budget_mode, budget_bucket, budget_pv_part, budget_grid_part, pv_now, std::max(pv_ac - load_yesterday, 0.0), house_from_battery_ac, budget_stored_to_ac); + if (battery_export_ac > allowed) { + battery_draw -= (battery_export_ac - allowed) / inverter_loss; + battery_ac = battery_draw * inverter_loss; + // The iBoost solar excess below must only see the export left after the cap + diff = get_diff(battery_draw, pv_dc, pv_ac, load_yesterday, inverter_loss, inverter_loss_recp); + } + } + budget_house_frac = battery_ac > 0 ? house_from_battery_ac / battery_ac : 1.0; + } + const double budget_soc_before = soc; + // Adjust battery soc - prediction.py:1060-1064 if (battery_draw > 0) { soc = std::max(soc - battery_draw / battery_loss_discharge, reserve_expected); @@ -1344,6 +1436,14 @@ static int32_t pk_run_one(const ContextStore *store, const PkScenario *s, PkResu diff += car_gone_kwh; } + if (budget_mode) { + double budget_export = 0.0; + if (diff < 0) { + budget_export = !c->car_energy_reported_load ? std::max(0.0, -diff - car_load_energy_bypass) : -diff; + } + pk_export_budget_update(budget_mode, budget_bucket, budget_pv_part, budget_grid_part, budget_soc_before, soc, std::max(-battery_draw, 0.0), pv_now, budget_house_frac, budget_export, budget_cap); + } + // Metric keep - prediction.py:1100-1102 if (best_soc_keep > 0 && soc <= best_soc_keep) { metric_keep += (best_soc_keep - soc) * import_rate * keep_minute_scaling * step / 60.0; diff --git a/apps/predbat/prediction_kernel.py b/apps/predbat/prediction_kernel.py index dfb9a80be..c4ea3e7d2 100644 --- a/apps/predbat/prediction_kernel.py +++ b/apps/predbat/prediction_kernel.py @@ -32,8 +32,8 @@ from utils import get_curve_value, find_battery_temperature_cap, in_car_slot, in_iboost_slot, export_limit_from_stored # Expected ABI/parity revisions of the shared library (see prediction_kernel.cpp) -KERNEL_ABI_VERSION = 7 -KERNEL_PARITY_REVISION = 17 +KERNEL_ABI_VERSION = 8 +KERNEL_PARITY_REVISION = 18 # Maximum number of cars supported by the kernel (PK_MAX_CARS in prediction_kernel.cpp) KERNEL_MAX_CARS = PREDBAT_MAX_CARS @@ -137,6 +137,10 @@ class PkContext(ctypes.Structure): ("iboost_on_export", ctypes.c_int32), ("has_rate_gas", ctypes.c_int32), ("has_iboost_plan", ctypes.c_int32), + ("export_budget_mode", ctypes.c_int32), + ("export_budget_cap", ctypes.c_double), + ("export_budget_seed_bucket", ctypes.c_double), + ("export_budget_seed_pv_share", ctypes.c_double), ] @@ -830,6 +834,11 @@ def create_kernel_context(pred, static_cache=None): ctx.iboost_on_export = 1 if pred.iboost_on_export else 0 ctx.has_rate_gas = 1 if pred.rate_gas else 0 ctx.has_iboost_plan = 1 if pred.iboost_plan else 0 + ctx.export_budget_mode = int(getattr(pred, "export_budget_mode", 0) or 0) + budget_cap = getattr(pred, "export_budget_cap_kwh", 0.0) or 0.0 + ctx.export_budget_cap = budget_cap if budget_cap > 0 else pred.soc_max + budget_seed = getattr(pred, "export_budget_seed", None) if ctx.export_budget_mode else None + ctx.export_budget_seed_bucket, ctx.export_budget_seed_pv_share = budget_seed if budget_seed else (0.0, 0.0) handle = lib.pk_context_create(ctypes.byref(ctx)) if handle: diff --git a/apps/predbat/prediction_kernel_lib_aarch64.so b/apps/predbat/prediction_kernel_lib_aarch64.so index fb05c3fe5..f754fb585 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 e2531e978..00d354909 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 ae37c4125..2a516f8c6 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 30cedcc41..4e7852494 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 88d80113e..89d77fcf1 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 3e7e3c91b..528231dd7 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_execute.py b/apps/predbat/tests/test_execute.py index 0f1b7fd46..15f61b2bb 100644 --- a/apps/predbat/tests/test_execute.py +++ b/apps/predbat/tests/test_execute.py @@ -3373,6 +3373,32 @@ def run_execute_tests(my_predbat): if failed: return failed + # Export energy budget: a kept plan's export to 0% must be raised to the measured floor before it reaches the + # inverter - 9 kWh stored, 10% of it PV (0.9 kWh exportable), so the target is 81% + saved_budget = (my_predbat.export_budget_mode, my_predbat.export_budget_seed) + my_predbat.export_budget_mode = 1 + my_predbat.export_budget_seed = (0.0, 0.1) + try: + failed |= run_execute_test( + my_predbat, + "export_budget_floor", + export_window_best=[{"start": my_predbat.minutes_now, "end": my_predbat.minutes_now + 60, "average": 1}], + export_limits_best=[0], + assert_force_export=True, + set_charge_window=True, + set_export_window=True, + soc_kw=9, + assert_status="Exporting", + assert_immediate_soc_target=81, + assert_discharge_start_time_minutes=my_predbat.minutes_now, + assert_discharge_end_time_minutes=my_predbat.minutes_now + 60 + 1, + update_plan=True, + ) + finally: + my_predbat.export_budget_mode, my_predbat.export_budget_seed = saved_budget + if failed: + return failed + # Test inverter_limit_export: discharge rate during forced export should be capped to battery_max_export_rate (500) not battery_max_rate (1000) failed |= run_execute_test( my_predbat, diff --git a/apps/predbat/tests/test_export_budget.py b/apps/predbat/tests/test_export_budget.py new file mode 100644 index 000000000..80daf57ed --- /dev/null +++ b/apps/predbat/tests/test_export_budget.py @@ -0,0 +1,753 @@ +# ----------------------------------------------------------------------------- +# 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 +"""Tests for the export energy budget (export_budget_mode)""" + +import copy +from datetime import timedelta + +from const import PV_SCENARIO_NOMINAL, TIME_FORMAT +from prediction import Prediction +from utils import unpack_export_limit +from tests.test_infra import FIXTURE_MINUTES_NOW, reset_inverter, reset_rates +from export_budget import ( + EXPORT_BUDGET_OFF, + EXPORT_BUDGET_PV_ORIGIN, + EXPORT_BUDGET_PV_EQUIVALENT, + parse_export_budget_mode, + export_budget_battery_export_allowed, + export_budget_house_from_battery, + export_budget_update, + export_budget_replay, +) + + +def reset_budget_base(my_predbat, minutes_now, import_rate=10.0, export_rate=5.0): + """Put my_predbat into a known state for the export budget scenarios. + + Earlier tests leave overrides on the shared fixture, so everything these scenarios depend on is set + explicitly here rather than inherited. + """ + reset_inverter(my_predbat) + # After reset_inverter, which pins minutes_now to the fixture clock, and before reset_rates so the + # rate tables cover the shifted horizon + my_predbat.minutes_now = minutes_now + reset_rates(my_predbat, import_rate, export_rate) + my_predbat.battery_rate_max_export = my_predbat.battery_rate_max_discharge + my_predbat.inverter_hybrid = False + my_predbat.inverter_can_charge_during_export = True + my_predbat.inverter_support_feedin_first = False + my_predbat.inverter_freeze_export_discharge_rate = 0.0 + my_predbat.set_charge_freeze = False + my_predbat.set_reserve_enable = False + my_predbat.set_export_freeze = False + my_predbat.set_export_freeze_only = False + my_predbat.set_discharge_during_charge = True + my_predbat.set_export_low_power = False + my_predbat.battery_charge_power_curve = {} + my_predbat.battery_discharge_power_curve = {} + my_predbat.battery_temperature = 20 + my_predbat.battery_temperature_prediction = {} + my_predbat.best_soc_keep = 0.0 + my_predbat.best_soc_keep_weight = 0.5 + my_predbat.io_adjusted = {} + my_predbat.all_active_keep = {} + my_predbat.all_active_keep_max = {} + my_predbat.carbon_enable = False + my_predbat.carbon_intensity = {} + my_predbat.num_cars = 0 + my_predbat.car_energy_reported_load = True + my_predbat.iboost_enable = False + my_predbat.iboost_plan = [] + my_predbat.rate_gas = {} + my_predbat.export_budget_mode = 0 + my_predbat.export_budget_cap_kwh = 0.0 + my_predbat.export_budget_seed = None + + +def close(a, b, tol=1e-9): + """Float comparison helper""" + return abs(a - b) <= tol + + +def cumulative(steps, step=5): + """Build a Predbat-style cumulative series keyed by minutes ago from per-step energies listed oldest first""" + total_minutes = len(steps) * step + data = {} + running = 0.0 + data[total_minutes] = 0.0 + for index, energy in enumerate(steps): + running += energy + data[total_minutes - (index + 1) * step] = running + return data + + +def check(failed, condition, message): + """Print and record a failure""" + if not condition: + print("ERROR: export_budget: " + message) + return True + return failed + + +def test_parse_mode(): + """apps.yaml values map to mode constants, unknown values to None""" + failed = False + failed = check(failed, parse_export_budget_mode(None) == EXPORT_BUDGET_OFF, "None -> off") + failed = check(failed, parse_export_budget_mode(False) == EXPORT_BUDGET_OFF, "False -> off") + failed = check(failed, parse_export_budget_mode("off") == EXPORT_BUDGET_OFF, "off") + failed = check(failed, parse_export_budget_mode(" PV_Origin ") == EXPORT_BUDGET_PV_ORIGIN, "pv_origin, case/space insensitive") + failed = check(failed, parse_export_budget_mode("pv_equivalent") == EXPORT_BUDGET_PV_EQUIVALENT, "pv_equivalent") + failed = check(failed, parse_export_budget_mode("pv_origin_house_first") is None, "removed pv_origin_house_first -> None") + failed = check(failed, parse_export_budget_mode("surplus_only") is None, "unknown -> None") + failed = check(failed, parse_export_budget_mode(3) is None, "number -> None") + return failed + + +def test_allowed(): + """Allowed battery export per mode""" + failed = False + # pv_equivalent: bucket 1 + pv 0.3 - PV already exported directly 0.2 + failed = check(failed, close(export_budget_battery_export_allowed(EXPORT_BUDGET_PV_EQUIVALENT, 1.0, 0, 0, 0.3, 0.2, 0, 1.0), 1.1), "pv_equivalent allowance") + failed = check(failed, close(export_budget_battery_export_allowed(EXPORT_BUDGET_PV_EQUIVALENT, 0.0, 0, 0, 0.1, 0.4, 0, 1.0), 0.0), "pv_equivalent floor 0") + # pv_origin: house takes 0.5 stored, grid part 0.3 covers part of it, the rest (0.2) comes from pv 1.0 -> 0.8 left + failed = check(failed, close(export_budget_battery_export_allowed(EXPORT_BUDGET_PV_ORIGIN, 0, 1.0, 0.3, 0, 0, 0.5, 1.0), 0.8), "pv_origin house uses grid first") + # stored_to_ac 0.9: house AC 0.45 = 0.5 stored, all from grid part 2.0, pv 1.0 left -> 0.9 AC + failed = check(failed, close(export_budget_battery_export_allowed(EXPORT_BUDGET_PV_ORIGIN, 0, 1.0, 2.0, 0, 0, 0.45, 0.9), 0.9), "pv_origin losses") + return failed + + +def test_house_from_battery(): + """Battery output that counts as serving on-site load: pv_origin all of it up to the load, pv_equivalent only what PV leaves uncovered""" + failed = False + # PV 1.0 covers the 0.5 load: pv_origin counts 0.5 of the battery's 0.8 as house, pv_equivalent none + failed = check(failed, close(export_budget_house_from_battery(EXPORT_BUDGET_PV_ORIGIN, 0.8, 0.5, 1.0), 0.5), "pv_origin PV covers load") + failed = check(failed, close(export_budget_house_from_battery(EXPORT_BUDGET_PV_EQUIVALENT, 0.8, 0.5, 1.0), 0.0), "pv_equivalent PV covers load") + # PV 0.2 of a 0.5 load: pv_origin 0.5, pv_equivalent 0.3 + failed = check(failed, close(export_budget_house_from_battery(EXPORT_BUDGET_PV_ORIGIN, 0.8, 0.5, 0.2), 0.5), "pv_origin part load") + failed = check(failed, close(export_budget_house_from_battery(EXPORT_BUDGET_PV_EQUIVALENT, 0.8, 0.5, 0.2), 0.3), "pv_equivalent part load") + # Never more than the battery delivers + failed = check(failed, close(export_budget_house_from_battery(EXPORT_BUDGET_PV_ORIGIN, 0.3, 0.5, 0.0), 0.3), "pv_origin capped by battery") + return failed + + +def test_update_pv_equivalent(): + """Bucket fills with PV, drains with export, clamps to [0, cap]""" + failed = False + bucket, _, _ = export_budget_update(EXPORT_BUDGET_PV_EQUIVALENT, 1.0, 0, 0, 5, 5, 0, 0.5, 1.0, 0.2, 7.1) + failed = check(failed, close(bucket, 1.3), "fill/drain") + bucket, _, _ = export_budget_update(EXPORT_BUDGET_PV_EQUIVALENT, 7.0, 0, 0, 5, 5, 0, 0.5, 1.0, 0.0, 7.1) + failed = check(failed, close(bucket, 7.1), "cap") + bucket, _, _ = export_budget_update(EXPORT_BUDGET_PV_EQUIVALENT, 0.1, 0, 0, 5, 5, 0, 0.0, 1.0, 0.5, 7.1) + failed = check(failed, close(bucket, 0.0), "floor") + return failed + + +def test_update_pv_origin(): + """Charge is PV up to the step's PV; discharge serves the house from grid first, export from PV""" + failed = False + # Charge 0.5 DC with 0.2 PV: soc 1 -> 1.5 lossless, 40% PV + _, pv, grid = export_budget_update(EXPORT_BUDGET_PV_ORIGIN, 0, 0.5, 0.5, 1.0, 1.5, 0.5, 0.2, 1.0, 0, 10) + failed = check(failed, close(pv, 0.7) and close(grid, 0.8), "charge split {} {}".format(pv, grid)) + # Discharge 1.0, 40% to the house: house 0.4 from grid (0.8 -> 0.4), export 0.6 from pv (0.7 -> 0.1) + _, pv, grid = export_budget_update(EXPORT_BUDGET_PV_ORIGIN, 0, 0.7, 0.8, 1.5, 0.5, 0, 0, 0.4, 0, 10) + failed = check(failed, close(pv, 0.1) and close(grid, 0.4), "discharge split {} {}".format(pv, grid)) + # Parts renormalise to the SoC after a clamp + _, pv, grid = export_budget_update(EXPORT_BUDGET_PV_ORIGIN, 0, 1.0, 1.0, 2.0, 1.0, 0, 0, 1.0, 0, 10) + failed = check(failed, close(pv + grid, 1.0) and pv >= 0 and grid >= 0, "renormalise {} {}".format(pv, grid)) + # Empty parts with SoC left (e.g. reserve) count as grid + _, pv, grid = export_budget_update(EXPORT_BUDGET_PV_ORIGIN, 0, 0.0, 0.0, 0.5, 0.5, 0, 0, 1.0, 0, 10) + failed = check(failed, close(pv, 0.0) and close(grid, 0.5), "empty parts -> grid") + return failed + + +def test_replay(): + """Seed replay from measured cumulative history""" + failed = False + # pv_equivalent: 12 steps of 1 kWh PV and 0.2 export -> 9.6, capped at 7.1 + n = 12 + seed, noncompliant, steps = export_budget_replay(EXPORT_BUDGET_PV_EQUIVALENT, cumulative([1.0] * n), cumulative([0.2] * n), cumulative([0.0] * n), cumulative([0.0] * n), 10.0, 7.1) + failed = check(failed, steps == n and close(seed[0], 7.1) and close(noncompliant, 0.0), "pv_equivalent sunny {} {} {}".format(seed, noncompliant, steps)) + # pv_origin: 6 steps grid charge 1 kWh, then 6 steps PV charge 1 kWh -> share 0.5 + pv = [0.0] * 6 + [1.0] * 6 + imp = [1.0] * 6 + [0.0] * 6 + zero = [0.0] * 12 + seed, noncompliant, steps = export_budget_replay(EXPORT_BUDGET_PV_ORIGIN, cumulative(pv), cumulative(zero), cumulative(imp), cumulative(zero), 20.0, 20.0) + failed = check(failed, steps == 12 and close(seed[1], 0.5) and close(noncompliant, 0.0), "pv_origin share {} {}".format(seed, noncompliant)) + # pv_origin: 2 kWh grid charge then 1 kWh exported with no PV -> 1 kWh non-compliant + seed, noncompliant, steps = export_budget_replay(EXPORT_BUDGET_PV_ORIGIN, cumulative([0.0, 0.0, 0.0]), cumulative([0.0, 0.0, 1.0]), cumulative([1.0, 1.0, 0.0]), cumulative([0.0, 0.0, 0.0]), 20.0, 20.0) + failed = check(failed, close(noncompliant, 1.0) and close(seed[1], 0.0), "pv_origin non-compliant {} {}".format(seed, noncompliant)) + # pv_equivalent: export beyond bucket + pv is non-compliant + seed, noncompliant, steps = export_budget_replay(EXPORT_BUDGET_PV_EQUIVALENT, cumulative([0.5, 0.0]), cumulative([0.0, 1.0]), cumulative([0.0, 0.0]), cumulative([0.0, 0.0]), 10.0, 10.0) + failed = check(failed, close(noncompliant, 0.5) and close(seed[0], 0.0), "pv_equivalent non-compliant {} {}".format(seed, noncompliant)) + # Grid charge 2 kWh, then a sunny step where the battery covers the 0.5 kWh load and all 1 kWh of PV is exported: + # pv_origin counts the battery output as the house's own use, not as exported grid energy + history = (cumulative([0.0, 0.0, 1.0]), cumulative([0.0, 0.0, 1.0]), cumulative([1.0, 1.0, 0.0]), cumulative([0.0, 0.0, 0.5]), 20.0, 20.0) + seed, noncompliant, _ = export_budget_replay(EXPORT_BUDGET_PV_ORIGIN, *history, soc_history=cumulative([1.0, 1.0, -0.5])) + failed = check(failed, close(noncompliant, 0.0) and close(seed[1], 0.0), "pv_origin battery covers load while PV exports {} {}".format(seed, noncompliant)) + # No history at all + seed, noncompliant, steps = export_budget_replay(EXPORT_BUDGET_PV_ORIGIN, {}, {}, {}, {}, 10.0, 10.0) + failed = check(failed, seed is None and steps == 0, "no history") + return failed + + +def test_replay_measured_soc(): + """pv_origin with measured SoC history: battery flow is the SoC change, so losses and BMS resyncs cannot create PV energy""" + failed = False + zero = [0.0] * 18 + # 6 steps of grid charge (5 kWh), then 12 sunny steps with the battery held: 1 kWh PV, 0.97 exported (3% loss) + pv = [0.0] * 6 + [1.0] * 12 + imp = [5.0 / 6] * 6 + [0.0] * 12 + exp = [0.0] * 6 + [0.97] * 12 + soc = [5.0 / 6] * 6 + [0.0] * 12 + seed_flow, _, _ = export_budget_replay(EXPORT_BUDGET_PV_ORIGIN, cumulative(pv), cumulative(exp), cumulative(imp), cumulative(zero), 10.0, 10.0) + seed, noncompliant, steps = export_budget_replay(EXPORT_BUDGET_PV_ORIGIN, cumulative(pv), cumulative(exp), cumulative(imp), cumulative(zero), 10.0, 10.0, soc_history=cumulative(soc)) + # Without SoC history the loss is read as PV charge (the fallback); with it the stored energy stays all grid + failed = check(failed, seed_flow[1] > 0.01, "flow fallback should show the loss as PV charge {}".format(seed_flow)) + failed = check(failed, steps == 18 and close(seed[1], 0.0) and close(noncompliant, 0.0), "measured SoC phantom charge {} {} {}".format(seed, noncompliant, steps)) + # 2 kWh PV charge, 3 kWh grid charge, then an unexplained 0.3 kWh drop (no load, no export): taken from the PV part first + pv = [1.0, 1.0, 0.0, 0.0, 0.0, 0.0] + imp = [0.0, 0.0, 1.0, 1.0, 1.0, 0.0] + soc = [1.0, 1.0, 1.0, 1.0, 1.0, -0.3] + zero = [0.0] * 6 + seed, noncompliant, steps = export_budget_replay(EXPORT_BUDGET_PV_ORIGIN, cumulative(pv), cumulative(zero), cumulative(imp), cumulative(zero), 10.0, 10.0, soc_history=cumulative(soc)) + failed = check(failed, close(seed[1], 1.7 / 4.7) and close(noncompliant, 0.0), "measured SoC unexplained drop {} {}".format(seed, noncompliant)) + # Grid charge 2 kWh, then 1 kWh exported from the battery with no PV: non-compliant + seed, noncompliant, steps = export_budget_replay(EXPORT_BUDGET_PV_ORIGIN, cumulative([0.0] * 3), cumulative([0.0, 0.0, 1.0]), cumulative([1.0, 1.0, 0.0]), cumulative([0.0] * 3), 10.0, 10.0, soc_history=cumulative([1.0, 1.0, -1.0])) + failed = check(failed, close(noncompliant, 1.0) and close(seed[1], 0.0), "measured SoC non-compliant {} {}".format(seed, noncompliant)) + # SoC history starting part-way: the stored energy at the start counts as grid + seed, noncompliant, steps = export_budget_replay(EXPORT_BUDGET_PV_ORIGIN, cumulative([1.0]), cumulative([0.0]), cumulative([0.0]), cumulative([0.0]), 10.0, 10.0, soc_history={5: 3.0, 0: 4.0}) + failed = check(failed, close(seed[1], 0.25), "measured SoC initial grid {}".format(seed)) + return failed + + +def test_replay_skips_counter_glitch(): + """A counter reset pair (+300 then -300 kWh) is skipped, not counted""" + failed = False + pv = [1.0, 300.0, -300.0, 1.0] + exported = [0.0, 0.0, 0.0, 0.0] + seed, noncompliant, steps = export_budget_replay(EXPORT_BUDGET_PV_EQUIVALENT, cumulative(pv), cumulative(exported), cumulative([0.0] * 4), cumulative([0.0] * 4), 10.0, 7.1) + failed = check(failed, steps == 2 and close(seed[0], 2.0), "glitch skipped {} {}".format(seed, steps)) + return failed + + +# Hand-computed cases on a lossless, non-hybrid battery (6 kW, 0.5 kWh per 5 minute step). +# Each row: name, soc_kw, seed (bucket, pv_share), pv_kw by minute range, load_kw, charge windows (start, end, limit), +# export windows (start, end), end_record, and the expected (export_kwh, final_soc) per mode. +BUDGET_CASES = [ + # Grid-charged night then an evening export: pv_origin may only sell the 1 kWh PV part, pv_equivalent the 3 kWh bucket + {"name": "origin_night_charge", "soc": 2.0, "seed": (3.0, 0.5), "pv": [], "load": 0.0, "charge": [(0, 120, 10.0)], "export": [(180, 240)], "end": 240, "expect": {0: (6.0, 4.0), 1: (1.0, 9.0), 2: (3.0, 7.0)}}, + # Export with house load: house takes the grid part first, export stops when the PV part is gone, battery keeps covering the house + {"name": "origin_house_cover", "soc": 10.0, "seed": (1.5, 0.2), "pv": [], "load": 1.0, "charge": [], "export": [(0, 60)], "end": 60, "expect": {0: (5.0, 4.0), 1: (2.0, 7.0), 2: (1.5, 7.5)}}, + # Same with the starting SoC overridden (compare.py start_soc): the PV share applies to the new SoC + {"name": "origin_house_soc_override", "soc": 5.0, "seed": (1.5, 0.2), "pv": [], "load": 1.0, "charge": [], "export": [(0, 60)], "end": 60, "expect": {0: (25.0 / 6.0, 0.0), 1: (1.0, 3.0), 2: (1.5, 2.5)}}, + # PV during a forced export: PV is exported, the empty budget stops the battery adding to it + {"name": "equivalent_pv_during_export", "soc": 10.0, "seed": (0.0, 0.0), "pv": [(0, 60, 1.2)], "load": 0.0, "charge": [], "export": [(0, 60)], "end": 60, "expect": {0: (7.2, 4.0), 1: (1.2, 10.0), 2: (1.2, 10.0)}}, + # Grid charge window with sun: charge counts as PV up to the step's PV (0.2 of every 0.5), then export + {"name": "origin_sunny_charge", "soc": 0.0, "seed": (0.0, 0.0), "pv": [(0, 60, 2.4)], "load": 0.0, "charge": [(0, 60, 10.0)], "export": [(60, 120)], "end": 120, "expect": {0: (6.0, 0.0), 1: (2.4, 3.6), 2: (2.4, 3.6)}}, + # Forced export in the sun (PV 2.4 kW, load 1.2 kW) from an all-grid battery: pv_origin lets the battery cover the load + # while all the PV is exported, but exports nothing from the battery + {"name": "origin_sunny_export_all_grid", "soc": 10.0, "seed": (0.0, 0.0), "pv": [(0, 60, 2.4)], "load": 1.2, "charge": [], "export": [(0, 60)], "end": 60, "expect": {0: (7.2, 4.0), 1: (2.4, 8.8), 2: (2.4, 8.8)}}, + # Same with 1 kWh of PV stored: the grid part covers the load, so all 2.4 kWh of PV goes out with the 1 kWh + {"name": "origin_sunny_export_pv_part", "soc": 10.0, "seed": (0.0, 0.1), "pv": [(0, 60, 2.4)], "load": 1.2, "charge": [], "export": [(0, 60)], "end": 60, "expect": {0: (7.2, 4.0), 1: (3.4, 7.8), 2: (2.4, 8.8)}}, +] + + +def setup_budget_case(my_predbat, case): + """Configure my_predbat for one BUDGET_CASES row, returns the run_prediction arguments""" + minutes_now = FIXTURE_MINUTES_NOW + reset_budget_base(my_predbat, minutes_now) + my_predbat.soc_max = 10.0 + my_predbat.soc_kw = case["soc"] + my_predbat.reserve = 0.0 + my_predbat.best_soc_min = 0.0 + my_predbat.inverter_loss = 1.0 + my_predbat.battery_loss = 1.0 + my_predbat.battery_loss_discharge = 1.0 + my_predbat.inverter_limit = 20 / 60.0 + my_predbat.export_limit = 20 / 60.0 + for attr in ("battery_rate_max_charge", "battery_rate_max_discharge", "battery_rate_max_export", "battery_rate_max_charge_dc", "charge_rate_now", "discharge_rate_now"): + setattr(my_predbat, attr, 6 / 60.0) + my_predbat.battery_rate_max_scaling = 1.0 + my_predbat.battery_rate_max_scaling_discharge = 1.0 + my_predbat.battery_rate_min = 0.0 + my_predbat.export_budget_cap_kwh = 10.0 + pv_step = {} + load_step = {} + for minute in range(0, my_predbat.forecast_minutes, 5): + pv_kw = 0.0 + for start, end, kw in case["pv"]: + if start <= minute < end: + pv_kw = kw + pv_step[minute] = pv_kw / 12.0 + load_step[minute] = case["load"] / 12.0 + charge_window = [{"start": minutes_now + start, "end": minutes_now + end, "average": 10.0} for start, end, _ in case["charge"]] + charge_limit = [limit for _, _, limit in case["charge"]] + export_window = [{"start": minutes_now + start, "end": minutes_now + end, "average": 50.0} for start, end in case["export"]] + export_limits = [unpack_export_limit(0.0) for _ in case["export"]] + return {"pv_step": pv_step, "load_step": load_step, "charge_limit": charge_limit, "charge_window": charge_window, "export_window": export_window, "export_limits": export_limits, "end_record": case["end"]} + + +def run_budget_case_python(my_predbat, case, mode, step=5, save=None): + """Run one BUDGET_CASES row through the Python engine with the given mode, returns (result, prediction)""" + args = setup_budget_case(my_predbat, case) + my_predbat.export_budget_mode = mode + my_predbat.export_budget_seed = case["seed"] if mode else None + my_predbat.prediction_kernel_enable = False + pv, load = args["pv_step"], args["load_step"] + prediction = Prediction(my_predbat, pv, pv, load, load, pv, load) + result = prediction.run_prediction(args["charge_limit"], args["charge_window"], args["export_window"], args["export_limits"], PV_SCENARIO_NOMINAL, args["end_record"], save=save, step=step, cache=False) + return result, prediction + + +def test_budget_engine_cases(my_predbat): + """Hand-computed export and final SoC for every case and mode""" + failed = False + for case in BUDGET_CASES: + for mode, (expect_export, expect_soc) in case["expect"].items(): + result, _ = run_budget_case_python(my_predbat, case, mode) + export_kwh, final_soc = result[3], result[5] + # Results are rounded to 4 decimal places + if abs(export_kwh - expect_export) > 1e-3 or abs(final_soc - expect_soc) > 1e-3: + print("ERROR: export_budget case {} mode {}: export {} soc {} expected export {} soc {}".format(case["name"], mode, export_kwh, final_soc, expect_export, expect_soc)) + failed = True + return failed + + +def test_budget_parts_invariant(my_predbat): + """pv_origin's PV part stays within [0, soc_max] and the bucket within [0, cap] across lossy random runs""" + import random + from tests.test_kernel_parity import apply_random_scenario, make_step_data, make_windows + from tests.test_infra import reset_inverter, reset_rates + + failed = False + # The fixture has one car; random scenarios use up to two, so widen the car lists for this test only + saved_car_lists = {attr: value for attr, value in vars(my_predbat).items() if attr.startswith("car_charging") and isinstance(value, list) and len(value) == 1} + for attr, value in saved_car_lists.items(): + setattr(my_predbat, attr, value + [copy.deepcopy(value[0]) for _ in range(3)]) + for seed in range(20): + rng = random.Random(seed + 4000000) + reset_inverter(my_predbat) + reset_rates(my_predbat, 10.0, 5.0) + my_predbat.battery_rate_max_export = my_predbat.battery_rate_max_discharge + apply_random_scenario(my_predbat, rng) + pv_step, pv10_step, load_step, load10_step = make_step_data(my_predbat, rng=rng) + charge_window = make_windows(rng, my_predbat.minutes_now, my_predbat.forecast_minutes, 2) + charge_limit = [my_predbat.soc_max for _ in charge_window] + export_window = make_windows(rng, my_predbat.minutes_now, my_predbat.forecast_minutes, 2) + export_limits = [unpack_export_limit(0.0) for _ in export_window] + for mode in (1, 2): + my_predbat.export_budget_mode = mode + my_predbat.export_budget_cap_kwh = 0.0 + my_predbat.export_budget_seed = (rng.uniform(0, my_predbat.soc_max), rng.uniform(0, 1)) + my_predbat.prediction_kernel_enable = False + prediction = Prediction(my_predbat, pv_step, pv10_step, load_step, load10_step) + prediction.run_prediction(charge_limit, charge_window, export_window, export_limits, PV_SCENARIO_NOMINAL, my_predbat.forecast_minutes, save="best", cache=False) + for minute, value in prediction.predict_export_budget.items(): + if value < -1e-9 or value > my_predbat.soc_max + 1e-9: + print("ERROR: export_budget invariant seed {} mode {} minute {} value {} soc_max {}".format(seed, mode, minute, value, my_predbat.soc_max)) + failed = True + break + for attr, value in saved_car_lists.items(): + setattr(my_predbat, attr, value) + my_predbat.export_budget_mode = 0 + my_predbat.export_budget_seed = None + return failed + + +def test_budget_fast_mode_step(my_predbat): + """The cap holds with 10 minute steps (Python fast mode, kernel unavailable)""" + case = BUDGET_CASES[1] + result, _ = run_budget_case_python(my_predbat, case, 1, step=10) + if not (0 < result[3] <= 2.0 + 1e-6): + print("ERROR: export_budget fast mode exported {} (budget 2.0)".format(result[3])) + return True + return False + + +def test_budget_save_outputs(my_predbat): + """save='best' records capped minutes and the planned battery export""" + case = BUDGET_CASES[1] + _, prediction = run_budget_case_python(my_predbat, case, 1, save="best") + failed = False + if abs(prediction.export_budget_planned_battery_export - 2.0) > 1e-6: + print("ERROR: export_budget planned battery export {} expected 2.0".format(prediction.export_budget_planned_battery_export)) + failed = True + if not prediction.export_budget_capped_minutes: + print("ERROR: export_budget capped minutes empty for a capped export") + failed = True + return failed + + +def test_config_parse(my_predbat): + """apps.yaml parsing: valid modes, invalid mode -> off with Warn, invalid cap -> default""" + failed = False + for value, expect_mode in (("pv_origin", 1), ("pv_equivalent", 2), ("off", 0), ("bogus", 0)): + my_predbat.args["export_budget_mode"] = value + my_predbat.args["export_budget_cap_kwh"] = "abc" if value == "bogus" else 7.1 + my_predbat.fetch_config_options() + if my_predbat.export_budget_mode != expect_mode: + print("ERROR: export_budget config {} parsed as {} expected {}".format(value, my_predbat.export_budget_mode, expect_mode)) + failed = True + expect_cap = 0.0 if value == "bogus" else 7.1 + if my_predbat.export_budget_cap_kwh != expect_cap: + print("ERROR: export_budget cap for {} parsed as {} expected {}".format(value, my_predbat.export_budget_cap_kwh, expect_cap)) + failed = True + my_predbat.args.pop("export_budget_mode", None) + my_predbat.args.pop("export_budget_cap_kwh", None) + # `export_budget_mode: off` unquoted is YAML false: it means off and must pass apps.yaml validation + my_predbat.args["export_budget_mode"] = False + saved_arg_errors = my_predbat.arg_errors + my_predbat.arg_errors = {} + try: + my_predbat.validate_config() + if "export_budget_mode" in my_predbat.arg_errors: + print("ERROR: export_budget_mode false failed validation: {}".format(my_predbat.arg_errors["export_budget_mode"])) + failed = True + finally: + my_predbat.arg_errors = saved_arg_errors + my_predbat.args.pop("export_budget_mode", None) + my_predbat.fetch_config_options() + return failed + + +def test_seed_hook(my_predbat): + """update_export_budget_seed replays the loaded history, and is a no-op with the mode off""" + failed = False + n = 12 + my_predbat.pv_today = cumulative([1.0] * n) + my_predbat.export_today = cumulative([0.2] * n) + my_predbat.import_today = cumulative([0.0] * n) + my_predbat.load_minutes = cumulative([0.0] * n) + my_predbat.soc_max = 10.0 + my_predbat.export_budget_cap_kwh = 0.0 + my_predbat.export_budget_mode = 2 + my_predbat.update_export_budget_seed() + if not my_predbat.export_budget_seed or abs(my_predbat.export_budget_seed[0] - 9.6) > 1e-9 or my_predbat.export_budget_replay_steps != n: + print("ERROR: export_budget seed hook gave {} steps {}".format(my_predbat.export_budget_seed, my_predbat.export_budget_replay_steps)) + failed = True + my_predbat.export_budget_mode = 0 + my_predbat.update_export_budget_seed() + if my_predbat.export_budget_seed is not None: + print("ERROR: export_budget seed hook with mode off left {}".format(my_predbat.export_budget_seed)) + failed = True + return failed + + +def test_soc_history_fetch(my_predbat): + """The SoC history comes from each inverter's soc_percent (or soc_kw) sensor, in stored kWh keyed by minutes ago, summed""" + from datetime import timedelta + + failed = False + now = my_predbat.now_utc + records = [{"last_updated": (now - timedelta(minutes=60)).isoformat(), "state": "50"}, {"last_updated": (now - timedelta(minutes=30)).isoformat(), "state": "60"}] + saved_soc_max = [inverter.__dict__.get("soc_max", "missing") for inverter in my_predbat.inverters] + saved_scaling = [inverter.__dict__.get("battery_scaling", "missing") for inverter in my_predbat.inverters] + for inverter in my_predbat.inverters: + inverter.soc_max = 10.0 + my_predbat.args["soc_percent"] = "sensor.test_battery_soc" + my_predbat.args.pop("soc_kw", None) + my_predbat.get_history_wrapper = lambda entity_id, days=30, required=True, tracked=True: [records] + try: + history = my_predbat.fetch_export_budget_soc_history() + total = 10.0 * len(my_predbat.inverters) + if not history or abs(history.get(0, -1) - 0.6 * total) > 1e-6 or abs(history.get(45, -1) - 0.5 * total) > 1e-6: + print("ERROR: export_budget SoC history now {} 45 minutes ago {} expected {} / {}".format(history.get(0) if history else None, history.get(45) if history else None, 0.6 * total, 0.5 * total)) + failed = True + # A soc_kw sensor is scaled by battery_scaling, as the live SoC is (inverter.py) + my_predbat.args.pop("soc_percent", None) + my_predbat.args["soc_kw"] = "sensor.test_battery_soc_kw" + for inverter in my_predbat.inverters: + inverter.battery_scaling = 0.8 + kw_records = [{"last_updated": (now - timedelta(minutes=60)).isoformat(), "state": "5"}, {"last_updated": (now - timedelta(minutes=30)).isoformat(), "state": "6"}] + my_predbat.get_history_wrapper = lambda entity_id, days=30, required=True, tracked=True: [kw_records] + history = my_predbat.fetch_export_budget_soc_history() + expect_now, expect_45 = 0.8 * 6 * len(my_predbat.inverters), 0.8 * 5 * len(my_predbat.inverters) + if not history or abs(history.get(0, -1) - expect_now) > 1e-6 or abs(history.get(45, -1) - expect_45) > 1e-6: + print("ERROR: export_budget soc_kw history now {} 45 minutes ago {} expected {} / {}".format(history.get(0) if history else None, history.get(45) if history else None, expect_now, expect_45)) + failed = True + my_predbat.get_history_wrapper = lambda entity_id, days=30, required=True, tracked=True: [] + if my_predbat.fetch_export_budget_soc_history(): + print("ERROR: export_budget SoC history should be empty without sensor history") + failed = True + finally: + del my_predbat.get_history_wrapper + my_predbat.args.pop("soc_kw", None) + my_predbat.args["soc_percent"] = "sensor.test_battery_soc" + for inverter, soc_max, scaling in zip(my_predbat.inverters, saved_soc_max, saved_scaling): + if soc_max == "missing": + del inverter.soc_max + else: + inverter.soc_max = soc_max + if scaling == "missing": + inverter.__dict__.pop("battery_scaling", None) + else: + inverter.battery_scaling = scaling + return failed + + +def test_seed_hook_measured_soc(my_predbat): + """The pv_origin seed uses the measured SoC history when there is one, and falls back to the flow estimate otherwise""" + failed = False + pv = [0.0] * 6 + [1.0] * 12 + my_predbat.pv_today = cumulative(pv) + my_predbat.export_today = cumulative([0.0] * 6 + [0.97] * 12) + my_predbat.import_today = cumulative([5.0 / 6] * 6 + [0.0] * 12) + my_predbat.load_minutes = cumulative([0.0] * 18) + my_predbat.soc_max = 10.0 + my_predbat.export_budget_cap_kwh = 0.0 + my_predbat.export_budget_mode = 1 + soc = cumulative([5.0 / 6] * 6 + [0.0] * 12) + for history, expect_pv in ((soc, False), ({}, True)): + my_predbat.fetch_export_budget_soc_history = lambda history=history: history + try: + my_predbat.update_export_budget_seed() + finally: + del my_predbat.fetch_export_budget_soc_history + share = my_predbat.export_budget_seed[1] if my_predbat.export_budget_seed else None + if share is None or (share > 0.01) != expect_pv: + print("ERROR: export_budget seed hook with SoC history {} gave share {}".format(bool(history), share)) + failed = True + my_predbat.export_budget_mode = 0 + return failed + + +def test_publish_sensor(my_predbat): + """The sensor carries the agreed state and attributes and the manual export note is logged""" + failed = False + my_predbat.export_budget_mode = 1 + my_predbat.export_budget_cap_kwh = 0.0 + my_predbat.soc_max = 10.0 + my_predbat.soc_kw = 8.0 + my_predbat.export_budget_seed = (0.0, 0.25) + my_predbat.export_budget_noncompliant_kwh = 0.4 + my_predbat.export_budget_replay_steps = 2016 + my_predbat.export_budget_planned_battery_export = 1.5 + start = my_predbat.minutes_now + 60 + other = start + 120 + my_predbat.export_window_best = [{"start": start, "end": start + 30, "average": 50}, {"start": other, "end": other + 30, "average": 50}] + # Only the first window is manual; both are capped, so exactly one manual note is expected + my_predbat.manual_export_times = [start] + my_predbat.export_budget_capped_minutes = [start + 10, other + 5] + # Budget at the end of each 5 minute step of the best plan, keyed by minutes from now + my_predbat.predict_export_budget = {0: 1.8, 5: 1.5, 10: 1.5, 15: 1.5, 20: 3.0} + logged = [] + saved_log = my_predbat.log + my_predbat.log = lambda message, *args, **kwargs: logged.append(message) + try: + my_predbat.publish_export_budget() + finally: + my_predbat.log = saved_log + entity = my_predbat.prefix + ".export_budget" + item = my_predbat.dashboard_values.get(entity, {}) + state = item.get("state") + attributes = item.get("attributes", {}) + if state is None or abs(float(state) - 2.0) > 1e-6: + print("ERROR: export_budget sensor state {} expected 2.0".format(state)) + failed = True + for key, value in (("mode", "pv_origin"), ("pv_part_kwh", 2.0), ("grid_part_kwh", 6.0), ("cap", 10.0), ("planned_battery_export_kwh", 1.5), ("noncompliant_export_kwh", 0.4)): + if attributes.get(key) != value: + print("ERROR: export_budget attribute {} = {} expected {}".format(key, attributes.get(key), value)) + failed = True + + def stamp(minutes_from_now): + return (my_predbat.midnight_utc + timedelta(minutes=my_predbat.minutes_now + minutes_from_now)).strftime(TIME_FORMAT) + + # Starts at the budget now; repeated values in the middle of a run are dropped, as for other forecast sensors + expected_results = {stamp(0): 2.0, stamp(5): 1.8, stamp(10): 1.5, stamp(20): 1.5, stamp(25): 3.0} + if attributes.get("results") != expected_results: + print("ERROR: export_budget results {} expected {}".format(attributes.get("results"), expected_results)) + failed = True + manual_notes = [message for message in logged if "capped manual export" in message] + if len(manual_notes) != 1 or my_predbat.time_abs_str(start) not in manual_notes[0]: + print("ERROR: export_budget manual export notes {} expected one for {}".format(manual_notes, my_predbat.time_abs_str(start))) + failed = True + if not any(message.startswith("Export energy budget (pv_origin)") for message in logged): + print("ERROR: export_budget summary log line missing: {}".format(logged)) + failed = True + return failed + + +def test_budget_exportable_output(my_predbat): + """save runs record the stored energy the budget lets the battery export at the start of each step""" + failed = False + case = BUDGET_CASES[1] + # Seed (1.5, 0.2) with 10 kWh stored: pv_origin may export the 2 kWh PV part, pv_equivalent the 1.5 kWh bucket + for mode, expect in ((1, 2.0), (2, 1.5)): + _, prediction = run_budget_case_python(my_predbat, case, mode, save="best") + value = prediction.predict_export_budget_start.get(0) + if value is None or abs(value - expect) > 1e-6: + print("ERROR: export_budget exportable at start mode {} = {} expected {}".format(mode, value, expect)) + failed = True + return failed + + +def test_clip_budget_floor(my_predbat): + """In budget mode an export target is never below start SoC minus the exportable budget, whatever the house load does""" + from const import EXPORT_MODE_TARGET + from utils import pack_export_limit, export_target_of + + failed = False + reset_budget_base(my_predbat, FIXTURE_MINUTES_NOW) + my_predbat.soc_max = 10.0 + my_predbat.debug_enable = False + my_predbat.battery_rate_max_discharge = 1 / 60.0 + my_predbat.battery_rate_max_scaling_discharge = 1.0 + minutes_now = FIXTURE_MINUTES_NOW + # Simulated SoC falls 8 -> 5 kWh over the window (export plus the house load) + predict_soc = {minute: 8.0 - 3.0 * min(minute, 30) / 30.0 for minute in range(0, 65, 5)} + # Uncapped clip: display target at the lowest SoC (50%), command 10 minutes of discharge below it (48%) + for mode, exportable, expect_limit, expect_target in ((1, 0.2, 78, 78), (1, 5.0, 48, 50), (0, 0.2, 48, 50)): + my_predbat.export_budget_mode = mode + windows = [{"start": minutes_now, "end": minutes_now + 30, "average": 50.0}] + limits = [pack_export_limit(EXPORT_MODE_TARGET, 0, 1.0)] + _, limits = my_predbat.clip_export_slots(minutes_now, predict_soc, windows, limits, 1, 5, budget_exportable={0: exportable}) + if export_target_of(limits[0]) != expect_limit or windows[0]["target"] != expect_target: + print("ERROR: export_budget clip floor mode {} exportable {} gave target {} window {} expected {} / {}".format(mode, exportable, export_target_of(limits[0]), windows[0]["target"], expect_limit, expect_target)) + failed = True + my_predbat.export_budget_mode = 0 + return failed + + +def test_measured_floor(my_predbat): + """Every cycle the export window running now (or starting before the next cycle) is held at or above measured SoC minus the budget left now""" + from const import EXPORT_MODE_TARGET, EXPORT_MODE_FREEZE + from utils import pack_export_limit, export_target_of, export_mode_of + + failed = False + minutes_now = FIXTURE_MINUTES_NOW + my_predbat.minutes_now = minutes_now + my_predbat.soc_max = 10.0 + my_predbat.soc_kw = 5.0 + my_predbat.battery_loss_discharge = 1.0 + my_predbat.inverter_loss = 1.0 + # mode, seed, expected target of the running window: pv_origin 20% of 5 kWh -> 1 kWh exportable -> 40%; + # pv_equivalent bucket 2 kWh -> 30%; off -> unchanged 10%; no history -> nothing exportable -> 50% + for mode, seed, expect in ((1, (0.0, 0.2), 40), (2, (2.0, 0.0), 30), (0, (0.0, 0.2), 10), (1, None, 50)): + my_predbat.export_budget_mode = mode + my_predbat.export_budget_seed = seed + my_predbat.export_window_best = [ + {"start": minutes_now - 30, "end": minutes_now + 30, "average": 50.0, "target": 10.0}, + {"start": minutes_now + 3, "end": minutes_now + 30, "average": 50.0, "target": 10.0}, + {"start": minutes_now + 120, "end": minutes_now + 150, "average": 50.0, "target": 10.0}, + {"start": minutes_now - 5, "end": minutes_now + 30, "average": 50.0, "target": 99.0}, + ] + my_predbat.export_limits_best = [pack_export_limit(EXPORT_MODE_TARGET, 10, 1.0), pack_export_limit(EXPORT_MODE_TARGET, 10, 1.0), pack_export_limit(EXPORT_MODE_TARGET, 10, 1.0), pack_export_limit(EXPORT_MODE_FREEZE)] + my_predbat.apply_export_budget_floor() + targets = [export_target_of(limit) for limit in my_predbat.export_limits_best[:3]] + if targets != [expect, expect, 10] or my_predbat.export_window_best[0]["target"] != expect: + print("ERROR: export_budget measured floor mode {} seed {} gave {} (window {}) expected [{}, {}, 10]".format(mode, seed, targets, my_predbat.export_window_best[0]["target"], expect, expect)) + failed = True + if export_mode_of(my_predbat.export_limits_best[3]) != EXPORT_MODE_FREEZE: + print("ERROR: export_budget measured floor changed a freeze window: {}".format(my_predbat.export_limits_best[3])) + failed = True + my_predbat.export_budget_mode = 0 + my_predbat.export_budget_seed = None + return failed + + +def test_budget_iboost_excess(my_predbat): + """iBoost solar-excess diversion only sees the export left after the cap, so the cap never makes the house import""" + failed = False + case = BUDGET_CASES[1] + iboost = { + "iboost_enable": True, + "iboost_solar": True, + "iboost_solar_excess": True, + "iboost_on_export": True, + "iboost_max_power": 3 / 60.0, + "iboost_max_energy": 100.0, + "iboost_min_power": 0.0, + "iboost_min_soc": 0.0, + "iboost_rate_threshold": 9999.0, + "iboost_rate_threshold_export": 9999.0, + "iboost_today": 0.0, + } + saved = {attr: getattr(my_predbat, attr, None) for attr in iboost} + try: + for mode in (1, 2): + args = setup_budget_case(my_predbat, case) + for attr, value in iboost.items(): + setattr(my_predbat, attr, value) + my_predbat.export_budget_mode = mode + my_predbat.export_budget_seed = case["seed"] + my_predbat.prediction_kernel_enable = False + pv, load = args["pv_step"], args["load_step"] + prediction = Prediction(my_predbat, pv, pv, load, load, pv, load) + result = prediction.run_prediction(args["charge_limit"], args["charge_window"], args["export_window"], args["export_limits"], PV_SCENARIO_NOMINAL, args["end_record"], save=None, cache=False) + if result[2] > 1e-6: + print("ERROR: export_budget with iBoost solar excess mode {} imported {} kWh for the house".format(mode, result[2])) + failed = True + finally: + for attr, value in saved.items(): + setattr(my_predbat, attr, value) + return failed + + +def run_export_budget_tests(my_predbat): + """Run all export budget tests, returns True on failure""" + print("**** Running export budget tests ****") + failed = False + failed |= test_parse_mode() + failed |= test_allowed() + failed |= test_house_from_battery() + failed |= test_update_pv_equivalent() + failed |= test_update_pv_origin() + failed |= test_replay() + failed |= test_replay_skips_counter_glitch() + failed |= test_replay_measured_soc() + from tests.test_kernel_parity import snapshot_scenario_state, restore_scenario_state + + state = snapshot_scenario_state(my_predbat) + try: + failed |= test_budget_engine_cases(my_predbat) + failed |= test_budget_parts_invariant(my_predbat) + failed |= test_budget_fast_mode_step(my_predbat) + failed |= test_budget_save_outputs(my_predbat) + failed |= test_budget_exportable_output(my_predbat) + failed |= test_budget_iboost_excess(my_predbat) + failed |= test_clip_budget_floor(my_predbat) + # Config parsing and the seed hook mutate the args and the loaded history + saved = { + attr: copy.deepcopy(getattr(my_predbat, attr, None)) + for attr in ( + "pv_today", + "export_today", + "import_today", + "load_minutes", + "soc_max", + "args", + "export_budget_noncompliant_kwh", + "export_budget_replay_steps", + "export_budget_planned_battery_export", + "export_budget_capped_minutes", + "soc_kw", + "export_window_best", + "manual_export_times", + "export_limits_best", + "minutes_now", + "battery_loss_discharge", + "inverter_loss", + ) + } + try: + failed |= test_config_parse(my_predbat) + failed |= test_seed_hook(my_predbat) + failed |= test_soc_history_fetch(my_predbat) + failed |= test_seed_hook_measured_soc(my_predbat) + failed |= test_publish_sensor(my_predbat) + failed |= test_measured_floor(my_predbat) + finally: + for attr, value in saved.items(): + setattr(my_predbat, attr, value) + finally: + restore_scenario_state(my_predbat, state) + if not failed: + print("PASS") + return failed diff --git a/apps/predbat/tests/test_kernel_parity.py b/apps/predbat/tests/test_kernel_parity.py index bea9d9fb6..762d92df0 100644 --- a/apps/predbat/tests/test_kernel_parity.py +++ b/apps/predbat/tests/test_kernel_parity.py @@ -24,6 +24,7 @@ import copy import ctypes import gc +import itertools import os import random import signal @@ -142,6 +143,9 @@ "rate_gas", "iboost_plan", "end_record", + "export_budget_mode", + "export_budget_cap_kwh", + "export_budget_seed", ] @@ -777,6 +781,77 @@ def run_edge_case_tests(my_predbat): load90_step=load90_step, ) my_predbat.io_adjusted = {} + + failed |= run_export_budget_parity_tests(my_predbat) + return failed + + +def run_export_budget_parity_tests(my_predbat): + """Every export budget case and mode through both engines and all PV scenarios, returns True on failure""" + from tests.test_export_budget import BUDGET_CASES, setup_budget_case + + print("**** Running export budget parity tests ****") + failed = False + state = snapshot_scenario_state(my_predbat) + try: + # Lossless (as hand-computed), lossy, a lossy hybrid inverter with an export limit below the battery rate, and lossy with iBoost + variants = { + "lossless": {}, + "lossy": {"inverter_loss": 0.96, "battery_loss": 0.95, "battery_loss_discharge": 0.95}, + "lossy_hybrid": {"inverter_loss": 0.96, "battery_loss": 0.95, "battery_loss_discharge": 0.95, "inverter_hybrid": True, "export_limit": 4 / 60.0}, + # iBoost solar excess reads the grid balance the cap has just changed + "lossy_iboost": { + "inverter_loss": 0.96, + "battery_loss": 0.95, + "battery_loss_discharge": 0.95, + "iboost_enable": True, + "iboost_solar": True, + "iboost_solar_excess": True, + "iboost_on_export": True, + "iboost_max_power": 3 / 60.0, + "iboost_max_energy": 100.0, + "iboost_min_power": 0.0, + "iboost_min_soc": 0.0, + "iboost_rate_threshold": 9999.0, + "iboost_rate_threshold_export": 9999.0, + "iboost_today": 0.0, + }, + } + for case, mode, variant in itertools.product(BUDGET_CASES, (0, 1, 2), variants): + args = setup_budget_case(my_predbat, case) + for attr, value in variants[variant].items(): + setattr(my_predbat, attr, value) + my_predbat.export_budget_mode = mode + my_predbat.export_budget_seed = case["seed"] if mode else None + pv, load = args["pv_step"], args["load_step"] + pv10 = {m: v * 0.5 for m, v in pv.items()} + pv90 = {m: v * 1.5 for m, v in pv.items()} + for scenario in (PV_SCENARIO_NOMINAL, PV_SCENARIO_PV10, PV_SCENARIO_PV90): + failed |= dual_run( + "budget_{}_{}_m{}_s{}".format(case["name"], variant, mode, scenario), + my_predbat, + pv, + pv10, + load, + load, + args["charge_limit"], + args["charge_window"], + args["export_window"], + [0.0 for _ in args["export_window"]], + scenario, + args["end_record"], + pv90_step=pv90, + load90_step=load, + ) + # Seed present but mode off must be ignored by both engines + args = setup_budget_case(my_predbat, BUDGET_CASES[1]) + my_predbat.export_budget_mode = 0 + my_predbat.export_budget_seed = (5.0, 1.0) + failed |= dual_run("budget_seed_mode_off", my_predbat, args["pv_step"], args["pv_step"], args["load_step"], args["load_step"], [], [], args["export_window"], [0.0], PV_SCENARIO_NOMINAL, args["end_record"]) + finally: + restore_scenario_state(my_predbat, state) + if not failed: + print("PASS") return failed @@ -789,6 +864,7 @@ def run_random_sweep_tests(my_predbat, count=150): the sweep is fast enough to absorb the 3x. """ failed = False + budget_count = 0 scenario_counts = {PV_SCENARIO_NOMINAL: 0, PV_SCENARIO_PV10: 0, PV_SCENARIO_PV90: 0} for seed in range(count): rng = random.Random(seed) @@ -823,6 +899,43 @@ def run_random_sweep_tests(my_predbat, count=150): break if failed: break + + # The same configuration with an export budget. Own generator, so every scenario above is unchanged. + rng_budget = random.Random(seed + 3000000) + my_predbat.export_budget_mode = rng_budget.choice([1, 2]) + my_predbat.export_budget_cap_kwh = rng_budget.choice([0.0, round(rng_budget.uniform(0.5, my_predbat.soc_max), 2)]) + my_predbat.export_budget_seed = rng_budget.choice([None, (round(rng_budget.uniform(0, my_predbat.soc_max), 3), round(rng_budget.uniform(0, 1), 3))]) + budget_scenario = rng_budget.choice([PV_SCENARIO_NOMINAL, PV_SCENARIO_PV10, PV_SCENARIO_PV90]) + # Lighter load so forced exports reach the grid; at the full random load the house absorbs most of + # the battery output and the cap would rarely bind + load_scale = rng_budget.uniform(0.0, 0.5) + budget_load = {minute: value * load_scale for minute, value in load_step.items()} + budget_load10 = {minute: value * load_scale for minute, value in load10_step.items()} + budget_load90 = {minute: value * load_scale for minute, value in load90_step.items()} + failed |= dual_run( + "random_{}_budget{}_s{}".format(seed, my_predbat.export_budget_mode, budget_scenario), + my_predbat, + pv_step, + pv10_step, + budget_load, + budget_load10, + charge_limit, + charge_window, + export_window, + export_limits, + budget_scenario, + end_record, + pv90_step=pv90_step, + load90_step=budget_load90, + ) + my_predbat.export_budget_mode = 0 + my_predbat.export_budget_cap_kwh = 0.0 + my_predbat.export_budget_seed = None + budget_count += 1 + if failed: + print("Random sweep failed at seed {} with export budget".format(seed)) + break + print("Random sweep ran {} export budget configurations".format(budget_count)) print("Random sweep ran {} configurations: nominal {}, pv10 {}, pv90 {}".format(sum(scenario_counts.values()), scenario_counts[PV_SCENARIO_NOMINAL], scenario_counts[PV_SCENARIO_PV10], scenario_counts[PV_SCENARIO_PV90])) return failed diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index add6d2081..55ddc97f5 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -33,6 +33,7 @@ from tests.test_dashboard_device_class import test_dashboard_device_class from tests.test_inverter_config_sensor import test_inverter_config_sensor from tests.test_kernel_parity import run_kernel_parity_tests, run_model_kernel_tests +from tests.test_export_budget import run_export_budget_tests from tests.test_prediction_batch import run_prediction_batch_tests from tests.test_kernel_static_cache import run_kernel_static_cache_tests from tests.test_execute import run_execute_tests @@ -454,6 +455,7 @@ def main(): ("inverter_config_sensor", test_inverter_config_sensor, "Aggregated static prediction inputs published as sensor._inverter_config", False), ("model_kernel", run_model_kernel_tests, "Model tests run with the C++ prediction kernel enabled", False), ("kernel_parity", run_kernel_parity_tests, "C++ prediction kernel vs Python engine parity tests", False), + ("export_budget", run_export_budget_tests, "Export energy budget (export_budget_mode) tests", False), ("prediction_batch", run_prediction_batch_tests, "Batched prediction fan-out tests", False), ("inverter", run_inverter_tests, "Inverter tests", False), ("execute", run_execute_tests, "Execute tests", False), diff --git a/docs/energy-rates.md b/docs/energy-rates.md index a6c3480f2..8fbe32bca 100644 --- a/docs/energy-rates.md +++ b/docs/energy-rates.md @@ -708,3 +708,52 @@ Once the integration is active, update `apps.yaml` to link Predbat to the Carbon # Carbon Intensity data from National grid carbon_intensity: 're:(sensor.carbon_intensity_uk_sensor)' ``` + +## Export energy budget + +Some prosumer schemes only let a household sell energy its own PV produced - for example Poland, +where energy bought from the grid to be stored may not be resold without a licence. Predbat can plan +battery exports within such a rule. Set in `apps.yaml`: + +```yaml + export_budget_mode: pv_origin # off (default) | pv_origin | pv_equivalent + # export_budget_cap_kwh: 7.1 # pv_equivalent only, defaults to the battery size +``` + +- **pv_origin**: Predbat tracks how much of the stored energy came from PV and how much from the + grid. Charging counts as PV up to the PV produced at the time, the rest as grid. Energy the house + (and car, iBoost) uses comes from the grid part first, even while PV is exported; only the PV part + may be force exported. Every kWh that reaches the grid is PV, sent directly or stored first, and + grid energy is only ever used on site. Use this where the rule is "only PV energy may be sold" and + storing grid energy for your own use is allowed. + + For example (ignoring losses), the battery charges 5 kWh from the grid while PV makes 5 kWh and the + house uses 5 kWh. The 5 kWh stored counts as PV and the house is deemed to have used the grid energy, + so the battery may export those 5 kWh later. Over the whole period 5 kWh was imported and used on + site, and the 5 kWh exported equals the PV produced. Had the battery charged 5 kWh from the grid at + night with no PV, that energy would be grid part: it can run the house but is never force exported. +- **pv_equivalent**: a credit bucket that all PV production fills (including PV the house uses) and + every kWh exported drains. The battery may force export while the bucket has credit. Direct PV + surplus export is never limited and doesn't reduce the credit, because the PV it sends out is added + and removed in the same step; only battery export draws the credit down. `export_budget_cap_kwh` (default the + battery size) caps how much credit is carried over. This can sell grid-charged energy when the + bucket has credit, so it is the less strict reading. + +Only battery-to-grid export is limited; PV surplus export and grid charging for your own use are not. +When the budget runs out during an export window the battery keeps covering the house and only the +export stops. Manual forced exports are limited too; set the mode to `off` to export without it. +Because the inverter exports at full power down to a window's target SoC, every cycle Predbat raises +the target of the export running now to at least the measured SoC minus the energy the budget still +allows (rounded up to a whole percent). + +Each cycle Predbat replays up to the last 7 days of `pv_today`, `export_today`, `import_today` and +`load_today` history to find where the budget stands. It uses the history already loaded for load +prediction, so the replay is shorter when [days_previous](apps-yaml.md#days_previous) covers fewer +days (`history_steps` on the sensor shows how many 5 minute steps were replayed). For `pv_origin` it also reads 7 days of the +battery's `soc_percent` (or `soc_kw`) history, so conversion losses are not mistaken for PV charge; +without that history it estimates the battery flow and logs a warning. It publishes `predbat.export_budget` +(exportable kWh now, with `planned_battery_export_kwh` and `noncompliant_export_kwh` - export in the +replayed history the rule would not have allowed). Its `results` attribute holds the budget over the +best plan in the same format as `predbat.soc_kw_best`, so it can be charted next to the planned SoC. + +This is a planning aid, not legal advice. Check the rules that apply to you.