diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt
index a1c7382ab..7f388c4f0 100644
--- a/.cspell/custom-dictionary-workspace.txt
+++ b/.cspell/custom-dictionary-workspace.txt
@@ -54,6 +54,8 @@ chargehold
chargelater
Chrg
citem
+clearsky
+clipping
Codespaces
collapsable
compareform
@@ -93,6 +95,7 @@ dischargeenergytotal
dischargefreeze
diverter
dlimit
+dno
dnoregion
docstrings
dstamp
@@ -166,6 +169,7 @@ homeassistant
houseb
htmlcov
husforbrukning
+hybrid
hypervolt
iboost
idag
@@ -177,6 +181,7 @@ INTELLI
intelligentdevice
interp
invbatpower
+inverter
invname
isoformat
isort
diff --git a/apps/predbat/compare.py b/apps/predbat/compare.py
index 6fe5aa766..51b46fe2d 100644
--- a/apps/predbat/compare.py
+++ b/apps/predbat/compare.py
@@ -230,10 +230,10 @@ def run_scenario(self, end_record):
my_predbat.calculate_plan(recompute=True, debug_mode=False, publish=False)
- cost, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g = my_predbat.run_prediction(
+ cost, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g, *_ = my_predbat.run_prediction(
my_predbat.charge_limit_best, my_predbat.charge_window_best, my_predbat.export_window_best, my_predbat.export_limits_best, False, end_record=end_record, save="compare"
)
- cost10, import_kwh_battery10, import_kwh_house10, export_kwh10, soc_min10, soc10, soc_min_minute10, battery_cycle10, metric_keep10, final_iboost10, final_carbon_g10 = my_predbat.run_prediction(
+ cost10, import_kwh_battery10, import_kwh_house10, export_kwh10, soc_min10, soc10, soc_min_minute10, battery_cycle10, metric_keep10, final_iboost10, final_carbon_g10, *_ = my_predbat.run_prediction(
my_predbat.charge_limit_best,
my_predbat.charge_window_best,
my_predbat.export_window_best,
diff --git a/apps/predbat/component_base.py b/apps/predbat/component_base.py
index d6152d15d..6d8bab25c 100644
--- a/apps/predbat/component_base.py
+++ b/apps/predbat/component_base.py
@@ -123,6 +123,11 @@ def midnight_utc(self):
"""Get today's midnight time in UTC"""
return self.base.midnight_utc
+ @property
+ def midnight(self):
+ """Get today's midnight time in local time"""
+ return self.base.midnight
+
@property
def now_utc_exact(self):
"""Get the current time in the local timezone"""
diff --git a/apps/predbat/config.py b/apps/predbat/config.py
index 0a1bbdb2f..48c379c66 100644
--- a/apps/predbat/config.py
+++ b/apps/predbat/config.py
@@ -990,6 +990,144 @@
"enable": "num_cars",
"enable_condition": "num_cars > 0",
},
+ {
+ "name": "clipping_buffer_enable",
+ "friendly_name": "Clipping Buffer Enable",
+ "type": "switch",
+ "default": False,
+ },
+ {
+ "name": "clipping_buffer_forecast",
+ "friendly_name": "Clipping Buffer Forecast",
+ "type": "select",
+ "options": ["pv_estimate", "pv_estimate10", "pv_estimate90", "pv_clearsky", "pv_historical"],
+ "icon": "mdi:solar-power",
+ "default": "pv_estimate90",
+ "enable": "clipping_buffer_enable",
+ },
+ {
+ "name": "clipping_buffer_min_kwh",
+ "friendly_name": "Clipping Buffer Min kWh",
+ "type": "input_number",
+ "min": 0,
+ "max": 50.0,
+ "step": 0.1,
+ "unit": "kWh",
+ "icon": "mdi:battery-50",
+ "default": 0,
+ "enable": "clipping_buffer_enable",
+ },
+ {
+ "name": "clipping_buffer_max_kwh",
+ "friendly_name": "Clipping Buffer Max kWh",
+ "type": "input_number",
+ "min": 0,
+ "max": 50.0,
+ "step": 0.1,
+ "unit": "kWh",
+ "icon": "mdi:battery-50",
+ "default": 0,
+ "enable": "clipping_buffer_enable",
+ },
+ {
+ "name": "clipping_buffer_limit_override",
+ "friendly_name": "Clipping Buffer Limit Override",
+ "type": "input_number",
+ "min": 0,
+ "max": 100000.0,
+ "step": 100,
+ "unit": "W",
+ "icon": "mdi:speedometer",
+ "default": 0,
+ "enable": "clipping_buffer_enable",
+ },
+ {
+ "name": "clipping_buffer_window_offset",
+ "friendly_name": "Clipping Window Offset",
+ "type": "input_number",
+ "min": 0,
+ "max": 120.0,
+ "step": 5,
+ "unit": "min",
+ "icon": "mdi:clock-fast",
+ "default": 15,
+ "enable": "clipping_buffer_enable",
+ },
+ {
+ "name": "clipping_buffer_fallback_window",
+ "friendly_name": "Clipping Fallback Duration",
+ "type": "input_number",
+ "min": 0,
+ "max": 12.0,
+ "step": 0.5,
+ "unit": "h",
+ "icon": "mdi:clock-alert-outline",
+ "default": 2.0,
+ "enable": "clipping_buffer_enable",
+ },
+ {
+ "name": "clipping_buffer_risk_threshold",
+ "friendly_name": "Clipping Risk Threshold",
+ "type": "input_number",
+ "min": 0,
+ "max": 1.0,
+ "step": 0.05,
+ "unit": "factor",
+ "icon": "mdi:alert-outline",
+ "default": 0.8,
+ "enable": "clipping_buffer_enable",
+ },
+ {
+ "name": "clipping_buffer_duration_threshold",
+ "friendly_name": "Clipping Duration Threshold",
+ "type": "input_number",
+ "min": 0,
+ "max": 1.0,
+ "step": 0.05,
+ "unit": "factor",
+ "icon": "mdi:timer-sand",
+ "default": 0.2,
+ "enable": "clipping_buffer_enable",
+ },
+ {
+ "name": "clipping_buffer_safety_margin",
+ "friendly_name": "Clipping Safety Margin",
+ "type": "input_number",
+ "min": 0,
+ "max": 1.0,
+ "step": 0.01,
+ "unit": "factor",
+ "icon": "mdi:shield-check",
+ "default": 0.05,
+ "enable": "clipping_buffer_enable",
+ },
+ {
+ "name": "clipping_buffer_can_discharge",
+ "friendly_name": "Clipping Buffer Discharge",
+ "type": "select",
+ "options": ["None", "Cost Optimal", "Always"],
+ "icon": "mdi:battery-arrow-down",
+ "default": "Cost Optimal",
+ "enable": "clipping_buffer_enable",
+ },
+ {
+ "name": "clipping_buffer_start_time",
+ "friendly_name": "Clipping Buffer Start Time",
+ "type": "select",
+ "options": ["None"] + OPTIONS_TIME,
+ "icon": "mdi:clock-start",
+ "default": "None",
+ "enable": "clipping_buffer_enable",
+ },
+ {
+ "name": "clipping_buffer_end_time",
+ "friendly_name": "Clipping Buffer End Time",
+ "type": "select",
+ "options": ["None"] + OPTIONS_TIME,
+ "icon": "mdi:clock-end",
+ "default": "None",
+ "enable": "clipping_buffer_enable",
+ },
{
"name": "mode",
"friendly_name": "Predbat mode",
@@ -2110,6 +2248,18 @@
"inverter_limit_discharge": {"type": "sensor_list", "sensor_type": "integer", "modify": False, "zero": False, "entries": "num_inverters"},
"inverter_limit_export": {"type": "sensor_list", "sensor_type": "integer", "modify": False, "zero": False, "entries": "num_inverters"},
"inverter_limit_override": {"type": "sensor_list", "sensor_type": "integer", "modify": False, "zero": False, "entries": "num_inverters"},
+ "clipping_buffer_enable": {"type": "boolean"},
+ "clipping_buffer_forecast": {"type": "string", "allowed": ["pv_estimate", "pv_estimate10", "pv_estimate90", "pv_clearsky", "pv_historical"]},
+ "clipping_buffer_min_kwh": {"type": "float"},
+ "clipping_buffer_max_kwh": {"type": "float"},
+ "clipping_buffer_limit_override": {"type": "integer"},
+ "clipping_buffer_window_offset": {"type": "integer"},
+ "clipping_buffer_fallback_window": {"type": "float"},
+ "clipping_buffer_risk_threshold": {"type": "float"},
+ "clipping_buffer_duration_threshold": {"type": "float"},
+ "clipping_buffer_can_discharge": {"type": "string", "allowed": ["None", "Cost Optimal", "Always"]},
+ "clipping_buffer_start_time": {"type": "string"},
+ "clipping_buffer_end_time": {"type": "string"},
"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"},
"inverter_battery_rate_min": {"type": "integer", "zero": False, "entries": "num_inverters"},
diff --git a/apps/predbat/execute.py b/apps/predbat/execute.py
index a20b9a8ca..d2413947c 100644
--- a/apps/predbat/execute.py
+++ b/apps/predbat/execute.py
@@ -914,6 +914,40 @@ def publish_inverter_data(self):
},
)
+ # Individual inverter data
+ if self.inverters:
+ for inverter in self.inverters:
+ self.dashboard_item(
+ self.prefix + ".pv_power_{}".format(inverter.id),
+ state=dp3(inverter.pv_power / 1000.0),
+ attributes={
+ "friendly_name": "Current PV Power Inverter {}".format(inverter.id),
+ "state_class": "measurement",
+ "unit_of_measurement": "kW",
+ "icon": "mdi:solar-power",
+ },
+ )
+ self.dashboard_item(
+ self.prefix + ".load_power_{}".format(inverter.id),
+ state=dp3(inverter.load_power / 1000.0),
+ attributes={
+ "friendly_name": "Current Load Power Inverter {}".format(inverter.id),
+ "state_class": "measurement",
+ "unit_of_measurement": "kW",
+ "icon": "mdi:home-lightning-bolt",
+ },
+ )
+ self.dashboard_item(
+ self.prefix + ".battery_power_{}".format(inverter.id),
+ state=dp3(inverter.battery_power / 1000.0),
+ attributes={
+ "friendly_name": "Current Battery Power Inverter {}".format(inverter.id),
+ "state_class": "measurement",
+ "unit_of_measurement": "kW",
+ "icon": "mdi:battery",
+ },
+ )
+
def balance_inverters(self, test_mode=False):
"""
Attempt to balance multiple inverters
diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py
index 350e16b20..86a9727a8 100644
--- a/apps/predbat/fetch.py
+++ b/apps/predbat/fetch.py
@@ -1020,7 +1020,7 @@ def fetch_sensor_data(self, save=True):
self.cost_today_sofar, self.carbon_today_sofar = self.today_cost(self.import_today, self.export_today, self.car_charging_energy, self.load_minutes, save=save)
# Fetch PV forecast if enabled, today must be enabled, other days are optional
- self.pv_forecast_minute, self.pv_forecast_minute10 = self.fetch_pv_forecast()
+ self.pv_forecast_minute, self.pv_forecast_minute10, self.pv_forecast_minute90, self.pv_forecast_minuteCS, self.pv_forecast_minuteMAX = self.fetch_pv_forecast()
if self.load_minutes and not self.load_forecast_only:
# Apply modal filter to historical data
@@ -1233,11 +1233,21 @@ def fetch_pv_forecast(self):
"""
pv_forecast_minute = {}
pv_forecast_minute10 = {}
+ pv_forecast_minute90 = {}
+ pv_forecast_minuteCS = {}
+ pv_forecast_minuteHIST = {}
# Get data from forecast sensor
entity_id = "sensor." + self.prefix + "_pv_forecast_raw"
pv_forecast_packed_ld = self.get_state_wrapper(entity_id=entity_id, attribute="forecast")
pv_forecast10_packed_ld = self.get_state_wrapper(entity_id=entity_id, attribute="forecast10")
+ pv_forecast90_packed_ld = self.get_state_wrapper(entity_id=entity_id, attribute="forecast90")
+ pv_forecastCS_packed_ld = self.get_state_wrapper(entity_id=entity_id, attribute="forecast_clearsky")
+ pv_forecastHIST_packed_ld = self.get_state_wrapper(entity_id=entity_id, attribute="forecast_historical")
+ if pv_forecastCS_packed_ld is None:
+ pv_forecastCS_packed_ld = self.get_state_wrapper(entity_id=entity_id, attribute="forecastCS")
+ if pv_forecastHIST_packed_ld is None:
+ pv_forecastHIST_packed_ld = self.get_state_wrapper(entity_id=entity_id, attribute="forecastMAX")
relative_time = self.get_state_wrapper(entity_id=entity_id, attribute="relative_time")
try:
relative_time = datetime.strptime(relative_time, TIME_FORMAT)
@@ -1248,6 +1258,9 @@ def fetch_pv_forecast(self):
# Convert keys to integers and values to floats
pv_forecast_packed = {}
pv_forecast10_packed = {}
+ pv_forecast90_packed = {}
+ pv_forecastCS_packed = {}
+ pv_forecastHIST_packed = {}
if pv_forecast_packed_ld:
for key, value in pv_forecast_packed_ld.items():
@@ -1265,10 +1278,37 @@ def fetch_pv_forecast(self):
except (ValueError, TypeError):
pass
+ if pv_forecast90_packed_ld:
+ for key, value in pv_forecast90_packed_ld.items():
+ try:
+ minute = int(key)
+ pv_forecast90_packed[minute] = float(value)
+ except (ValueError, TypeError):
+ pass
+
+ if pv_forecastCS_packed_ld:
+ for key, value in pv_forecastCS_packed_ld.items():
+ try:
+ minute = int(key)
+ pv_forecastCS_packed[minute] = float(value)
+ except (ValueError, TypeError):
+ pass
+
+ if pv_forecastHIST_packed_ld:
+ for key, value in pv_forecastHIST_packed_ld.items():
+ try:
+ minute = int(key)
+ pv_forecastHIST_packed[minute] = float(value)
+ except (ValueError, TypeError):
+ pass
+
# Unpack the forecast data
max_minute = max(pv_forecast_packed.keys()) if pv_forecast_packed else 0
last_value = 0
last_value10 = 0
+ last_value90 = 0
+ last_valueCS = 0
+ last_valueHIST = 0
# The forecast could be for a different time to our relative time, so we need to offset the minutes to align with our midnight_utc.
# relative_time is the midnight at which the forecast was saved, so stored minute keys are relative to that midnight.
# We subtract the offset so that stored minute X (= relative_time + X) maps to (relative_time + X - midnight_utc) minutes from today's midnight.
@@ -1277,10 +1317,16 @@ def fetch_pv_forecast(self):
target_minute = minute - minute_offset
last_value = pv_forecast_packed.get(minute, last_value)
last_value10 = pv_forecast10_packed.get(minute, last_value10)
+ last_value90 = pv_forecast90_packed.get(minute, last_value90)
+ last_valueCS = pv_forecastCS_packed.get(minute, last_valueCS)
+ last_valueHIST = pv_forecastHIST_packed.get(minute, last_valueHIST)
pv_forecast_minute[target_minute] = last_value
pv_forecast_minute10[target_minute] = last_value10
+ pv_forecast_minute90[target_minute] = last_value90
+ pv_forecast_minuteCS[target_minute] = last_valueCS
+ pv_forecast_minuteHIST[target_minute] = last_valueHIST
- return pv_forecast_minute, pv_forecast_minute10
+ return pv_forecast_minute, pv_forecast_minute10, pv_forecast_minute90, pv_forecast_minuteCS, pv_forecast_minuteHIST
def predict_battery_temperature(self, battery_temperature_history, step):
"""
@@ -2235,6 +2281,18 @@ def fetch_config_options(self):
self.set_read_only = True
self.set_read_only_axle = True
+ # Clipping Buffer options
+ self.clipping_buffer_enable = self.get_arg("clipping_buffer_enable")
+ self.clipping_buffer_forecast = self.get_arg("clipping_buffer_forecast")
+ self.clipping_buffer_min_kwh = self.get_arg("clipping_buffer_min_kwh")
+ self.clipping_buffer_max_kwh = self.get_arg("clipping_buffer_max_kwh")
+ self.clipping_buffer_limit_override = self.get_arg("clipping_buffer_limit_override", 0.0) / 60.0 / 1000.0 # Convert W to kW/min
+ self.clipping_buffer_window_offset = self.get_arg("clipping_buffer_window_offset", 15)
+ self.clipping_buffer_fallback_window = self.get_arg("clipping_buffer_fallback_window", 2.0)
+ self.clipping_buffer_can_discharge = self.get_arg("clipping_buffer_can_discharge", "Cost Optimal")
+ self.clipping_buffer_start_time = self.get_arg("clipping_buffer_start_time")
+ self.clipping_buffer_end_time = self.get_arg("clipping_buffer_end_time")
+
# hard wired options, can be configured per inverter later on
self.set_soc_enable = True
self.set_reserve_enable = self.get_arg("set_reserve_enable")
diff --git a/apps/predbat/load_ml_component.py b/apps/predbat/load_ml_component.py
index d0bc62358..d609dc473 100644
--- a/apps/predbat/load_ml_component.py
+++ b/apps/predbat/load_ml_component.py
@@ -100,6 +100,7 @@ def initialize(self, load_ml_enable, load_ml_source=True, load_ml_max_days_histo
# Data state
self.load_data = None
self.load_data_age_days = 0
+ self.load_minutes_now = 0
self.pv_data = None
self.temperature_data = None
self.import_rates_data = None
@@ -404,7 +405,7 @@ async def _fetch_load_data(self):
energy = self.get_from_incrementing(pv_data_cumulative, m, PREDICT_STEP, backwards=True)
pv_data[m] = dp4(energy)
- pv_forecast_minute, pv_forecast_minute10 = self.base.fetch_pv_forecast()
+ pv_forecast_minute, pv_forecast_minute10, _, _, _ = self.base.fetch_pv_forecast()
# Add future PV forecast as per-5-min energy with negative keys (negative = future)
# key -5 = first future step, -10 = second, etc.
if pv_forecast_minute:
diff --git a/apps/predbat/output.py b/apps/predbat/output.py
index 0f864b222..12d2dc84a 100644
--- a/apps/predbat/output.py
+++ b/apps/predbat/output.py
@@ -912,6 +912,31 @@ def short_textual_plan(self, soc_min, soc_min_minute, pv_forecast_minute_step, p
else:
sentence += "- You will reach a minimum of {}% battery in {}.\n".format(soc_min_percent, self.duration_string(soc_min_minute - self.minutes_now))
+ if getattr(self, "clipping_buffer_kwh", 0) > 0:
+ start_str = self.time_abs_str(self.clipping_buffer_start) if self.clipping_buffer_start is not None else None
+ end_str = self.time_abs_str(self.clipping_buffer_end) if self.clipping_buffer_end is not None else None
+ clipping_mode = getattr(self, "clipping_mode", "Inverter Limit")
+ discharge_note = ""
+ clipping_can_discharge = getattr(self, "clipping_buffer_can_discharge", "None")
+ if clipping_can_discharge == "Cost Optimal":
+ # Check if any charge window is currently overriding the buffer
+ override_active = False
+ for n, window in enumerate(self.charge_window_best):
+ if window["start"] < (self.clipping_buffer_end or 0):
+ if self.charge_limit_best[n] >= 99.0:
+ override_active = True
+ break
+ if override_active:
+ discharge_note = " (Mitigation bypassed: Grid charging outweighs clipped solar)"
+ else:
+ discharge_note = " (Cost-optimal mitigation enabled)"
+ elif clipping_can_discharge == "Always":
+ discharge_note = " (Active mitigation enabled)"
+
+ if start_str and end_str:
+ sentence += "- {} kWh clipping forecast ({}) between {} and {}. Setting charge target to mitigate{}.\n".format(dp2(self.clipping_buffer_kwh), clipping_mode, start_str, end_str, discharge_note)
+ else:
+ sentence += "- {} kWh clipping buffer active based on your settings (restricted by {}). No immediate clipping forecast{}.\n".format(dp2(self.clipping_buffer_kwh), clipping_mode, discharge_note)
car_charging_kwh = self.car_charge_slot_kwh(self.minutes_now, self.minutes_now + 5)
if car_charging_kwh > 0:
sentence += "- Your car is currently charging.\n"
@@ -1254,6 +1279,12 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,
had_state = True
if plan_debug:
show_limit += " ({})".format(str(calc_percent_limit(limit, self.soc_max)))
+
+ # Clipping Buffer Financial Override indicator
+ buffer_kwh_expected = getattr(self, "clipping_buffer_forecast_kwh", {}).get(minute, 0)
+ if buffer_kwh_expected > 0 and limit_percent == 100.0:
+ show_limit += " (Override 💸)"
+
raw_state_target = str(limit_percent)
else:
if export_window_n >= 0:
@@ -2982,19 +3013,9 @@ def calculate_yesterday(self):
# Simulate yesterday
self.prediction = Prediction(self, yesterday_pv_step, yesterday_pv_step, yesterday_load_step, yesterday_load_step, soc_kw=soc_yesterday)
- (
- metric_baseline,
- import_kwh_battery,
- import_kwh_house,
- export_kwh,
- soc_min,
- final_soc,
- soc_min_minute,
- battery_cycle,
- metric_keep,
- final_iboost,
- final_carbon_g,
- ) = self.run_prediction(charge_limit_best, charge_window_best, [], [], False, end_record=end_record, save="yesterday")
+ (metric_baseline, import_kwh_battery, import_kwh_house, export_kwh, soc_min, final_soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g, *_) = self.run_prediction(
+ charge_limit_best, charge_window_best, [], [], False, end_record=end_record, save="yesterday"
+ )
# Add back in battery value
overall_metric, battery_value_baseline = self.compute_metric(
@@ -3192,7 +3213,9 @@ def calculate_yesterday(self):
# Simulate no PV or battery
self.prediction = Prediction(self, yesterday_pv_step_zero, yesterday_pv_step_zero, yesterday_load_step, yesterday_load_step, soc_kw=0, soc_max=0)
- metric_no_pvbat, import_kwh_battery, import_kwh_house, export_kwh, soc_min, final_soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g = self.run_prediction([], [], [], [], False, end_record=end_record, save="yesterday")
+ metric_no_pvbat, import_kwh_battery, import_kwh_house, export_kwh, soc_min, final_soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g, *_ = self.run_prediction(
+ [], [], [], [], False, end_record=end_record, save="yesterday"
+ )
# Add back in battery value
overall_metric, battery_value_no_pvbat = self.compute_metric(
diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py
index 2ef436436..63156f850 100644
--- a/apps/predbat/plan.py
+++ b/apps/predbat/plan.py
@@ -22,7 +22,7 @@
from datetime import datetime, timedelta
from multiprocessing import Pool, cpu_count
from const import PREDICT_STEP, TIME_FORMAT, MINUTE_WATT
-from utils import calc_percent_limit, dp0, dp1, dp2, dp3, dp4, remove_intersecting_windows, calc_percent_limit, in_car_slot
+from utils import calc_percent_limit, dp0, dp1, dp2, dp3, dp4, remove_intersecting_windows, in_car_slot, get_discharge_rate_curve
from prediction import Prediction, wrapped_run_prediction_single, wrapped_run_prediction_charge, wrapped_run_prediction_charge_min_max, wrapped_run_prediction_export, wrapped_run_prediction_charge_min_max
from predbat_metrics import metrics
import time
@@ -451,9 +451,8 @@ def optimise_charge_limit_price_threads(
max_charge_slots = pred["max_charge_slots"]
max_export_slots = pred["max_export_slots"]
- cost, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g = handle.get()
- cost10, import_kwh_battery10, import_kwh_house10, export_kwh10, soc_min10, soc10, soc_min_minute10, battery_cycle10, metric_keep10, final_iboost10, final_carbon_g10 = handle10.get()
-
+ cost, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g, *_ = handle.get()
+ cost10, import_kwh_battery10, import_kwh_house10, export_kwh10, soc_min10, soc10, soc_min_minute10, battery_cycle10, metric_keep10, final_iboost10, final_carbon_g10, *_ = handle10.get()
metric, battery_value = self.compute_metric(end_record, soc, soc10, cost, cost10, final_iboost, final_iboost10, battery_cycle, metric_keep, final_carbon_g, import_kwh_battery, import_kwh_house, export_kwh)
tried_list[try_hash] = metric
@@ -774,19 +773,7 @@ def run_prediction_metric(self, charge_limit_best, charge_window_best, export_wi
if end_record is None:
end_record = self.forecast_minutes
- (
- cost10,
- import_kwh_battery10,
- import_kwh_house10,
- export_kwh10,
- soc_min10,
- soc10,
- soc_min_minute10,
- battery_cycle10,
- metric_keep10,
- final_iboost10,
- final_carbon_g10,
- ) = self.run_prediction(
+ (cost10, import_kwh_battery10, import_kwh_house10, export_kwh10, soc_min10, soc10, soc_min_minute10, battery_cycle10, metric_keep10, final_iboost10, final_carbon_g, clipping_mitigated, *_) = self.run_prediction(
charge_limit_best,
charge_window_best,
export_window_best,
@@ -795,19 +782,7 @@ def run_prediction_metric(self, charge_limit_best, charge_window_best, export_wi
end_record=end_record,
)
# Run new plan
- (
- cost,
- 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(
+ (cost, 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(
charge_limit_best,
charge_window_best,
export_window_best,
@@ -844,6 +819,292 @@ def in_charge_window(self, charge_window, minute_abs):
window_n += 1
return -1
+ def calculate_clipping_buffer(self):
+ """
+ Calculate the required clipping buffer (kWh) and times based on the selected forecast.
+ Supports 48-hour planning and dynamic buffer decay.
+ """
+ if not self.clipping_buffer_enable:
+ self.clipping_buffer_forecast_kwh = {}
+ return 0, 0, 0, []
+
+ # Determine effective clipping limit (Hierarchy of constraints)
+ # All power limits must be converted to kW/min to compare against pv_data
+
+ # 1. Start with Physical Inverter AC Capacity (summed)
+ inverter_ac_limit = 0.0
+ if self.inverters:
+ for inverter in self.inverters:
+ inverter_ac_limit += inverter.inverter_limit
+
+ # If no inverter limit is found, start with a very high limit so others can restrict it
+ base_limit = inverter_ac_limit if inverter_ac_limit > 0 else 99999.0 / 60.0
+ clipping_mode = "Inverter AC Capacity"
+
+ # 2. Check DNO Grid Export Limits
+ grid_export_limit = 0.0
+ if self.inverters:
+ for inverter in self.inverters:
+ grid_export_limit += inverter.export_limit
+
+ if grid_export_limit > 0 and grid_export_limit < base_limit:
+ base_limit = grid_export_limit
+ clipping_mode = "DNO Export Limit"
+
+ # 3. Check PV AC Limit (Microinverters / AC-coupled)
+ if not self.inverter_hybrid and self.pv_ac_limit > 0 and self.pv_ac_limit < base_limit:
+ base_limit = self.pv_ac_limit
+ clipping_mode = "PV AC Capacity"
+
+ # 4. Apply Manual Override if set (already converted to kW/min by fetch.py)
+ if self.clipping_buffer_limit_override > 0:
+ base_limit = self.clipping_buffer_limit_override
+ clipping_mode = "Manual Override"
+
+ self.clipping_mode = clipping_mode
+ self.clipping_limit = base_limit
+
+ # Forecast selection (Standardized)
+ forecast_type = self.clipping_buffer_forecast
+ has_cs_data = self.pv_forecast_minuteCS and max(self.pv_forecast_minuteCS.values() or [0]) > 0
+ if forecast_type == "pv_estimate":
+ pv_data = self.pv_forecast_minute
+ elif forecast_type == "pv_estimate10":
+ pv_data = self.pv_forecast_minute10
+ elif forecast_type == "pv_estimate90":
+ pv_data = self.pv_forecast_minute90
+ elif forecast_type == "pv_clearsky":
+ pv_data = self.pv_forecast_minuteCS if has_cs_data else self.pv_forecast_minute90
+ elif forecast_type == "pv_historical":
+ pv_data = self.pv_forecast_minuteHIST
+ else:
+ pv_data = self.pv_forecast_minute90
+
+ if not pv_data:
+ self.clipping_buffer_forecast_kwh = {}
+ return 0, 0, 0, []
+
+ # Refine Hierarchy: Battery charge capacity for AC-coupled systems
+ limit_minute = {}
+ battery_charge_limit = getattr(self, "battery_rate_max_charge", 3.0) / 60.0
+
+ for minute in range(0, self.forecast_minutes):
+ eff_limit = base_limit
+ if not self.inverter_hybrid:
+ # AC-coupled: max absorption = battery charge + load + grid export
+ load_now = self.load_minutes.get(minute, self.load_avg / 60.0)
+ # Export limit is already in base_limit
+ can_absorb = battery_charge_limit + load_now + base_limit
+ eff_limit = min(base_limit, can_absorb)
+ limit_minute[minute] = eff_limit
+
+ # 1. Determine Clipping Windows for all forecast days
+ window_source = self.pv_forecast_minuteCS if has_cs_data else pv_data
+ clipping_windows = [] # List of (start, end, noon) per day
+
+ # 4. Handle Manual Overrides for today's primary window
+ manual_start_minute = None
+ manual_end_minute = None
+
+ # We need a reference date to avoid Naive datetime issues with time_string_to_stamp
+ ref_date = self.now_utc.strftime("%Y-%m-%d")
+
+ if self.clipping_buffer_start_time and self.clipping_buffer_start_time != "None":
+ # Append today's date so strptime works reliably
+ time_str = f"{ref_date} {self.clipping_buffer_start_time}"
+ start_stamp = datetime.strptime(time_str, "%Y-%m-%d %H:%M:%S")
+ midnight_stamp = datetime.strptime(f"{ref_date} 00:00:00", "%Y-%m-%d %H:%M:%S")
+ manual_start_minute = int((start_stamp - midnight_stamp).total_seconds() / 60)
+
+ if self.clipping_buffer_end_time and self.clipping_buffer_end_time != "None":
+ time_str = f"{ref_date} {self.clipping_buffer_end_time}"
+ end_stamp = datetime.strptime(time_str, "%Y-%m-%d %H:%M:%S")
+ midnight_stamp = datetime.strptime(f"{ref_date} 00:00:00", "%Y-%m-%d %H:%M:%S")
+ manual_end_minute = int((end_stamp - midnight_stamp).total_seconds() / 60)
+
+ def find_daily_windows():
+ # Use max(pv_data) to handle the full range of available forecast
+ max_m = max(pv_data.keys()) if pv_data else self.forecast_minutes
+ for day in range(int(max_m / 1440) + 1):
+ day_start = day * 1440
+ day_end = day_start + 1440
+ if day_start > max_m:
+ break
+
+ # If this is day 0 and we have a manual override, use it
+ if day == 0 and manual_start_minute is not None and manual_end_minute is not None:
+ self.log(f"DEBUG Clipping Buffer: Manual window {manual_start_minute}-{manual_end_minute} added for day {day}")
+ clipping_windows.append((manual_start_minute, manual_end_minute, manual_start_minute + 60, manual_start_minute, manual_end_minute))
+ continue
+
+ solar_start, solar_end, max_pv, solar_noon = None, None, 0, day_start + 12 * 60
+ for m in range(day_start, day_end):
+ pv_cs = window_source.get(m, 0)
+ if pv_cs > (0.1 / 60.0):
+ if solar_start is None:
+ solar_start = m
+ solar_end = m
+ if pv_cs > max_pv:
+ max_pv = pv_cs
+ solar_noon = m
+
+ # Window logic: If Clear Sky exceeds the risk threshold, we have a clipping risk day.
+ # The protection window should then span the entire period where spikes are possible.
+ # We use the duration threshold as a broad boundary for protection.
+ risk_threshold = base_limit * getattr(self, "clipping_buffer_risk_threshold", 0.8)
+ duration_threshold = base_limit * getattr(self, "clipping_buffer_duration_threshold", 0.2)
+
+ has_risk = False
+ auto_start, auto_end = None, None
+ risk_peak_start, risk_peak_end = None, None
+
+ for m in range(day_start, day_end):
+ pv_cs = window_source.get(m, 0)
+ if pv_cs > risk_threshold:
+ has_risk = True
+
+ if pv_cs > duration_threshold:
+ if auto_start is None:
+ auto_start = m
+ auto_end = m
+
+ # Peak reporting: When PV actually exceeds the limit
+ lim = limit_minute.get(m, base_limit)
+ if pv_cs > lim:
+ if risk_peak_start is None:
+ risk_peak_start = m
+ risk_peak_end = m
+
+ if not has_risk:
+ # No high-power risk today, discard the window
+ auto_start, auto_end = None, None
+
+ if auto_start is not None:
+ # Apply offsets
+ offset = getattr(self, "clipping_buffer_window_offset", 15)
+ auto_start = max(day_start, auto_start - offset)
+ auto_end = min(day_end, auto_end + offset)
+
+ # Store reportable peaks (fallback to broad window if no hard clipping)
+ report_start = risk_peak_start if risk_peak_start is not None else auto_start
+ report_end = risk_peak_end if risk_peak_end is not None else auto_end
+ clipping_windows.append((auto_start, auto_end, solar_noon, report_start, report_end))
+
+ find_daily_windows()
+
+ # 2. Pre-calculate window metrics for fast lookup
+ auto_spike_buffer_kwh = {}
+ window_totals_kwh = {}
+ for win in clipping_windows:
+ ws, we, wn, rs, re = win
+ clearsky_clipping_kwh = 0
+ win_total_pv = 0
+ for t in range(ws, we):
+ pv_cs = window_source.get(t, 0)
+ lim = limit_minute.get(t, base_limit)
+ win_total_pv += pv_cs
+ if pv_cs > lim:
+ clearsky_clipping_kwh += pv_cs - lim
+
+ user_min_kwh = getattr(self, "clipping_buffer_min_kwh", 0)
+ # If no manual min is set, use clearsky clipping BUT also add a small safety margin
+ # (e.g. 5% of window PV) if we detected risk, to account for spikes/unpredictability.
+ safety_margin_pct = getattr(self, "clipping_buffer_safety_margin", 0.05)
+ spike_protection = (win_total_pv * safety_margin_pct) if win_total_pv > 0 else 0
+ auto_spike_buffer_kwh[win] = user_min_kwh if user_min_kwh > 0 else max(clearsky_clipping_kwh, spike_protection)
+ window_totals_kwh[win] = win_total_pv
+
+ # 3. Calculate Clipping Volume (kWh) with Dynamic Decay
+ self.clipping_buffer_minute = {}
+ natural_sum = 0
+ max_m = max(pv_data.keys()) if pv_data else self.forecast_minutes
+
+ # Track cumulative PV within the currently active window for decay math
+ active_window_cumulative_pv = 0
+ current_active_win = None
+
+ for m in range(max_m, -1, -1):
+ pv_now = pv_data.get(m, 0)
+ limit_now = limit_minute.get(m, base_limit)
+
+ # Natural clipping integration (backwards for decay)
+ # Reset at midnight
+ if m % 1440 == 1439 or m == max_m:
+ natural_sum = 0
+
+ if pv_now > limit_now:
+ natural_sum += pv_now - limit_now
+
+ # Find which window this minute belongs to
+ active_win = None
+ for win in clipping_windows:
+ ws, we, wn, rs, re = win
+ start_of_day_ws = (ws // 1440) * 1440
+ if m < we and m >= start_of_day_ws:
+ active_win = win
+ break
+
+ # Manage cumulative PV for decay within the window
+ if active_win != current_active_win:
+ current_active_win = active_win
+ active_window_cumulative_pv = 0
+
+ if active_win:
+ ws, we, wn, rs, re = active_win
+ # When moving backwards, we accumulate PV as we move from 'end' to 'start'
+ if m >= ws and m < we:
+ active_window_cumulative_pv += window_source.get(m, 0)
+
+ # Smart Decay for Spike Buffer
+ eff_sum = natural_sum
+ if active_win:
+ ws, we, wn, rs, re = active_win
+ spike_kwh = auto_spike_buffer_kwh.get(active_win, 0)
+ if spike_kwh > 0:
+ if m < ws: # Before window: full spike buffer
+ eff_sum = max(eff_sum, spike_kwh)
+ elif m < we: # During window: decay based on Clear Sky accumulation
+ total_pv = window_totals_kwh.get(active_win, 0)
+ # active_window_cumulative_pv is the sum from m to we (because we iterate backwards)
+ decay_factor = active_window_cumulative_pv / total_pv if total_pv > 0 else 0
+ eff_sum = max(eff_sum, spike_kwh * decay_factor)
+
+ if getattr(self, "clipping_buffer_max_kwh", 0) > 0:
+ eff_sum = min(eff_sum, self.clipping_buffer_max_kwh)
+ self.clipping_buffer_minute[m] = eff_sum
+
+ # Export directly to the outputs (already in kWh)
+ self.clipping_buffer_forecast_kwh = self.clipping_buffer_minute
+ self.clipping_remaining_today = self.clipping_buffer_forecast_kwh.get(self.minutes_now, 0)
+ self.clipping_tomorrow = self.clipping_buffer_forecast_kwh.get(1440, 0)
+
+ # 3. Finalize Primary Window for "Now" (Indicators for today's sensors)
+ # Find the first window that is relevant for 'today' (starts before 1440)
+ auto_start_today, auto_end_today = None, None
+ for ws, we, wn, rs, re in clipping_windows:
+ if ws < 1440:
+ auto_start_today = rs
+ auto_end_today = re
+ break
+
+ final_start = manual_start_minute if manual_start_minute is not None else auto_start_today
+ final_end = manual_end_minute if manual_end_minute is not None else auto_end_today
+
+ if self.debug_enable:
+ self.log(
+ "Clipping Buffer DEBUG: remaining_today={:.4f}, max_m={}, has_cs_data={}, base_limit={:.4f}, risk_threshold={:.4f}, clipping_windows={}".format(
+ self.clipping_remaining_today, max_m, has_cs_data, base_limit, base_limit * getattr(self, "clipping_buffer_risk_threshold", 0.8), clipping_windows
+ )
+ )
+ if self.clipping_remaining_today > 0:
+ self.log(
+ "Clipping Buffer: Calculated buffer of {:.2f}kWh based on {}, starts at {}, ends at {} (at minute {})".format(
+ self.clipping_remaining_today, forecast_type, self.time_abs_str(final_start) if final_start is not None else "N/A", self.time_abs_str(final_end) if final_end is not None else "N/A", self.minutes_now
+ )
+ )
+
+ return self.clipping_remaining_today, final_start, final_end, clipping_windows
+
def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
"""
Calculate the new plan (best)
@@ -858,6 +1119,15 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
plan_start_time = time.time()
+ # Calculate clipping buffer
+ if self.clipping_buffer_enable:
+ self.clipping_buffer_kwh, self.clipping_buffer_start, self.clipping_buffer_end, clipping_windows = self.calculate_clipping_buffer()
+ else:
+ self.clipping_buffer_kwh = 0
+ self.clipping_buffer_start = None
+ self.clipping_buffer_end = None
+ clipping_windows = []
+
# Re-compute plan due to time wrap
if self.plan_last_updated_minutes > self.minutes_now:
self.log("Force recompute due to start of day")
@@ -922,6 +1192,68 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
# Pre-fill best export enable with Off
self.export_limits_best = [100.0 for i in range(len(self.export_window_best))]
+ # 1. Apply Clipping Buffer caps and injection for ALL forecast days
+ # This ensures the optimizer and simulation both respect the reservation
+ if getattr(self, "clipping_buffer_enable", False) and getattr(self, "clipping_buffer_forecast_kwh", {}):
+ # A. Cap grid charging that overlaps with a needed buffer
+ for n, window in enumerate(self.charge_window_best):
+ buffer_needed = self.clipping_buffer_forecast_kwh.get(window["start"], 0)
+ if buffer_needed > 0:
+ new_target_soc_kw = max(0, self.soc_max - buffer_needed)
+ new_target_percent = (new_target_soc_kw / self.soc_max) * 100.0 if self.soc_max > 0 else 0
+ self.charge_limit_best[n] = min(self.charge_limit_best[n], new_target_percent)
+
+ # B. Inject 'Hold' Export windows for all solar peaks
+ can_discharge = getattr(self, "clipping_buffer_can_discharge", "")
+ if can_discharge in ["Always", "Cost Optimal"] and clipping_windows:
+ for ws, we, wn, rs, re in clipping_windows:
+ if we <= self.minutes_now:
+ continue
+
+ c_rem = self.clipping_buffer_forecast_kwh.get(ws, 0)
+ if c_rem <= 0:
+ continue
+
+ # Check predicted SOC at peak start to see if we need to dump energy early
+ current_predicted_soc = self.predict_soc.get(max(0, ws - self.minutes_now), self.soc_kw)
+ target_kw = max(0, self.soc_max - c_rem)
+ target_percent = (target_kw / self.soc_max) * 100.0 if self.soc_max > 0 else 0.0
+
+ # Avoid exactly 99.0 as this triggers Freeze Export which disables charging
+ if target_percent == 99.0:
+ target_percent = 98.9
+
+ if current_predicted_soc > (target_kw + 0.1):
+ discharge_rate_kw = getattr(self, "battery_rate_max_discharge", 3.0)
+ if discharge_rate_kw <= 0:
+ discharge_rate_kw = 3.0
+ energy_to_dump = current_predicted_soc - target_kw
+ minutes_needed = int((energy_to_dump / discharge_rate_kw) * 60) + 10
+ effective_start = max(self.minutes_now, ws - minutes_needed)
+ else:
+ effective_start = ws
+
+ if effective_start < we:
+ clip_window = {"start": effective_start, "end": we, "average": self.rate_export.get(effective_start, 0), "target": target_percent}
+ # Add/Merge window
+ overlap = False
+ for i, e_win in enumerate(self.export_window_best):
+ if (effective_start >= e_win["start"] and effective_start < e_win["end"]) or (we > e_win["start"] and we <= e_win["end"]):
+ overlap = True
+ self.export_limits_best[i] = min(self.export_limits_best[i], target_percent)
+ self.export_window_best[i]["target"] = self.export_limits_best[i]
+ break
+ if not overlap:
+ self.export_window_best.append(clip_window)
+ self.export_limits_best.append(target_percent)
+
+ # Re-sort paired arrays
+ if len(self.export_window_best) > 0:
+ paired = list(zip(self.export_window_best, self.export_limits_best))
+ paired.sort(key=lambda x: x[0]["start"])
+ self.export_window_best = [x[0] for x in paired]
+ self.export_limits_best = [x[1] for x in paired]
+
self.end_record = self.forecast_minutes
# Show best windows
self.log("Best charge window {}".format(self.window_as_text(self.charge_window_best, calc_percent_limit(self.charge_limit_best, self.soc_max))))
@@ -1000,7 +1332,7 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
self.log("Not using threading as threads is set to 0 in apps.yaml")
# Simulate current settings to get initial data
- 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(
+ (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, end_record=self.end_record
)
@@ -1022,6 +1354,77 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
# Remove charge windows that overlap with export windows
self.charge_limit_best, self.charge_window_best = remove_intersecting_windows(self.charge_limit_best, self.charge_window_best, self.export_limits_best, self.export_window_best)
+ # Enforce clipping buffer on physical charge limits
+ # This is critical to ensure the real-world inverter matches the simulation's behavior
+ if self.clipping_buffer_kwh > 0 and self.clipping_buffer_end is not None:
+ len_windows = len(self.charge_window_best)
+ len_limits = len(self.charge_limit_best)
+ if len_windows != len_limits:
+ self.log(f"Warn: Clipping enforcement list mismatch - windows={len_windows}, limits={len_limits}. Attempting to resync.")
+ # Resync if possible
+ if len_limits < len_windows:
+ self.charge_limit_best.extend([self.soc_max] * (len_windows - len_limits))
+ else:
+ self.charge_limit_best = self.charge_limit_best[:len_windows]
+
+ can_discharge = getattr(self, "clipping_buffer_can_discharge", "")
+ for n, window in enumerate(self.charge_window_best):
+ if window["end"] <= self.clipping_buffer_end:
+ # Financial Override Check
+ # If Cost Optimal, check if the grid pays us enough to abandon the buffer
+ apply_cap = True
+ if can_discharge == "Cost Optimal":
+ # Find the cheapest import rate in this specific charge window
+ min_import_rate = 999
+ for m in range(window["start"], window["end"]):
+ ir = self.rate_import.get(m, 0)
+ if ir < min_import_rate:
+ min_import_rate = ir
+
+ # Estimate export value during the upcoming clipping window
+ export_rate_est = self.rate_export.get(self.clipping_buffer_start if self.clipping_buffer_start else self.minutes_now, 0)
+
+ # Calculate the threshold where grid charging beats saving the solar
+ # Logic: Grid Profit (Import + Export) > Solar Profit (Export)
+ # Difference = (Export_Eff * Export_Rate) * (1 - Import_Eff)
+ # Note: self.inverter_loss/battery_loss/etc. are efficiencies (e.g. 0.96)
+
+ eff_out = self.battery_loss_discharge * self.inverter_loss
+ eff_solar = self.battery_loss * eff_out
+
+ # The extra loss from a grid cycle is exactly one AC->DC conversion (inverter_loss)
+ threshold_rate = -(eff_solar * (1.0 - self.inverter_loss) * export_rate_est)
+
+ if min_import_rate < threshold_rate:
+ self.log(f"Clipping Buffer: Financial Override. Relaxing hard cap in window {window['start']}-{window['end']} because min import rate {min_import_rate}p < {dp2(threshold_rate)}p threshold.")
+ apply_cap = False
+
+ if apply_cap:
+ self.charge_limit_best[n] = min(self.charge_limit_best[n], self.soc_max - self.clipping_buffer_kwh)
+
+ # Enforce physical export window for clipping buffer if 'Always' is requested
+ if self.clipping_buffer_kwh > 0 and self.clipping_buffer_start is not None and getattr(self, "clipping_buffer_can_discharge", "") == "Always":
+ clipping_start = self.clipping_buffer_start
+ export_start = max(self.minutes_now, clipping_start - 60)
+
+ if export_start < clipping_start:
+ target_kwh = self.soc_max - self.clipping_buffer_kwh
+ target_percent = (target_kwh / self.soc_max) * 100.0 if self.soc_max > 0 else 0.0
+
+ # Avoid exactly 99.0 as this triggers Freeze Export which disables charging
+ if target_percent == 99.0:
+ target_percent = 98.9
+
+ new_window = {"start": export_start, "end": clipping_start, "average": self.rate_export.get(export_start, 0), "target": target_percent}
+ self.export_window_best.append(new_window)
+ self.export_limits_best.append(target_percent)
+
+ # We need to sort both parallel arrays together
+ paired = list(zip(self.export_window_best, self.export_limits_best))
+ paired.sort(key=lambda x: x[0]["start"])
+ self.export_window_best = [x[0] for x in paired]
+ self.export_limits_best = [x[1] for x in paired]
+
# Filter out any unused export windows
if self.calculate_best_export and self.export_window_best:
# Filter out the windows we disabled
@@ -1030,19 +1433,7 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
# Clipping windows
if self.export_window_best:
# Re-run prediction to get data for clipping
- (
- best_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(
+ (best_metric, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g, clipping_mitigated, *_) = self.run_prediction(
self.charge_limit_best,
self.charge_window_best,
self.export_window_best,
@@ -1064,19 +1455,7 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
# Filter out any unused charge slots
if self.calculate_best_charge and self.charge_window_best:
# Re-run prediction to get data for clipping
- (
- best_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(
+ (best_metric, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g, clipping_mitigated, *_) = self.run_prediction(
self.charge_limit_best,
self.charge_window_best,
self.export_window_best,
@@ -1135,24 +1514,17 @@ 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
+ # Refine target values based on Clipping Buffer (runs every loop, regardless of recompute)
+ if self.calculate_best:
+ # 1. Update target values from the current best plan
+ self.update_target_values()
+
# 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(
+ (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
)
# And base 10
- (
- metricb10,
- import_kwh_batteryb10,
- import_kwh_houseb10,
- export_kwhb10,
- soc_minb10,
- socb10,
- soc_min_minuteb10,
- battery_cycle10,
- metric_keep10,
- final_iboost10,
- final_carbon_g10,
- ) = self.run_prediction(
+ (metricb10, import_kwh_batteryb10, import_kwh_houseb10, export_kwhb10, soc_minb10, socb10, soc_min_minuteb10, battery_cycle10, metric_keep10, final_iboost10, final_carbon_g, clipping_mitigated, *_) = self.run_prediction(
self.charge_limit,
self.charge_window,
self.export_window,
@@ -1163,20 +1535,116 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
)
if self.calculate_best:
- # Final simulation of best, do 10% and normal scenario
- (
- best_metric10,
- import_kwh_battery10,
- import_kwh_house10,
- export_kwh10,
- soc_min10,
- soc10,
- soc_min_minute10,
- battery_cycle10,
- metric_keep10,
- final_iboost10,
- final_carbon_g10,
- ) = self.run_prediction(
+ # 1. Apply Clipping Buffer constraints to the "Best" plan for ALL forecast days
+ if getattr(self, "clipping_buffer_enable", False) and getattr(self, "clipping_buffer_forecast_kwh", {}):
+ can_discharge = getattr(self, "clipping_buffer_can_discharge", "")
+
+ for ws, we, wn, rs, re in clipping_windows:
+ if we <= self.minutes_now:
+ continue
+
+ # Required buffer for this specific solar peak
+ c_rem = self.clipping_buffer_forecast_kwh.get(ws, 0)
+ if c_rem <= 0:
+ continue
+
+ target_kw = max(0, self.soc_max - c_rem)
+ target_percent = (target_kw / self.soc_max) * 100.0 if self.soc_max > 0 else 0.0
+
+ # Avoid exactly 99.0 as this triggers Freeze Export which disables charging
+ if target_percent == 99.0:
+ target_percent = 98.9
+
+ # A. Cap any planned grid charging that happens BEFORE this peak (e.g. overnight)
+ # We check windows ending up to 24 hours before the peak starts
+ for n, window in enumerate(self.charge_window_best):
+ if window["end"] <= ws and window["end"] > (ws - 1440):
+ apply_cap = True
+ if can_discharge == "Cost Optimal":
+ eff_out = self.battery_loss_discharge * self.inverter_loss
+ eff_solar = self.battery_loss * eff_out
+ export_rate_est = self.rate_export.get(ws if ws else self.minutes_now, 0)
+ cycle_cost = getattr(self, "metric_battery_cycle", 0)
+ threshold_rate = -(eff_solar * (1.0 - self.inverter_loss) * export_rate_est) - cycle_cost
+
+ min_import_rate = 999
+ for m in range(window["start"], window["end"]):
+ ir = self.rate_import.get(m, 0)
+ if ir < min_import_rate:
+ min_import_rate = ir
+
+ if min_import_rate < threshold_rate:
+ self.log(f"Clipping Buffer: Financial Override. Relaxing hard cap for window {window['start']}-{window['end']} (peak at {ws}) because min import rate {min_import_rate}p < {dp2(threshold_rate)}p threshold.")
+ apply_cap = False
+
+ if apply_cap:
+ self.charge_limit_best[n] = min(self.charge_limit_best[n], target_percent)
+
+ # B. Inject 'Hold' Export windows to clear space if the battery is already too full
+ if can_discharge in ["Always", "Cost Optimal"]:
+ # How much do we need to dump? Use predicted SOC from last iteration or now
+ current_predicted_soc = self.predict_soc.get(max(0, ws - self.minutes_now), self.soc_kw)
+
+ if current_predicted_soc > (target_kw + 0.1):
+ # Calculate how early to start discharging using native battery curves
+ curve_rate_kw = get_discharge_rate_curve(
+ current_predicted_soc,
+ getattr(self, "battery_rate_max_discharge", 3.0),
+ self.soc_max,
+ getattr(self, "battery_rate_max_discharge", 3.0),
+ getattr(self, "battery_discharge_power_curve", {}),
+ getattr(self, "battery_rate_min", 0),
+ getattr(self, "battery_temperature", 20.0),
+ getattr(self, "battery_temperature_discharge_curve", {}),
+ )
+
+ # Account for PV filling the battery while we try to empty it
+ # We look at average PV in the 2 hours before the peak
+ lookback = 120
+ avg_pv_kw = 0
+ pv_data_local = self.pv_forecast_minute90 # Use conservative forecast
+ pv_points = [pv_data_local.get(t, 0) for t in range(max(0, ws - lookback), ws)]
+ if pv_points:
+ avg_pv_kw = (sum(pv_points) / len(pv_points)) * 60.0
+
+ # Effective clearing rate = Battery Discharge - PV filling
+ # (Max discharge rate is limited by inverter AC cap which PV also uses,
+ # so this is a conservative estimate)
+ effective_rate = max(0.5, (curve_rate_kw * 0.9) - avg_pv_kw)
+
+ energy_to_dump = current_predicted_soc - target_kw
+ minutes_needed = int((energy_to_dump / effective_rate) * 60) + 15
+ effective_start = max(self.minutes_now, ws - minutes_needed)
+ else:
+ effective_start = ws
+
+ if effective_start < we:
+ clip_window = {"start": effective_start, "end": we, "average": self.rate_export.get(effective_start, 0), "target": target_percent, "clipping": True}
+
+ # Merge or add window
+ overlap = False
+ for i, e_win in enumerate(self.export_window_best):
+ if (effective_start >= e_win["start"] and effective_start < e_win["end"]) or (we > e_win["start"] and we <= e_win["end"]) or (effective_start <= e_win["start"] and we >= e_win["end"]):
+ overlap = True
+ # If it overlaps, the more restrictive (lower) target wins
+ self.export_limits_best[i] = min(self.export_limits_best[i], target_percent)
+ self.export_window_best[i]["target"] = self.export_limits_best[i]
+ self.export_window_best[i]["clipping"] = True
+ break
+
+ if not overlap:
+ self.export_window_best.append(clip_window)
+ self.export_limits_best.append(target_percent)
+
+ # Re-sort paired arrays after all injections
+ if len(self.export_window_best) > 0:
+ paired = list(zip(self.export_window_best, self.export_limits_best))
+ paired.sort(key=lambda x: x[0]["start"])
+ self.export_window_best = [x[0] for x in paired]
+ self.export_limits_best = [x[1] for x in paired]
+
+ # 2. Final simulation of best (now including all clipping caps), do 10% and normal scenario
+ (best_metric10, import_kwh_battery10, import_kwh_house10, export_kwh10, soc_min10, soc10, soc_min_minute10, battery_cycle10, metric_keep10, final_iboost10, final_carbon_g, clipping_mitigated, *_) = self.run_prediction(
self.charge_limit_best,
self.charge_window_best,
self.export_window_best,
@@ -1185,19 +1653,7 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
save="best10" if publish else None,
end_record=self.end_record,
)
- (
- best_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(
+ (best_metric, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g, clipping_mitigated, *_) = self.run_prediction(
self.charge_limit_best,
self.charge_window_best,
self.export_window_best,
@@ -1206,9 +1662,9 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
save="best" if publish else None,
end_record=self.end_record,
)
+
# round charge_limit_best (kWh) to 3 decimal places
self.charge_limit_best = [dp3(elem) for elem in self.charge_limit_best]
-
self.log(
"Best charging limit SoC's {}kWh, export {}kWh gives import battery {}kWh, house {}kWh, export {}kWh, metric {}{}, metric10 {}{}".format(
self.charge_limit_best, self.export_limits_best, dp2(import_kwh_battery), dp2(import_kwh_house), dp2(export_kwh), dp2(best_metric), curr, dp2(best_metric10), curr
@@ -1339,21 +1795,7 @@ def optimise_charge_limit(self, window_n, record_charge_windows, charge_limit, c
hans.append(self.launch_run_prediction_charge_min_max(best_soc_min, window_n, charge_limit, charge_window, export_window, export_limits, True, all_n, end_record))
id = 0
for han in hans:
- (
- cost,
- import_kwh_battery,
- import_kwh_house,
- export_kwh,
- soc_min,
- soc,
- soc_min_minute,
- battery_cycle,
- metric_keep,
- final_iboost,
- final_carbon_g,
- min_soc,
- max_soc,
- ) = han.get()
+ (cost, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g, clipping_mitigated, min_soc, max_soc, *_) = han.get()
all_min_soc = min(all_min_soc, min_soc)
all_max_soc = max(all_max_soc, max_soc)
if id == 0:
@@ -1369,6 +1811,7 @@ def optimise_charge_limit(self, window_n, record_charge_windows, charge_limit, c
metric_keep,
final_iboost,
final_carbon_g,
+ clipping_mitigated,
]
elif id == 1:
result10[loop_soc] = [
@@ -1383,6 +1826,7 @@ def optimise_charge_limit(self, window_n, record_charge_windows, charge_limit, c
metric_keep,
final_iboost,
final_carbon_g,
+ clipping_mitigated,
]
elif id == 2:
resultmid[best_soc_min] = [
@@ -1397,6 +1841,7 @@ def optimise_charge_limit(self, window_n, record_charge_windows, charge_limit, c
metric_keep,
final_iboost,
final_carbon_g,
+ clipping_mitigated,
]
elif id == 3:
result10[best_soc_min] = [
@@ -1411,6 +1856,7 @@ def optimise_charge_limit(self, window_n, record_charge_windows, charge_limit, c
metric_keep,
final_iboost,
final_carbon_g,
+ clipping_mitigated,
]
id += 1
@@ -1486,8 +1932,8 @@ def optimise_charge_limit(self, window_n, record_charge_windows, charge_limit, c
try_charge_limit[window_n] = try_soc
# Simulate with medium PV
- (cost, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g) = resultmid[try_soc]
- (cost10, import_kwh_battery10, import_kwh_house10, export_kwh10, soc_min10, soc10, soc_min_minute10, battery_cycle10, metric_keep10, final_iboost10, final_carbon_g10) = result10[try_soc]
+ (cost, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g, *_) = resultmid[try_soc]
+ (cost10, import_kwh_battery10, import_kwh_house10, export_kwh10, soc_min10, soc10, soc_min_minute10, battery_cycle10, metric_keep10, final_iboost10, final_carbon_g10, *_) = result10[try_soc]
# Compute the metric from simulation results
metric, battery_value = self.compute_metric(end_record, soc, soc10, cost, cost10, final_iboost, final_iboost10, battery_cycle, metric_keep, final_carbon_g, import_kwh_battery, import_kwh_house, export_kwh)
@@ -1689,20 +2135,8 @@ def optimise_export(self, window_n, record_charge_windows, try_charge_limit, cha
start, this_export_limit, hanres, hanres10 = try_option
# Simulate with medium PV
- cost, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g = hanres
- (
- cost10,
- import_kwh_battery10,
- import_kwh_house10,
- export_kwh10,
- soc_min10,
- soc10,
- soc_min_minute10,
- battery_cycle10,
- metric_keep10,
- final_iboost10,
- final_carbon_g10,
- ) = hanres10
+ cost, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g, *_ = hanres
+ (cost10, import_kwh_battery10, import_kwh_house10, export_kwh10, soc_min10, soc10, soc_min_minute10, battery_cycle10, metric_keep10, final_iboost10, final_carbon_g10, *_) = hanres10
# Compute the metric from simulation results
metric, battery_value = self.compute_metric(end_record, soc, soc10, cost, cost10, final_iboost, final_iboost10, battery_cycle, metric_keep, final_carbon_g, import_kwh_battery, import_kwh_house, export_kwh)
@@ -2216,6 +2650,11 @@ def clip_export_slots(self, minutes_now, predict_soc, export_window_best, export
if limit == 100:
# Ignore disabled windows
pass
+ elif window.get("clipping", False):
+ # DO NOT CLIP CLIPPING BUFFER WINDOWS!
+ # These are designed to PREVENT charging above a limit,
+ # so the fact that the battery is currently below the limit is the WHOLE POINT.
+ pass
elif window_length > 0:
predict_minute_start = max(int((window_start - minutes_now) / 5) * 5, 0)
predict_minute_end = int((window_end - minutes_now) / 5) * 5
@@ -2360,19 +2799,7 @@ def plan_write_debug(self, debug_mode, name, pv_forecast_minute_step, pv_forecas
orig_charge_window_best = copy.deepcopy(self.charge_window_best)
self.charge_limit_best, self.charge_window_best = remove_intersecting_windows(self.charge_limit_best, self.charge_window_best, self.export_limits_best, self.export_window_best)
- (
- cost10,
- import_kwh_battery10,
- import_kwh_house10,
- export_kwh10,
- soc_min10,
- soc10,
- soc_min_minute10,
- battery_cycle10,
- metric_keep10,
- final_iboost10,
- final_carbon_g10,
- ) = self.run_prediction(
+ (cost10, import_kwh_battery10, import_kwh_house10, export_kwh10, soc_min10, soc10, soc_min_minute10, battery_cycle10, metric_keep10, final_iboost10, final_carbon_g, clipping_mitigated, *_) = self.run_prediction(
self.charge_limit_best,
self.charge_window_best,
self.export_window_best,
@@ -3325,6 +3752,8 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
iboost_running,
iboost_running_solar,
iboost_running_full,
+ mitigated_today,
+ *_,
) = pred.run_prediction(charge_limit, charge_window, export_window, export_limits, pv10, end_record, save, step)
self.predict_soc = predict_soc
self.car_charging_soc_next = car_charging_soc_next
@@ -3332,6 +3761,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
self.iboost_running = iboost_running
self.iboost_running_solar = iboost_running_solar
self.iboost_running_full = iboost_running_full
+ self.clipping_mitigated_today = mitigated_today
if save or pred.debug_enable:
predict_soc_time = pred.predict_soc_time
first_charge = pred.first_charge
@@ -3812,6 +4242,102 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
"icon": "mdi:currency-usd",
},
)
+
+ # Add Clipping Summary
+ self.dashboard_item(
+ self.prefix + ".clipping_buffer_kwh",
+ state=dp2(self.clipping_buffer_kwh),
+ attributes={
+ "friendly_name": "Clipping Buffer kWh",
+ "state_class": "measurement",
+ "unit_of_measurement": "kWh",
+ "icon": "mdi:solar-power",
+ },
+ )
+
+ # Expose specific clipping sensors for easier dashboards
+ self.dashboard_item(
+ self.prefix + ".clipping_remaining_today",
+ state=dp2(self.clipping_remaining_today),
+ attributes={
+ "friendly_name": "Clipping Remaining Today",
+ "unit_of_measurement": "kWh",
+ "device_class": "energy",
+ "icon": "mdi:solar-power-variant",
+ },
+ )
+ self.dashboard_item(
+ self.prefix + ".clipping_tomorrow",
+ state=dp2(self.clipping_tomorrow),
+ attributes={
+ "friendly_name": "Clipping Forecast Tomorrow",
+ "unit_of_measurement": "kWh",
+ "device_class": "energy",
+ "icon": "mdi:solar-power-variant-outline",
+ },
+ )
+ # Note: mitigated_today comes from the simulation results
+ self.dashboard_item(
+ self.prefix + ".clipping_mitigated_today",
+ state=dp2(getattr(self, "clipping_mitigated_today", 0.0)),
+ attributes={
+ "friendly_name": "Clipping Mitigated Today",
+ "unit_of_measurement": "kWh",
+ "device_class": "energy",
+ "icon": "mdi:battery-check",
+ },
+ )
+
+ clipping_status_text = "No clipping forecast."
+ clipping_start_iso = None
+ clipping_end_iso = None
+
+ if self.clipping_buffer_kwh > 0:
+
+ def format_time_human(minute):
+ if minute is None:
+ return "N/A"
+ target_dt = self.midnight + timedelta(minutes=minute)
+ if target_dt.date() == self.midnight.date():
+ return target_dt.strftime("%H:%M")
+ else:
+ return target_dt.strftime("Tomorrow %H:%M")
+
+ start_str = format_time_human(self.clipping_buffer_start)
+ end_str = format_time_human(self.clipping_buffer_end)
+
+ if self.clipping_buffer_start is not None:
+ clipping_start_iso = (self.midnight_utc + timedelta(minutes=self.clipping_buffer_start)).isoformat()
+ if self.clipping_buffer_end is not None:
+ clipping_end_iso = (self.midnight_utc + timedelta(minutes=self.clipping_buffer_end)).isoformat()
+
+ discharge_note = ""
+ if self.clipping_buffer_can_discharge == "Always":
+ discharge_note = " (Active discharge enabled)"
+ elif self.clipping_buffer_can_discharge == "Cost Optimal":
+ discharge_note = " (Cost-optimal discharge enabled)"
+
+ if self.clipping_buffer_start is not None and self.clipping_buffer_end is not None:
+ clipping_status_text = "{} kWh clipping forecast ({}) between {} and {}. Setting charge target to mitigate{}.".format(dp2(self.clipping_buffer_kwh), self.clipping_mode, start_str, end_str, discharge_note)
+ else:
+ clipping_status_text = "{} kWh clipping buffer active based on your settings (restricted by {}). No immediate clipping window forecast{}.".format(dp2(self.clipping_buffer_kwh), self.clipping_mode, discharge_note)
+
+ self.dashboard_item(
+ self.prefix + ".clipping_status",
+ state=clipping_status_text,
+ attributes={
+ "friendly_name": "Clipping Buffer Status",
+ "icon": "mdi:information-outline",
+ "results": self.filtered_times(self.clipping_buffer_forecast_kwh),
+ "clipping_start": clipping_start_iso,
+ "clipping_end": clipping_end_iso,
+ "clipping_mode": self.clipping_mode,
+ "clipping_remaining_today": dp2(self.clipping_remaining_today),
+ "clipping_tomorrow": dp2(self.clipping_tomorrow),
+ "clipping_mitigated_today": dp2(getattr(self, "clipping_mitigated_today", 0.0)),
+ "clipping_can_discharge": self.clipping_buffer_can_discharge,
+ },
+ )
self.dashboard_item(self.prefix + ".record", state=0.0, attributes={"results": self.filtered_times(record_time), "friendly_name": "Prediction window", "state_class": "measurement"})
self.dashboard_item(
self.prefix + ".iboost_best",
@@ -4044,6 +4570,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
final_metric_keep,
final_iboost_kwh,
final_carbon_g,
+ mitigated_today,
)
def plan_iboost_smart(self):
diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py
index 068f7a775..a74b56120 100644
--- a/apps/predbat/predbat.py
+++ b/apps/predbat/predbat.py
@@ -644,6 +644,11 @@ def reset(self):
self.isCharging_Target = 0
self.isExporting = False
self.isExporting_Target = 0
+ self.clipping_buffer_kwh = 0
+ self.clipping_buffer_forecast_kwh = {}
+ self.clipping_buffer_min_kwh = 0
+ self.clipping_buffer_start = 0
+ self.clipping_buffer_end = 0
self.savings_today_predbat = 0.0
self.savings_today_predbat_soc = 0.0
self.savings_today_pvbat = 0.0
@@ -670,6 +675,9 @@ def reset(self):
self.load_forecast_array = []
self.pv_forecast_minute = {}
self.pv_forecast_minute10 = {}
+ self.pv_forecast_minute90 = {}
+ self.pv_forecast_minuteCS = {}
+ self.pv_forecast_minuteHIST = {}
self.load_scaling_dynamic = {}
self.carbon_intensity = {}
self.carbon_history = {}
@@ -1526,35 +1534,45 @@ def is_running(self):
Check if the app is running
"""
if self.stop_thread:
+ self.log("is_running false: stop_thread is True")
return False
if not self.dashboard_index:
+ self.log("is_running false: dashboard_index is empty")
return False
if self.fatal_error:
+ self.log("is_running false: fatal_error is True")
return False
if not self.ha_interface:
+ self.log("is_running false: ha_interface is None")
return False
if self.components:
if not self.components.is_all_alive():
+ self.log(f"is_running false: components not all alive. Statuses: {{name: self.components.is_alive(name) for name in self.components.components.keys()}}")
return False
# Read predbat.status
status_entity = self.prefix + ".status"
predbat_error = self.get_state_wrapper(status_entity, attribute="error", default=True)
if predbat_error is None or predbat_error:
+ self.log(f"is_running false: predbat_error is {predbat_error}")
return False
predbat_last_updated = self.get_state_wrapper(status_entity, attribute="last_updated", default=None)
if predbat_last_updated is None:
+ self.log("is_running false: predbat_last_updated is None")
return False
try:
predbat_last_updated = datetime.fromisoformat(predbat_last_updated)
except ValueError:
+ self.log("is_running false: ValueError parsing last_updated")
return False
# Check if the last updated time is within the last 15 minutes
- if (datetime.now() - predbat_last_updated).total_seconds() > 15 * 60:
+ dt_diff = (datetime.now() - predbat_last_updated).total_seconds()
+ if dt_diff > 15 * 60:
+ self.log(f"is_running false: last_updated too old (dt_diff={dt_diff} > 900). now={datetime.now()}, last_updated={predbat_last_updated}")
return False
return True
diff --git a/apps/predbat/prediction.py b/apps/predbat/prediction.py
index 6846d56e9..dfb8add0b 100644
--- a/apps/predbat/prediction.py
+++ b/apps/predbat/prediction.py
@@ -118,6 +118,18 @@ def __init__(self, base=None, pv_forecast_minute_step=None, pv_forecast_minute10
self.car_charging_loss = base.car_charging_loss
self.car_energy_reported_load = base.car_energy_reported_load
self.reserve = base.reserve
+ self.clipping_buffer_kwh = getattr(base, "clipping_buffer_kwh", 0)
+ self.clipping_buffer_forecast_kwh = getattr(base, "clipping_buffer_forecast_kwh", {})
+ self.clipping_buffer_min_kwh = getattr(base, "clipping_buffer_min_kwh", 0)
+ self.clipping_buffer_start = getattr(base, "clipping_buffer_start", None)
+ self.clipping_buffer_end = getattr(base, "clipping_buffer_end", None)
+ self.clipping_limit = getattr(base, "clipping_limit", 0)
+ # Fallback to legacy pv_ac_limit for unit tests and older configs if clipping_limit isn't set yet
+ if self.clipping_limit <= 0 and not base.inverter_hybrid and getattr(base, "pv_ac_limit", 0) > 0:
+ self.clipping_limit = base.pv_ac_limit
+
+ self.clipping_mode = getattr(base, "clipping_mode", "")
+ self.clipping_buffer_can_discharge = getattr(base, "clipping_buffer_can_discharge", "None")
self.metric_standing_charge = base.metric_standing_charge
self.set_charge_freeze = base.set_charge_freeze
self.set_reserve_enable = base.set_reserve_enable
@@ -222,8 +234,23 @@ def thread_run_prediction_single(self, charge_limit, charge_window, export_windo
iboost_running,
iboost_running_solar,
iboost_running_full,
+ mitigated_today,
+ *_,
) = self.run_prediction(charge_limit, charge_window, export_window, export_limits, pv10, end_record=end_record, step=step, cache=self.prediction_cache_enable)
- return (cost, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g)
+ return (
+ cost,
+ import_kwh_battery,
+ import_kwh_house,
+ export_kwh,
+ soc_min,
+ soc,
+ soc_min_minute,
+ battery_cycle,
+ metric_keep,
+ final_iboost,
+ final_carbon_g,
+ mitigated_today,
+ )
def thread_run_prediction_charge(self, try_soc, window_n, charge_limit, charge_window, export_window, export_limits, pv10, all_n, end_record):
"""
@@ -255,6 +282,8 @@ def thread_run_prediction_charge(self, try_soc, window_n, charge_limit, charge_w
iboost_running,
iboost_running_solar,
iboost_running_full,
+ mitigated_today,
+ *_,
) = self.run_prediction(try_charge_limit, charge_window, export_window, export_limits, pv10, end_record=end_record, cache=self.prediction_cache_enable)
return (
cost,
@@ -268,6 +297,7 @@ def thread_run_prediction_charge(self, try_soc, window_n, charge_limit, charge_w
metric_keep,
final_iboost,
final_carbon_g,
+ mitigated_today,
)
def thread_run_prediction_charge_min_max(self, try_soc, window_n, charge_limit, charge_window, export_window, export_limits, pv10, all_n, end_record):
@@ -300,6 +330,8 @@ def thread_run_prediction_charge_min_max(self, try_soc, window_n, charge_limit,
iboost_running,
iboost_running_solar,
iboost_running_full,
+ mitigated_today,
+ *_,
) = self.run_prediction(try_charge_limit, charge_window, export_window, export_limits, pv10, end_record=end_record, cache=False)
min_soc = self.soc_max
max_soc = 0
@@ -326,6 +358,7 @@ def thread_run_prediction_charge_min_max(self, try_soc, window_n, charge_limit,
metric_keep,
final_iboost,
final_carbon_g,
+ mitigated_today,
min_soc,
max_soc,
)
@@ -365,20 +398,34 @@ def thread_run_prediction_export(self, this_export_limit, start, window_n, charg
iboost_running,
iboost_running_solar,
iboost_running_full,
+ mitigated_today,
+ *_,
) = self.run_prediction(charge_limit, charge_window, export_window, export_limits, pv10, end_record=end_record, cache=self.prediction_cache_enable)
- return metricmid, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g
+ return metricmid, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g, mitigated_today
def find_charge_window_optimised(self, charge_windows, charge_limit, is_export=False):
"""
Takes in an array of charge windows
- Returns a dictionary defining for each minute that is in the charge window will contain the window number
+ Returns a dictionary defining for each minute that is in the charge window will contain the window number.
+ In case of overlaps, the window with the LOWEST target SOC wins.
"""
charge_window_optimised = {}
for window_n in range(len(charge_windows)):
+ limit = charge_limit[window_n]
+ # Skip disabled windows
+ if is_export and limit >= 100.0:
+ continue
+ if not is_export and limit <= 0.0:
+ continue
+
for minute in range(charge_windows[window_n]["start"], charge_windows[window_n]["end"], PREDICT_STEP):
- if is_export and charge_limit[window_n] < 100.0:
- charge_window_optimised[minute] = window_n
- elif not is_export and charge_limit[window_n] > 0.0:
+ if minute in charge_window_optimised:
+ # Overlap! Lowest SOC target wins
+ existing_window_n = charge_window_optimised[minute]
+ existing_limit = charge_limit[existing_window_n]
+ if limit < existing_limit:
+ charge_window_optimised[minute] = window_n
+ else:
charge_window_optimised[minute] = window_n
return charge_window_optimised
@@ -386,6 +433,16 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
"""
Run a prediction scenario given a charge limit, return the results
"""
+
+ # Enforce clipping buffer on grid charging
+ if self.clipping_buffer_forecast_kwh:
+ charge_limit = list(charge_limit)
+ for n, window in enumerate(charge_window):
+ buffer_needed_kwh = self.clipping_buffer_forecast_kwh.get(window["start"], 0)
+ if buffer_needed_kwh > 0:
+ target_soc_kwh = max(0, self.soc_max - buffer_needed_kwh)
+ charge_limit[n] = min(charge_limit[n], target_soc_kwh)
+
window_hash = 0
for window in charge_window:
window_hash ^= hash(window["start"]) ^ hash(window["end"])
@@ -481,6 +538,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
first_charge = end_record
export_to_first_charge = 0
clipped_today = 0
+ mitigated_today = 0
predict_soc = {}
car_charging_soc_next = self.car_charging_soc_next[:]
iboost_next = self.iboost_next
@@ -661,12 +719,32 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
# Count PV kWh
pv_kwh += pv_now
- # Clip PV for AC-coupled inverters with a PV AC limit (e.g. microinverters).
- # For non-hybrid systems pv_dc=0 and inverter_loss_ac=1.0, so pv_ac == pv_now; clipping pv_now here is mathematically equivalent to clipping pv_ac in each branch.
- if not inverter_hybrid and pv_ac_limit > 0 and pv_now > pv_ac_limit:
- clipped_today += pv_now - pv_ac_limit
- pv_now = pv_ac_limit
-
+ # Use hierarchical clipping limit
+ # Scale limit by step to match pv_now (which is total over the step)
+ sim_clipping_limit = self.clipping_limit * step
+ this_minute_clipped = 0
+ if sim_clipping_limit > 0 and pv_now > sim_clipping_limit:
+ this_minute_clipped = pv_now - sim_clipping_limit
+ clipped_today += this_minute_clipped
+ pv_now = sim_clipping_limit
+
+ # Active Mitigation: Create a 'Hole' in the battery
+ soc_max_effective = soc_max
+ purge_target_soc = None
+ buffer_now = self.clipping_buffer_forecast_kwh.get(minute_absolute, 0)
+ if buffer_now > 0:
+ target_soc = max(0, soc_max - buffer_now)
+ soc_max_effective = target_soc
+
+ # Ensure that any export window (forced or optimizer-selected) stops at the buffer floor
+ if getattr(self, "clipping_buffer_can_discharge", "") in ["Always", "Cost Optimal"]:
+ purge_target_soc = target_soc
+
+ # Always mode: Force discharge if above target natively in simulation
+ if getattr(self, "clipping_buffer_can_discharge", "") == "Always":
+ if soc > target_soc:
+ export_window_active = True
+ export_limit_now = min(export_limit_now, 0.0) # Force export to 0% (but clamped by purge_target_soc later)
# Modelling reset of charge/discharge rate
if set_charge_window or set_export_window:
charge_rate_now = battery_rate_max_charge
@@ -786,12 +864,15 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
discharge_rate_now_curve_step = discharge_rate_now_curve * step
battery_to_min = max(soc - reserve_expected, 0) * battery_loss_discharge
- battery_to_max = max(soc_max - soc, 0) * battery_loss
+ battery_to_max = max(soc_max_effective - soc, 0) * battery_loss
discharge_min = reserve
if export_window_active:
discharge_min = max(soc_max * export_limit_now / 100.0, reserve, self.best_soc_min)
+ if purge_target_soc is not None:
+ discharge_min = max(discharge_min, purge_target_soc)
+
if not set_export_freeze_only and export_window_active and export_limit_now < 99.0 and (soc > discharge_min):
# Discharge enable, capped at export limit
if self.set_export_low_power:
@@ -1038,7 +1119,30 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
if battery_draw > 0:
soc = max(soc - battery_draw / battery_loss_discharge, reserve_expected)
else:
- soc = min(soc - battery_draw * battery_loss, soc_max)
+ # Normal charging is capped at effective ceiling (target SOC)
+ soc = min(soc - battery_draw * battery_loss, soc_max_effective)
+
+ # Allow clipping energy to soak into the buffer up to physical max
+ # Only if the clipping buffer feature is enabled
+ if self.clipping_buffer_kwh > 0 and this_minute_clipped > 0:
+ space_in_buffer = max(0, soc_max - soc)
+ soak_up = min(this_minute_clipped * battery_loss, space_in_buffer)
+ soc += soak_up
+ # Record energy successfully mitigated (stored in buffer)
+ mitigated_today += soak_up / battery_loss
+ # Reduce reported loss by what we captured
+ truly_lost = this_minute_clipped - (soak_up / battery_loss)
+ clipped_today -= soak_up / battery_loss
+
+ # Penalize only the part that was TRULY lost (if cost optimal)
+ if truly_lost > 0 and self.clipping_buffer_can_discharge == "Cost Optimal":
+ current_export_rate = rate_export.get(minute_absolute, 0)
+ # Value lost is the revenue we could have earned by exporting that solar later
+ # Factor in discharge and inverter efficiency
+ eff_out = self.battery_loss_discharge * self.inverter_loss
+ revenue_lost = truly_lost * eff_out * current_export_rate
+ metric += revenue_lost
+ metric_keep += revenue_lost
# Iboost finally count
if self.iboost_enable:
@@ -1238,6 +1342,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
iboost_running,
iboost_running_solar,
iboost_running_full,
+ round(mitigated_today, 4),
)
return (
@@ -1258,4 +1363,5 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
iboost_running,
iboost_running_solar,
iboost_running_full,
+ round(mitigated_today, 4),
)
diff --git a/apps/predbat/solcast.py b/apps/predbat/solcast.py
index c4d743057..49022acb9 100644
--- a/apps/predbat/solcast.py
+++ b/apps/predbat/solcast.py
@@ -330,7 +330,7 @@ async def download_open_meteo_data(self, configs=None):
else:
self.log("Warn: SolarAPI: Postcode {} could not be resolved to latitude and longitude, using default".format(postcode))
- url = "https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}&hourly=global_tilted_irradiance,temperature_2m,wind_speed_10m&wind_speed_unit=ms&tilt={tilt}&azimuth={az}&forecast_days=4&timezone=UTC".format(
+ url = "https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}&hourly=global_tilted_irradiance,clear_sky_gti,temperature_2m,wind_speed_10m&wind_speed_unit=ms&tilt={tilt}&azimuth={az}&forecast_days=4&timezone=UTC".format(
lat=lat, lon=lon, tilt=tilt, az=az
)
data = await self.cache_get_url(url, params={}, max_age=self.open_meteo_forecast_max_age * 60)
@@ -341,6 +341,7 @@ async def download_open_meteo_data(self, configs=None):
hourly = data.get("hourly", {})
times = hourly.get("time", [])
gti_values = hourly.get("global_tilted_irradiance", [])
+ cs_gti_values = hourly.get("clear_sky_gti", [])
temp_values = hourly.get("temperature_2m", [])
wind_values = hourly.get("wind_speed_10m", [])
@@ -353,7 +354,7 @@ async def download_open_meteo_data(self, configs=None):
# Pass 1: compute instantaneous kW at each UTC timestamp sample.
# Open-Meteo returns point-in-time irradiance (W/m²) at the start of each hour,
# so we must integrate over the period rather than treating the sample as the period energy.
- instant_kw = {} # datetime stamp -> (pv50_kw, pv10_kw)
+ instant_kw = {} # datetime stamp -> (pv50_kw, pv10_kw, pv_cs_kw)
instant_stamps = []
for idx, ts in enumerate(times):
if idx >= len(gti_values):
@@ -361,6 +362,7 @@ async def download_open_meteo_data(self, configs=None):
gti = gti_values[idx]
if gti is None:
gti = 0.0
+ cs_gti = cs_gti_values[idx] if idx < len(cs_gti_values) and cs_gti_values[idx] is not None else gti
temp = temp_values[idx] if idx < len(temp_values) and temp_values[idx] is not None else 25.0
wind = wind_values[idx] if idx < len(wind_values) and wind_values[idx] is not None else 1.0
# Cell temperature via SAPM/PVWatts model: irradiance heats the cell above ambient
@@ -370,6 +372,7 @@ async def download_open_meteo_data(self, configs=None):
# Cap at 1.1 (10% above STC) to prevent unrealistic gains at very cold temperatures.
eta_temp = max(0.5, min(1.1, 1.0 - 0.004 * (t_cell - 25.0)))
pv50_inst = dp4((gti / 1000.0) * kwp * eta_temp * (1.0 - system_loss))
+ pv_cs_inst = dp4((cs_gti / 1000.0) * kwp * eta_temp * (1.0 - system_loss))
raw_p10 = ensemble_p10.get(ts)
# ensemble_p10 was computed without temperature derating; apply eta_temp now
pv10_inst = dp4(min(raw_p10 * eta_temp, pv50_inst) if raw_p10 is not None else pv50_inst * 0.7)
@@ -378,7 +381,7 @@ async def download_open_meteo_data(self, configs=None):
stamp = stamp.replace(tzinfo=pytz.utc)
except (ValueError, TypeError):
continue
- instant_kw[stamp] = (pv50_inst, pv10_inst)
+ instant_kw[stamp] = (pv50_inst, pv10_inst, pv_cs_inst)
instant_stamps.append(stamp)
# Pass 2: trapezoidal integration — energy over [T, T+1h] = 0.5*(kW_at_T + kW_at_T+1h).
@@ -389,21 +392,24 @@ async def download_open_meteo_data(self, configs=None):
next_stamp = instant_stamps[i + 1]
if (next_stamp - stamp) != timedelta(hours=1):
continue
- pv50_start, pv10_start = instant_kw[stamp]
- pv50_end, pv10_end = instant_kw[next_stamp]
+ pv50_start, pv10_start, pv_cs_start = instant_kw[stamp]
+ pv50_end, pv10_end, pv_cs_end = instant_kw[next_stamp]
pv50 = dp4(0.5 * (pv50_start + pv50_end))
pv10 = dp4(0.5 * (pv10_start + pv10_end))
+ pv_cs = dp4(0.5 * (pv_cs_start + pv_cs_end))
# Apply per-month site shading correction from Google Solar API if available
if shading_factors and len(shading_factors) == 12:
shading_month = shading_factors[stamp.month - 1]
pv50 = dp4(pv50 * shading_month)
pv10 = dp4(pv10 * shading_month)
+ pv_cs = dp4(pv_cs * shading_month)
- data_item = {"period_start": stamp.strftime(TIME_FORMAT), "pv_estimate": pv50, "pv_estimate10": pv10}
+ data_item = {"period_start": stamp.strftime(TIME_FORMAT), "pv_estimate": pv50, "pv_estimate10": pv10, "pv_clearsky": pv_cs}
if stamp in period_data:
period_data[stamp]["pv_estimate"] = dp4(period_data[stamp]["pv_estimate"] + pv50)
period_data[stamp]["pv_estimate10"] = dp4(period_data[stamp]["pv_estimate10"] + pv10)
+ period_data[stamp]["pv_clearsky"] = dp4(period_data[stamp]["pv_clearsky"] + pv_cs)
else:
period_data[stamp] = data_item
@@ -658,12 +664,14 @@ async def download_solcast_data(self):
pv50 = forecast.get("pv_estimate", 0) / 60 * period_minutes
pv10 = forecast.get("pv_estimate10", forecast.get("pv_estimate", 0)) / 60 * period_minutes
pv90 = forecast.get("pv_estimate90", forecast.get("pv_estimate", 0)) / 60 * period_minutes
+ pv_cs = forecast.get("clearsky_estimate", pv90) / 60 * period_minutes
- data_item = {"period_start": period_start_stamp.strftime(TIME_FORMAT), "pv_estimate": pv50, "pv_estimate10": pv10, "pv_estimate90": pv90}
+ data_item = {"period_start": period_start_stamp.strftime(TIME_FORMAT), "pv_estimate": pv50, "pv_estimate10": pv10, "pv_estimate90": pv90, "pv_clearsky": pv_cs}
if period_start_stamp in period_data:
period_data[period_start_stamp]["pv_estimate"] += pv50
period_data[period_start_stamp]["pv_estimate10"] += pv10
period_data[period_start_stamp]["pv_estimate90"] += pv90
+ period_data[period_start_stamp]["pv_clearsky"] += pv_cs
else:
period_data[period_start_stamp] = data_item
@@ -744,17 +752,20 @@ def publish_pv_stats(self, pv_forecast_data, divide_by, period):
total_left_today10 = 0
total_left_today90 = 0
total_left_todayCL = 0
+ total_left_todayCS = 0
forecast_day = {}
total_day = {}
total_day10 = {}
total_day90 = {}
total_dayCL = {}
+ total_dayCS = {}
days = 0
for day in range(7):
total_day[day] = 0
total_day10[day] = 0
total_day90[day] = 0
total_dayCL[day] = 0
+ total_dayCS[day] = 0
forecast_day[day] = []
midnight_today = self.midnight_utc
@@ -765,6 +776,7 @@ def publish_pv_stats(self, pv_forecast_data, divide_by, period):
power_now10 = 0
power_now90 = 0
power_nowCL = 0
+ power_nowCS = 0
point_gap = period
for entry in pv_forecast_data:
@@ -782,6 +794,7 @@ def publish_pv_stats(self, pv_forecast_data, divide_by, period):
total_day10[day] = 0
total_day90[day] = 0
total_dayCL[day] = 0
+ total_dayCS[day] = 0
forecast_day[day] = []
days = max(days, day + 1)
@@ -789,22 +802,26 @@ def publish_pv_stats(self, pv_forecast_data, divide_by, period):
pv_estimate10 = entry.get("pv_estimate10", pv_estimate)
pv_estimate90 = entry.get("pv_estimate90", pv_estimate)
pv_estimateCL = entry.get("pv_estimateCL", pv_estimate)
+ pv_clearsky = entry.get("pv_clearsky", pv_estimate90)
pv_estimate /= divide_by
pv_estimate10 /= divide_by
pv_estimate90 /= divide_by
pv_estimateCL /= divide_by
+ pv_clearsky /= divide_by
total_day[day] += pv_estimate
total_day10[day] += pv_estimate10
total_day90[day] += pv_estimate90
total_dayCL[day] += pv_estimateCL
+ total_dayCS[day] += pv_clearsky
if day == 0 and this_point > now:
total_left_today += pv_estimate
total_left_today10 += pv_estimate10
total_left_today90 += pv_estimate90
total_left_todayCL += pv_estimateCL
+ total_left_todayCS += pv_clearsky
next_point = this_point + timedelta(minutes=point_gap)
if this_point <= now and next_point > now:
@@ -812,6 +829,7 @@ def publish_pv_stats(self, pv_forecast_data, divide_by, period):
power_now10 = pv_estimate10 * power_scale
power_now90 = pv_estimate90 * power_scale
power_nowCL = pv_estimateCL * power_scale
+ power_nowCS = pv_clearsky * power_scale
# Add this slot into the total left today but scaled for the time since this point
if day == 0:
@@ -820,6 +838,7 @@ def publish_pv_stats(self, pv_forecast_data, divide_by, period):
total_left_today10 += pv_estimate10 * left_this_slot_scale
total_left_today90 += pv_estimate90 * left_this_slot_scale
total_left_todayCL += pv_estimateCL * left_this_slot_scale
+ total_left_todayCS += pv_clearsky * left_this_slot_scale
fentry = {
"period_start": entry["period_start"],
@@ -827,6 +846,7 @@ def publish_pv_stats(self, pv_forecast_data, divide_by, period):
"pv_estimate10": dp2(pv_estimate10 * power_scale),
"pv_estimate90": dp2(pv_estimate90 * power_scale),
"pv_estimateCL": dp2(pv_estimateCL * power_scale),
+ "pv_clearsky": dp2(pv_clearsky * power_scale),
}
forecast_day[day].append(fentry)
@@ -836,15 +856,17 @@ def publish_pv_stats(self, pv_forecast_data, divide_by, period):
for day in range(days):
if day == 0:
self.log(
- "SolarAPI: PV Forecast for today is {} ({} 10%, {} 90%, {} calibrated) kWh, and PV left today is {} ({} 10%, {} 90%, {} calibrated) kWh".format(
+ "SolarAPI: PV Forecast for today is {} ({} 10%, {} 90%, {} calibrated, {} clearsky) kWh, and PV left today is {} ({} 10%, {} 90%, {} calibrated, {} clearsky) kWh".format(
dp2(total_day[day]),
dp2(total_day10[day]),
dp2(total_day90[day]),
dp2(total_dayCL[day]),
+ dp2(total_dayCS[day]),
dp2(total_left_today),
dp2(total_left_today10),
dp2(total_left_today90),
dp2(total_left_todayCL),
+ dp2(total_left_todayCS),
)
)
self.dashboard_item(
@@ -860,10 +882,12 @@ def publish_pv_stats(self, pv_forecast_data, divide_by, period):
"total10": dp2(total_day10[day]),
"total90": dp2(total_day90[day]),
"totalCL": dp2(total_dayCL[day]),
+ "totalCS": dp2(total_dayCS[day]),
"remaining": dp2(total_left_today),
"remaining10": dp2(total_left_today10),
"remaining90": dp2(total_left_today90),
"remainingCL": dp2(total_left_todayCL),
+ "remainingCS": dp2(total_left_todayCS),
"detailedForecast": forecast_day[day],
},
app="solar",
@@ -881,18 +905,19 @@ def publish_pv_stats(self, pv_forecast_data, divide_by, period):
"now10": dp2(power_now10),
"now90": dp2(power_now90),
"nowCL": dp2(power_nowCL),
+ "nowCS": dp2(power_nowCS),
"remaining": dp2(total_left_today),
"remaining10": dp2(total_left_today10),
"remaining90": dp2(total_left_today90),
"remainingCL": dp2(total_left_todayCL),
+ "remainingCS": dp2(total_left_todayCS),
},
app="solar",
)
else:
day_name = "tomorrow" if day == 1 else "d{}".format(day)
day_name_long = day_name if day == 1 else "day {}".format(day)
- self.log("SolarAPI: PV Forecast for day {} is {} ({} 10%, {} 90%, {} calibrated) kWh".format(day_name, dp2(total_day[day]), dp2(total_day10[day]), dp2(total_day90[day]), dp2(total_dayCL[day])))
-
+ self.log("SolarAPI: PV Forecast for day {} is {} ({} 10%, {} 90%, {} calibrated, {} clearsky) kWh".format(day_name, dp2(total_day[day]), dp2(total_day10[day]), dp2(total_day90[day]), dp2(total_dayCL[day]), dp2(total_dayCS[day])))
self.dashboard_item(
"sensor." + self.prefix + "_pv_" + day_name,
state=dp2(total_dayCL[day] if calibration_on else total_day[day]),
@@ -906,6 +931,7 @@ def publish_pv_stats(self, pv_forecast_data, divide_by, period):
"total10": dp2(total_day10[day]),
"total90": dp2(total_day90[day]),
"totalCL": dp2(total_dayCL[day]),
+ "totalCS": dp2(total_dayCS[day]),
"detailedForecast": forecast_day[day],
},
app="solar",
@@ -1142,6 +1168,7 @@ def pv_calibration(self, pv_forecast_minute, pv_forecast_minute10, pv_forecast_d
pv_estimateCL = {}
pv_estimate10 = {}
pv_estimate90 = {}
+ pv_historical = {}
# The after scaling cap will be applied, but remember that the input data is
# When we have a valid observed peak (from history or forecast history) cap to the lower of
# the inverter rating and that observed peak. With no valid history (e.g. all days excluded
@@ -1153,6 +1180,10 @@ def pv_calibration(self, pv_forecast_minute, pv_forecast_minute10, pv_forecast_d
capped_data = min(max_kwh_cap, observed_cap)
else:
capped_data = max_kwh_cap
+
+ # Historical max curve
+ peak_hist_avg = max(pv_power_hist_by_slot.values()) if pv_power_hist_by_slot else 0
+ hist_max_scaling = max_pv_power_hist / peak_hist_avg if peak_hist_avg > 0 else 1.0
for minute in range(0, max(pv_forecast_minute.keys()) + 1, self.plan_interval_minutes):
pv_value = 0
for offset in range(0, self.plan_interval_minutes, 1):
@@ -1162,6 +1193,10 @@ def pv_calibration(self, pv_forecast_minute, pv_forecast_minute10, pv_forecast_d
pv_estimate10[minute] = dp4(min(pv_value * worst_day_scaling, capped_data))
pv_estimate90[minute] = dp4(min(pv_value * best_day_scaling, capped_data))
+ slot = (int(minute / self.plan_interval_minutes) * self.plan_interval_minutes) % (24 * 60)
+ pv_max = pv_power_hist_by_slot.get(slot, 0) * hist_max_scaling
+ pv_historical[minute] = dp4(min(pv_max / 60 * self.plan_interval_minutes, capped_data))
+
for entry in pv_forecast_data:
period_start = entry.get("period_start", "")
if period_start:
@@ -1175,9 +1210,11 @@ def pv_calibration(self, pv_forecast_minute, pv_forecast_minute10, pv_forecast_d
calibrated = 0
calibrated10 = 0
calibrated90 = 0
+ calibratedMAX = 0
has_calibrated = False
has_calibrated10 = False
has_calibrated90 = False
+ has_calibratedMAX = False
for i in range(slots_per_period):
s = slot + i * self.plan_interval_minutes
v = pv_estimateCL.get(s, None)
@@ -1192,10 +1229,16 @@ def pv_calibration(self, pv_forecast_minute, pv_forecast_minute10, pv_forecast_d
if v90 is not None:
calibrated90 += v90
has_calibrated90 = True
+ vMAX = pv_historical.get(s, None)
+ if vMAX is not None:
+ calibratedMAX += vMAX
+ has_calibratedMAX = True
# When we store the data we have to reverse the divide_by factor
if has_calibrated:
entry["pv_estimateCL"] = calibrated * divide_by
+ if has_calibratedMAX:
+ entry["pv_historical"] = calibratedMAX * divide_by
if create_pv10 and has_calibrated10:
entry["pv_estimate10"] = calibrated10 * divide_by
if create_pv10 and has_calibrated90:
@@ -1212,26 +1255,54 @@ def pv_calibration(self, pv_forecast_minute, pv_forecast_minute10, pv_forecast_d
# Do we use calibrated or raw data?
if self.get_arg("metric_pv_calibration_enable", default=True):
self.log("SolarAPI: PV Calibration: Using calibrated PV data")
- return pv_forecast_minute_adjusted, pv_forecast_minute10, pv_forecast_data
+ return pv_forecast_minute_adjusted, pv_forecast_minute10, pv_forecast_data, pv_historical
else:
- return pv_forecast_minute, pv_forecast_minute10, pv_forecast_data
+ return pv_forecast_minute, pv_forecast_minute10, pv_forecast_data, pv_historical
- def pack_and_store_forecast(self, pv_forecast_minute, pv_forecast_minute10):
+ def pack_and_store_forecast(self, pv_forecast_minute, pv_forecast_minute10, pv_forecast_minute90=None, pv_clearsky_minute=None, pv_max_minute=None):
pv_forecast_pack = {}
pv_forecast_pack10 = {}
-
+ pv_forecast_pack90 = {}
+ pv_forecast_pack_clearsky = {}
+ pv_forecast_pack_historical = {}
prev_value = -1
prev_value10 = -1
+ prev_value90 = -1
+ prev_value_clearsky = -1
+ prev_value_historical = -1
+
+ # Pre-fill dictionaries to ensure interpolation for packing
+ def get_interp_val(data, m):
+ if not data:
+ return 0
+ if m in data:
+ return data[m]
+ # Use plan_interval fallback
+ last_tick = (m // self.plan_interval_minutes) * self.plan_interval_minutes
+ return data.get(last_tick, 0)
for minute in range(0, self.forecast_days * 24 * 60):
current_value = dp4(pv_forecast_minute.get(minute, 0))
current_value10 = dp4(pv_forecast_minute10.get(minute, 0))
+ current_value90 = dp4(get_interp_val(pv_forecast_minute90, minute))
+ current_value_clearsky = dp4(get_interp_val(pv_clearsky_minute, minute))
+ current_value_historical = dp4(get_interp_val(pv_max_minute, minute))
+
if current_value != prev_value:
pv_forecast_pack[minute] = current_value
prev_value = current_value
if current_value10 != prev_value10:
pv_forecast_pack10[minute] = current_value10
prev_value10 = current_value10
+ if current_value90 != prev_value90:
+ pv_forecast_pack90[minute] = current_value90
+ prev_value90 = current_value90
+ if current_value_clearsky != prev_value_clearsky:
+ pv_forecast_pack_clearsky[minute] = current_value_clearsky
+ prev_value_clearsky = current_value_clearsky
+ if current_value_historical != prev_value_historical:
+ pv_forecast_pack_historical[minute] = current_value_historical
+ prev_value_historical = current_value_historical
current_pv_power = dp4(pv_forecast_minute.get(self.minutes_now, 0))
@@ -1244,6 +1315,9 @@ def pack_and_store_forecast(self, pv_forecast_minute, pv_forecast_minute10):
"relative_time": self.midnight_utc.strftime(TIME_FORMAT),
"forecast": pv_forecast_pack,
"forecast10": pv_forecast_pack10,
+ "forecast90": pv_forecast_pack90,
+ "forecast_clearsky": pv_forecast_pack_clearsky,
+ "forecast_historical": pv_forecast_pack_historical,
"unit_of_measurement": "kW",
"device_class": "power",
"state_class": "measurement",
@@ -1364,11 +1438,33 @@ async def fetch_pv_forecast(self):
scale=self.pv_scaling,
spreading=period,
)
+ pv_forecast_minute90, _ = minute_data(
+ pv_forecast_data,
+ self.forecast_days,
+ self.midnight_utc,
+ "pv_estimate90",
+ "period_start",
+ backwards=False,
+ divide_by=divide_by,
+ scale=self.pv_scaling,
+ spreading=period,
+ )
+ pv_clearsky_minute, _ = minute_data(
+ pv_forecast_data,
+ self.forecast_days,
+ self.midnight_utc,
+ "pv_clearsky",
+ "period_start",
+ backwards=False,
+ divide_by=divide_by,
+ scale=self.pv_scaling,
+ spreading=period,
+ )
# Run calibration on the data
- pv_forecast_minute, pv_forecast_minute10, pv_forecast_data = self.pv_calibration(pv_forecast_minute, pv_forecast_minute10, pv_forecast_data, create_pv10, divide_by / period, max_kwh, self.forecast_days, period)
+ pv_forecast_minute, pv_forecast_minute10, pv_forecast_data, pv_max_minute = self.pv_calibration(pv_forecast_minute, pv_forecast_minute10, pv_forecast_data, create_pv10, divide_by / period, max_kwh, self.forecast_days, period)
self.publish_pv_stats(pv_forecast_data, divide_by / period, period)
- self.pack_and_store_forecast(pv_forecast_minute, pv_forecast_minute10)
+ self.pack_and_store_forecast(pv_forecast_minute, pv_forecast_minute10, pv_forecast_minute90, pv_clearsky_minute, pv_max_minute)
self.update_success_timestamp()
self.last_fetched_timestamp = self.now_utc_exact
else:
diff --git a/apps/predbat/tests/test_clipping.py b/apps/predbat/tests/test_clipping.py
new file mode 100644
index 000000000..f42242f4d
--- /dev/null
+++ b/apps/predbat/tests/test_clipping.py
@@ -0,0 +1,196 @@
+import sys
+import os
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from predbat import PredBat
+from tests.test_infra import TestHAInterface
+
+
+def create_mock_predbat():
+ """
+ Create a fresh mock Predbat instance using the standard unit test pattern.
+ """
+ my_predbat = PredBat()
+ my_predbat.states = {}
+ my_predbat.reset()
+ my_predbat.update_time()
+ my_predbat.ha_interface = TestHAInterface()
+ my_predbat.ha_interface.history_enable = False
+ my_predbat.auto_config()
+ my_predbat.load_user_config()
+ my_predbat.fetch_config_options()
+ my_predbat.forecast_minutes = 24 * 60
+ my_predbat.ha_interface.history_enable = True
+
+ # Standard hardware configuration
+ my_predbat.minutes_now = 0
+ my_predbat.soc_max = 10.0
+ my_predbat.soc_kw = 10.0
+
+ class DummyInverter:
+ def __init__(self):
+ self.inverter_limit = 5.0 / 60.0
+ self.export_limit = 5.0 / 60.0
+
+ my_predbat.inverters = [DummyInverter()]
+ my_predbat.inverter_limit = 5.0 / 60.0
+ my_predbat.export_limit = 5.0 / 60.0
+ my_predbat.battery_rate_max_charge = 3600
+ my_predbat.battery_rate_max_discharge = 3600
+ my_predbat.pv_ac_limit = 0.0
+ my_predbat.export_limits = []
+ my_predbat.debug_enable = True
+ my_predbat.args = {"threads": 0}
+
+ # Enable optimization flags
+ my_predbat.calculate_best = True
+ my_predbat.calculate_best_charge = True
+ my_predbat.calculate_best_export = True
+ my_predbat.charge_threshold = 20.0
+ my_predbat.export_threshold = 0.0
+
+ # Standard pricing
+ for m in range(24 * 60):
+ my_predbat.rate_export[m] = 12.0
+ my_predbat.load_minutes[m] = 0.0
+ my_predbat.pv_forecast_minute[m] = 0.01 # Non-zero base
+ my_predbat.pv_forecast_minuteCS[m] = 0.01
+ my_predbat.rate_import[m] = 10.0
+ my_predbat.rate_min_forward[m] = 10.0
+
+ return my_predbat
+
+
+def test_clipping_buffer_plan_inverter_injection():
+ """
+ Test that the calculate_plan method correctly injects an export window
+ to force the inverter to route surplus solar into the battery buffer.
+ """
+ my_predbat = create_mock_predbat()
+ my_predbat.minutes_now = 11 * 60 # Start just before peak
+
+ # Fake PV forecast: 7kW flat during noon, but inverter is 5kW
+ for m in range(720, 840): # 12:00 to 14:00
+ my_predbat.pv_forecast_minute[m] = 7.0 / 60.0
+ my_predbat.pv_forecast_minuteCS[m] = 7.0 / 60.0
+
+ # Enable clipping buffer (auto-calculated)
+ my_predbat.clipping_buffer_enable = True
+ my_predbat.clipping_buffer_forecast = "pv_estimate"
+ my_predbat.clipping_buffer_can_discharge = "Always"
+
+ # Calculate clipping limits
+ rem, c_start, c_end, _ = my_predbat.calculate_clipping_buffer()
+
+ # Verify that the calculation correctly identified a need for a buffer
+ assert rem > 0, "Clipping buffer should be > 0"
+ assert c_start is not None and c_end is not None, "Clipping window should be identified"
+
+ my_predbat.calculate_plan(recompute=False, publish=False)
+
+ # Check that an export window was injected
+ injected = False
+ for e_win in my_predbat.export_window_best:
+ if e_win.get("clipping", False):
+ injected = True
+ assert injected, "An export window should have been injected for the auto clipping buffer"
+
+
+def test_clipping_financial_override_protects_buffer_on_normal_rates():
+ """
+ Test that at a -1p import rate, the grid profit does not outweigh
+ the 12p solar value, so the clipping buffer is preserved.
+ """
+ pb = create_mock_predbat()
+ pb.clipping_buffer_enable = True
+ pb.clipping_buffer_forecast = "pv_clearsky"
+ pb.clipping_buffer_can_discharge = "Cost Optimal"
+
+ # 4% losses (0.96 efficiency)
+ pb.inverter_loss = 0.96
+ pb.battery_loss = 0.96
+ pb.battery_loss_discharge = 0.96
+
+ # Solar spike at 12:00
+ for m in range(720, 750):
+ pb.pv_forecast_minuteCS[m] = 7.0 / 60.0
+ pb.pv_forecast_minute[m] = 7.0 / 60.0
+
+ # -0.1p import rate overnight (above the threshold of -0.42p)
+ # At this rate, saving solar is more profitable than a grid cycle
+ for m in range(120, 150):
+ pb.rate_import[m] = -0.1
+ pb.rate_min_forward[m] = -0.1
+
+ # Manually set the plan as if the optimizer had already run
+ pb.charge_window_best = [{"start": 120, "end": 150, "average": -0.1}]
+ pb.charge_limit_best = [100.0]
+
+ rem, c_start, c_end, _ = pb.calculate_clipping_buffer()
+ assert rem > 0
+ print(f"DEBUG: Calculated buffer {rem}kWh from {c_start} to {c_end}")
+
+ pb.calculate_plan(recompute=False, publish=False)
+
+ # Check that the charge limit was CAPPED at soc_max - buffer
+ target_kwh = pb.soc_max - rem
+ target_percent = (target_kwh / pb.soc_max) * 100.0
+ found_cap = False
+ print(f"DEBUG: Charge limits best: {pb.charge_limit_best}")
+ print(f"DEBUG: Target percent cap: {target_percent}")
+ for limit in pb.charge_limit_best:
+ if limit <= target_percent + 0.1:
+ found_cap = True
+ assert found_cap, f"Hard cap should be applied to protect buffer at -1p (Target SoC should be <= {target_percent}%)"
+
+
+def test_clipping_financial_override_takes_grid_cash_on_extreme_rates():
+ """
+ Test that at a -15p import rate, the grid profit heavily outweighs
+ the 12p solar value, so the clipping buffer is abandoned.
+ """
+ pb = create_mock_predbat()
+ pb.clipping_buffer_enable = True
+ pb.clipping_buffer_forecast = "pv_clearsky"
+ pb.clipping_buffer_can_discharge = "Cost Optimal"
+
+ # 4% losses
+ pb.inverter_loss = 0.96
+ pb.battery_loss = 0.96
+ pb.battery_loss_discharge = 0.96
+
+ # Solar spike at 12:00
+ for m in range(720, 750):
+ pb.pv_forecast_minuteCS[m] = 7.0 / 60.0
+ pb.pv_forecast_minute[m] = 7.0 / 60.0
+
+ # -15p import rate overnight
+ for m in range(120, 150):
+ pb.rate_import[m] = -15.0
+ pb.rate_min_forward[m] = -15.0
+
+ # Manually set the plan as if the optimizer had already run
+ pb.charge_window_best = [{"start": 120, "end": 150, "average": -15.0}]
+ pb.charge_limit_best = [100.0]
+
+ rem, c_start, c_end, _ = pb.calculate_clipping_buffer()
+ assert rem > 0
+
+ pb.calculate_plan(recompute=False, publish=False)
+
+ # Check that the charge limit was NOT capped (it should be 100% / soc_max)
+ target_kwh = pb.soc_max - rem
+ target_percent = (target_kwh / pb.soc_max) * 100.0
+ capped = False
+ for limit in pb.charge_limit_best:
+ if limit <= target_percent + 0.1:
+ capped = True
+ assert not capped, "Hard cap should be RELAXED at -15p to capture grid profit"
+
+
+if __name__ == "__main__":
+ test_clipping_buffer_plan_inverter_injection()
+ test_clipping_financial_override_protects_buffer_on_normal_rates()
+ test_clipping_financial_override_takes_grid_cash_on_extreme_rates()
+ print("Clipping plan tests passed!")
diff --git a/apps/predbat/tests/test_clipping_buffer.py b/apps/predbat/tests/test_clipping_buffer.py
new file mode 100644
index 000000000..16fcebdfd
--- /dev/null
+++ b/apps/predbat/tests/test_clipping_buffer.py
@@ -0,0 +1,65 @@
+from unittest.mock import MagicMock
+import sys
+import os
+
+# Add apps/predbat to sys.path so we can import modules
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from predbat import PredBat
+from plan import Plan
+
+
+def test_clipping_buffer_automated_spike_protection():
+ # Setup mock PredBat
+ pb = MagicMock(spec=PredBat)
+
+ # Enable buffer and use a dummy forecast
+ pb.clipping_buffer_enable = True
+ pb.clipping_buffer_forecast = "pv_estimate90"
+
+ # 5kW hardware limit
+ pb.clipping_buffer_limit_override = 5000 / 60.0 / 1000.0 # 5kW in kW/min
+ pb.pv_ac_limit = 5.0
+ pb.inverter_limit = 5.0
+ pb.inverters = []
+
+ # No user minimum - testing the automated spike protection
+ pb.clipping_buffer_min_kwh = 0.0
+ pb.clipping_buffer_max_kwh = 10.0
+
+ # Timing
+ pb.clipping_buffer_start_time = "10:00:00"
+ pb.clipping_buffer_end_time = "14:00:00"
+ pb.midnight_utc = None # Not needed for basic math test if we mock time correctly
+ pb.forecast_minutes = 24 * 60
+ pb.minutes_now = 0
+
+ # Set up normal forecast (pv_estimate90): never hits the 5kW limit
+ # e.g., maxes out at 4.0kW
+ pb.pv_forecast_minute90 = {m: 4.0 / 60.0 for m in range(24 * 60)}
+
+ # Set up Clear Sky forecast (pv_estimateCS): spikes above 5kW limit
+ # e.g., hits 6.0kW between minute 660 (11:00) and 780 (13:00)
+ cs_forecast = {m: 4.0 / 60.0 for m in range(24 * 60)}
+ for m in range(660, 780):
+ cs_forecast[m] = 6.0 / 60.0 # 1kW of clipping potential for 120 minutes = 120 kW-minutes = 2 kWh
+ pb.pv_forecast_minuteCS = cs_forecast
+
+ pb.inverter_hybrid = True
+ pb.export_limits = []
+
+ # Required for calculate_clipping_buffer logging and math
+ pb.log = MagicMock()
+ pb.time_abs_str = MagicMock(return_value="12:00:00")
+
+ # Call the method
+ remaining, start, end, windows = Plan.calculate_clipping_buffer(pb)
+
+ # The normal forecast predicted 0 clipping.
+ # However, the clear sky forecast predicted 120 mins * 1kW = 120 kW-minutes = 2.0 kWh of clipping.
+ # Because of automated spike protection, the buffer should reserve exactly 2.0 kWh.
+ assert pb.clipping_buffer_forecast_kwh is not None
+ assert round(remaining, 2) == 2.0
+
+ # Ensure it's correctly populated backwards in time
+ assert round(pb.clipping_buffer_forecast_kwh[600], 2) == 2.0 # Still 2.0 before the spike starts
diff --git a/apps/predbat/tests/test_download.py b/apps/predbat/tests/test_download.py
index de386381c..6e6549959 100644
--- a/apps/predbat/tests/test_download.py
+++ b/apps/predbat/tests/test_download.py
@@ -257,8 +257,8 @@ def _test_compute_file_sha1(my_predbat):
Test Git blob SHA1 hash computation (matches GitHub's SHA)
"""
# Create a temporary file with known content
- with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
- f.write("test content\n")
+ with tempfile.NamedTemporaryFile(mode="wb", delete=False) as f:
+ f.write(b"test content\n")
temp_path = f.name
try:
diff --git a/apps/predbat/tests/test_fetch_pv_forecast.py b/apps/predbat/tests/test_fetch_pv_forecast.py
index a257150df..9e4451c78 100644
--- a/apps/predbat/tests/test_fetch_pv_forecast.py
+++ b/apps/predbat/tests/test_fetch_pv_forecast.py
@@ -107,7 +107,7 @@ def test_fetch_pv_forecast_with_relative_time():
)
# Call fetch_pv_forecast
- pv_forecast_minute, pv_forecast_minute10 = fetch.fetch_pv_forecast()
+ pv_forecast_minute, pv_forecast_minute10, *rest = fetch.fetch_pv_forecast()
# With the corrected formula (target = stored_minute - offset), a +120 min offset
# (relative_time is 2 hours before midnight_utc) shifts data BACK by 120 minutes:
@@ -171,7 +171,7 @@ def test_fetch_pv_forecast_no_relative_time():
)
# Call fetch_pv_forecast
- pv_forecast_minute, pv_forecast_minute10 = fetch.fetch_pv_forecast()
+ pv_forecast_minute, pv_forecast_minute10, *rest = fetch.fetch_pv_forecast()
# With no relative_time, it should fall back to midnight_utc
# minute_offset = 0, so forecast data should map directly
@@ -226,7 +226,7 @@ def test_fetch_pv_forecast_invalid_relative_time():
)
# Call fetch_pv_forecast
- pv_forecast_minute, pv_forecast_minute10 = fetch.fetch_pv_forecast()
+ pv_forecast_minute, pv_forecast_minute10, *rest = fetch.fetch_pv_forecast()
# With invalid relative_time, it should fall back to midnight_utc
# minute_offset = 0
@@ -279,7 +279,7 @@ def test_fetch_pv_forecast_relative_time_same_as_midnight():
)
# Call fetch_pv_forecast
- pv_forecast_minute, pv_forecast_minute10 = fetch.fetch_pv_forecast()
+ pv_forecast_minute, pv_forecast_minute10, *rest = fetch.fetch_pv_forecast()
# minute_offset = 0 (same time), so data maps directly
assert pv_forecast_minute[0] == 0.0, f"Expected minute 0 to be 0.0, got {pv_forecast_minute[0]}"
@@ -339,7 +339,7 @@ def test_fetch_pv_forecast_previous_day():
)
# Call fetch_pv_forecast
- pv_forecast_minute, pv_forecast_minute10 = fetch.fetch_pv_forecast()
+ pv_forecast_minute, pv_forecast_minute10, *rest = fetch.fetch_pv_forecast()
# With the corrected formula (target = stored_minute - offset), a +1440 min offset
# maps yesterday's stored minutes to today-relative minutes:
@@ -408,7 +408,7 @@ def test_fetch_pv_forecast_negative_offset():
)
# Call fetch_pv_forecast
- pv_forecast_minute, pv_forecast_minute10 = fetch.fetch_pv_forecast()
+ pv_forecast_minute, pv_forecast_minute10, *rest = fetch.fetch_pv_forecast()
# With the corrected formula (target = stored_minute - offset), a -60 min offset
# (relative_time is 1 hour AFTER midnight_utc) shifts data FORWARD by 60 minutes:
diff --git a/apps/predbat/tests/test_infra.py b/apps/predbat/tests/test_infra.py
index fee51563b..1f8fd1820 100644
--- a/apps/predbat/tests/test_infra.py
+++ b/apps/predbat/tests/test_infra.py
@@ -13,6 +13,7 @@
from matplotlib import pyplot as plt
import asyncio
import numpy as np
+import os
from unittest.mock import MagicMock
@@ -529,9 +530,11 @@ def plot(name, prediction):
ax.plot(minutes, metric, label="metric")
ax.set_xticks(range(0, prediction.forecast_minutes, 240))
ax.set(xlabel="time (minutes)", ylabel="Value", title=name)
- ax.legend()
+ plt.legend()
plt.savefig("{}.png".format(name))
- plt.show()
+ if os.environ.get("PREDBAT_PLOT", False):
+ plt.show()
+ plt.close()
def simple_scenario(
@@ -767,6 +770,7 @@ def simple_scenario(
metric_keep,
final_iboost,
final_carbon_g,
+ clipping_mitigated,
) = wrapped_run_prediction_single(charge_limit_best, charge_window_best, export_window_best, export_limit_best, pv10, end_record=(my_predbat.end_record), step=5)
else:
(
@@ -787,6 +791,7 @@ def simple_scenario(
iboost_running,
iboost_running_solar,
iboost_running_full,
+ mitigated_today,
) = prediction.run_prediction(charge_limit_best, charge_window_best, export_window_best, export_limit_best, pv10, end_record=(my_predbat.end_record), save=save)
prediction.predict_soc = predict_soc
prediction.car_charging_soc_next = car_charging_soc_next
@@ -858,6 +863,7 @@ def simple_scenario(
iboost_running,
iboost_running_solar,
iboost_running_full,
+ mitigated_today,
) = prediction.run_prediction(charge_limit_best, charge_window_best, export_window_best, export_limit_best, pv10, end_record=(my_predbat.end_record), save=save)
prediction.predict_soc = predict_soc
prediction.car_charging_soc_next = car_charging_soc_next
diff --git a/apps/predbat/tests/test_model.py b/apps/predbat/tests/test_model.py
index a27a6df37..87002074c 100644
--- a/apps/predbat/tests/test_model.py
+++ b/apps/predbat/tests/test_model.py
@@ -1892,6 +1892,268 @@ def run_model_tests(my_predbat):
# pv_ac_limit must NOT apply to hybrid inverters (PV is DC-coupled, clipping handled by inverter_limit)
failed |= simple_scenario("pv_ac_limit_hybrid_ignored", my_predbat, 0, 2.0, assert_final_metric=-export_rate * 24, assert_final_soc=24, with_battery=True, hybrid=True, pv_ac_limit=1.5, assert_clipped=0)
+ # Clipping Buffer test: Verify that grid charge is capped to leave room for solar clipping
+ # Buffer is 2.0kWh, battery size 10kWh. Max grid charge should be 8.0kWh (80%)
+ my_predbat.clipping_buffer_enable = True
+ my_predbat.clipping_buffer_can_discharge = "None"
+ my_predbat.clipping_buffer_start = 0
+ my_predbat.clipping_buffer_end = my_predbat.minutes_now + 24 * 60
+ # Simulate a constant 2kWh buffer requirement
+ my_predbat.clipping_buffer_forecast_kwh = {m: 2.0 for m in range(0, 48 * 60)}
+
+ failed |= simple_scenario(
+ "clipping_buffer_grid_charge",
+ my_predbat,
+ 0,
+ 0,
+ assert_final_metric=import_rate * 8.0,
+ assert_final_soc=8.0,
+ with_battery=True,
+ battery_size=10.0,
+ battery_soc=0.0,
+ charge=10.0, # Try to charge to 100%
+ )
+ my_predbat.clipping_buffer_forecast_kwh = {}
+
+ # Clipping Buffer test: Verify dynamic decay over a specific time window
+ # Buffer is 3.0kWh initially, battery size 10kWh. Max grid charge should be 7.0kWh (70%)
+ my_predbat.clipping_buffer_start = my_predbat.minutes_now + 60
+ my_predbat.clipping_buffer_end = my_predbat.minutes_now + 120
+ my_predbat.clipping_buffer_forecast_kwh = {m: 3.0 for m in range(0, 48 * 60)}
+
+ # Simulate a single charge window that ends before clipping decay
+ failed |= simple_scenario(
+ "clipping_buffer_decay_charge",
+ my_predbat,
+ 0,
+ 0,
+ assert_final_metric=import_rate * 7.0,
+ assert_final_soc=7.0,
+ with_battery=True,
+ battery_size=10.0,
+ battery_soc=0.0,
+ charge=10.0,
+ )
+ my_predbat.clipping_buffer_forecast_kwh = {}
+ my_predbat.clipping_buffer_enable = False
+ my_predbat.clipping_buffer_start = None
+ my_predbat.clipping_buffer_end = None
+
if failed:
print("**** ERROR: Some Model tests failed ****")
+
+ # Run the plan injection tests
+ failed |= test_clipping_buffer_plan_inverter_injection(my_predbat)
+ failed |= test_clipping_buffer_plan_manual_injection(my_predbat)
+
+ if failed:
+ print("**** ERROR: Some Model tests failed including clipping plan tests ****")
+ return failed
+
+
+def test_clipping_buffer_plan_inverter_injection(my_predbat):
+ """
+ Test that the calculate_plan method correctly injects an export window
+ to force the inverter to route surplus solar into the battery buffer.
+ """
+ failed = False
+
+ orig_minutes_now = my_predbat.minutes_now
+ orig_forecast_minutes = my_predbat.forecast_minutes
+ orig_soc_max = my_predbat.soc_max
+ orig_soc_kw = my_predbat.soc_kw
+
+ my_predbat.minutes_now = 12 * 60
+ my_predbat.forecast_minutes = 24 * 60
+ my_predbat.soc_max = 10.0
+ my_predbat.soc_kw = 10.0 # Battery is full!
+
+ # Fake PV forecast: 7kW flat during noon, but inverter is 5kW
+ orig_pv_minute = getattr(my_predbat, "pv_forecast_minute", {})
+ orig_pv_minuteCS = getattr(my_predbat, "pv_forecast_minuteCS", {})
+
+ my_predbat.pv_forecast_minute = {m: 0.0 for m in range(24 * 60)}
+ my_predbat.pv_forecast_minuteCS = {m: 0.0 for m in range(24 * 60)}
+ for m in range(11 * 60, 15 * 60):
+ my_predbat.pv_forecast_minute[m] = 7.0 / 60.0
+ my_predbat.pv_forecast_minuteCS[m] = 7.0 / 60.0
+
+ orig_inverters = getattr(my_predbat, "inverters", [])
+ orig_inverter_limit = my_predbat.inverter_limit
+ orig_pv_ac_limit = getattr(my_predbat, "pv_ac_limit", 0.0)
+ orig_inverter_hybrid = my_predbat.inverter_hybrid
+ orig_export_limits = getattr(my_predbat, "export_limits", [])
+
+ my_predbat.inverters = []
+ my_predbat.inverter_limit = 5.0
+ my_predbat.pv_ac_limit = 0.0
+ my_predbat.inverter_hybrid = True
+ my_predbat.export_limits = []
+
+ # Enable clipping buffer (auto-calculated)
+ my_predbat.clipping_buffer_enable = True
+ my_predbat.clipping_buffer_forecast = "pv_estimate"
+ my_predbat.clipping_buffer_can_discharge = "Cost Optimal"
+ my_predbat.clipping_buffer_limit_override = 5000 / 60.0 / 1000.0 # 5kW limit (W -> kW/min, matching fetch.py)
+
+ # Needs a rate import / export to calculate correctly
+ for m in range(24 * 60):
+ my_predbat.rate_import[m] = 10.0
+ my_predbat.rate_export[m] = 5.0
+ my_predbat.load_minutes[m] = 0.5 / 60.0
+
+ my_predbat.calculate_second_pass = False
+
+ def mock_optimise_all_windows(*args, **kwargs):
+ my_predbat.charge_limit_best = [0.0 for _ in range(48)]
+ my_predbat.charge_window_best = [{"start": 0, "end": 0, "average": 0} for _ in range(48)]
+ my_predbat.export_window_best = []
+ my_predbat.export_limits_best = []
+
+ orig_optimise_all = my_predbat.optimise_all_windows
+ my_predbat.optimise_all_windows = mock_optimise_all_windows
+
+ # Calculate clipping limits by doing a full plan run
+ my_predbat.calculate_plan(recompute=True, publish=False)
+
+ my_predbat.optimise_all_windows = orig_optimise_all
+
+ # We can fetch the properties it saved
+ rem = my_predbat.clipping_remaining_today
+ c_start = my_predbat.clipping_buffer_start
+ c_end = my_predbat.clipping_buffer_end
+
+ target_kw = max(0, my_predbat.soc_max - rem)
+ target_percent = (target_kw / my_predbat.soc_max) * 100.0
+
+ injected = False
+ for i, e_win in enumerate(my_predbat.export_window_best):
+ # The logic injects a dump window that ENDS at the start of the peak (or during it)
+ # So we just check if it overlaps the region before the peak where the dump happens
+ if e_win["end"] > my_predbat.minutes_now and e_win["start"] <= c_end:
+ if e_win.get("target", 100.0) < 99.0:
+ injected = True
+
+ if not injected:
+ print(f"ERROR in {__name__}: An export window should have been injected for the clipping buffer.")
+ print(f"Found export windows: {my_predbat.export_window_best}")
+ print(f"Buffer params: rem={rem}, start={c_start}, end={c_end}")
+ failed = True
+
+ # Restore state
+ my_predbat.minutes_now = orig_minutes_now
+ my_predbat.forecast_minutes = orig_forecast_minutes
+ my_predbat.soc_max = orig_soc_max
+ my_predbat.soc_kw = orig_soc_kw
+ my_predbat.pv_forecast_minute = orig_pv_minute
+ my_predbat.pv_forecast_minuteCS = orig_pv_minuteCS
+ my_predbat.inverters = orig_inverters
+ my_predbat.inverter_limit = orig_inverter_limit
+ my_predbat.pv_ac_limit = orig_pv_ac_limit
+ my_predbat.inverter_hybrid = orig_inverter_hybrid
+ my_predbat.export_limits = orig_export_limits
+
+ return failed
+
+
+def test_clipping_buffer_plan_manual_injection(my_predbat):
+ """
+ Test that the calculate_plan method correctly injects an export window
+ when the user specifies a manual fixed buffer size.
+ """
+ failed = False
+
+ orig_minutes_now = my_predbat.minutes_now
+ orig_forecast_minutes = my_predbat.forecast_minutes
+ orig_soc_max = my_predbat.soc_max
+ orig_soc_kw = my_predbat.soc_kw
+
+ my_predbat.minutes_now = 12 * 60
+ my_predbat.forecast_minutes = 24 * 60
+ my_predbat.soc_max = 10.0
+ my_predbat.soc_kw = 10.0 # Battery is full!
+
+ orig_pv_minute = getattr(my_predbat, "pv_forecast_minute", {})
+ orig_pv_minuteCS = getattr(my_predbat, "pv_forecast_minuteCS", {})
+
+ my_predbat.pv_forecast_minute = {m: 0.0 for m in range(24 * 60)}
+ my_predbat.pv_forecast_minuteCS = {m: 0.0 for m in range(24 * 60)}
+
+ orig_inverters = getattr(my_predbat, "inverters", [])
+ orig_inverter_limit = my_predbat.inverter_limit
+ orig_pv_ac_limit = getattr(my_predbat, "pv_ac_limit", 0.0)
+ orig_inverter_hybrid = my_predbat.inverter_hybrid
+ orig_export_limits = getattr(my_predbat, "export_limits", [])
+
+ my_predbat.inverters = []
+ my_predbat.inverter_limit = 5.0
+ my_predbat.pv_ac_limit = 0.0
+ my_predbat.inverter_hybrid = True
+ my_predbat.export_limits = []
+
+ # Manual clipping buffer overrides
+ my_predbat.clipping_buffer_enable = True
+ my_predbat.clipping_buffer_can_discharge = "Always"
+ my_predbat.clipping_buffer_min_kwh = 2.5
+ my_predbat.clipping_buffer_max_kwh = 2.5
+ my_predbat.clipping_buffer_start_time = "12:00:00"
+ my_predbat.clipping_buffer_end_time = "14:00:00"
+ my_predbat.minutes_now = 0 # Ensure we are before the window
+
+ for m in range(24 * 60):
+ my_predbat.rate_import[m] = 10.0
+ my_predbat.rate_export[m] = 5.0
+ my_predbat.load_minutes[m] = 0.5 / 60.0
+
+ from prediction import PRED_GLOBAL
+
+ PRED_GLOBAL["dict"] = my_predbat.__dict__.copy()
+
+ rem, c_start, c_end, *rest = my_predbat.calculate_clipping_buffer()
+
+ if rem != 2.5:
+ print(f"ERROR: Clipping buffer should be exactly 2.5kWh based on manual overrides, got {rem}")
+ failed = True
+ if c_start != 12 * 60:
+ print("ERROR: Clipping start should be 12:00")
+ failed = True
+ if c_end != 14 * 60:
+ print("ERROR: Clipping end should be 14:00")
+ failed = True
+
+ my_predbat.calculate_plan(recompute=False, publish=False)
+
+ target_kw = max(0, my_predbat.soc_max - rem)
+ target_percent = (target_kw / my_predbat.soc_max) * 100.0
+
+ injected = False
+ for i, e_win in enumerate(my_predbat.export_window_best):
+ # The logic injects a dump window that ENDS at the start of the peak (or during it)
+ # So we just check if it overlaps the region before the peak where the dump happens
+ if e_win["end"] > my_predbat.minutes_now and e_win["start"] <= c_end:
+ if e_win.get("target", 100.0) < 99.0:
+ injected = True
+
+ if not injected:
+ print(f"ERROR in manual test: An export window should have been injected. export_window_best={my_predbat.export_window_best}, target_percent={target_percent}, rem={rem}")
+ failed = True
+
+ # Restore state
+ my_predbat.minutes_now = orig_minutes_now
+ my_predbat.forecast_minutes = orig_forecast_minutes
+ my_predbat.soc_max = orig_soc_max
+ my_predbat.soc_kw = orig_soc_kw
+ my_predbat.pv_forecast_minute = orig_pv_minute
+ my_predbat.pv_forecast_minuteCS = orig_pv_minuteCS
+ my_predbat.inverters = orig_inverters
+ my_predbat.inverter_limit = orig_inverter_limit
+ my_predbat.pv_ac_limit = orig_pv_ac_limit
+ my_predbat.inverter_hybrid = orig_inverter_hybrid
+ my_predbat.export_limits = orig_export_limits
+
+ my_predbat.clipping_buffer_start_time = None
+ my_predbat.clipping_buffer_end_time = None
+ my_predbat.clipping_buffer_min_kwh = 0
+ my_predbat.clipping_buffer_max_kwh = 0
+
return failed
diff --git a/apps/predbat/tests/test_optimise_all_windows.py b/apps/predbat/tests/test_optimise_all_windows.py
index ea71021bc..f0da517e8 100644
--- a/apps/predbat/tests/test_optimise_all_windows.py
+++ b/apps/predbat/tests/test_optimise_all_windows.py
@@ -75,7 +75,7 @@ def run_optimise_all_windows(
export_limits_best = [100 for n in range(len(export_window_best))]
failed = False
- metric, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g = my_predbat.run_prediction(
+ metric, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g, *_ = my_predbat.run_prediction(
charge_limit_best, charge_window_best, export_window_best, export_limits_best, False, end_record=end_record
)
# Save plan
@@ -92,7 +92,7 @@ def run_optimise_all_windows(
export_window_best = my_predbat.export_window_best
# Predict
- metric, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g = my_predbat.run_prediction(
+ metric, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g, *_ = my_predbat.run_prediction(
charge_limit_best, charge_window_best, export_window_best, export_limits_best, False, end_record=end_record, save="best"
)
diff --git a/apps/predbat/tests/test_optimise_levels.py b/apps/predbat/tests/test_optimise_levels.py
index 2ebfff83c..1d44826a5 100644
--- a/apps/predbat/tests/test_optimise_levels.py
+++ b/apps/predbat/tests/test_optimise_levels.py
@@ -123,7 +123,7 @@ def run_optimise_levels(
best_price_charge, best_price_export, best_price_charge_level, best_price_export_level = my_predbat.find_price_levels(price_set, price_links, window_index, charge_limit_best, charge_window_best, export_window_best, export_limits_best)
# Predict
- metric, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g = my_predbat.run_prediction(
+ metric, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g, *_ = my_predbat.run_prediction(
charge_limit_best, charge_window_best, export_window_best, export_limits_best, False, end_record=end_record, save="best"
)
diff --git a/apps/predbat/tests/test_publish_inverter_data.py b/apps/predbat/tests/test_publish_inverter_data.py
new file mode 100644
index 000000000..9e2e08c21
--- /dev/null
+++ b/apps/predbat/tests/test_publish_inverter_data.py
@@ -0,0 +1,230 @@
+# -----------------------------------------------------------------------------
+# Predbat Home Battery System
+# Copyright Trefor Southwell 2026 - All Rights Reserved
+# This application maybe used for personal use only and not for commercial use
+# -----------------------------------------------------------------------------
+# fmt off
+# pylint: disable=consider-using-f-string
+# pylint: disable=line-too-long
+# pylint: disable=attribute-defined-outside-init
+
+from unittest.mock import MagicMock
+
+
+# Mock necessary functions/classes that publish_inverter_data uses
+def dp3(val):
+ return round(val, 3)
+
+
+class DummyInverter:
+ def __init__(self, inverter_id):
+ self.id = inverter_id
+ self.pv_power = 0.0
+ self.load_power = 0.0
+ self.battery_power = 0.0
+
+
+class MockExecute:
+ def __init__(self):
+ self.prefix = "predbat"
+ self.pv_power = 5000.0
+ self.grid_power = 2000.0
+ self.load_power = 3000.0
+ self.battery_power = 1000.0
+ self.inverters = []
+ self.dashboard_item = MagicMock()
+
+ # Copy the method from execute.py to test it in isolation
+ def publish_inverter_data(self):
+ """
+ Publish inverter data to dashboard
+ """
+ self.dashboard_item(
+ self.prefix + ".pv_power",
+ state=dp3(self.pv_power / 1000.0),
+ attributes={
+ "friendly_name": "Current PV Power",
+ "state_class": "measurement",
+ "unit_of_measurement": "kW",
+ "icon": "mdi:battery",
+ },
+ )
+ self.dashboard_item(
+ self.prefix + ".grid_power",
+ state=dp3(self.grid_power / 1000.0),
+ attributes={
+ "friendly_name": "Current Grid Power",
+ "state_class": "measurement",
+ "unit_of_measurement": "kW",
+ "icon": "mdi:battery",
+ },
+ )
+ self.dashboard_item(
+ self.prefix + ".load_power",
+ state=dp3(self.load_power / 1000.0),
+ attributes={
+ "friendly_name": "Current Load Power",
+ "state_class": "measurement",
+ "unit_of_measurement": "kW",
+ "icon": "mdi:battery",
+ },
+ )
+ self.dashboard_item(
+ self.prefix + ".battery_power",
+ state=dp3(self.battery_power / 1000.0),
+ attributes={
+ "friendly_name": "Current Battery Power",
+ "state_class": "measurement",
+ "unit_of_measurement": "kW",
+ "icon": "mdi:battery",
+ },
+ )
+
+ # Individual inverter data
+ if self.inverters:
+ for inverter in self.inverters:
+ self.dashboard_item(
+ self.prefix + ".pv_power_{}".format(inverter.id),
+ state=dp3(inverter.pv_power / 1000.0),
+ attributes={
+ "friendly_name": "Current PV Power Inverter {}".format(inverter.id),
+ "state_class": "measurement",
+ "unit_of_measurement": "kW",
+ "icon": "mdi:solar-power",
+ },
+ )
+ self.dashboard_item(
+ self.prefix + ".load_power_{}".format(inverter.id),
+ state=dp3(inverter.load_power / 1000.0),
+ attributes={
+ "friendly_name": "Current Load Power Inverter {}".format(inverter.id),
+ "state_class": "measurement",
+ "unit_of_measurement": "kW",
+ "icon": "mdi:home-lightning-bolt",
+ },
+ )
+ self.dashboard_item(
+ self.prefix + ".battery_power_{}".format(inverter.id),
+ state=dp3(inverter.battery_power / 1000.0),
+ attributes={
+ "friendly_name": "Current Battery Power Inverter {}".format(inverter.id),
+ "state_class": "measurement",
+ "unit_of_measurement": "kW",
+ "icon": "mdi:battery",
+ },
+ )
+
+
+def test_publish_inverter_data():
+ """
+ Test that publish_inverter_data correctly publishes summary and individual inverter sensors.
+ """
+ pb = MockExecute()
+
+ # Setup two mock inverters
+ inv0 = DummyInverter(inverter_id=0)
+ inv0.pv_power = 3000.0
+ inv0.load_power = 1500.0
+ inv0.battery_power = 600.0
+
+ inv1 = DummyInverter(inverter_id=1)
+ inv1.pv_power = 2000.0
+ inv1.load_power = 1500.0
+ inv1.battery_power = 400.0
+
+ pb.inverters = [inv0, inv1]
+
+ # Call the method under test
+ pb.publish_inverter_data()
+
+ # Verify summary sensors
+ pb.dashboard_item.assert_any_call(
+ "predbat.pv_power",
+ state=5.0,
+ attributes={
+ "friendly_name": "Current PV Power",
+ "state_class": "measurement",
+ "unit_of_measurement": "kW",
+ "icon": "mdi:battery",
+ },
+ )
+ pb.dashboard_item.assert_any_call(
+ "predbat.battery_power",
+ state=1.0,
+ attributes={
+ "friendly_name": "Current Battery Power",
+ "state_class": "measurement",
+ "unit_of_measurement": "kW",
+ "icon": "mdi:battery",
+ },
+ )
+
+ # Verify individual inverter sensors for Inverter 0
+ pb.dashboard_item.assert_any_call(
+ "predbat.pv_power_0",
+ state=3.0,
+ attributes={
+ "friendly_name": "Current PV Power Inverter 0",
+ "state_class": "measurement",
+ "unit_of_measurement": "kW",
+ "icon": "mdi:solar-power",
+ },
+ )
+ pb.dashboard_item.assert_any_call(
+ "predbat.load_power_0",
+ state=1.5,
+ attributes={
+ "friendly_name": "Current Load Power Inverter 0",
+ "state_class": "measurement",
+ "unit_of_measurement": "kW",
+ "icon": "mdi:home-lightning-bolt",
+ },
+ )
+ pb.dashboard_item.assert_any_call(
+ "predbat.battery_power_0",
+ state=0.6,
+ attributes={
+ "friendly_name": "Current Battery Power Inverter 0",
+ "state_class": "measurement",
+ "unit_of_measurement": "kW",
+ "icon": "mdi:battery",
+ },
+ )
+
+ # Verify individual inverter sensors for Inverter 1
+ pb.dashboard_item.assert_any_call(
+ "predbat.pv_power_1",
+ state=2.0,
+ attributes={
+ "friendly_name": "Current PV Power Inverter 1",
+ "state_class": "measurement",
+ "unit_of_measurement": "kW",
+ "icon": "mdi:solar-power",
+ },
+ )
+ pb.dashboard_item.assert_any_call(
+ "predbat.load_power_1",
+ state=1.5,
+ attributes={
+ "friendly_name": "Current Load Power Inverter 1",
+ "state_class": "measurement",
+ "unit_of_measurement": "kW",
+ "icon": "mdi:home-lightning-bolt",
+ },
+ )
+ pb.dashboard_item.assert_any_call(
+ "predbat.battery_power_1",
+ state=0.4,
+ attributes={
+ "friendly_name": "Current Battery Power Inverter 1",
+ "state_class": "measurement",
+ "unit_of_measurement": "kW",
+ "icon": "mdi:battery",
+ },
+ )
+
+ print("test_publish_inverter_data passed!")
+
+
+if __name__ == "__main__":
+ test_publish_inverter_data()
diff --git a/apps/predbat/tests/test_random_scenarios.py b/apps/predbat/tests/test_random_scenarios.py
index 8839a36ac..4bd06e175 100644
--- a/apps/predbat/tests/test_random_scenarios.py
+++ b/apps/predbat/tests/test_random_scenarios.py
@@ -600,8 +600,8 @@ def run_scenario(my_predbat, scenario, debug=False):
my_predbat.calculate_plan(recompute=True)
# Normal prediction (cost = raw import/export money)
- cost, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g = my_predbat.run_prediction(
- my_predbat.charge_limit_best,
+ cost, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g, *_ = my_predbat.run_prediction(
+ my_predbat.charge_limit_best,
my_predbat.charge_window_best,
my_predbat.export_window_best,
my_predbat.export_limits_best,
diff --git a/apps/predbat/tests/test_single_debug.py b/apps/predbat/tests/test_single_debug.py
index 184f9f4cc..01888e494 100644
--- a/apps/predbat/tests/test_single_debug.py
+++ b/apps/predbat/tests/test_single_debug.py
@@ -164,7 +164,7 @@ def run_single_debug(test_name, my_predbat, debug_file, expected_file=None, comp
failed = False
my_predbat.log("> ORIGINAL PLAN")
- metric, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g = my_predbat.run_prediction(
+ metric, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g, *_ = my_predbat.run_prediction(
my_predbat.charge_limit_best, my_predbat.charge_window_best, my_predbat.export_window_best, my_predbat.export_limits_best, False, end_record=my_predbat.end_record, save="best"
)
@@ -202,7 +202,7 @@ def run_single_debug(test_name, my_predbat, debug_file, expected_file=None, comp
# Predict
my_predbat.log("> FINAL PLAN")
- metric, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g = my_predbat.run_prediction(
+ metric, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g, *_ = my_predbat.run_prediction(
my_predbat.charge_limit_best, my_predbat.charge_window_best, my_predbat.export_window_best, my_predbat.export_limits_best, False, end_record=my_predbat.end_record, save="best"
)
my_predbat.log("Final plan soc_min {} final_soc {}".format(soc_min, soc))
diff --git a/apps/predbat/tests/test_solcast.py b/apps/predbat/tests/test_solcast.py
index aac1440a8..905a4d041 100644
--- a/apps/predbat/tests/test_solcast.py
+++ b/apps/predbat/tests/test_solcast.py
@@ -2355,7 +2355,7 @@ def mock_minute_data_import_export(max_days_previous, now_utc, key, scale=1.0, r
pv_forecast_minute10 = {m: 0.04 for m in range(total_minutes)}
pv_forecast_data = [{"period_start": base.midnight_utc.strftime("%Y-%m-%dT%H:%M:%S+0000"), "pv_estimate": 0.05}]
- adj_minute, adj_minute10, adj_data = solar.pv_calibration(pv_forecast_minute, pv_forecast_minute10, pv_forecast_data, create_pv10=False, divide_by=1.0, max_kwh=5.0, forecast_days=solar.forecast_days)
+ adj_minute, adj_minute10, adj_data, *rest = solar.pv_calibration(pv_forecast_minute, pv_forecast_minute10, pv_forecast_data, create_pv10=False, divide_by=1.0, max_kwh=5.0, forecast_days=solar.forecast_days)
# Returned minute data must be non-negative
if any(v < 0 for v in adj_minute.values()):
@@ -2575,8 +2575,7 @@ def mock_minute_data_import_export(max_days_previous, now_utc, key, scale=1.0, r
pv_forecast_data.append({"period_start": ts.strftime("%Y-%m-%dT%H:%M:%S+0000"), "pv_estimate": 3.0 * plan_interval / 60})
max_kwh = 2.0 # panel peak output cap in kW
- adj_minute, adj_minute10, adj_data = solar.pv_calibration(pv_forecast_minute, pv_forecast_minute10, pv_forecast_data, create_pv10=False, divide_by=1.0, max_kwh=max_kwh, forecast_days=solar.forecast_days)
-
+ adj_minute, adj_minute10, adj_data, *rest = solar.pv_calibration(pv_forecast_minute, pv_forecast_minute10, pv_forecast_data, create_pv10=False, divide_by=1.0, max_kwh=max_kwh, forecast_days=solar.forecast_days)
# capped_data = min(max(max_pv_power_hist, max_pv_power_forecast), max_kwh) * plan_interval / 60
# max_pv_power_hist ≈ 1 kW (per minute), max_pv_power_forecast ≈ 3/60 kW per minute
# The cap applied per-slot is min(max_kwh, max_hist_or_forecast) / 60 * plan_interval
@@ -2764,7 +2763,7 @@ def mock_minute_import_export(max_days_prev, now_utc, key, scale=1.0, required_u
# synthetic pv_forecast dict without going through the real h0 pipeline
# (which relies on now_utc_exact returning the mocked time).
with patch("solcast.history_attribute_to_minute_data", return_value=(pv_forecast_hist, days_back)):
- adj_m, adj_m10, adj_data = solar.pv_calibration(pv_m, pv_m10, pv_data, create_pv10=True, divide_by=1.0, max_kwh=5.0, forecast_days=solar.forecast_days)
+ adj_m, adj_m10, adj_data, *rest = solar.pv_calibration(pv_m, pv_m10, pv_data, create_pv10=True, divide_by=1.0, max_kwh=5.0, forecast_days=solar.forecast_days)
result = {
"total_adj": solar.pv_calibration_total_adjustment,
"avg_scaling": getattr(solar, "pv_calibration_average_scaling", None),
@@ -2973,7 +2972,7 @@ def mock_minute_import_export(max_days_prev, now_utc, key, scale=1.0, required_u
pv_forecast_hist[minutes_ago] = float(FORECAST_KW)
with patch("solcast.history_attribute_to_minute_data", return_value=(pv_forecast_hist, days)):
- adj_m, adj_m10, adj_data = solar.pv_calibration(pv_m, pv_m10, pv_data, create_pv10=True, divide_by=divide_by_factor, max_kwh=10.0, forecast_days=solar.forecast_days, period=FORECAST_PERIOD)
+ adj_m, adj_m10, adj_data, *rest = solar.pv_calibration(pv_m, pv_m10, pv_data, create_pv10=True, divide_by=divide_by_factor, max_kwh=10.0, forecast_days=solar.forecast_days, period=FORECAST_PERIOD)
# Each annotated entry should cover the full FORECAST_PERIOD minutes.
# Expected calibrated kWh per entry ≈ FORECAST_KW * FORECAST_PERIOD / 60 = 2.0 kWh.
@@ -3096,7 +3095,7 @@ def mock_minute_import_export(max_days_prev, now_utc, key, scale=1.0, required_u
pv_forecast_hist[minutes_ago] = float(FORECAST_KW)
with patch("solcast.history_attribute_to_minute_data", return_value=(pv_forecast_hist, days)):
- adj_m, adj_m10, adj_data = solar.pv_calibration(pv_m, pv_m10, pv_data, create_pv10=True, divide_by=divide_by_factor, max_kwh=10.0, forecast_days=solar.forecast_days, period=FORECAST_PERIOD)
+ adj_m, adj_m10, adj_data, *rest = solar.pv_calibration(pv_m, pv_m10, pv_data, create_pv10=True, divide_by=divide_by_factor, max_kwh=10.0, forecast_days=solar.forecast_days, period=FORECAST_PERIOD)
# Each 15-min entry should be annotated with the single 30-min plan slot that
# starts at the entry timestamp. slots_per_period=max(1,round(15/30))=1, so
diff --git a/apps/predbat/web.py b/apps/predbat/web.py
index 80514cfef..948309894 100644
--- a/apps/predbat/web.py
+++ b/apps/predbat/web.py
@@ -1558,13 +1558,17 @@ def get_chart_series(self, name, results, chart_type, color):
first = False
text += "{"
text += "x: new Date('{}').getTime(),".format(key)
- text += "y: {}".format(results[key])
+ val = results[key]
+ if val is None:
+ text += "y: null"
+ else:
+ text += "y: {}".format(val)
text += "}"
text += " ]\n"
text += " }\n"
return text
- def render_chart(self, series_data, yaxis_name, chart_name, now_str, tagname="chart", daily_chart=True, extra_yaxis=None):
+ def render_chart(self, series_data, yaxis_name, chart_name, now_str, tagname="chart", daily_chart=True, extra_yaxis=None, yaxis_annotations=None, xaxis_annotations=None, yaxis_min=None, yaxis_max=None, yaxis_tick_amount=None):
"""
Render a chart
"""
@@ -1667,6 +1671,14 @@ def render_chart(self, series_data, yaxis_name, chart_name, now_str, tagname="ch
text += " },\n"
text += " xaxis: {\n"
text += " type: 'datetime',\n"
+
+ # Enforce +/- 48 hours bounding box to prevent chart from showing too much trailing history
+ # Use numeric timestamps (ms) which is much safer than string parsing in the browser
+ min_x = int((self.now_utc - timedelta(hours=48)).timestamp() * 1000)
+ max_x = int((self.now_utc + timedelta(hours=48)).timestamp() * 1000)
+ text += " min: {},\n".format(min_x)
+ text += " max: {},\n".format(max_x)
+
text += " labels: {\n"
text += " datetimeUTC: false\n"
text += " }\n"
@@ -1695,6 +1707,13 @@ def render_chart(self, series_data, yaxis_name, chart_name, now_str, tagname="ch
" title: {{ text: '{}' }}".format(yaxis_name),
" decimalsInFloat: 2",
]
+ if yaxis_min is not None:
+ primary_parts.append(" min: {}".format(yaxis_min))
+ if yaxis_max is not None:
+ primary_parts.append(" max: {}".format(yaxis_max))
+ if yaxis_tick_amount is not None:
+ primary_parts.append(" tickAmount: {}".format(yaxis_tick_amount))
+
if primary_series_names:
names = ",".join("'{}'".format(name) for name in primary_series_names)
primary_parts.append(" seriesName: [{}]".format(names))
@@ -1710,6 +1729,11 @@ def render_chart(self, series_data, yaxis_name, chart_name, now_str, tagname="ch
opposite = axis.get("opposite", False)
labels_formatter = axis.get("labels_formatter")
+ # Apply global scaling to secondary axes as well to force grid line alignment
+ ax_min = axis.get("min", yaxis_min)
+ ax_max = axis.get("max", yaxis_max)
+ ax_tick = axis.get("tickAmount", yaxis_tick_amount)
+
axis_parts = []
if series_names:
names = ",".join("'{}'".format(name) for name in series_names)
@@ -1718,6 +1742,14 @@ def render_chart(self, series_data, yaxis_name, chart_name, now_str, tagname="ch
axis_parts.append(" seriesName: '{}'".format(series_name))
axis_parts.append(" title: {{ text: '{}' }}".format(title))
axis_parts.append(" decimalsInFloat: {}".format(decimals))
+
+ if ax_min is not None:
+ axis_parts.append(" min: {}".format(ax_min))
+ if ax_max is not None:
+ axis_parts.append(" max: {}".format(ax_max))
+ if ax_tick is not None:
+ axis_parts.append(" tickAmount: {}".format(ax_tick))
+
if opposite:
axis_parts.append(" opposite: true")
if labels_formatter:
@@ -1743,7 +1775,20 @@ def render_chart(self, series_data, yaxis_name, chart_name, now_str, tagname="ch
text += " }\n"
text += " },\n"
text += " annotations: {\n"
+ if yaxis_annotations:
+ text += " yaxis: [\n"
+ for ann in yaxis_annotations:
+ text += " {\n"
+ text += " y: {},\n".format(ann.get("y", 0))
+ text += " borderColor: '{}',\n".format(ann.get("color", "#FF0000"))
+ text += " label: {\n"
+ text += " text: '{}',\n".format(ann.get("text", ""))
+ text += " style: {{ background: '{}', color: '#fff' }}\n".format(ann.get("color", "#FF0000"))
+ text += " }\n"
+ text += " },\n"
+ text += " ],\n"
text += " xaxis: [\n"
+ # Always add 'now'
text += " {\n"
text += " x: new Date('{}').getTime(),\n".format(now_str)
text += " borderColor: '#775DD0',\n"
@@ -1752,12 +1797,27 @@ def render_chart(self, series_data, yaxis_name, chart_name, now_str, tagname="ch
text += " text: 'now'\n"
text += " }\n"
text += " },\n"
+
+ # Add custom X-axis annotations
+ if xaxis_annotations:
+ for ann in xaxis_annotations:
+ text += " {\n"
+ text += " x: new Date('{}').getTime(),\n".format(ann.get("x", ""))
+ text += " borderColor: '{}',\n".format(ann.get("color", "#00E396"))
+ text += " textAnchor: 'middle',\n"
+ text += " label: {\n"
+ text += " text: '{}',\n".format(ann.get("text", ""))
+ text += " }\n"
+ text += " },\n"
+
+ # Always add 'midnight'
text += " {\n"
text += " x: new Date('{}').getTime(),\n".format(midnight_str)
text += " borderColor: '#000000',\n"
text += " textAnchor: 'middle',\n"
text += " label: {\n"
- text += " text: 'midnight'\n"
+ text += " text: 'midnight',\n"
+ text += " offsetY: 20\n"
text += " }\n"
text += " }\n"
text += " ]\n"
@@ -2820,13 +2880,19 @@ def get_chart(self, chart):
"""
now_str = self.now_utc.strftime(TIME_FORMAT)
soc_kw_h0 = {}
- if self.base.soc_kwh_history:
+ if self.base.soc_kwh_history and self.midnight_utc:
hist = self.base.soc_kwh_history
- for minute in range(0, self.minutes_now, self.plan_interval_minutes):
+ # Fetch up to 48 hours of history
+ start_minute = self.minutes_now - (48 * 60)
+ for minute in range(start_minute, self.minutes_now, self.plan_interval_minutes):
minute_timestamp = self.midnight_utc + timedelta(minutes=minute)
stamp = minute_timestamp.strftime(TIME_FORMAT)
- soc_kw_h0[stamp] = hist.get(self.minutes_now - minute, 0)
+ age_minutes = self.minutes_now - minute
+ # Only add if we actually have history data to avoid filling with 0s
+ if age_minutes in hist or age_minutes < 1440: # Fallback to 0 for recent history, but avoid plotting old 0s if no data
+ soc_kw_h0[stamp] = hist.get(age_minutes, 0)
soc_kw_h0[now_str] = self.base.soc_kw
+
soc_kw = self.get_entity_results(self.prefix + ".soc_kw")
soc_kw_best = self.get_entity_results(self.prefix + ".soc_kw_best")
soc_kw_best10 = self.get_entity_results(self.prefix + ".soc_kw_best10")
@@ -2960,6 +3026,146 @@ def get_chart(self, chart):
{"name": "Forecast CL", "data": pv_today_forecastCL, "opacity": "0.3", "stroke_width": "2", "stroke_curve": "smooth", "chart_type": "area", "color": "#e90a0a"},
]
text += self.render_chart(series_data, "kW", "Solar Forecast", now_str)
+ elif chart == "Clipping":
+ pv_power_hist = history_attribute(self.get_history_wrapper(self.prefix + ".pv_power", 7, required=False))
+ pv_power = prune_today(pv_power_hist, self.now_utc, self.midnight_utc, prune=False)
+
+ # Selected forecast for clipping
+ clipping_forecast_type = self.get_arg("clipping_buffer_forecast", "pv_estimate90")
+ subitem = clipping_forecast_type
+
+ clipping_forecast = prune_today(self.get_entity_detailedForecast("sensor." + self.prefix + "_pv_today", subitem), self.now_utc, self.midnight_utc, prune=False, intermediate=True)
+ clipping_forecast.update(prune_today(self.get_entity_detailedForecast("sensor." + self.prefix + "_pv_tomorrow", subitem), self.now_utc, self.midnight_utc, prune=False, intermediate=True))
+
+ soc_kw_best = self.get_entity_results(self.prefix + ".soc_kw_best")
+
+ soc_kw_h0 = {}
+ if self.base.soc_kwh_history:
+ hist = self.base.soc_kwh_history
+ for minute in range(0, self.minutes_now, self.plan_interval_minutes):
+ minute_timestamp = self.midnight_utc + timedelta(minutes=minute)
+ stamp = minute_timestamp.strftime(TIME_FORMAT)
+ soc_kw_h0[stamp] = hist.get(self.minutes_now - minute, 0)
+ soc_kw_h0[now_str] = self.base.soc_kw
+
+ # Limits
+ # Get physical inverter limit (kW)
+ inverter_ac_limit_kw = 0.0
+ if self.base.inverters:
+ for inverter in self.base.inverters:
+ inverter_ac_limit_kw += inverter.inverter_limit * 60.0 # internal kW/min back to kW
+
+ # Get effective limit from logic
+ clipping_limit_kw = getattr(self.base, "clipping_limit", 0.0) * 60.0
+ clipping_mode = getattr(self.base, "clipping_mode", "Inverter Limit")
+
+ # Ceiling for both axes (kWh and kW) to ensure ticks line up
+ # Max of (SOC Max, Inverter Limit, Max expected Solar)
+ axis_max = 12.0
+ if self.base.soc_max > 12.0 or inverter_ac_limit_kw > 12.0:
+ axis_max = max(self.base.soc_max, inverter_ac_limit_kw, 12.0)
+
+ # Use 6 ticks for even division (e.g. 0, 2, 4, 6, 8, 10, 12)
+ axis_ticks = 6
+
+ annotations = []
+ if clipping_limit_kw > 0:
+ annotations.append({"y": clipping_limit_kw, "text": "{} ({} kW)".format(clipping_mode, round(clipping_limit_kw, 2)), "color": "#FF0000"})
+
+ # If the physical inverter limit is different from the effective limit (e.g. DNO limit is lower), show it too
+ if inverter_ac_limit_kw > 0 and abs(inverter_ac_limit_kw - clipping_limit_kw) > 0.1:
+ annotations.append({"y": inverter_ac_limit_kw, "text": "Inverter Capacity ({} kW)".format(round(inverter_ac_limit_kw, 2)), "color": "#999999"})
+
+ # New X-Axis annotations for clipping window
+ xaxis_annotations = []
+ clipping_start = getattr(self.base, "clipping_buffer_start", None)
+ clipping_end = getattr(self.base, "clipping_buffer_end", None)
+
+ # Today's window
+ if clipping_start is not None and clipping_end is not None:
+ start_dt = self.midnight_utc + timedelta(minutes=clipping_start)
+ end_dt = self.midnight_utc + timedelta(minutes=clipping_end)
+ xaxis_annotations.append({"x": start_dt.strftime(TIME_FORMAT), "text": "Today Buffer Start", "color": "#FF9800"})
+ xaxis_annotations.append({"x": end_dt.strftime(TIME_FORMAT), "text": "Today Buffer End", "color": "#FF9800"})
+
+ # Tomorrow's window (if available in forecast)
+ if getattr(self.base, "clipping_buffer_forecast_kwh", None):
+ forecast = self.base.clipping_buffer_forecast_kwh
+ tomorrow_start = 1440
+ if any(forecast.get(m, 0) > 0 for m in range(tomorrow_start, tomorrow_start + 1440)):
+ mid_tomorrow = self.midnight_utc + timedelta(days=1)
+ xaxis_annotations.append({"x": mid_tomorrow.strftime(TIME_FORMAT), "text": "Tomorrow Buffer Active", "color": "#FF9800"})
+
+ # Clipping Remaining Time-Series
+ clipping_remaining_series = {}
+ if getattr(self.base, "clipping_buffer_forecast_kwh", None):
+ forecast = self.base.clipping_buffer_forecast_kwh
+ step_size = getattr(self.base, "plan_interval_minutes", 30)
+ for minute, kwh in forecast.items():
+ if minute % step_size == 0:
+ minute_timestamp = self.midnight_utc + timedelta(minutes=minute)
+ stamp = minute_timestamp.strftime(TIME_FORMAT)
+ clipping_remaining_series[stamp] = round(kwh, 2)
+
+ # Clipping Target SOC series (The "Red Line")
+ clipping_target_series = {}
+ if getattr(self.base, "clipping_buffer_forecast_kwh", None):
+ forecast = self.base.clipping_buffer_forecast_kwh
+ step_size = getattr(self.base, "plan_interval_minutes", 30)
+ for minute, kwh in forecast.items():
+ if minute % step_size == 0:
+ minute_timestamp = self.midnight_utc + timedelta(minutes=minute)
+ stamp = minute_timestamp.strftime(TIME_FORMAT)
+ # Target is the "Ceiling" we want to stay under
+ target_val = max(0, self.base.soc_max - kwh)
+ if kwh > 0:
+ clipping_target_series[stamp] = round(target_val, 2)
+ else:
+ clipping_target_series[stamp] = None # Don't plot the ceiling when there is no buffer
+
+ series_data = [
+ {"name": "Clipping Ceiling", "data": clipping_target_series, "opacity": "1.0", "stroke_width": "3", "stroke_curve": "smooth", "color": "#eb2323", "unit": "kWh"},
+ {"name": "Actual SOC", "data": soc_kw_best, "opacity": "1.0", "stroke_width": "3", "stroke_curve": "stepline", "color": "#9b23eb", "unit": "kWh"},
+ {"name": "Clipping Remaining", "data": clipping_remaining_series, "opacity": "0.4", "stroke_width": "2", "stroke_curve": "smooth", "color": "#2196F3", "unit": "kWh"},
+ {"name": "PV Power", "data": pv_power, "opacity": "1.0", "stroke_width": "3", "stroke_curve": "smooth", "color": "#f5c43d", "unit": "kW"},
+ {"name": "Clipping Forecast (" + clipping_forecast_type + ")", "data": clipping_forecast, "opacity": "0.3", "stroke_width": "2", "stroke_curve": "smooth", "chart_type": "area", "color": "#a8a8a7", "unit": "kW"},
+ ]
+
+ secondary_axis = [
+ {
+ "title": "kW",
+ "series_name": "PV Power",
+ "decimals": 1,
+ "opposite": True,
+ "min": 0,
+ "max": axis_max,
+ "tickAmount": axis_ticks,
+ },
+ {
+ "title": "kW",
+ "series_name": "Clipping Forecast (" + clipping_forecast_type + ")",
+ "show": False,
+ "min": 0,
+ "max": axis_max,
+ "tickAmount": axis_ticks,
+ },
+ {
+ "title": "kWh",
+ "series_name": "Clipping Remaining",
+ "show": False,
+ "min": 0,
+ "max": axis_max,
+ "tickAmount": axis_ticks,
+ },
+ ]
+
+ # Append dynamic stats to chart title
+ clipping_remaining = getattr(self.base, "clipping_remaining_today", 0.0)
+ clipping_mitigated = getattr(self.base, "clipping_mitigated_today", 0.0)
+ chart_title = "Clipping Analysis (Remaining: {:.2f} kWh, Mitigated: {:.2f} kWh)".format(clipping_remaining, clipping_mitigated)
+
+ text += self.render_chart(series_data, "kWh", chart_title, now_str, yaxis_annotations=annotations, xaxis_annotations=xaxis_annotations, extra_yaxis=secondary_axis, yaxis_min=0, yaxis_max=axis_max, yaxis_tick_amount=axis_ticks)
+
elif chart == "PVAccuracy":
# Get pv_today history once and extract total and remaining attributes per timestamp
pv_today_hist = self.get_history_wrapper("sensor." + self.prefix + "_pv_today", 7, required=False)
@@ -3280,6 +3486,7 @@ async def html_charts(self, request):
text += f'PV'
text += f'PV7'
text += f'PVAccuracy'
+ text += f'Clipping'
text += f'Savings'
text += f'BatteryDegradation'
text += f'MarginalCosts'
diff --git a/docs/clipping.md b/docs/clipping.md
new file mode 100644
index 000000000..3ed9ebf74
--- /dev/null
+++ b/docs/clipping.md
@@ -0,0 +1,91 @@
+# Solar Clipping Buffer
+
+The **Clipping Buffer** is a native feature in Predbat designed to prevent solar energy loss (clipping) on days where PV generation exceeds the inverter's AC capacity.
+
+## The Problem
+
+Predbat's cost-optimization engine often plans to fill your home battery to 100% during cheap overnight grid rates. While this is economically sound for grid imports, it creates a physical problem during the day:
+
+1. **Inverter AC Limit**: Hybrid inverters can only export or provide house load up to their AC limit (e.g., 5kW).
+2. **DC Spikes**: On sunny or scattered-cloud days, DC solar generation can spike significantly above this limit (e.g., 7kW).
+3. **No Storage**: If the battery is already full from grid charging, there is nowhere for the excess 2kW of DC power to go. It cannot be stored, and it cannot be exported because the inverter is already at its AC limit.
+4. **Lost Energy**: This energy is simply "clipped" and lost.
+
+## The Native Solution
+
+Instead of using external Home Assistant automations to force battery levels, Predbat natively reserves a **clipping buffer** in the battery specifically for this excess solar.
+
+### 1. Multi-Forecast Safety Margin
+
+Standard solar forecasts are often "smoothed" or averaged, meaning they might miss the short, high-intensity spikes that cause clipping. Predbat solves this by allowing you to choose from five different forecast types for the buffer calculation:
+
+- **Main Forecast (`pv_estimate`)**: The standard calibrated forecast.
+- **Worst Case (`pv_estimate10`)**: 10th percentile (conservative).
+- **Best Case (`pv_estimate90`)**: 90th percentile (likely for sunny days).
+- **Clear Sky (`pv_clearsky`)**: **Recommended.** Theoretical maximum based on site orientation. This provides a "physics-based" upper bound that won't change with the weather.
+- **Historical Max (`pv_historical`)**: Calculated based on your site's absolute peak production in the last 7 days. Best for adapting to local environmental factors (like shade or seasonal variations).
+
+### 2. Buffer Calculation
+
+Predbat calculates the `clipping_buffer_kwh` by integrating the area of your chosen safety forecast that exceeds your **Effective Clipping Limit**.
+
+- **Dynamic Sizing**: If the forecast shows 2 hours of 1kW clipping, Predbat reserves a 2kWh buffer.
+- **Proactive Risk Management**: Predbat uses a two-stage approach to manage unpredictable spikes on high-generation days:
+ - **The Trigger (`risk_threshold`)**: If the forecast comes close to your limit (default >80% of capacity), Predbat marks the period as a "risk window".
+ - **The Payload (`clipping_buffer_safety_margin`)**: Once a risk is detected, Predbat proactively reserves an additional safety margin (default 5% of the total forecasted solar in that window), even if hard clipping isn't explicitly forecast. This ensures there is always a buffer available for un-forecasted cloud-edge spikes.
+- **48-Hour Planning**: The buffer is aware of Predbat's full 48-hour planning window. If clipping is forecast for tomorrow, Predbat will automatically begin reserving space (by capping overnight grid charging) at midnight of that day.
+- **Dynamic Decay**: As your solar panels produce energy and the peak of the day passes, the reserved buffer dynamically shrinks. This ensures that you don't unnecessarily limit your battery usage in the late afternoon or evening after the risk of clipping has passed.
+
+### 3. Proactive Reservation (Grid-Charge Capping)
+
+The core of the implementation is inside the simulation engine. Predbat enforces the buffer by restricting **Grid Charging** only:
+
+- Any grid charge window is proactively capped at `soc_max - Buffer_Needed`.
+- **PV Priority**: Solar generation *below* the AC limit is prioritized for load or export, ensuring you get the full financial benefit of your solar while keeping the buffer empty for spikes.
+- **Active Mitigation**: During the clipping window, any solar production *above* the AC limit is diverted into the reserved buffer.
+- **Buffer Protection**: The battery is always allowed to charge to 100% using solar power, but only the "clipping" portion is allowed to fill the reserved buffer space during the peak.
+
+### Advanced Configuration
+
+| Setting | Description |
+| ------- | ----------- |
+| `clipping_buffer_enable` | Master toggle to enable/disable the feature. |
+| `clipping_buffer_forecast` | Which solar curve to use for calculating the buffer. **Recommended: `pv_clearsky`** for maximum safety. |
+| `clipping_buffer_min_kwh` | The minimum floor for the buffer. Setting this equal to `max_kwh` creates a **fixed manual buffer**. |
+| `clipping_buffer_max_kwh` | A hard cap on the buffer size to prevent leaving the battery too empty on over-optimistic forecasts. |
+| `clipping_buffer_safety_margin` | **(Default: `0.05`)** The percentage of total window solar to reserve as a safety margin when a risk is detected. |
+| `clipping_buffer_risk_threshold` | **(Default: `0.80`)** How close the forecast must get to your limit (as a factor) to trigger the safety margin logic. |
+| `clipping_buffer_can_discharge` | **Optional (Default: `Cost Optimal`).** Controls how aggressively Predbat creates the buffer.
• `None`: Only stops grid charging.
• `Cost Optimal`: **(Recommended)** Automatically chooses to discharge early if the financial value of the saved solar (valued at the current export rate) outweighs the costs of discharging now. **Also enables the Financial Override**, which abandons the clipping buffer cap if grid import rates drop so low (e.g., extreme negative plunge pricing) that filling the battery from the grid is more profitable than saving the solar.
• `Always`: Forces a discharge to ensure the buffer is physically available before clipping begins (strictly enforces the buffer at all costs). |
+| `clipping_buffer_fallback_window` | **Optional (Default: `2.0`).** The duration (in hours) of the clipping window centered around solar noon for days when the sun does not naturally exceed your hardware limits (e.g. winter). Set to `0` to disable the buffer entirely on those days. |
+| `clipping_buffer_window_offset` | **Optional (Default: `15`).** The safety padding (in minutes) added to the start and end of the auto-detected clipping window. |
+| `clipping_buffer_start_time` | **Optional.** Manually override the start of the clipping window (e.g., `11:00:00`). |
+| `clipping_buffer_end_time` | **Optional.** Manually override the end of the clipping window (e.g., `15:00:00`). |
+| `clipping_buffer_limit_override` | **Optional.** Manually set the power threshold (in Watts) above which clipping is considered active. |
+
+### How the Clipping Limit is Determined
+
+Predbat automatically calculates the **Effective Clipping Limit** by choosing the *most restrictive* constraint on your system:
+
+1. **Manual Override:** If `clipping_buffer_limit_override` is set, this is used exclusively.
+2. **DNO Export Limit:** If your `export_limit` is configured and is lower than your hardware capacity, Predbat will reserve space to prevent export throttling.
+3. **Battery Charge Capacity:** For AC-coupled systems, Predbat limits the "absorbable" PV to the sum of your battery charge rate, house load, and grid export limit.
+4. **Physical Inverter AC Capacity:** The maximum AC power your inverters can convert from DC solar.
+5. **PV AC Capacity:** For non-hybrid systems, the rated limit of your separate PV inverters (e.g., microinverters).
+
+### Understanding 'Cost Optimal' Mode
+
+The default `Cost Optimal` mode allows the Clipping Buffer to work seamlessly with Predbat's primary goal: saving you money.
+In this mode, Predbat treats any "Clipped Solar" as a direct financial loss equivalent to your current export rate. This allows the optimizer to make a smart decision: *"Is it cheaper to force-export some battery power now (at a low rate) to ensure I can capture this high-value solar spike later?"*
+
+**Financial Override**: The `Cost Optimal` mode also includes a dynamic safety check for extreme grid rates. If grid import rates drop to extreme negatives (e.g., you are being paid heavily to take energy), Predbat will calculate if filling the battery from the grid is more profitable than saving space for the solar spike. It calculates the exact round-trip efficiency loss of your system (using your `inverter_loss` and `battery_loss`) and your battery cycle degradation cost (`metric_battery_cycle`). If the grid profit outweighs the lost solar value, Predbat will automatically "override" the buffer and allow the battery to fill from the grid.
+
+If you want the buffer to be created regardless of immediate profit or grid rates (e.g., to maximize self-consumption at all costs), use the `Always` mode.
+
+You can check the active constraint in Home Assistant via the `clipping_mode` attribute on the `sensor.predbat_clipping_status` entity.
+
+## Visualization
+
+You can monitor the buffer in two ways:
+
+1. **Predbat Web UI**: Use the new **Clipping** chart to see your actual PV power overlaid with your chosen safety forecast. The chart now includes a **Clipping Remaining** line showing how the reservation decays throughout the day.
+2. **Home Assistant**: Add the `sensor.predbat_clipping_buffer_kwh` to your dashboard to see exactly how much space is being reserved in real-time.
diff --git a/mkdocs.yml b/mkdocs.yml
index 5fb8786bc..f8869a84d 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -14,6 +14,7 @@ nav:
- components.md
- load-ml.md
- car-charging.md
+ - clipping.md
- configuration-guide.md
- customisation.md
- video-guides.md