Skip to content

Commit 1a4368f

Browse files
committed
feat: add configurable whole-site import diagnostics
1 parent d7d8c60 commit 1a4368f

11 files changed

Lines changed: 567 additions & 1 deletion

File tree

‎apps/predbat/config.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2705,6 +2705,8 @@
27052705
"inverter_limit_override": {"type": "sensor_list", "sensor_type": "integer", "modify": False, "zero": False, "entries": "num_inverters"},
27062706
"battery_rate_max": {"type": "sensor_list", "sensor_type": "float", "modify": False, "zero": False, "entries": "num_inverters"},
27072707
"export_limit": {"type": "sensor_list", "sensor_type": "float", "entries": "num_inverters"},
2708+
# Whole-site diagnostic threshold in W, not a per-inverter limit or a control setting.
2709+
"import_limit": {"type": "float", "zero": True},
27082710
"inverter_battery_rate_min": {"type": "integer", "zero": False, "entries": "num_inverters"},
27092711
"inverter_reserve_max": {"type": "integer", "zero": False, "entries": "num_inverters"},
27102712
"battery_charge_power_curve": {"type": "int_float_dict", "or_auto": True},

‎apps/predbat/const.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@
9898
INVERTER_QUICK_UPDATE_SECONDS = 60 # Minimum seconds between quick inverter data updates, also the balance re-apply interval
9999
INVERTER_LIMIT_DEFAULT_W = 7500.0 # Standing inverter AC limit when the user has not configured inverter_limit
100100
EXPORT_LIMIT_DEFAULT_W = 99999.0 # Standing export limit when the user has not configured export_limit - effectively unlimited
101+
# Whole-site diagnostic threshold in W; 0 disables reporting. Not a control or optimiser constraint.
102+
IMPORT_LIMIT_DEFAULT_W = 0.0
101103
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
102104
# A car_charging_now sensor that reports a charging power (in W, after unit conversion) rather than
103105
# on/off counts as charging from this power: above a charger's standby draw, below the smallest charge

‎apps/predbat/fetch.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"""
2020

2121
from datetime import datetime, timedelta
22+
from math import isfinite
2223
from utils import minutes_to_time, str2time, dp1, dp2, dp3, dp4, time_string_to_stamp, minute_data, get_now_from_cumulative, MinuteArray
2324
from const import (
2425
MINUTE_WATT,
@@ -36,6 +37,7 @@
3637
CLOUD_WINDOW_MINUTES,
3738
CLOUD_ARRAY_MARGIN,
3839
PV_ARRAY_KWP_UNKNOWN,
40+
IMPORT_LIMIT_DEFAULT_W,
3941
)
4042
from predbat_metrics import metrics
4143
from futurerate import FutureRate
@@ -52,6 +54,18 @@ class Fetch:
5254
and other sensor inputs. Converts all data to per-minute dictionaries.
5355
"""
5456

57+
def fetch_import_limit(self):
58+
"""Read one finite, non-negative site threshold in W; never coerce bad input to off.
59+
60+
This first stage accepts a literal YAML number only. Read the raw argument because
61+
get_arg with a numeric default coerces invalid values to that default, hiding errors.
62+
The generic float schema does not reject lists, booleans or non-finite values.
63+
"""
64+
value = self.args.get("import_limit", IMPORT_LIMIT_DEFAULT_W)
65+
if type(value) not in (int, float) or not isfinite(value) or value < 0:
66+
raise ValueError("import_limit must be a finite non-negative scalar number in W (0 disables diagnostics)")
67+
return float(value)
68+
5569
def get_cloud_factor(self, minutes_now, pv_data, pv_data10):
5670
"""
5771
Work out approximated cloud factor
@@ -3415,6 +3429,7 @@ def fetch_config_options(self):
34153429
self.inverter_loss = 1.0 - self.get_arg("inverter_loss")
34163430
self.inverter_hybrid = self.get_arg("inverter_hybrid")
34173431
self.pv_ac_limit = self.get_arg("pv_ac_limit", 0.0) / MINUTE_WATT
3432+
self.import_limit = self.fetch_import_limit()
34183433
self.inverter_freeze_export_discharge_rate = max(self.get_arg("inverter_freeze_export_discharge_rate", 0.0), 0.0) / MINUTE_WATT
34193434
self.log("Freeze Export discharge rate configured: {:.0f} W".format(self.inverter_freeze_export_discharge_rate * MINUTE_WATT))
34203435
self.base_load = self.get_arg("base_load", 100) / 1000.0

‎apps/predbat/plan.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5100,6 +5100,32 @@ def optimise_charge_windows_reset(self, reset_all):
51005100
else:
51015101
self.export_limits_best[window_n] = pack_export_limit(EXPORT_MODE_IDLE)
51025102

5103+
def publish_import_limit_diagnostics(self, pred):
5104+
"""Publish the final best forecast's diagnostic, never an enforcement/feasibility claim."""
5105+
attributes = {
5106+
"friendly_name": "Best plan grid import limit diagnostic",
5107+
"icon": "mdi:transmission-tower-import",
5108+
"scope": "whole_site",
5109+
"enforced": False,
5110+
"limit_w": self.import_limit,
5111+
"peak_w": None,
5112+
"excess_kwh": None,
5113+
"minutes_over": None,
5114+
"first_exceeded": None,
5115+
"step_minutes": None,
5116+
"checked_minutes": 0,
5117+
"start": None,
5118+
"end": None,
5119+
}
5120+
state = "disabled" if self.import_limit == 0 else "unavailable"
5121+
diagnostic = getattr(pred, "import_limit_diagnostics", None)
5122+
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:
5123+
attributes.update(diagnostic)
5124+
attributes["peak_w"] = round(diagnostic["peak_w"], 3)
5125+
attributes["excess_kwh"] = round(diagnostic["excess_kwh"], 6)
5126+
state = "exceeded" if diagnostic["minutes_over"] else "within_limit"
5127+
self.dashboard_item(self.prefix + ".best_import_limit", state=state, attributes=attributes)
5128+
51035129
def run_prediction(self, charge_limit, charge_window, export_window, export_limits, pv_scenario, end_record, save=None, step=PREDICT_STEP):
51045130
"""
51055131
Run a prediction scenario given a charge limit, options to save the results or not to HA entity
@@ -5395,6 +5421,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
53955421

53965422
if save and save == "best":
53975423
self.log("Saving plan best values to HA entities")
5424+
self.publish_import_limit_diagnostics(pred)
53985425
self.dashboard_item(
53995426
self.prefix + ".best_battery_hours_left",
54005427
state=dp2(hours_left),

‎apps/predbat/predbat.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,8 @@ def reset(self):
499499
self.current_charge_limit_kwh = 0.0
500500
self.inverter_limit = 0.0
501501
self.export_limit = 0.0
502+
# Whole-site diagnostic threshold, stored in W (not kWh/minute). 0 disables reporting.
503+
self.import_limit = 0.0
502504
self.charge_limit = []
503505
self.charge_limit_best = []
504506
self.charge_window = []

‎apps/predbat/prediction.py‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"""
1919

2020
from datetime import timedelta
21-
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
21+
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
2222

2323
from utils import (
2424
remove_intersecting_windows,
@@ -172,6 +172,8 @@ def __init__(
172172
self.inverter_hybrid = base.inverter_hybrid
173173
self.inverter_limit = base.inverter_limit
174174
self.export_limit = base.export_limit
175+
# Diagnostic only, in W. It does not modify dispatch, costs or candidate selection.
176+
self.import_limit = getattr(base, "import_limit", 0.0)
175177
self.pv_ac_limit = base.pv_ac_limit
176178
self.battery_rate_min = base.battery_rate_min
177179
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
520522
if save and self.pending_batch:
521523
self.flush_batch()
522524

525+
# Only saved Python runs report this diagnostic. Clear it even on a cache/kernel return
526+
# so a later trial cannot appear to have inherited a successful check of a different plan.
527+
self.import_limit_diagnostics = None
528+
523529
# The cache key is only wanted when the cache is actually in play - a saving run always
524530
# simulates - so it is not computed otherwise. Building it as one tuple hash keeps the
525531
# 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
578584
predict_pv_power = {}
579585
predict_state = {}
580586
predict_grid_power = {}
587+
import_diagnostics = None
588+
if save and self.import_limit > 0:
589+
import_diagnostics = {
590+
"limit_w": self.import_limit,
591+
"peak_w": 0.0,
592+
"excess_kwh": 0.0,
593+
"minutes_over": 0,
594+
"first_exceeded": None,
595+
"step_minutes": step,
596+
"checked_minutes": 0,
597+
"start": (self.midnight_utc + timedelta(minutes=self.minutes_now)).strftime(TIME_FORMAT),
598+
"end": (self.midnight_utc + timedelta(minutes=self.minutes_now + self.forecast_minutes)).strftime(TIME_FORMAT),
599+
}
581600
predict_load_power = {}
582601
predict_iboost = {}
583602
predict_carbon_g = {}
@@ -1361,6 +1380,21 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
13611380
# The car in a dispatch that has gone away is met by the grid (or PV surplus), never the battery
13621381
diff += car_gone_kwh
13631382

1383+
# Observe the raw whole-site AC balance, before presentation rounding. Do not clamp
1384+
# grid demand, invent battery discharge, or change a candidate's cost. This covers the
1385+
# full displayed forecast, including steps after the optimiser's end_record boundary.
1386+
if import_diagnostics is not None:
1387+
# A modelled car outside the house CT is absent from diff; it still shares the
1388+
# grid connection. Net it against any surplus, without counting metered cars twice.
1389+
import_w = max(diff + car_load_energy_bypass, 0.0) * MINUTE_WATT / step
1390+
import_diagnostics["peak_w"] = max(import_diagnostics["peak_w"], import_w)
1391+
import_diagnostics["checked_minutes"] += step
1392+
if import_w - self.import_limit > 1e-6:
1393+
import_diagnostics["excess_kwh"] += (import_w - self.import_limit) * step / MINUTE_WATT
1394+
import_diagnostics["minutes_over"] += step
1395+
if import_diagnostics["first_exceeded"] is None:
1396+
import_diagnostics["first_exceeded"] = stamp
1397+
13641398
# Metric keep - pretend the battery is empty and you have to import instead of using the battery
13651399
if best_soc_keep > 0 and soc <= best_soc_keep:
13661400
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
14741508
minute += step
14751509

14761510
hours_left = minute_left / 60.0
1511+
self.import_limit_diagnostics = import_diagnostics
14771512
if self.debug_enable or save:
14781513
self.hours_left = hours_left
14791514
self.final_car_soc = final_car_soc

‎apps/predbat/prediction_kernel.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -897,6 +897,7 @@ def reset_kernel_run_state(pred):
897897
pred.predict_carbon_best = {}
898898
pred.predict_clipped_best = {}
899899
pred.predict_car_hold_best = {}
900+
pred.import_limit_diagnostics = None
900901
pred.iboost_running = False
901902
pred.iboost_running_solar = False
902903
pred.iboost_running_full = False

0 commit comments

Comments
 (0)