Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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: 2 additions & 4 deletions apps/predbat/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,9 @@ def execute_plan(self):
self.log("Include original charge start {}, keeping this instead of new start {}".format(self.time_abs_str(inverter.charge_start_time_minutes), self.time_abs_str(minutes_start)))
minutes_start = inverter.charge_start_time_minutes

plan_interval_minutes = getattr(self, "plan_interval_minutes", 30)
# Avoid having too long a period to configure as registers only support 24-hours
if (minutes_start < self.minutes_now) and ((minutes_end - minutes_start) >= 24 * 60):
minutes_start = int(self.minutes_now / plan_interval_minutes) * plan_interval_minutes
minutes_start = int(self.minutes_now / self.plan_interval_minutes) * self.plan_interval_minutes
self.log("Move on charge window start time to avoid wrap - new start {}".format(self.time_abs_str(minutes_start)))

# Span midnight allowed?
Expand Down Expand Up @@ -280,10 +279,9 @@ def execute_plan(self):
)
)

plan_interval_minutes = getattr(self, "plan_interval_minutes", 30)
# Avoid having too long a period to configure as registers only support 24-hours
if (minutes_start < self.minutes_now) and ((minutes_end - minutes_start) >= 24 * 60):
minutes_start = int(self.minutes_now / plan_interval_minutes) * plan_interval_minutes
minutes_start = int(self.minutes_now / self.plan_interval_minutes) * self.plan_interval_minutes
self.log("Move on export window start time to avoid wrap - new start {}".format(self.time_abs_str(minutes_start)))

export_adjust = 1
Expand Down
22 changes: 9 additions & 13 deletions apps/predbat/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,7 @@ def step_data_history(
values = {}
cloud_diff = 0

plan_interval_minutes = getattr(self, "plan_interval_minutes", 30)
for minute in range(0, self.forecast_minutes + plan_interval_minutes, step):
for minute in range(0, self.forecast_minutes + self.plan_interval_minutes, step):
value = 0
minute_absolute = minute + minutes_now

Expand Down Expand Up @@ -130,7 +129,7 @@ def step_data_history(
for offset in range(step):
load_extra += self.get_from_incrementing(load_forecast, minute_absolute, backwards=False)
if load_adjust:
load_extra += load_adjust.get(minute_absolute, 0) * step / float(plan_interval_minutes) # The kWh figure is for the plan interval period, so divide by plan_interval_minutes and times by step
load_extra += load_adjust.get(minute_absolute, 0) * step / float(self.plan_interval_minutes) # The kWh figure is for the plan interval period, so divide by plan_interval_minutes and times by step
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)

Expand Down Expand Up @@ -236,9 +235,8 @@ def previous_days_modal_filter(self, data):
del self.days_previous[min_sum_day_idx]
del self.days_previous_weight[min_sum_day_idx]

plan_interval_minutes = getattr(self, "plan_interval_minutes", 30)
# Gap filling
gap_size = max(self.get_arg("load_filter_threshold", plan_interval_minutes), 5)
gap_size = max(self.get_arg("load_filter_threshold", self.plan_interval_minutes), 5)
for days in days_list:
use_days = max(min(days, self.load_minutes_age), 1)
num_gaps = 0
Expand Down Expand Up @@ -1055,7 +1053,6 @@ def find_charge_window(self, rates, minute, threshold_rate, find_high, alt_rates
rate_low_count = 0
alternate_rate_boundary = False
alt_rate_last = None
plan_interval_minutes = getattr(self, "plan_interval_minutes", 30)

# Work out alternate rate threshold
alt_rate_max = max(alt_rates.values()) if alt_rates else 0
Expand Down Expand Up @@ -1085,11 +1082,11 @@ def find_charge_window(self, rates, minute, threshold_rate, find_high, alt_rates
# If combine is disabled, for import slots make them all N minutes so we can select some not all
rate_low_end = minute
break
if (rate_low_start in self.manual_all_times or minute in self.manual_all_times) and (rate_low_start >= 0) and ((minute - rate_low_start) >= plan_interval_minutes):
if (rate_low_start in self.manual_all_times or minute in self.manual_all_times) and (rate_low_start >= 0) and ((minute - rate_low_start) >= self.plan_interval_minutes):
# Manual slot
rate_low_end = minute
break
if find_high and (rate_low_start >= 0) and (((minute - rate_low_start) >= 60 * 24) or (((minute - rate_low_start) >= plan_interval_minutes) and alternate_rate_boundary)):
if find_high and (rate_low_start >= 0) and (((minute - rate_low_start) >= 60 * 24) or (((minute - rate_low_start) >= self.plan_interval_minutes) and alternate_rate_boundary)):
# Export slot can never be bigger than 4 hours
rate_low_end = minute
break
Expand Down Expand Up @@ -1735,9 +1732,8 @@ def fetch_config_options(self):
self.best_soc_max = self.get_arg("best_soc_max")
self.best_soc_keep = self.get_arg("best_soc_keep")
self.best_soc_keep_weight = self.get_arg("best_soc_keep_weight")
plan_interval_minutes = getattr(self, "plan_interval_minutes", 30)
self.set_soc_minutes = plan_interval_minutes
self.set_window_minutes = plan_interval_minutes
self.set_soc_minutes = self.plan_interval_minutes
self.set_window_minutes = self.plan_interval_minutes
self.inverter_set_charge_before = self.get_arg("inverter_set_charge_before")
if not self.inverter_set_charge_before:
self.set_soc_minutes = 0
Expand All @@ -1751,8 +1747,8 @@ def fetch_config_options(self):
self.combine_rate_threshold = self.get_arg("combine_rate_threshold")
self.combine_export_slots = self.get_arg("combine_export_slots")
self.combine_charge_slots = self.get_arg("combine_charge_slots")
self.charge_slot_split = plan_interval_minutes
self.export_slot_split = plan_interval_minutes
self.charge_slot_split = self.plan_interval_minutes
self.export_slot_split = self.plan_interval_minutes
self.calculate_best = True
self.set_read_only = self.get_arg("set_read_only")

Expand Down
4 changes: 2 additions & 2 deletions apps/predbat/futurerate.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class FutureRate:
def __init__(self, base):
self.base = base
self.record_status = base.record_status
self.plan_interval_minutes = base.plan_interval_minutes
self.log = base.log
self.get_arg = base.get_arg
self.midnight = base.midnight
Expand Down Expand Up @@ -212,11 +213,10 @@ def futurerate_analysis_new(self, url_template, rate_import_real, rate_export_re
item["to"] = time_date_end.strftime(TIME_FORMAT)
item["rate_import"] = dp2(rate_import)
item["rate_export"] = dp2(rate_export)
plan_interval_minutes = getattr(self, "plan_interval_minutes", 30)

# Create intermediate 30 minute data points
if prev_time_date_end == time_date_start and (minutes_end - minutes_start) == 60:
time_end_intermediate = time_date_start + timedelta(minutes=plan_interval_minutes)
time_end_intermediate = time_date_start + timedelta(minutes=self.plan_interval_minutes)
item["to"] = time_end_intermediate.strftime(TIME_FORMAT)
item["rate_import"] = dp2((rate_import + prev_rate_import) / 2)
item["rate_export"] = dp2((rate_export + prev_rate_export) / 2)
Expand Down
9 changes: 5 additions & 4 deletions apps/predbat/octopus.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,8 @@ def __init__(self, api_key, account_id, automatic, base):
self.api_key = api_key
self.base = base
self.log = base.log
self.local_tz = base.local_tz
self.plan_interval_minutes = base.plan_interval_minutes
self.api = OctopusEnergyApiClient(api_key, self.log)
self.stop_api = False
self.account_id = account_id
Expand Down Expand Up @@ -400,7 +402,7 @@ async def start(self):
try:
# Update time every minute
self.now = datetime.now()
self.now_utc = datetime.now(timezone.utc).astimezone()
self.now_utc = datetime.now(self.local_tz)

Copilot AI Nov 7, 2025

Copy link

Choose a reason for hiding this comment

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

This changes the timezone from UTC to local timezone. The variable name now_utc suggests it should contain UTC time. The original code used datetime.now(timezone.utc).astimezone() which converts to local time but the variable name indicates UTC is expected. This change may introduce bugs if other code relies on now_utc being in UTC timezone.

Suggested change
self.now_utc = datetime.now(self.local_tz)
self.now_utc = datetime.now(timezone.utc)

Copilot uses AI. Check for mistakes.
count_minutes = self.now_utc.minute + self.now_utc.hour * 60

if first or (count_minutes % 30) == 0:
Expand Down Expand Up @@ -956,13 +958,12 @@ async def fetch_tariffs(self, tariffs):
standing = self.get_octopus_rates_direct(tariff, standingCharge=True)

rates_stamp = []
plan_interval_minutes = getattr(self, "plan_interval_minutes", 30)
for minute in range(0, 60 * 24 * 2, plan_interval_minutes):
for minute in range(0, 60 * 24 * 2, self.plan_interval_minutes):
time_now = self.midnight_utc + timedelta(minutes=minute)
rate_value = rates.get(minute, None)
if rate_value is not None:
start_time = time_now.strftime(TIME_FORMAT)
end_time = (time_now + timedelta(minutes=plan_interval_minutes)).strftime(TIME_FORMAT)
end_time = (time_now + timedelta(minutes=self.plan_interval_minutes)).strftime(TIME_FORMAT)
rates_stamp.append({"start": start_time, "end": end_time, "value_inc_vat": dp4(rate_value / 100)})
rate_now = rates.get(self.now_utc.minute + self.now_utc.hour * 60, None)
if rate_now:
Expand Down
21 changes: 9 additions & 12 deletions apps/predbat/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -766,10 +766,9 @@ def get_export_type(self, export_limit, current=False):

def get_pv_forecast_slots(self, pv_forecast_minute_step):
pv_forecast_slots = []
plan_interval_minutes = self.plan_interval_minutes
for minute_relative in range(0, self.forecast_minutes, plan_interval_minutes):
for minute_relative in range(0, self.forecast_minutes, self.plan_interval_minutes):
minute_relative_start = minute_relative
minute_relative_slot_end = minute_relative + plan_interval_minutes
minute_relative_slot_end = minute_relative + self.plan_interval_minutes

pv_forecast = 0.0
for offset in range(minute_relative_start, minute_relative_slot_end, PREDICT_STEP):
Expand Down Expand Up @@ -949,8 +948,7 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,
html += "<tr>"
html += self.get_html_plan_header(plan_debug)
# Use plan_interval_minutes instead of hardcoded 30
plan_interval_minutes = self.plan_interval_minutes
minute_now_align = int(self.minutes_now / plan_interval_minutes) * plan_interval_minutes
minute_now_align = int(self.minutes_now / self.plan_interval_minutes) * self.plan_interval_minutes
end_plan = min(end_record, self.forecast_minutes) + minute_now_align
rowspan = 0
in_span = False
Expand Down Expand Up @@ -988,11 +986,11 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,
raw_plan["carbon_enable"] = self.carbon_enable

rate_start = self.midnight_utc
for minute in range(minute_now_align, end_plan, plan_interval_minutes):
for minute in range(minute_now_align, end_plan, self.plan_interval_minutes):
minute_relative = minute - self.minutes_now
minute_relative_start = max(minute_relative, 0)
minute_start = minute_relative_start + self.minutes_now
minute_relative_end = minute_relative + plan_interval_minutes
minute_relative_end = minute_relative + self.plan_interval_minutes
minute_end = minute_relative_end + self.minutes_now
minute_relative_slot_end = minute_relative_end
minute_timestamp = self.midnight_utc + timedelta(minutes=(minute_relative_start + self.minutes_now))
Expand All @@ -1003,7 +1001,7 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,
charge_window_n = -1
export_window_n = -1
in_alert = True if self.alert_active_keep.get(minute, 0) > 0 else False
periods_left = int((end_plan - minute + plan_interval_minutes - 1) / plan_interval_minutes)
periods_left = int((end_plan - minute + self.plan_interval_minutes - 1) / self.plan_interval_minutes)

show_limit = ""

Expand Down Expand Up @@ -1039,7 +1037,7 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,
if discharge_intersect >= 0:
charge_end_minute = min(charge_end_minute, self.export_window_best[discharge_intersect]["start"])

rowspan = min(int((charge_end_minute - minute) / plan_interval_minutes), periods_left)
rowspan = min(int((charge_end_minute - minute) / self.plan_interval_minutes), periods_left)
if rowspan > 1 and (export_window_n < 0):
in_span = True
start_span = True
Expand All @@ -1049,7 +1047,7 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,

if export_window_n >= 0 and not in_span:
export_end_minute = self.export_window_best[export_window_n]["end"]
rowspan = min(int((export_end_minute - minute) / plan_interval_minutes), periods_left)
rowspan = min(int((export_end_minute - minute) / self.plan_interval_minutes), periods_left)
start = self.export_window_best[export_window_n]["start"]
if start <= minute and rowspan > 1 and (charge_window_n < 0):
in_span = True
Expand Down Expand Up @@ -1574,8 +1572,7 @@ def publish_rates(self, rates, export, gas=False):
Create rates/time every plan_interval_minutes
"""
rates_time = {}
plan_interval_minutes = self.plan_interval_minutes
for minute in range(-24 * 60, self.minutes_now + self.forecast_minutes + 24 * 60, plan_interval_minutes):
for minute in range(-24 * 60, self.minutes_now + self.forecast_minutes + 24 * 60, self.plan_interval_minutes):
minute_timestamp = self.midnight_utc + timedelta(minutes=minute)
stamp = minute_timestamp.strftime(TIME_FORMAT)
rates_time[stamp] = dp2(rates[minute])
Expand Down
21 changes: 9 additions & 12 deletions apps/predbat/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,9 +534,8 @@ def launch_run_prediction_export(self, this_export_limit, start, window_n, try_c

def scenario_summary_title(self, record_time):
txt = ""
plan_interval_minutes = self.plan_interval_minutes
minute_start = self.minutes_now - self.minutes_now % plan_interval_minutes
for minute_absolute in range(minute_start, self.forecast_minutes + minute_start, plan_interval_minutes):
minute_start = self.minutes_now - self.minutes_now % self.plan_interval_minutes
for minute_absolute in range(minute_start, self.forecast_minutes + minute_start, self.plan_interval_minutes):
this_minute_absolute = max(minute_absolute, self.minutes_now)
minute_timestamp = self.midnight_utc + timedelta(seconds=60 * this_minute_absolute)
dstamp = minute_timestamp.strftime(TIME_FORMAT)
Expand All @@ -550,9 +549,8 @@ def scenario_summary_title(self, record_time):

def scenario_summary(self, record_time, datap):
txt = ""
plan_interval_minutes = self.plan_interval_minutes
minute_start = self.minutes_now - self.minutes_now % plan_interval_minutes
for minute_absolute in range(minute_start, self.forecast_minutes + minute_start, plan_interval_minutes):
minute_start = self.minutes_now - self.minutes_now % self.plan_interval_minutes
for minute_absolute in range(minute_start, self.forecast_minutes + minute_start, self.plan_interval_minutes):
this_minute_absolute = max(minute_absolute, self.minutes_now)
minute_timestamp = self.midnight_utc + timedelta(seconds=60 * this_minute_absolute)
stamp = minute_timestamp.strftime(TIME_FORMAT)
Expand All @@ -570,26 +568,25 @@ def scenario_summary(self, record_time, datap):

def scenario_summary_state(self, record_time):
txt = ""
plan_interval_minutes = self.plan_interval_minutes
minute_start = self.minutes_now - self.minutes_now % plan_interval_minutes
for minute_absolute in range(minute_start, self.forecast_minutes + minute_start, plan_interval_minutes):
minute_start = self.minutes_now - self.minutes_now % self.plan_interval_minutes
for minute_absolute in range(minute_start, self.forecast_minutes + minute_start, self.plan_interval_minutes):
minute_relative_start = max(minute_absolute - self.minutes_now, 0)
minute_relative_end = minute_relative_start + plan_interval_minutes
minute_relative_end = minute_relative_start + self.plan_interval_minutes
this_minute_absolute = max(minute_absolute, self.minutes_now)
minute_timestamp = self.midnight_utc + timedelta(seconds=60 * this_minute_absolute)
stamp = minute_timestamp.strftime(TIME_FORMAT)
value = ""

charge_window_n = -1
for try_minute in range(this_minute_absolute, minute_absolute + plan_interval_minutes, 5):
for try_minute in range(this_minute_absolute, minute_absolute + self.plan_interval_minutes, 5):
charge_window_n = self.in_charge_window(self.charge_window_best, try_minute)
if charge_window_n >= 0 and self.charge_limit_best[charge_window_n] == 0:
charge_window_n = -1
if charge_window_n >= 0:
break

export_window_n = -1
for try_minute in range(this_minute_absolute, minute_absolute + plan_interval_minutes, 5):
for try_minute in range(this_minute_absolute, minute_absolute + self.plan_interval_minutes, 5):
export_window_n = self.in_charge_window(self.export_window_best, try_minute)
if export_window_n >= 0 and self.export_limits_best[export_window_n] == 100.0:
export_window_n = -1
Expand Down
4 changes: 2 additions & 2 deletions apps/predbat/predbat.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
import requests
import asyncio

THIS_VERSION = "v8.27.4"
THIS_VERSION = "v8.27.5"

# 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", "carbon.py", "web_mcp.py"]
Expand Down Expand Up @@ -334,6 +334,7 @@ def reset(self):
self.text_plan = "Computing please wait..."
self.prediction_cache_enable = True
self.base_load = 0
self.plan_interval_minutes = self.args.get("plan_interval_minutes", 30)
self.db_manager = None
self.plan_debug = False
self.arg_errors = {}
Expand Down Expand Up @@ -624,7 +625,6 @@ def reset(self):
self.config_root = "./"
self.inverter_can_charge_during_export = True
self.octopus_last_joined_try = None
self.plan_interval_minutes = self.args.get("plan_interval_minutes", 30)

for root in CONFIG_ROOTS:
if os.path.exists(root):
Expand Down
Loading