From 2d69d5c7b135d09bb33ada99e28fc1e233f6d7af Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Fri, 7 Nov 2025 19:36:32 +0000 Subject: [PATCH 1/6] Tidy up plan interval minutes --- apps/predbat/execute.py | 6 ++---- apps/predbat/fetch.py | 22 +++++++++------------- apps/predbat/futurerate.py | 3 +-- apps/predbat/octopus.py | 6 +++--- apps/predbat/output.py | 21 +++++++++------------ apps/predbat/plan.py | 21 +++++++++------------ apps/predbat/predbat.py | 4 ++-- apps/predbat/web.py | 13 +++++-------- apps/predbat/web_mcp.py | 4 ++-- 9 files changed, 42 insertions(+), 58 deletions(-) diff --git a/apps/predbat/execute.py b/apps/predbat/execute.py index 2d276b7f2..11a0a32a7 100644 --- a/apps/predbat/execute.py +++ b/apps/predbat/execute.py @@ -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? @@ -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 diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 9f0b78d46..3065687ca 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -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 @@ -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) @@ -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 @@ -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 @@ -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 @@ -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 @@ -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") diff --git a/apps/predbat/futurerate.py b/apps/predbat/futurerate.py index 70b6c29d6..38306300b 100644 --- a/apps/predbat/futurerate.py +++ b/apps/predbat/futurerate.py @@ -212,11 +212,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) diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index 0ca075c6a..f4d5e44b3 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -320,6 +320,7 @@ def __init__(self, api_key, account_id, automatic, base): self.api_key = api_key self.base = base self.log = base.log + self.plan_interval_minutes = base.plan_interval_minutes self.api = OctopusEnergyApiClient(api_key, self.log) self.stop_api = False self.account_id = account_id @@ -956,13 +957,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: diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 0ff9eba7c..18b445d74 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -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): @@ -949,8 +948,7 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10, html += "" 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 @@ -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)) @@ -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 = "" @@ -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 @@ -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 @@ -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]) diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index b2ef72115..008016b3b 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -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) @@ -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) @@ -570,18 +568,17 @@ 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 @@ -589,7 +586,7 @@ def scenario_summary_state(self, record_time): 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 diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 32f773991..cf01dd606 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -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"] @@ -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 = {} @@ -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): diff --git a/apps/predbat/web.py b/apps/predbat/web.py index 2a1d8a73f..597525c5e 100644 --- a/apps/predbat/web.py +++ b/apps/predbat/web.py @@ -51,6 +51,7 @@ class WebInterface: def __init__(self, web_port, base) -> None: self.abort = False self.base = base + self.plan_interval_minutes = base.plan_interval_minutes self.log = base.log self.default_page = "./dash" self.pv_power_hist = {} @@ -1359,8 +1360,7 @@ def add_button_to_time(match): dropdown_counter += 1 now_utc = self.base.now_utc - plan_interval_minutes = getattr(self.base, "plan_interval_minutes", 30) - time_stamp = get_override_time_from_string(now_utc, time_text, plan_interval_minutes) + time_stamp = get_override_time_from_string(now_utc, time_text, self.plan_interval_minutes) if time_stamp is None: return match.group(0) @@ -1822,8 +1822,7 @@ def get_chart(self, chart): soc_kw_h0 = {} if self.base.soc_kwh_history: hist = self.base.soc_kwh_history - plan_interval_minutes = getattr(self.base, "plan_interval_minutes", 30) - for minute in range(0, self.base.minutes_now, plan_interval_minutes): + for minute in range(0, self.base.minutes_now, self.plan_interval_minutes): minute_timestamp = self.base.midnight_utc + timedelta(minutes=minute) stamp = minute_timestamp.strftime(TIME_FORMAT) soc_kw_h0[stamp] = hist.get(self.base.minutes_now - minute, 0) @@ -2804,8 +2803,7 @@ async def html_rate_override(self, request): return web.json_response({"success": False, "message": "Missing required parameters"}, status=400) now_utc = self.base.now_utc - plan_interval_minutes = getattr(self.base, "plan_interval_minutes", 30) - override_time = get_override_time_from_string(now_utc, time_str, plan_interval_minutes) + override_time = get_override_time_from_string(now_utc, time_str, self.plan_interval_minutes) minutes_from_now = (override_time - now_utc).total_seconds() / 60 if minutes_from_now >= 17 * 60: @@ -2865,8 +2863,7 @@ async def html_plan_override(self, request): return web.json_response({"success": False, "message": "Missing required parameters"}, status=400) now_utc = self.base.now_utc - plan_interval_minutes = getattr(self.base, "plan_interval_minutes", 30) - override_time = get_override_time_from_string(now_utc, time_str, plan_interval_minutes) + override_time = get_override_time_from_string(now_utc, time_str, self.plan_interval_minutes) if not override_time: return web.json_response({"success": False, "message": "Invalid time format"}, status=400) diff --git a/apps/predbat/web_mcp.py b/apps/predbat/web_mcp.py index e25479db3..4270ad994 100644 --- a/apps/predbat/web_mcp.py +++ b/apps/predbat/web_mcp.py @@ -949,6 +949,7 @@ def __init__(self, base, log_func=None): self.base = base self.log = log_func or print self.is_running = False + self.plan_interval_minutes = base.plan_interval_minutes if log_func: log_func("Creating HTTP MCP Server with Predbat integration") @@ -1037,8 +1038,7 @@ async def _execute_set_plan_override(self, arguments: Dict[str, Any]) -> Dict[st action = action.replace(" ", "_") now_utc = self.base.now_utc - plan_interval_minutes = getattr(self.base, "plan_interval_minutes", 30) - override_time = get_override_time_from_string(now_utc, time_str, plan_interval_minutes) + override_time = get_override_time_from_string(now_utc, time_str, self.plan_interval_minutes) if not override_time: return {"success": False, "error": "Invalid time format. Use 'Day HH:MM' format e.g. Sat 14:30", "data": None} From b7c9bd27ead5e93089301cb6d01189fc45e86860 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Fri, 7 Nov 2025 19:38:26 +0000 Subject: [PATCH 2/6] Fix --- apps/predbat/futurerate.py | 1 + apps/predbat/predbat.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/predbat/futurerate.py b/apps/predbat/futurerate.py index 38306300b..73308f2d4 100644 --- a/apps/predbat/futurerate.py +++ b/apps/predbat/futurerate.py @@ -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 diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index cf01dd606..3f087e94f 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -334,7 +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.plan_interval_minutes = 30 self.db_manager = None self.plan_debug = False self.arg_errors = {} From 208c7d432b5b84cabd9337a895503b1433a02af9 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Fri, 7 Nov 2025 19:42:49 +0000 Subject: [PATCH 3/6] Solcast fixes --- apps/predbat/solcast.py | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/apps/predbat/solcast.py b/apps/predbat/solcast.py index a12a8048d..5c0e39f1c 100644 --- a/apps/predbat/solcast.py +++ b/apps/predbat/solcast.py @@ -58,16 +58,16 @@ def __init__(self, prefix, solcast_host, solcast_api_key, solcast_sites, solcast self.forecast_solar_failures_total = 0 self.config_root = base.config_root self.forecast_days = 4 + self.local_tz = self.base.local_tz + self.plan_interval_minutes = base.plan_interval_minutes async def start(self): """ Start the Solar API """ seconds = 0 - plan_interval_minutes = getattr(self.base, "plan_interval_minutes", 30) while not self.api_stop: - plan_interval_minutes = getattr(self.base, "plan_interval_minutes", 30) - if seconds % (plan_interval_minutes * 60) == 0: # Every plan_interval_minutes + if seconds % (self.plan_interval_minutes * 60) == 0: # Every plan_interval_minutes try: self.fetch_pv_forecast() except Exception as e: @@ -240,7 +240,6 @@ def download_forecast_solar_data(self): period_data = {} max_kwh = 0 - plan_interval_minutes = getattr(self.base, "plan_interval_minutes", 30) for config in configs: lat = config.get("latitude", 51.5072) lon = config.get("longitude", -0.1276) @@ -306,11 +305,11 @@ def download_forecast_solar_data(self): period_start_stamp = period_end_stamp - for minute in range(0, days_data * 24 * 60, plan_interval_minutes): + for minute in range(0, days_data * 24 * 60, self.plan_interval_minutes): pv50 = 0 - for offset in range(0, plan_interval_minutes, 1): + for offset in range(0, self.plan_interval_minutes, 1): pv50 += dp4(forecast_watt_data.get(minute + offset, 0) / 1000.0) - pv50 /= plan_interval_minutes + pv50 /= self.plan_interval_minutes period_start_stamp = self.midnight_utc.replace(tzinfo=pytz.utc) + timedelta(minutes=minute) data_item = {"period_start": period_start_stamp.strftime(TIME_FORMAT), "pv_estimate": pv50} if period_start_stamp in period_data: @@ -698,14 +697,12 @@ def pv_calibration(self, pv_forecast_minute, pv_forecast_minute10, pv_forecast_d pv_forecast_by_slot_count = {} past_day_forecast = {} past_day_actual = {} - plan_interval_minutes = getattr(self.base, "plan_interval_minutes", 30) - for minute in pv_power_hist: minute_absolute = self.minutes_now - minute if minute_absolute < 0: days_prev = int(abs(minute_absolute) / (24 * 60)) + 1 slot_abs = minute_absolute % (24 * 60) - slot = int(slot_abs / plan_interval_minutes) * plan_interval_minutes + slot = int(slot_abs / self.plan_interval_minutes) * self.plan_interval_minutes pv_power_hist_by_slot[slot] = pv_power_hist_by_slot.get(slot, 0) + pv_power_hist[minute] pv_power_hist_by_slot_count[slot] = pv_power_hist_by_slot_count.get(slot, 0) + 1 past_day_actual[days_prev] = past_day_actual.get(days_prev, 0) + pv_power_hist[minute] @@ -718,7 +715,7 @@ def pv_calibration(self, pv_forecast_minute, pv_forecast_minute10, pv_forecast_d minute_absolute = self.minutes_now - minute if minute_absolute < 0: slot_abs = minute_absolute % (24 * 60) - slot = int(slot_abs / plan_interval_minutes) * plan_interval_minutes + slot = int(slot_abs / self.plan_interval_minutes) * self.plan_interval_minutes pv_forecast_by_slot[slot] = pv_forecast_by_slot.get(slot, 0) + pv_forecast[minute] pv_forecast_by_slot_count[slot] = pv_forecast_by_slot_count.get(slot, 0) + 1 @@ -748,17 +745,17 @@ def pv_calibration(self, pv_forecast_minute, pv_forecast_minute10, pv_forecast_d pv_forecast_by_slot[slot] = dp4(pv_forecast_by_slot[slot] / pv_forecast_by_slot_count[slot]) total_production = 0 - for slot in range(0, 24 * 60, plan_interval_minutes): + for slot in range(0, 24 * 60, self.plan_interval_minutes): total_production += pv_power_hist_by_slot.get(slot, 0) total_forecast = 0 - for slot in range(0, 24 * 60, plan_interval_minutes): + for slot in range(0, 24 * 60, self.plan_interval_minutes): total_forecast += pv_forecast_by_slot.get(slot, 0) pv_distribution = {} forecast_distribution = {} slot_adjustment = {} - for slot in range(0, 24 * 60, plan_interval_minutes): + for slot in range(0, 24 * 60, self.plan_interval_minutes): pv_distribution[slot] = dp4((pv_power_hist_by_slot.get(slot, 0)) / total_production if total_production > 0 else 0) forecast_distribution[slot] = dp4((pv_forecast_by_slot.get(slot, 0)) / total_forecast if total_forecast > 0 else 0) @@ -779,15 +776,15 @@ def pv_calibration(self, pv_forecast_minute, pv_forecast_minute10, pv_forecast_d pv_forecast_minute_adjusted = {} for minute in range(0, max(pv_forecast_minute.keys()) + 1): pv_value = pv_forecast_minute.get(minute, 0) - slot = (int(minute / plan_interval_minutes) * plan_interval_minutes) % (24 * 60) + slot = (int(minute / self.plan_interval_minutes) * self.plan_interval_minutes) % (24 * 60) pv_forecast_minute_adjusted[minute] = pv_value * slot_adjustment.get(slot, 1.0) pv_estimateCL = {} pv_estimate10 = {} pv_estimate90 = {} - for minute in range(0, max(pv_forecast_minute.keys()) + 1, plan_interval_minutes): + for minute in range(0, max(pv_forecast_minute.keys()) + 1, self.plan_interval_minutes): pv_value = 0 - for offset in range(0, plan_interval_minutes, 1): + for offset in range(0, self.plan_interval_minutes, 1): pv_value += pv_forecast_minute_adjusted.get(minute + offset, 0) # Force timezone to UTC pv_estimateCL[minute] = min(dp4(pv_value), max_kwh / 2) # Clamp to max_kwh, divide max by 2 due to plan_interval_minutes slots @@ -798,7 +795,7 @@ def pv_calibration(self, pv_forecast_minute, pv_forecast_minute10, pv_forecast_d period_start = entry.get("period_start", "") if period_start: minutes_since_midnight = (datetime.strptime(period_start, TIME_FORMAT) - self.midnight_utc).total_seconds() / 60 - slot = int(minutes_since_midnight / plan_interval_minutes) * plan_interval_minutes + slot = int(minutes_since_midnight / self.plan_interval_minutes) * self.plan_interval_minutes calibrated = pv_estimateCL.get(slot, None) calibrated10 = pv_estimate10.get(slot, None) calibrated90 = pv_estimate90.get(slot, None) @@ -832,7 +829,6 @@ def pack_and_store_forecast(self, pv_forecast_minute, pv_forecast_minute10): prev_value = -1 prev_value10 = -1 - plan_interval_minutes = getattr(self.base, "plan_interval_minutes", 30) for minute in range(0, self.forecast_days * 24 * 60): current_value = dp4(pv_forecast_minute.get(minute, 0)) @@ -865,7 +861,7 @@ def fetch_pv_forecast(self): create_pv10 = False max_kwh = 9999 - self.now_utc = datetime.now(timezone.utc) + self.now_utc = datetime.now(self.local_tz) self.midnight_utc = self.now_utc.replace(hour=0, minute=0, second=0, microsecond=0) self.minutes_now = int((self.now_utc - self.midnight_utc).seconds / 60 / PREDICT_STEP) * PREDICT_STEP From f0d53d14ebf85a146f086f07a250c318da7f055f Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Fri, 7 Nov 2025 19:44:24 +0000 Subject: [PATCH 4/6] Fixes --- apps/predbat/prediction.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/predbat/prediction.py b/apps/predbat/prediction.py index 4085c1679..8a30773f1 100644 --- a/apps/predbat/prediction.py +++ b/apps/predbat/prediction.py @@ -179,6 +179,7 @@ def __init__(self, base=None, pv_forecast_minute_step=None, pv_forecast_minute10 self.inverter_can_charge_during_export = base.inverter_can_charge_during_export self.prediction_cache_enable = base.prediction_cache_enable self.prediction_cache = {} + self.plan_interval_minutes = base.plan_interval_minutes # Store this dictionary in global so we can reconstruct it in the thread without passing the data PRED_GLOBAL["dict"] = self.__dict__.copy() @@ -1055,8 +1056,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi predict_battery_power[stamp] = round(battery_draw * (60 / step), 3) predict_battery_cycle[stamp] = round(battery_cycle, 3) # Use plan_interval_minutes instead of hardcoded 30 for scaling - plan_interval_minutes = getattr(self, "plan_interval_minutes", 30) - predict_pv_power[stamp] = round((pv_forecast_minute_step[minute] + pv_forecast_minute_step.get(minute + step, 0)) * (plan_interval_minutes / step), 3) + predict_pv_power[stamp] = round((pv_forecast_minute_step[minute] + pv_forecast_minute_step.get(minute + step, 0)) * (self.plan_interval_minutes / step), 3) predict_grid_power[stamp] = round(diff * (60 / step), 3) predict_load_power[stamp] = round(load_yesterday * (60 / step), 3) if carbon_enable: From 2ab8b0035f2603d2a1200f1579c94a1337401fc5 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Fri, 7 Nov 2025 19:46:40 +0000 Subject: [PATCH 5/6] Octopus TZ fix --- apps/predbat/octopus.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index f4d5e44b3..da9bb8b02 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -320,6 +320,7 @@ 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 @@ -401,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) count_minutes = self.now_utc.minute + self.now_utc.hour * 60 if first or (count_minutes % 30) == 0: From 91e68a66533ecd284beaa7c75fa7153c26d80e0c Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Fri, 7 Nov 2025 19:48:53 +0000 Subject: [PATCH 6/6] Fix --- apps/predbat/predbat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 3f087e94f..cf01dd606 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -334,7 +334,7 @@ def reset(self): self.text_plan = "Computing please wait..." self.prediction_cache_enable = True self.base_load = 0 - self.plan_interval_minutes = 30 + self.plan_interval_minutes = self.args.get("plan_interval_minutes", 30) self.db_manager = None self.plan_debug = False self.arg_errors = {}