Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/predbat/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,12 @@
"name": "metric_pv_calibration_enable",
"friendly_name": "Enable use of Calibrated PV data",
"type": "switch",
"default": True,
},
{
"name": "metric_dynamic_load_adjust",
"friendly_name": "Dynamic Load Adjust",
"type": "switch",
"default": False,
},
{
Expand Down
2 changes: 0 additions & 2 deletions apps/predbat/energydataservice.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ def fetch_energidataservice_rates(self, entity_id, adjust_key=None):
if data_all:
# Sort to be safe
data_all.sort(key=lambda e: self._parse_iso(e.get("hour")) or datetime.min)
print(data_all)

# Add tariffs (HH:MM → H → HH → raw ISO)
for entry in data_all:
Expand All @@ -53,7 +52,6 @@ def fetch_energidataservice_rates(self, entity_id, adjust_key=None):
scale=1.0,
use_cent=use_cent,
)
print(rate_data)

return rate_data

Expand Down
8 changes: 8 additions & 0 deletions apps/predbat/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ def step_data_history(
base_offset=None,
flip=False,
load_adjust={},
load_baseline={},
):
"""
Create cached step data for historical array
Expand Down Expand Up @@ -132,6 +133,10 @@ def step_data_history(
load_extra = max(load_extra, -value) # Don't allow going to negative load values
values[minute] = dp4((value + load_extra) * scaling_dynamic * scale_today * scale_fixed)

# Apply dynamic baseline
if minute_absolute in load_baseline:
values[minute] = max(values[minute], load_baseline[minute_absolute])

# Simple divergence model keeps the same total but brings PV/Load up and down every 5 minutes
if cloud_factor and cloud_factor > 0:
for minute in range(0, self.forecast_minutes, step):
Expand Down Expand Up @@ -954,11 +959,13 @@ def fetch_sensor_data(self):
self.load_minutes, self.load_minutes_age = self.minute_data_load(self.now_utc, "load_today", self.max_days_previous, required_unit="kWh", load_scaling=self.load_scaling)
self.log("Found {} load_today datapoints going back {} days".format(len(self.load_minutes), self.load_minutes_age))
self.load_minutes_now = max(self.load_minutes.get(0, 0) - self.load_minutes.get(self.minutes_now, 0), 0)
self.load_last_period = (self.load_minutes.get(0, 0) - self.load_minutes.get(PREDICT_STEP, 0)) * 60 / PREDICT_STEP
else:
if self.load_forecast:
self.log("Using load forecast from load_forecast sensor")
self.load_minutes_now = self.load_forecast.get(0, 0)
self.load_minutes_age = 0
self.load_last_period = 0
else:
self.log("Error: You have not set load_today or load_forecast, you will have no load data")
self.record_status(message="Error: load_today not set correctly", had_errors=True)
Expand Down Expand Up @@ -2063,6 +2070,7 @@ def fetch_config_options(self):
self.metric_future_rate_offset_export = self.get_arg("metric_future_rate_offset_export")
self.metric_inday_adjust_damping = self.get_arg("metric_inday_adjust_damping")
self.metric_pv_calibration_enable = self.get_arg("metric_pv_calibration_enable")
self.metric_dynamic_load_adjust = self.get_arg("metric_dynamic_load_adjust")
self.rate_low_threshold = self.get_arg("rate_low_threshold")
self.rate_high_threshold = self.get_arg("rate_high_threshold")
self.inverter_soc_reset = self.get_arg("inverter_soc_reset")
Expand Down
6 changes: 4 additions & 2 deletions apps/predbat/gecloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import asyncio
import random
import time
from config import PREDICT_STEP

"""
GE Cloud data download
Expand Down Expand Up @@ -1332,9 +1333,9 @@ def download_ge_data(self, now_utc):
url = "https://api.givenergy.cloud/v1/inverter/{}/data-points/{}".format(geserial, datestr)
while url:
if "?" in url:
url += "&pageSize=4096"
url += "&pageSize=8000"
else:
url += "?pageSize=4096"
url += "?pageSize=8000"
Comment on lines +1336 to +1338

Copilot AI Sep 28, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The magic number 8000 for page size should be defined as a named constant (e.g., GE_API_PAGE_SIZE = 8000) to improve maintainability and make it easier to adjust if needed.

Copilot uses AI. Check for mistakes.
data = self.get_ge_url(url, headers, now_utc, 30 if days_prev == 0 else 8 * 60)
darray = data.get("data", None)
if darray is None:
Expand Down Expand Up @@ -1386,6 +1387,7 @@ def download_ge_data(self, now_utc):
self.pv_today = self.minute_data(mdata, self.max_days_previous, now_utc, "pv", "last_updated", backwards=True, smoothing=True, scale=self.import_export_scaling, clean_increment=True)

self.load_minutes_now = self.load_minutes.get(0, 0) - self.load_minutes.get(self.minutes_now, 0)
self.load_last_period = (self.load_minutes.get(0, 0) - self.load_minutes.get(PREDICT_STEP, 0)) * 60 / PREDICT_STEP
self.import_today_now = self.import_today.get(0, 0) - self.import_today.get(self.minutes_now, 0)
self.export_today_now = self.export_today.get(0, 0) - self.export_today.get(self.minutes_now, 0)
self.pv_today_now = self.pv_today.get(0, 0) - self.pv_today.get(self.minutes_now, 0)
Expand Down
47 changes: 43 additions & 4 deletions apps/predbat/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import traceback
from datetime import datetime, timedelta
from multiprocessing import Pool, cpu_count
from config import PREDICT_STEP, TIME_FORMAT
from config import PREDICT_STEP, TIME_FORMAT, MINUTE_WATT
from utils import calc_percent_limit, dp0, dp1, dp2, dp3, dp4, remove_intersecting_windows, calc_percent_limit
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

Expand All @@ -36,6 +36,38 @@ def get(self):


class Plan:
def dynamic_load(self):

# Last period load analysis
self.load_last_status = "baseline"
if self.load_last_period > self.battery_rate_max_discharge * MINUTE_WATT / 1000:

Copilot AI Sep 28, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The magic number 1000 appears to be a unit conversion factor. Consider defining it as a named constant (e.g., WATTS_TO_KW = 1000) to improve code readability and maintainability.

Copilot uses AI. Check for mistakes.
self.load_last_status = "high"
elif (self.load_last_period < self.battery_rate_max_discharge * 0.9 * MINUTE_WATT / 1000) and (self.load_last_period < self.car_charging_threshold * 0.9):
Comment on lines +39 to +44

Copilot AI Sep 28, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The magic number 0.9 appears twice in this condition. Consider defining it as a named constant (e.g., LOW_LOAD_FACTOR = 0.9) to improve maintainability and make the threshold configurable.

Suggested change
def dynamic_load(self):
# Last period load analysis
self.load_last_status = "baseline"
if self.load_last_period > self.battery_rate_max_discharge * MINUTE_WATT / 1000:
self.load_last_status = "high"
elif (self.load_last_period < self.battery_rate_max_discharge * 0.9 * MINUTE_WATT / 1000) and (self.load_last_period < self.car_charging_threshold * 0.9):
LOW_LOAD_FACTOR = 0.9
def dynamic_load(self):
# Last period load analysis
self.load_last_status = "baseline"
if self.load_last_period > self.battery_rate_max_discharge * MINUTE_WATT / 1000:
self.load_last_status = "high"
elif (self.load_last_period < self.battery_rate_max_discharge * self.LOW_LOAD_FACTOR * MINUTE_WATT / 1000) and (self.load_last_period < self.car_charging_threshold * self.LOW_LOAD_FACTOR):

Copilot uses AI. Check for mistakes.
# Check if the load is less than car charging threshold
self.load_last_status = "low"
else:
self.load_last_status = "baseline"

self.dynamic_load_baseline = {}
if self.metric_dynamic_load_adjust:
minutes_now = self.minutes_now
minutes_end_slot = int((self.minutes_now + 30) / 30) * 30

Copilot AI Sep 28, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The magic number 30 (representing a 30-minute period) appears multiple times. Consider defining it as a named constant (e.g., SLOT_DURATION_MINUTES = 30) to improve maintainability.

Copilot uses AI. Check for mistakes.
# When dynamic load is enabled we try can do two things
# 1. Increase the load prediction in the current 30 minute period to match the actual load (if its low)
# 2. If its low and car charging is predicted then cancel off that car slot
if self.load_last_status == "low":
for car_n in range(0, self.num_cars):
for slot in self.car_charging_slots[car_n]:
if (slot["start"] < minutes_end_slot) and (slot["end"] > minutes_now):
# If the slot is within the current 30 minute period
slot["kwh"] = 0
if self.load_last_status == "high":
for minute_absolute in range(minutes_now, minutes_end_slot, PREDICT_STEP):
car_load = self.in_car_slot(minute_absolute)
load_last_period = self.load_last_period / 60 * PREDICT_STEP
load_last_period = max(load_last_period - car_load, 0)
self.dynamic_load_baseline[minute_absolute] = load_last_period

def find_price_levels(
self,
price_set,
Expand Down Expand Up @@ -791,6 +823,7 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
load_scaling_dynamic=self.load_scaling_dynamic,
cloud_factor=self.metric_load_divergence,
load_adjust=self.manual_load_adjust,
load_baseline=self.dynamic_load_baseline,
)
load_minutes_step10 = self.step_data_history(
self.load_minutes,
Expand All @@ -803,6 +836,7 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
load_scaling_dynamic=self.load_scaling_dynamic,
cloud_factor=min(self.metric_load_divergence + 0.5, 1.0) if self.metric_load_divergence else None,
load_adjust=self.manual_load_adjust,
load_baseline=self.dynamic_load_baseline,
)
pv_forecast_minute_step = self.step_data_history(self.pv_forecast_minute, self.minutes_now, forward=True, cloud_factor=self.metric_cloud_coverage)
pv_forecast_minute10_step = self.step_data_history(self.pv_forecast_minute10, self.minutes_now, forward=True, cloud_factor=min(self.metric_cloud_coverage + 0.2, 1.0) if self.metric_cloud_coverage else None, flip=True)
Expand Down Expand Up @@ -3306,7 +3340,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
self.dashboard_item(
self.prefix + ".export_energy_h0",
state=dp3(export_kwh_h0),
attributes={"friendly_name": "Current export kWh", "state_class": "measurement", "unit_of_measurement": "kWh", "icon": "mdi:transmission-tower-export"},
attributes={"friendly_name": "Current export", "state_class": "measurement", "unit_of_measurement": "kWh", "icon": "mdi:transmission-tower-export"},
)
self.dashboard_item(
self.prefix + ".load_energy",
Expand All @@ -3323,7 +3357,12 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
self.dashboard_item(
self.prefix + ".load_energy_h0",
state=dp3(load_kwh_h0),
attributes={"friendly_name": "Current load kWh", "state_class": "measurement", "unit_of_measurement": "kWh", "icon": "mdi:home-lightning-bolt"},
attributes={"friendly_name": "Current load", "state_class": "measurement", "unit_of_measurement": "kWh", "icon": "mdi:home-lightning-bolt"},
)
self.dashboard_item(
self.prefix + ".load_energy_last_period",
state=dp3(self.load_last_period),
attributes={"friendly_name": "Last period load", "state_class": "measurement", "unit_of_measurement": "kW", "icon": "mdi:home-lightning-bolt", "status" : self.load_last_status},
)
self.dashboard_item(
self.prefix + ".pv_energy",
Expand All @@ -3333,7 +3372,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
self.dashboard_item(
self.prefix + ".pv_energy_h0",
state=dp3(pv_kwh_h0),
attributes={"friendly_name": "Current PV kWh", "state_class": "measurement", "unit_of_measurement": "kWh", "icon": "mdi:solar-power"},
attributes={"friendly_name": "Current PV", "state_class": "measurement", "unit_of_measurement": "kWh", "icon": "mdi:solar-power"},
)
self.dashboard_item(
self.prefix + ".import_energy",
Expand Down
8 changes: 7 additions & 1 deletion apps/predbat/predbat.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
import requests
import asyncio

THIS_VERSION = "v8.25.3"
THIS_VERSION = "v8.25.4"

# fmt: off
PREDBAT_FILES = ["predbat.py", "config.py", "prediction.py", "gecloud.py","utils.py", "inverter.py", "ha.py", "download.py", "unit_test.py", "web.py", "web_helper.py", "predheat.py", "futurerate.py", "octopus.py", "solcast.py","execute.py", "plan.py", "fetch.py", "output.py", "userinterface.py", "energydataservice.py", "alertfeed.py", "compare.py", "db_manager.py", "db_engine.py", "plugin_system.py", "ohme.py", "components.py", "fox.py"]
Expand Down Expand Up @@ -427,6 +427,9 @@ def reset(self):
self.metric_inday_adjust_damping = 1.0
self.metric_standing_charge = 0.0
self.metric_self_sufficiency = 0.0
self.metric_dynamic_load_adjust = False
self.metric_pv_calibration_enable = True
self.dynamic_load_baseline = {}
self.iboost_value_scaling = 1.0
self.rate_import = {}
self.rate_export = {}
Expand Down Expand Up @@ -526,6 +529,8 @@ def reset(self):
self.load_minutes = {}
self.load_minutes_now = 0
self.load_minutes_age = 0
self.load_last_period = 0
self.load_last_status = "baseline"
self.battery_capacity_nominal = False
self.releases = {}
self.balance_inverters_enable = False
Expand Down Expand Up @@ -681,6 +686,7 @@ def update_pred(self, scheduled=True):
self.fetch_config_options()
self.fetch_sensor_data()
self.fetch_inverter_data()
self.dynamic_load()

recompute = False
if not scheduled or not self.plan_valid:
Expand Down
Loading