diff --git a/apps/predbat/config.py b/apps/predbat/config.py index 7c7d39d4e..0dae6029c 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -2712,6 +2712,8 @@ "inverter_limit_override": {"type": "sensor_list", "sensor_type": "integer", "modify": False, "zero": False, "entries": "num_inverters"}, "battery_rate_max": {"type": "sensor_list", "sensor_type": "float", "modify": False, "zero": False, "entries": "num_inverters"}, "export_limit": {"type": "sensor_list", "sensor_type": "float", "entries": "num_inverters"}, + # Whole-site diagnostic threshold in W, not a per-inverter limit or a control setting. + "import_limit": {"type": "float", "zero": True}, "inverter_battery_rate_min": {"type": "integer", "zero": False, "entries": "num_inverters"}, "inverter_reserve_max": {"type": "integer", "zero": False, "entries": "num_inverters"}, "battery_charge_power_curve": {"type": "int_float_dict", "or_auto": True}, diff --git a/apps/predbat/const.py b/apps/predbat/const.py index 85a259080..f0e3f818f 100644 --- a/apps/predbat/const.py +++ b/apps/predbat/const.py @@ -101,6 +101,8 @@ INVERTER_QUICK_UPDATE_SECONDS = 60 # Minimum seconds between quick inverter data updates, also the balance re-apply interval INVERTER_LIMIT_DEFAULT_W = 7500.0 # Standing inverter AC limit when the user has not configured inverter_limit EXPORT_LIMIT_DEFAULT_W = 99999.0 # Standing export limit when the user has not configured export_limit - effectively unlimited +# Whole-site diagnostic threshold in W; 0 disables reporting. Not a control or optimiser constraint. +IMPORT_LIMIT_DEFAULT_W = 0.0 PREDBAT_MAX_CARS = 8 # Matches PK_MAX_CARS in prediction_kernel.cpp and the car_charging_rate/_1../_7 config items - the hard ceiling on num_cars # A car_charging_now sensor that reports a charging power (in W, after unit conversion) rather than # on/off counts as charging from this power: above a charger's standby draw, below the smallest charge diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 3220b69cc..db14a7978 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -19,6 +19,7 @@ """ from datetime import datetime, timedelta +from math import isfinite from utils import minutes_to_time, str2time, dp1, dp2, dp3, dp4, time_string_to_stamp, minute_data, get_now_from_cumulative, MinuteArray from const import ( MINUTE_WATT, @@ -36,6 +37,7 @@ CLOUD_WINDOW_MINUTES, CLOUD_ARRAY_MARGIN, PV_ARRAY_KWP_UNKNOWN, + IMPORT_LIMIT_DEFAULT_W, ) from predbat_metrics import metrics from futurerate import FutureRate @@ -82,6 +84,18 @@ class Fetch: and other sensor inputs. Converts all data to per-minute dictionaries. """ + def fetch_import_limit(self): + """Read one finite, non-negative site threshold in W; never coerce bad input to off. + + This first stage accepts a literal YAML number only. Read the raw argument because + get_arg with a numeric default coerces invalid values to that default, hiding errors. + The generic float schema does not reject lists, booleans or non-finite values. + """ + value = self.args.get("import_limit", IMPORT_LIMIT_DEFAULT_W) + if type(value) not in (int, float) or not isfinite(value) or value < 0: + raise ValueError("import_limit must be a finite non-negative scalar number in W (0 disables diagnostics)") + return float(value) + def get_cloud_factor(self, minutes_now, pv_data, pv_data10): """ Work out approximated cloud factor @@ -3746,6 +3760,7 @@ def fetch_config_options(self): self.inverter_loss = 1.0 - self.get_arg("inverter_loss") self.inverter_hybrid = self.get_arg("inverter_hybrid") self.pv_ac_limit = self.get_arg("pv_ac_limit", 0.0) / MINUTE_WATT + self.import_limit = self.fetch_import_limit() self.inverter_freeze_export_discharge_rate = max(self.get_arg("inverter_freeze_export_discharge_rate", 0.0), 0.0) / MINUTE_WATT self.log("Freeze Export discharge rate configured: {:.0f} W".format(self.inverter_freeze_export_discharge_rate * MINUTE_WATT)) self.base_load = self.get_arg("base_load", 100) / 1000.0 diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index 57332b6ef..24d50f534 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -5105,6 +5105,32 @@ def optimise_charge_windows_reset(self, reset_all): else: self.export_limits_best[window_n] = pack_export_limit(EXPORT_MODE_IDLE) + def publish_import_limit_diagnostics(self, pred): + """Publish the final best forecast's diagnostic, never an enforcement/feasibility claim.""" + attributes = { + "friendly_name": "Best plan grid import limit diagnostic", + "icon": "mdi:transmission-tower-import", + "scope": "whole_site", + "enforced": False, + "limit_w": self.import_limit, + "peak_w": None, + "excess_kwh": None, + "minutes_over": None, + "first_exceeded": None, + "step_minutes": None, + "checked_minutes": 0, + "start": None, + "end": None, + } + state = "disabled" if self.import_limit == 0 else "unavailable" + diagnostic = getattr(pred, "import_limit_diagnostics", None) + if self.import_limit > 0 and diagnostic and diagnostic["limit_w"] == self.import_limit and diagnostic["checked_minutes"] == pred.forecast_minutes and pred.forecast_minutes > 0: + attributes.update(diagnostic) + attributes["peak_w"] = round(diagnostic["peak_w"], 3) + attributes["excess_kwh"] = round(diagnostic["excess_kwh"], 6) + state = "exceeded" if diagnostic["minutes_over"] else "within_limit" + self.dashboard_item(self.prefix + ".best_import_limit", state=state, attributes=attributes) + def run_prediction(self, charge_limit, charge_window, export_window, export_limits, pv_scenario, end_record, save=None, step=PREDICT_STEP): """ Run a prediction scenario given a charge limit, options to save the results or not to HA entity @@ -5400,6 +5426,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi if save and save == "best": self.log("Saving plan best values to HA entities") + self.publish_import_limit_diagnostics(pred) self.dashboard_item( self.prefix + ".best_battery_hours_left", state=dp2(hours_left), diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 6981babd2..d8da3a7b1 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -503,6 +503,8 @@ def reset(self): self.current_charge_limit_kwh = 0.0 self.inverter_limit = 0.0 self.export_limit = 0.0 + # Whole-site diagnostic threshold, stored in W (not kWh/minute). 0 disables reporting. + self.import_limit = 0.0 self.charge_limit = [] self.charge_limit_best = [] self.charge_window = [] diff --git a/apps/predbat/prediction.py b/apps/predbat/prediction.py index d5310117b..0a11d9a64 100644 --- a/apps/predbat/prediction.py +++ b/apps/predbat/prediction.py @@ -18,7 +18,7 @@ """ from datetime import timedelta -from const import PREDICT_STEP, PV_SCENARIO_PV10, PV_SCENARIO_PV90, RUN_EVERY, TIME_FORMAT, EXPORT_LIMIT_FREEZE, EXPORT_LIMIT_IDLE, EXPORT_MODE_TARGET, EXPORT_MODE_FREEZE, EXPORT_MODE_IDLE +from const import PREDICT_STEP, PV_SCENARIO_PV10, PV_SCENARIO_PV90, RUN_EVERY, TIME_FORMAT, EXPORT_LIMIT_FREEZE, EXPORT_LIMIT_IDLE, EXPORT_MODE_TARGET, EXPORT_MODE_FREEZE, EXPORT_MODE_IDLE, MINUTE_WATT from utils import ( remove_intersecting_windows, @@ -172,6 +172,8 @@ def __init__( self.inverter_hybrid = base.inverter_hybrid self.inverter_limit = base.inverter_limit self.export_limit = base.export_limit + # Diagnostic only, in W. It does not modify dispatch, costs or candidate selection. + self.import_limit = getattr(base, "import_limit", 0.0) self.pv_ac_limit = base.pv_ac_limit self.battery_rate_min = base.battery_rate_min self.battery_rate_max_charge = base.battery_rate_max_charge @@ -520,6 +522,10 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi if save and self.pending_batch: self.flush_batch() + # Only saved Python runs report this diagnostic. Clear it even on a cache/kernel return + # so a later trial cannot appear to have inherited a successful check of a different plan. + self.import_limit_diagnostics = None + # The cache key is only wanted when the cache is actually in play - a saving run always # simulates - so it is not computed otherwise. Building it as one tuple hash keeps the # per-window hashing in C rather than looping in Python, which matters because this runs on @@ -578,6 +584,19 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi predict_pv_power = {} predict_state = {} predict_grid_power = {} + import_diagnostics = None + if save and self.import_limit > 0: + import_diagnostics = { + "limit_w": self.import_limit, + "peak_w": 0.0, + "excess_kwh": 0.0, + "minutes_over": 0, + "first_exceeded": None, + "step_minutes": step, + "checked_minutes": 0, + "start": (self.midnight_utc + timedelta(minutes=self.minutes_now)).strftime(TIME_FORMAT), + "end": (self.midnight_utc + timedelta(minutes=self.minutes_now + self.forecast_minutes)).strftime(TIME_FORMAT), + } predict_load_power = {} predict_iboost = {} predict_carbon_g = {} @@ -1361,6 +1380,21 @@ 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 + # Observe the raw whole-site AC balance, before presentation rounding. Do not clamp + # grid demand, invent battery discharge, or change a candidate's cost. This covers the + # full displayed forecast, including steps after the optimiser's end_record boundary. + if import_diagnostics is not None: + # A modelled car outside the house CT is absent from diff; it still shares the + # grid connection. Net it against any surplus, without counting metered cars twice. + import_w = max(diff + car_load_energy_bypass, 0.0) * MINUTE_WATT / step + import_diagnostics["peak_w"] = max(import_diagnostics["peak_w"], import_w) + import_diagnostics["checked_minutes"] += step + if import_w - self.import_limit > 1e-6: + import_diagnostics["excess_kwh"] += (import_w - self.import_limit) * step / MINUTE_WATT + import_diagnostics["minutes_over"] += step + if import_diagnostics["first_exceeded"] is None: + import_diagnostics["first_exceeded"] = stamp + # 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 @@ -1474,6 +1508,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi minute += step hours_left = minute_left / 60.0 + self.import_limit_diagnostics = import_diagnostics if self.debug_enable or save: self.hours_left = hours_left self.final_car_soc = final_car_soc diff --git a/apps/predbat/prediction_kernel.py b/apps/predbat/prediction_kernel.py index dfb9a80be..9fdcda19d 100644 --- a/apps/predbat/prediction_kernel.py +++ b/apps/predbat/prediction_kernel.py @@ -897,6 +897,7 @@ def reset_kernel_run_state(pred): pred.predict_carbon_best = {} pred.predict_clipped_best = {} pred.predict_car_hold_best = {} + pred.import_limit_diagnostics = None pred.iboost_running = False pred.iboost_running_solar = False pred.iboost_running_full = False diff --git a/apps/predbat/tests/test_import_limit.py b/apps/predbat/tests/test_import_limit.py new file mode 100644 index 000000000..515817b24 --- /dev/null +++ b/apps/predbat/tests/test_import_limit.py @@ -0,0 +1,366 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +"""Offline checks of import diagnostics, not of hard-limit enforcement or EV planning.""" + +from copy import deepcopy +from datetime import timedelta +import os +from unittest.mock import patch + +from const import IMPORT_LIMIT_DEFAULT_W, PREDICT_STEP, PREDBAT_MAX_CARS, TIME_FORMAT +from prediction import Prediction +from prediction_kernel import create_kernel_context, kernel_supported, reset_kernel_run_state, run_prediction_kernel +from tests.test_infra import FIXTURE_MINUTES_NOW, reset_inverter, reset_rates + + +def _close(actual, expected, tolerance=1e-6): + """Assert a numerical result with an explicit, unit-appropriate tolerance.""" + assert abs(actual - expected) <= tolerance, (actual, expected, tolerance) + + +def _fixture(base, limit=6000, load=1.2, pv=0, ev=0, reported=True, minutes=60): + """Build a synthetic forecast; reserve is kWh, efficiencies are fractions, rates kW.""" + base.car_charging_slots = [[] for _ in range(PREDBAT_MAX_CARS)] + reset_inverter(base) + base.forecast_minutes = minutes + base.end_record = minutes + base.minutes_now = FIXTURE_MINUTES_NOW + base.now_utc = base.midnight_utc + timedelta(minutes=base.minutes_now) + reset_rates(base, 10, 0) + base.prediction_kernel_enable = False + base.debug_enable = False + base.import_limit = float(limit) + base.soc_max = 32.1536 + base.reserve = base.soc_max * 0.10 + base.soc_kw = base.reserve + base.inverter_limit = 16 / 60 + base.battery_rate_max_charge = 3 / 60 + base.battery_rate_max_charge_dc = 3 / 60 + base.battery_rate_max_discharge = 8.192 / 60 + base.battery_rate_max_export = 8.192 / 60 + base.inverter_loss = 0.95 + base.battery_loss = base.battery_loss_discharge = 0.96 + base.battery_temperature = 20 + base.battery_temperature_prediction = {} + base.battery_temperature_charge_curve = {} + base.battery_temperature_discharge_curve = {} + base.best_soc_max = -1 + base.best_soc_min = 0 + base.all_active_keep = {} + base.all_active_keep_max = {} + base.io_adjusted = {} + base.set_charge_low_power = False + base.set_export_low_power = False + base.metric_standing_charge = 0 + base.export_today_now = base.import_today_now = base.pv_today_now = base.load_minutes_now = 0 + base.cost_today_sofar = base.carbon_today_sofar = 0 + base.iboost_today = 0 + base.iboost_plan = [] + base.num_cars = 1 + base.car_charging_limit_model = None + base.car_charging_limit = [25.0] * PREDBAT_MAX_CARS + base.car_charging_battery_size = [25.0] * PREDBAT_MAX_CARS + base.car_charging_loss = 0.92 + base.car_charging_from_battery = True + base.car_energy_reported_load = reported + if ev: + base.car_charging_slots[0] = [{"start": base.minutes_now, "end": base.minutes_now + minutes, "kwh": ev * minutes / 60}] + pv_steps = {m: pv * PREDICT_STEP / 60 for m in range(0, minutes, PREDICT_STEP)} + load_steps = {m: load * PREDICT_STEP / 60 for m in range(0, minutes, PREDICT_STEP)} + return Prediction(base, pv_steps, pv_steps, load_steps, load_steps) + + +def _run(pred, save="best", step=PREDICT_STEP, end_record=None, charge=False, cache=False): + """Run the real engine, optionally with a native whole-horizon charge window.""" + windows = [{"start": pred.minutes_now, "end": pred.minutes_now + pred.forecast_minutes, "average": 10}] if charge else [] + return pred.run_prediction([pred.soc_max] if charge else [], windows, [], [], 0, pred.forecast_minutes if end_record is None else end_record, save=save, step=step, cache=cache) + + +def _snapshot(pred, **kwargs): + """Capture the entire return tuple and existing saved curves for equivalence checks.""" + result = _run(pred, **kwargs) + curves = {name: getattr(pred, name) for name in ("predict_grid_power", "predict_battery_power", "predict_load_power", "predict_pv_power", "predict_soc", "predict_battery_cycle")} + return deepcopy((result, curves)) + + +def test_config(base): + """Exercise actual configuration fetch, invalid input, reloads and multi-inverter scope.""" + saved = base.args.copy() + try: + for count in (1, 3): + base.args["num_inverters"] = count + for limit in (0, 6000, 4000, 9000.5, 0): + base.args["import_limit"] = limit + base.validate_config() + assert "import_limit" not in base.arg_errors + base.fetch_config_options() + assert base.import_limit == limit + base.args.pop("import_limit") + base.fetch_config_options() + assert base.import_limit == IMPORT_LIMIT_DEFAULT_W + for bad in (True, False, None, "6000", [6000], {}, -1, float("nan"), float("inf"), -float("inf")): + base.args["import_limit"] = bad + try: + base.fetch_config_options() + except ValueError: + pass + else: + raise AssertionError("Invalid import limit accepted: {!r}".format(bad)) + finally: + base.args.clear() + base.args.update(saved) + base.fetch_config_options() + fresh = type(base)() + fresh.import_limit = 9000 + fresh.reset() + assert fresh.import_limit == 0 + + +def test_fixed_ev_at_reserve(base): + """A 6.77 kW EV plus 1.2 kW house cannot be supplied by a battery already at 10%.""" + for reported in (True, False): + pred = _fixture(base, limit=0, ev=6.77, reported=reported) + baseline = _snapshot(pred) + for limit in (0, 4000, 6000, 9000): + pred.import_limit = limit + assert _snapshot(pred) == baseline + _close(pred.final_soc, 3.21536, 0.00005) + _close(pred.final_battery_cycle, 0) + _close(pred.final_import_kwh, 7.97 if reported else 1.2) + _close(pred.final_car_soc[0], 6.77 * 0.92, 0.0005) + assert all(power == 0 for power in pred.predict_battery_power.values()) + diagnostic = pred.import_limit_diagnostics + if limit == 0: + assert diagnostic is None + else: + _close(diagnostic["peak_w"], 7970) + _close(diagnostic["excess_kwh"], max(7970 - limit, 0) / 1000) + assert diagnostic["minutes_over"] == (60 if limit < 7970 else 0) + # Both CT configurations see the same site demand; the existing CT graph is not rewritten. + assert set(pred.predict_grid_power.values()) == ({7.97} if reported else {1.2}) + + +def test_available_battery_balance(base): + """Available battery support respects energy, losses, inverter power and native dispatch.""" + pred = _fixture(base, limit=0, load=10) + pred.soc_kw = 8 + pred.inverter_limit = 2 / 60 + baseline = _snapshot(pred) + pred.import_limit = 6000 + assert _snapshot(pred) == baseline + assert pred.reserve <= pred.final_soc < pred.soc_kw + _close((pred.soc_kw - pred.final_soc) * pred.battery_loss_discharge, pred.final_battery_cycle, 0.0002) + _close(pred.final_import_kwh + pred.final_battery_cycle * pred.inverter_loss, 10, 0.0002) + assert max(pred.predict_battery_power.values()) <= 8.192 + assert max(pred.predict_battery_power.values()) * pred.inverter_loss <= 2.001 + assert pred.import_limit_diagnostics["minutes_over"] == 60 + + +def test_bypass_pv_netting(base): + """PV surplus meets outside-CT EV demand before computing net site import.""" + for reported in (True, False): + pred = _fixture(base, limit=1000, pv=6, ev=6.77, reported=reported) + pred.battery_rate_max_charge = pred.battery_rate_max_charge_dc = pred.battery_rate_max_discharge = 0 + _run(pred) + _close(pred.import_limit_diagnostics["peak_w"], 1970) + _close(pred.import_limit_diagnostics["excess_kwh"], 0.97) + _close(pred.final_battery_cycle, 0) + + +def test_charging_unchanged(base): + """Grid charge above the threshold is reported, while PV charging and energy stay intact.""" + for hybrid in (False, True): + for pv, load in ((0, 5), (6, 1)): + pred = _fixture(base, limit=0, load=load, pv=pv) + pred.inverter_hybrid = hybrid + baseline = _snapshot(pred, charge=True) + pred.import_limit = 4000 + assert _snapshot(pred, charge=True) == baseline + _close(pred.final_soc - pred.soc_kw, 3 * 0.96, 0.0001) + diagnostic = pred.import_limit_diagnostics + if pv: + assert diagnostic["peak_w"] == 0 + assert diagnostic["excess_kwh"] == 0 + else: + _close(diagnostic["peak_w"], (5 + 3 / 0.95) * 1000) + _close(diagnostic["excess_kwh"], 5 + 3 / 0.95 - 4) + assert diagnostic["minutes_over"] == 60 + + +def test_no_ev_and_empty_battery(base): + """No EV slots means no EV demand; empty or absent batteries cannot create energy.""" + pred = _fixture(base) + _run(pred) + _close(pred.import_limit_diagnostics["peak_w"], 1200) + assert pred.import_limit_diagnostics["excess_kwh"] == 0 + for capacity in (32.1536, 0): + pred = _fixture(base, load=7) + pred.soc_max = capacity + pred.soc_kw = pred.reserve = 0 + if capacity == 0: + pred.battery_rate_max_charge = pred.battery_rate_max_charge_dc = pred.battery_rate_max_discharge = pred.battery_rate_max_export = 0 + _run(pred) + _close(pred.final_import_kwh, 7) + _close(pred.final_battery_cycle, 0) + _close(pred.import_limit_diagnostics["peak_w"], 7000) + _close(pred.import_limit_diagnostics["excess_kwh"], 1) + + +def test_step_and_record_scope(base): + """Step conversion is correct and diagnostics cover the forecast beyond end_record.""" + for step in (5, 10, 30): + pred = _fixture(base, load=7) + _run(pred, step=step, end_record=5) + diagnostic = pred.import_limit_diagnostics + assert diagnostic["step_minutes"] == step + assert diagnostic["checked_minutes"] == diagnostic["minutes_over"] == 60 + _close(diagnostic["peak_w"], 7000) + _close(diagnostic["excess_kwh"], 1) + assert diagnostic["start"] == (pred.midnight_utc + timedelta(minutes=pred.minutes_now)).strftime(TIME_FORMAT) + assert diagnostic["end"] == (pred.midnight_utc + timedelta(minutes=pred.minutes_now + 60)).strftime(TIME_FORMAT) + + +def test_raw_boundary_and_first_excess(base): + """Compare before graph rounding, ignore numerical noise and stamp the first violating step.""" + for load, over in ((6, False), (6.0000000005, False), (6.0000001, True), (6.0001, True)): + pred = _fixture(base, load=load, minutes=5) + _run(pred) + diagnostic = pred.import_limit_diagnostics + assert diagnostic["minutes_over"] == (5 if over else 0) + _close(diagnostic["excess_kwh"], (load - 6) * 5 / 60 if over else 0, 1e-12) + assert set(pred.predict_grid_power.values()) == {6.0} + pred = _fixture(base, load=5) + for minute in range(30, 60, PREDICT_STEP): + pred.load_minutes_step[minute] = 7 * PREDICT_STEP / 60 + _run(pred) + diagnostic = pred.import_limit_diagnostics + assert diagnostic["first_exceeded"] == (pred.midnight_utc + timedelta(minutes=pred.minutes_now + 30)).strftime(TIME_FORMAT) + assert diagnostic["minutes_over"] == 30 + _close(diagnostic["excess_kwh"], 0.5) + + +def test_prices_do_not_change_diagnostics(base): + """Negative, zero and positive prices retain costs and cannot change constraint diagnostics.""" + diagnostics = [] + for rate in (-5, 0, 25): + pred = _fixture(base, limit=0, load=7) + pred.rate_import = {minute: rate for minute in pred.rate_import} + baseline = _snapshot(pred) + pred.import_limit = 6000 + assert _snapshot(pred) == baseline + _close(pred.final_metric, 7 * rate) + diagnostics.append(pred.import_limit_diagnostics) + assert diagnostics[0] == diagnostics[1] == diagnostics[2] + + +def test_save_cache_and_real_kernel_reset(base): + """Exercise a real cache hit and native kernel dispatch, not a patch on an unused import.""" + pred = _fixture(base, load=7) + for save in ("best", "base", "best10"): + _run(pred, save=save) + assert pred.import_limit_diagnostics["minutes_over"] == 60 + trial = _run(pred, save=None, cache=True) + assert pred.import_limit_diagnostics is None + assert len(pred.prediction_cache) == 1 + _run(pred) + # A cache hit must return before querying kernel support; this fails if no cache is used. + with patch("prediction.kernel_supported", side_effect=AssertionError("Expected a cache hit")): + cached = _run(pred, save=None, cache=True) + assert cached[:11] == trial[:11] + assert pred.import_limit_diagnostics is None + pred.kernel_handle = create_kernel_context(pred) + if pred.kernel_handle: + for limit in (0, 6000, 99999): + pred.import_limit = limit + assert kernel_supported(pred, save=None, step=5) + assert not kernel_supported(pred, save="best", step=5) + _run(pred) + with patch("prediction.run_prediction_kernel", wraps=run_prediction_kernel) as native: + result = _run(pred, save=None) + assert native.call_count == 1 + assert result[:11] == trial[:11] + assert pred.import_limit_diagnostics is None + else: + assert not os.environ.get("PREDBAT_KERNEL_REQUIRED"), "Native prediction kernel required but unavailable" + print("SKIP: native import diagnostic reset check (kernel unavailable); Python/cache checks ran") + pred.import_limit_diagnostics = {"stale": True} + reset_kernel_run_state(pred) + assert pred.import_limit_diagnostics is None + pred.import_limit = 0 + _run(pred) + assert pred.import_limit_diagnostics is None + + +def test_publication_states(base): + """Missing, partial, mismatched, empty or disabled diagnostics never appear successful.""" + pred = _fixture(base, load=7) + _run(pred) + valid = deepcopy(pred.import_limit_diagnostics) + key = base.prefix + ".best_import_limit" + for diagnostic in (valid, None, dict(valid, checked_minutes=30), dict(valid, limit_w=5000)): + pred.import_limit_diagnostics = diagnostic + base.publish_import_limit_diagnostics(pred) + item = base.dashboard_values[key] + assert item["state"] == ("exceeded" if diagnostic == valid else "unavailable") + assert item["attributes"]["enforced"] is False + assert item["attributes"]["scope"] == "whole_site" + if diagnostic != valid: + for field in ("peak_w", "excess_kwh", "minutes_over", "first_exceeded", "start", "end"): + assert item["attributes"][field] is None + pred.forecast_minutes = 0 + _run(pred) + assert pred.import_limit_diagnostics["checked_minutes"] == 0 + base.publish_import_limit_diagnostics(pred) + assert base.dashboard_values[key]["state"] == "unavailable" + base.import_limit = 0 + base.publish_import_limit_diagnostics(pred) + assert base.dashboard_values[key]["state"] == "disabled" + assert base.dashboard_values[key]["attributes"]["excess_kwh"] is None + + +def test_actual_plan_publication(base): + """Call the real Plan wrapper: only best saves publish, and the mocked HA receives the state.""" + key = base.prefix + ".best_import_limit" + for load, expected in ((7, "exceeded"), (4, "within_limit")): + pred = _fixture(base, load=load, minutes=1440) + base.prediction = pred + base.dashboard_values.pop(key, None) + for save in ("base", "base10", "best10"): + base.run_prediction([], [], [], [], 0, end_record=1440, save=save) + assert key not in base.dashboard_values + base.run_prediction([], [], [], [], 0, end_record=1440, save="best") + item = base.dashboard_values[key] + assert item["state"] == expected + assert item["attributes"]["checked_minutes"] == 1440 + assert base.ha_interface.get_state(key) == expected + _close(item["attributes"]["excess_kwh"], max(load - 6, 0) * 24) + base.import_limit = pred.import_limit = 0 + base.run_prediction([], [], [], [], 0, end_record=1440, save="best") + assert base.dashboard_values[key]["state"] == "disabled" + assert base.ha_interface.get_state(key) == "disabled" + + +def run_import_limit_tests(my_predbat): + """Run the focused diagnostic regressions on the runner's isolated PredBat instance.""" + tests = ( + test_config, + test_fixed_ev_at_reserve, + test_available_battery_balance, + test_bypass_pv_netting, + test_charging_unchanged, + test_no_ev_and_empty_battery, + test_step_and_record_scope, + test_raw_boundary_and_first_excess, + test_prices_do_not_change_diagnostics, + test_save_cache_and_real_kernel_reset, + test_publication_states, + test_actual_plan_publication, + ) + for test in tests: + test(my_predbat) + print("PASS:", test.__name__) + print("Import diagnostics: {} focused checks passed".format(len(tests))) + return False diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index 7dbeacf8c..a565bd8f6 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -75,6 +75,7 @@ from tests.test_plan_tiebreak import run_plan_tiebreak_tests from tests.test_plan_preclip import run_plan_preclip_tests from tests.test_export_commitment import run_export_commitment_tests +from tests.test_import_limit import run_import_limit_tests from tests.test_optimise_export_copy import run_optimise_export_copy_tests from tests.test_energydataservice import run_energydataservice_tests from tests.test_iboost import run_iboost_smart_tests @@ -413,6 +414,11 @@ def run_window_cache_tests_isolated(my_predbat): return run_window_cache_tests(create_predbat()) +def run_import_limit_tests_isolated(my_predbat): + """Keep diagnostic fixtures independent of the shared suite's configuration and clock.""" + return run_import_limit_tests(create_predbat()) + + def create_predbat(): my_predbat = PredBat() my_predbat.states = {} @@ -768,6 +774,7 @@ def main(): ("plan_tiebreak", run_plan_tiebreak_tests, "Plan fragmentation near-tie tie-break tests", False), ("plan_preclip", run_plan_preclip_tests, "Plan selection scores the pre-clip plan", True), ("export_commitment", run_export_commitment_tests, "Forced-export commitment / anti-flapping tests", False), + ("import_limit", run_import_limit_tests_isolated, "Whole-site grid import diagnostics (no enforcement)", False), ("optimise_solar", run_optimise_solar_tests, "Optimise export more solar tests", False), ("optimise_windows_kernel", run_optimise_all_windows_kernel_tests, "Optimise all windows tests with the C++ kernel", False), ("optimise_swap_charge", run_optimise_swap_charge_tests, "Optimise pairwise charge-window swap tests", False), diff --git a/docs/grid-import-limit.md b/docs/grid-import-limit.md new file mode 100644 index 000000000..715291ddb --- /dev/null +++ b/docs/grid-import-limit.md @@ -0,0 +1,108 @@ +# Grid import diagnostics (draft) + +> **Diagnostics only — not a hard limit, safety device or complete fix.** +> This draft reports when the existing best forecast exceeds a configurable site +> threshold. It does not change optimisation, EV scheduling or inverter commands. +> Related: [#2449](https://github.com/springfall2008/batpred/issues/2449) and +> [#1199](https://github.com/springfall2008/batpred/issues/1199). Neither is closed by this stage. + +## Configuration + +An experimental `apps.yaml` setting, in **watts**, applies once to the whole site: + +```yaml +pred_bat: + import_limit: 6000 +``` + +The default is `0` (diagnostics disabled). This stage accepts a finite, non-negative +literal number, not a list or entity. Invalid values are rejected during configuration +fetch rather than silently treated as disabled. Reloading a changed or removed setting +replaces the previous value; inverter count/order does not aggregate or retain it. + +This is separate from `inverter_limit`, battery charge/discharge power, hardware limits +and `export_limit`. It does not modify any of them. The name and eventual enforcement +contract remain open for upstream review. + +## Reported diagnostic + +`predbat.best_import_limit` (using your configured prefix) has these states: + +| State | Meaning | +| --- | --- | +| `disabled` | The threshold is zero or absent. Not a successful check. | +| `unavailable` | No complete, matching saved forecast was checked. | +| `within_limit` | No simulated step average exceeds the threshold in this forecast. | +| `exceeded` | At least one simulated step average exceeds the threshold. | + +Attributes include `limit_w`, `peak_w` (peak import, not peak excess), `excess_kwh`, +`minutes_over`, `first_exceeded`, `step_minutes`, `checked_minutes`, `start` and `end`. +`scope` is `whole_site` and `enforced` is always `false`. Disabled/unavailable reports +clear the previous diagnostic values. Only the final saved **best** plan publishes +this entity; baseline and alternative solar scenarios cannot overwrite it. + +The calculation observes the raw AC energy balance **without altering it**: + +```text +step import W = max(net site import kWh, 0) * 60000 / step minutes +excess kWh = sum(max(step import W - limit W, 0) * step minutes / 60000) +``` + +Comparisons precede display rounding, with a numerical tolerance of 0.000001 W. +The diagnostic covers the full forward forecast, not elapsed daily energy or only +the optimiser's shorter `end_record` interval. Modelled EV load outside the house +CT is included once, assuming it shares the grid connection; metered EV load is +already in the balance. The existing CT-based grid-power graph remains unchanged. + +## Important limitations + +- **No enforcement:** grid charging is not throttled, battery preparation is not + optimised for the threshold, and EV slots are not moved or rejected. An over-limit + plan can still be selected and executed. Do not rely on this for electrical protection. +- **No proof of feasibility:** `exceeded` describes this forecast, not whether every + alternative is impossible. `within_limit` does not guarantee real-time safety, + forecast accuracy, EV connection, charging deadlines or reserve-latch compatibility. +- **Step averages:** normally five minutes, not instantaneous peaks, per-phase/current + limits or rolling demand-tariff averages. Loads absent from the model cannot be checked. +- **No cost penalty:** even a large finite penalty cannot guarantee that every feasible + candidate outranks every violating one, especially across varying/negative prices. +- **No kernel slowdown:** optimisation trials continue to use the native C++ kernel. + Saved runs already use Python; only those produce this diagnostic. No optimisation + return tuple, cache key or kernel ABI changes are required for this stage. +- **Offline proposal:** this draft has not been installed on or validated against live + hardware. Existing inverter mappings (including local `SK` setups) are untouched. + +## Proposed follow-up: a real joint constraint + +1. Carry explicit constraint results through candidate search, caches, batching and + Python/C++ paths. Rank feasible candidates before monetary cost; when none are + found, report the unmet constraint without claiming global impossibility. +2. Include household demand, heating, PV, battery grid charging and actual fixed-rate + EV demand together. Preserve all energy, battery losses, reserve, temperature/rate + curves and shared/per-inverter AC limits. Never debit an empty battery while clamping + its SoC at the reserve, or crop the grid graph to hide unmet demand. +3. Limit only the grid-funded portion of battery charging, preserving available PV + charging. Model only dispatch/rate changes the real inverter can execute, including + ECO battery-first behaviour, rather than assuming ideal peak-shaving hardware. +4. Integrate native EV slot selection and battery preparation: household loads take + priority; a non-modulating charger remains a fixed on/off load. Use trustworthy + connection evidence, charging losses, departure targets and configured pause/restart + hysteresis. Do not invent a connection signal or a universal fixed SoC reserve. +5. Keep a single executor and real hardware protection. Validate the executed rates, + deadlines, failure reporting and battery/EV interaction before any live rollout. +6. Treat rolling capacity-tariff averaging as a separate extension, not as this + pointwise diagnostic or as an instantaneous protective limit. + +## Offline verification + +Tests use synthetic forecasts and mocked HA output, not private configuration or +hardware. Key checks cover disabled/enabled simulation equivalence, a battery at +reserve with fixed EV demand, raw excess accounting, PV/grid charging, configuration +reloads, step units, diagnostic reset and publication. They do not establish joint +optimisation, automatic connection-only starting or a charging deadline guarantee. + +```bash +cd coverage +./run_all --test import_limit > import-limit-tests.log 2>&1 +./run_all --quick > quick-tests.log 2>&1 +``` diff --git a/mkdocs.yml b/mkdocs.yml index 1ab2829c2..ba040d24a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -15,6 +15,7 @@ nav: - discovery-catalogue.md - load-ml.md - car-charging.md + - grid-import-limit.md - configuration-guide.md - customisation.md - video-guides.md