From b3cd7a230566970f00c2dcc65f79a8c382d43a7d Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sun, 25 Jan 2026 09:39:29 +0000 Subject: [PATCH 1/6] Removing calc_percent_limit Adding inverter filter to Fox to Solax --- apps/predbat/compare.py | 2 - apps/predbat/components.py | 8 + apps/predbat/execute.py | 7 +- apps/predbat/fox.py | 13 +- apps/predbat/output.py | 6 +- apps/predbat/plan.py | 14 +- apps/predbat/predbat.py | 4 +- apps/predbat/prediction_vectorized.py | 529 ++++++++++++++++++ apps/predbat/solax.py | 12 +- apps/predbat/tests/test_execute.py | 1 - apps/predbat/tests/test_fox_api.py | 2 + apps/predbat/tests/test_infra.py | 1 - .../tests/test_optimise_all_windows.py | 3 - apps/predbat/tests/test_optimise_levels.py | 2 - apps/predbat/tests/test_single_debug.py | 2 - 15 files changed, 572 insertions(+), 34 deletions(-) create mode 100644 apps/predbat/prediction_vectorized.py diff --git a/apps/predbat/compare.py b/apps/predbat/compare.py index 09338ef63..f7ce995df 100644 --- a/apps/predbat/compare.py +++ b/apps/predbat/compare.py @@ -416,7 +416,6 @@ def run_all(self, debug=False, fetch_sensor=True): save_export_window_best = my_predbat.export_window_best save_export_limits_best = my_predbat.export_limits_best save_charge_limit_best = my_predbat.charge_limit_best - save_charge_limit_percent_best = my_predbat.charge_limit_percent_best save_cost_today_sofar = my_predbat.cost_today_sofar save_carbon_today_sofar = my_predbat.carbon_today_sofar save_iboost_today = my_predbat.iboost_today @@ -462,7 +461,6 @@ def run_all(self, debug=False, fetch_sensor=True): my_predbat.export_window_best = save_export_window_best my_predbat.export_limits_best = save_export_limits_best my_predbat.charge_limit_best = save_charge_limit_best - my_predbat.charge_limit_percent_best = save_charge_limit_percent_best my_predbat.cost_today_sofar = save_cost_today_sofar my_predbat.carbon_today_sofar = save_carbon_today_sofar my_predbat.iboost_today = save_iboost_today diff --git a/apps/predbat/components.py b/apps/predbat/components.py index 0f4af8eb8..749b754da 100644 --- a/apps/predbat/components.py +++ b/apps/predbat/components.py @@ -191,6 +191,10 @@ "default": False, "config": "fox_automatic", }, + "inverter_sn": { + "required": False, + "config": "fox_inverter_sn", + }, }, "phase": 1, }, @@ -238,6 +242,10 @@ "region": {"required": False, "config": "solax_region", "default": "eu"}, "automatic": {"required": False, "config": "solax_automatic", "default": False}, "enable_controls": {"required": False, "config": "solax_enable_controls", "default": True}, + "plant_sn": { + "required": False, + "config": "solax_plant_sn", + }, }, "phase": 1, "can_restart": True, diff --git a/apps/predbat/execute.py b/apps/predbat/execute.py index b5879bb70..7ff256c7c 100644 --- a/apps/predbat/execute.py +++ b/apps/predbat/execute.py @@ -107,10 +107,10 @@ def execute_plan(self): if (not inExportWindow) and ((minutes_start - self.minutes_now) < (24 * 60)) and (minutes_end > self.minutes_now): charge_start_time = self.midnight_utc + timedelta(minutes=minutes_start) charge_end_time = self.midnight_utc + timedelta(minutes=minutes_end) - self.log("Inverter {} Charge window will be: {} - {} - current SoC {}%, target {}%".format(inverter.id, charge_start_time, charge_end_time, inverter.soc_percent, self.charge_limit_percent_best[0])) + self.log("Inverter {} Charge window will be: {} - {} - current SoC {}%, target {}%".format(inverter.id, charge_start_time, charge_end_time, inverter.soc_percent, calc_percent_limit(self.charge_limit_best[0], self.soc_max))) # Are we actually charging? if self.minutes_now >= minutes_start and self.minutes_now < minutes_end: - target_soc = self.charge_limit_percent_best[0] if not self.is_freeze_charge(self.charge_limit_best[0]) else calc_percent_limit(self.soc_kw, self.soc_max) + target_soc = calc_percent_limit(self.charge_limit_best[0], self.soc_max) if not self.is_freeze_charge(self.charge_limit_best[0]) else calc_percent_limit(self.soc_kw, self.soc_max) inv_target_soc = self.adjust_battery_target_multi(inverter, target_soc, True, False, check=True) current_charge_rate = inverter.get_current_charge_rate() @@ -819,8 +819,7 @@ def fetch_inverter_data(self, create=True): # Work out current charge limits and publish charge limit base self.charge_limit = [self.current_charge_limit * self.soc_max / 100.0 for i in range(len(self.charge_window))] - self.charge_limit_percent = calc_percent_limit(self.charge_limit, self.soc_max) - self.publish_charge_limit(self.charge_limit, self.charge_window, self.charge_limit_percent, best=False) + self.publish_charge_limit(self.charge_limit, self.charge_window, best=False) self.publish_inverter_data() def quick_inverter_data_update(self): diff --git a/apps/predbat/fox.py b/apps/predbat/fox.py index 64913c5be..708ac6236 100644 --- a/apps/predbat/fox.py +++ b/apps/predbat/fox.py @@ -199,7 +199,7 @@ def validate_schedule(new_schedule, reserve, fdPwr_max): class FoxAPI(ComponentBase): """Fox API client.""" - def initialize(self, key, automatic): + def initialize(self, key, automatic, inverter_sn=None): """Initialize the Fox API component""" self.key = key self.automatic = automatic @@ -223,6 +223,14 @@ def initialize(self, key, automatic): self.start_time_today = None self.last_midnight_utc = None + # Convert inverter_sn to list + if inverter_sn is None: + self.inverter_sn_filter = [] + elif isinstance(inverter_sn, str): + self.inverter_sn_filter = [inverter_sn] + else: + self.inverter_sn_filter = inverter_sn + def should_allow_retry(self): """ Calculate if retries should be allowed based on current API usage rate. @@ -1006,6 +1014,9 @@ async def get_device_list(self): devices = [] if result is not None: devices = result.get("data", []) + # Filter by self.inverter_sn_filter if its not + if self.inverter_sn_filter: + devices = [device for device in devices if device.get("deviceSN", "") in self.inverter_sn_filter] self.device_list = devices return devices diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 14e8e9a1b..f4e13035d 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -2139,7 +2139,7 @@ def publish_export_limit(self, export_window, export_limits, best): }, ) - def publish_charge_limit(self, charge_limit, charge_window, charge_limit_percent, best=False, soc={}): + def publish_charge_limit(self, charge_limit, charge_window, best=False, soc={}): """ Create entity to chart charge limit @@ -2147,11 +2147,13 @@ def publish_charge_limit(self, charge_limit, charge_window, charge_limit_percent - charge_limit (list): List of charge limits in kWh - charge_window (list): List of charge window dictionaries - - charge_limit_percent (list): List of charge limit percentages - best (bool, optional): Flag indicating if we publish as base or as best - soc (dict, optional): Dictionary of the predicted SoC over time """ + # Calculate charge_limit_percent from charge_limit + charge_limit_percent = calc_percent_limit(charge_limit, self.soc_max) + charge_limit_time = {} charge_limit_time_kw = {} prev_perc = -1 diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index 1cd2925f5..fcf652b96 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -829,7 +829,6 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True): sets: self.charge_window_best self.charge_limit_best - self.charge_limit_percent_best self.export_window_best self.export_limits_best """ @@ -849,7 +848,6 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True): if window["end"] <= self.minutes_now: del self.charge_window_best[0] del self.charge_limit_best[0] - del self.charge_limit_percent_best[0] self.log("Current charge window has expired, removing it") else: break @@ -898,14 +896,13 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True): # Pre-fill best charge limit with the current charge limit self.charge_limit_best = [self.current_charge_limit * self.soc_max / 100.0 for i in range(len(self.charge_window_best))] - self.charge_limit_percent_best = [self.current_charge_limit for i in range(len(self.charge_window_best))] # Pre-fill best export enable with Off self.export_limits_best = [100.0 for i in range(len(self.export_window_best))] self.end_record = self.forecast_minutes # Show best windows - self.log("Best charge window {}".format(self.window_as_text(self.charge_window_best, self.charge_limit_percent_best))) + self.log("Best charge window {}".format(self.window_as_text(self.charge_window_best, calc_percent_limit(self.charge_limit_best, self.soc_max)))) self.log("Best export window {}".format(self.window_as_text(self.export_window_best, self.export_limits_best))) # Created optimised step data @@ -987,8 +984,6 @@ 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) - # Update percent array to match the modified windows - self.charge_limit_percent_best = calc_percent_limit(self.charge_limit_best, self.soc_max) # Filter out any unused export windows if self.calculate_best_export and self.export_window_best: @@ -1068,10 +1063,8 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True): # Filter out the windows we disabled during clipping self.log("Unfiltered charge windows {} reserve {}".format(self.window_as_text(self.charge_window_best, calc_percent_limit(self.charge_limit_best, self.soc_max)), self.reserve)) self.charge_limit_best, self.charge_window_best = self.discard_unused_charge_slots(self.charge_limit_best, self.charge_window_best, self.reserve) - self.charge_limit_percent_best = calc_percent_limit(self.charge_limit_best, self.soc_max) self.log("Filtered charge windows {} reserve {}".format(self.window_as_text(self.charge_window_best, calc_percent_limit(self.charge_limit_best, self.soc_max)), self.reserve)) else: - self.charge_limit_percent_best = calc_percent_limit(self.charge_limit_best, self.soc_max) self.log("Unfiltered charge windows {} reserve {}".format(self.window_as_text(self.charge_window_best, calc_percent_limit(self.charge_limit_best, self.soc_max)), self.reserve)) # Plan comparison @@ -1185,8 +1178,7 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True): # Publish charge and export window best if publish: - self.charge_limit_percent_best = calc_percent_limit(self.charge_limit_best, self.soc_max) - self.publish_charge_limit(self.charge_limit_best, self.charge_window_best, self.charge_limit_percent_best, best=True, soc=self.predict_soc_best) + self.publish_charge_limit(self.charge_limit_best, self.charge_window_best, best=True, soc=self.predict_soc_best) self.publish_export_limit(self.export_window_best, self.export_limits_best, best=True) # HTML data @@ -2345,7 +2337,6 @@ def plan_write_debug(self, debug_mode, name, pv_forecast_minute_step, pv_forecas end_record=end_record, save="best10" if name else "yesterday10", ) - self.charge_limit_percent_best = calc_percent_limit(self.charge_limit_best, self.soc_max) self.update_target_values() if name: @@ -2356,7 +2347,6 @@ def plan_write_debug(self, debug_mode, name, pv_forecast_minute_step, pv_forecas self.charge_limit_best, self.charge_window_best, self.export_window_best, self.export_limits_best, end_record=end_record, save="best" if name else "yesterday" ) - self.charge_limit_percent_best = calc_percent_limit(self.charge_limit_best, self.soc_max) self.update_target_values() html_data, json_data = self.publish_html_plan(pv_forecast_minute_step, pv_forecast_minute10_step, load_minutes_step, load_minutes_step10, end_record, publish=False) diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 8b40d2572..7607f3dc0 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -27,7 +27,7 @@ import requests import asyncio -THIS_VERSION = "v8.32.11" +THIS_VERSION = "v8.32.12" # fmt: off PREDBAT_FILES = ["predbat.py", "const.py", "hass.py", "config.py", "prediction.py", "gecloud.py", "utils.py", "inverter.py", "ha.py", "download.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", "component_base.py", "axle.py", "solax.py", "solis.py", "unit_test.py"] @@ -532,9 +532,7 @@ def reset(self): self.io_adjusted = {} self.current_charge_limit = 0.0 self.charge_limit = [] - self.charge_limit_percent = [] self.charge_limit_best = [] - self.charge_limit_best_percent = [] self.charge_window = [] self.charge_window_best = [] self.car_charging_battery_size = [100] diff --git a/apps/predbat/prediction_vectorized.py b/apps/predbat/prediction_vectorized.py new file mode 100644 index 000000000..2afa6089f --- /dev/null +++ b/apps/predbat/prediction_vectorized.py @@ -0,0 +1,529 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2024 - 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 + +""" +Vectorized batch prediction engine for fast simulation of multiple scenarios. +Uses NumPy array operations to evaluate hundreds of charge/discharge window +combinations simultaneously. Designed for levels pass optimization. +""" + +import numpy as np +from const import PREDICT_STEP + + +class PredictionVectorized: + """ + Vectorized prediction engine that runs multiple scenarios in parallel using NumPy. + Simplified physics model: + - AC-only inverter (no hybrid DC path) + - Single SOC lookup per time step for charge curves + - No iboost, temperature effects, or other advanced features + - Suitable for initial filtering in levels pass optimization + """ + + def __init__(self, base, step_minutes=30): + """ + Initialize vectorized prediction from base Prediction object. + + Args: + base: Prediction object with configuration and forecasts + step_minutes: Time step size in minutes (default 30) + """ + self.log = base.log + self.step_minutes = step_minutes + self.minutes_now = base.minutes_now + self.forecast_minutes = base.forecast_minutes + + # Battery parameters + self.soc_kw = base.soc_kw + self.soc_max = base.soc_max + self.reserve = base.reserve + self.battery_loss = base.battery_loss + self.battery_loss_discharge = base.battery_loss_discharge + self.battery_rate_max_charge = base.battery_rate_max_charge + self.battery_rate_max_discharge = base.battery_rate_max_discharge + self.battery_rate_min = base.battery_rate_min + self.battery_rate_max_scaling = base.battery_rate_max_scaling + self.battery_rate_max_scaling_discharge = base.battery_rate_max_scaling_discharge + + # Inverter parameters + self.inverter_loss = base.inverter_loss + self.inverter_limit = base.inverter_limit + self.export_limit = base.export_limit + + # Cost tracking + self.cost_today_sofar = base.cost_today_sofar + self.import_today_now = base.import_today_now + self.export_today_now = base.export_today_now + + # Power curves (simplified) + self.battery_charge_power_curve = base.battery_charge_power_curve + self.battery_discharge_power_curve = base.battery_discharge_power_curve + + # Prepare arrays + self.pv_array = None + self.load_array = None + self.rate_import_array = None + self.rate_export_array = None + self.num_steps = 0 + + self.log("Vectorized prediction initialized with step_minutes={}".format(step_minutes)) + + def prepare_forecast_arrays(self, pv_forecast_minute_step, load_minutes_step): + """ + Convert forecast dictionaries to NumPy arrays aggregated to step_minutes. + Merges car charging into load data. + + Args: + pv_forecast_minute_step: Dict of {minute: kW} for PV forecast + load_minutes_step: Dict of {minute: kW} for load forecast + + Returns: + Tuple of (pv_array, load_array, num_steps) + """ + self.num_steps = int(self.forecast_minutes / self.step_minutes) + self.pv_array = np.zeros(self.num_steps) + self.load_array = np.zeros(self.num_steps) + + # Aggregate to larger time steps + for step_idx in range(self.num_steps): + minute_start = step_idx * self.step_minutes + pv_sum = 0.0 + load_sum = 0.0 + + for offset in range(0, self.step_minutes, PREDICT_STEP): + minute = minute_start + offset + if minute >= self.forecast_minutes: + break + pv_sum += pv_forecast_minute_step.get(minute, 0.0) + load_sum += load_minutes_step.get(minute, 0.0) + + self.pv_array[step_idx] = pv_sum + self.load_array[step_idx] = load_sum + + self.log("Prepared forecast arrays: {} steps of {} minutes".format(self.num_steps, self.step_minutes)) + return self.pv_array, self.load_array, self.num_steps + + def prepare_rate_arrays(self, rate_import, rate_export): + """ + Convert rate dictionaries to NumPy arrays indexed by time step. + + Args: + rate_import: Dict of {minute_absolute: £/kWh} + rate_export: Dict of {minute_absolute: £/kWh} + + Returns: + Tuple of (rate_import_array, rate_export_array) + """ + self.rate_import_array = np.zeros(self.num_steps) + self.rate_export_array = np.zeros(self.num_steps) + + for step_idx in range(self.num_steps): + minute_start = step_idx * self.step_minutes + minute_absolute = self.minutes_now + minute_start + + # Use rate at start of step (simplified) + self.rate_import_array[step_idx] = rate_import.get(minute_absolute, 0.0) + self.rate_export_array[step_idx] = rate_export.get(minute_absolute, 0.0) + + return self.rate_import_array, self.rate_export_array + + def prepare_window_masks(self, charge_windows, export_windows, num_scenarios): + """ + Convert window lists and scenario bits into boolean masks. + + Args: + charge_windows: List of {"start": minute, "end": minute} dicts + export_windows: List of {"start": minute, "end": minute} dicts + num_scenarios: Number of scenarios to generate + + Returns: + Tuple of (charge_masks, export_masks) both shape (num_scenarios, num_steps) + """ + num_charge_windows = len(charge_windows) + num_export_windows = len(export_windows) + + # Create masks for each window (num_windows, num_steps) + charge_window_masks = np.zeros((num_charge_windows, self.num_steps), dtype=bool) + export_window_masks = np.zeros((num_export_windows, self.num_steps), dtype=bool) + + for w_idx, window in enumerate(charge_windows): + start_step = max(0, int((window["start"] - self.minutes_now) / self.step_minutes)) + end_step = min(self.num_steps, int((window["end"] - self.minutes_now) / self.step_minutes)) + if start_step < end_step: + charge_window_masks[w_idx, start_step:end_step] = True + + for w_idx, window in enumerate(export_windows): + start_step = max(0, int((window["start"] - self.minutes_now) / self.step_minutes)) + end_step = min(self.num_steps, int((window["end"] - self.minutes_now) / self.step_minutes)) + if start_step < end_step: + export_window_masks[w_idx, start_step:end_step] = True + + # For now, return window masks - caller will combine based on scenario bit patterns + return charge_window_masks, export_window_masks + + def get_charge_rate(self, soc_array): + """ + Get charge rate for given SOC values using simplified curve lookup. + + Args: + soc_array: Array of SOC values in kWh (any shape) + + Returns: + Array of charge rates in kW (same shape as input) + """ + if not self.battery_charge_power_curve: + # No curve, use max rate + return np.full_like(soc_array, self.battery_rate_max_charge * self.battery_rate_max_scaling) + + # Extract curve points + soc_points = np.array([point[0] * self.soc_max / 100.0 for point in self.battery_charge_power_curve]) + power_points = np.array([point[1] for point in self.battery_charge_power_curve]) + + # Interpolate + charge_rates = np.interp(soc_array, soc_points, power_points) + charge_rates = charge_rates * self.battery_rate_max_scaling + + return charge_rates + + def get_discharge_rate(self, soc_array): + """ + Get discharge rate for given SOC values using simplified curve lookup. + + Args: + soc_array: Array of SOC values in kWh (any shape) + + Returns: + Array of discharge rates in kW (same shape as input) + """ + if not self.battery_discharge_power_curve: + # No curve, use max rate + return np.full_like(soc_array, self.battery_rate_max_discharge * self.battery_rate_max_scaling_discharge) + + # Extract curve points + soc_points = np.array([point[0] * self.soc_max / 100.0 for point in self.battery_discharge_power_curve]) + power_points = np.array([point[1] for point in self.battery_discharge_power_curve]) + + # Interpolate + discharge_rates = np.interp(soc_array, soc_points, power_points) + discharge_rates = discharge_rates * self.battery_rate_max_scaling_discharge + + return discharge_rates + + def run_prediction_batch(self, charge_window_enable, export_window_enable): + """ + Run batch prediction for multiple scenarios. + + Args: + charge_window_enable: Boolean array (num_scenarios, num_steps) - True where charging is forced + export_window_enable: Boolean array (num_scenarios, num_steps) - True where discharging is forced + + Returns: + Dict with keys: + - final_cost: Array of final costs (num_scenarios,) + - final_soc: Array of final SOC in kWh (num_scenarios,) + - import_kwh: Array of total import (num_scenarios,) + - export_kwh: Array of total export (num_scenarios,) + - import_kwh_battery: Array of import for charging (num_scenarios,) + - import_kwh_house: Array of import for load (num_scenarios,) + - battery_cycle: Array of total throughput (num_scenarios,) + - soc_min: Array of minimum SOC reached (num_scenarios,) + """ + num_scenarios = charge_window_enable.shape[0] + + # Initialize state arrays (num_scenarios, num_steps+1) + soc = np.full((num_scenarios, self.num_steps + 1), self.soc_kw) + cost = np.full(num_scenarios, self.cost_today_sofar) + import_kwh = np.full(num_scenarios, self.import_today_now) + export_kwh = np.full(num_scenarios, self.export_today_now) + import_kwh_battery = np.zeros(num_scenarios) + import_kwh_house = np.zeros(num_scenarios) + battery_cycle = np.zeros(num_scenarios) + + # Inverter and battery limits (scaled to step size) + inverter_limit_step = self.inverter_limit * self.step_minutes + export_limit_step = self.export_limit * self.step_minutes + inverter_loss = self.inverter_loss + + # Time loop (not vectorized over time, but vectorized over scenarios) + for step_idx in range(self.num_steps): + soc_current = soc[:, step_idx] + + # Get PV and load for this step + pv_now = self.pv_array[step_idx] + load_now = self.load_array[step_idx] + + # Get rates + import_rate = self.rate_import_array[step_idx] + export_rate = self.rate_export_array[step_idx] + + # Get charge/discharge windows for this step + charge_active = charge_window_enable[:, step_idx] # (num_scenarios,) + export_active = export_window_enable[:, step_idx] # (num_scenarios,) + + # Get SOC-dependent rates + charge_rate = self.get_charge_rate(soc_current) # (num_scenarios,) + discharge_rate = self.get_discharge_rate(soc_current) # (num_scenarios,) + + # Scale to step size + charge_rate_step = charge_rate * self.step_minutes + discharge_rate_step = discharge_rate * self.step_minutes + + # Calculate battery capacity limits + battery_to_min = np.maximum(soc_current - self.reserve, 0) * self.battery_loss_discharge + battery_to_max = np.maximum(self.soc_max - soc_current, 0) * self.battery_loss + + # Initialize battery draw + battery_draw = np.zeros(num_scenarios) + + # Mode 1: Force discharge (export window active) + force_discharge = export_active + battery_draw = np.where( + force_discharge, + np.minimum(discharge_rate_step, battery_to_min), + battery_draw + ) + + # Mode 2: Force charge (charge window active, not discharge) + force_charge = charge_active & ~export_active + battery_draw = np.where( + force_charge, + -np.minimum(charge_rate_step, battery_to_max), + battery_draw + ) + + # Mode 3: ECO mode (no windows active) + eco_mode = ~charge_active & ~export_active + + # For ECO mode: calculate PV AC and determine battery action + pv_ac = pv_now * inverter_loss # AC-only inverter + diff_eco = load_now - pv_ac # Shortfall (positive) or excess (negative) + + # If shortfall, discharge to meet it + battery_draw_eco = np.where( + diff_eco > 0, + np.minimum(np.minimum(diff_eco, discharge_rate_step), battery_to_min), + # If excess, charge from it + np.maximum(np.maximum(diff_eco, -charge_rate_step), -battery_to_max) + ) + + battery_draw = np.where(eco_mode, battery_draw_eco, battery_draw) + + # Apply inverter limit (AC-only, simplified) + # Limit discharge + battery_draw = np.where( + battery_draw > 0, + np.minimum(battery_draw, inverter_limit_step), + battery_draw + ) + # Limit charge + battery_draw = np.where( + battery_draw < 0, + np.maximum(battery_draw, -inverter_limit_step), + battery_draw + ) + + # Update SOC with asymmetric losses + soc_delta = np.where( + battery_draw > 0, + -battery_draw / self.battery_loss_discharge, # Discharge + -battery_draw * self.battery_loss # Charge (battery_draw is negative) + ) + + soc_next = soc_current + soc_delta + soc_next = np.clip(soc_next, self.reserve, self.soc_max) + soc[:, step_idx + 1] = soc_next + + # Calculate grid import/export (AC-only model) + # Grid balance = load - pv - battery (positive battery_draw = discharge helps, negative = charge consumes) + grid_balance = load_now - pv_ac - battery_draw / inverter_loss + + # Positive grid_balance = import, negative = export + step_import = np.maximum(grid_balance, 0) + step_export = np.maximum(-grid_balance, 0) + + # Limit export + step_export = np.minimum(step_export, export_limit_step) + + # Update cumulative energy + import_kwh += step_import + export_kwh += step_export + + # Track battery vs house import + import_kwh_battery += np.where(charge_active, step_import, 0) + import_kwh_house += np.where(~charge_active, step_import, 0) + + # Update cost + cost += step_import * import_rate - step_export * export_rate + + # Update battery cycles + battery_cycle += np.abs(battery_draw) + + # Calculate minimum SOC + soc_min = np.min(soc[:, :-1], axis=1) + + # Return results + return { + "final_cost": cost, + "final_soc": soc[:, -1], + "import_kwh": import_kwh, + "export_kwh": export_kwh, + "import_kwh_battery": import_kwh_battery, + "import_kwh_house": import_kwh_house, + "battery_cycle": battery_cycle, + "soc_min": soc_min, + "soc_trajectories": soc, # For debugging + } + + +# Test harness +if __name__ == "__main__": + print("Vectorized Prediction Test Harness") + print("=" * 60) + + # Create a dummy base object + class DummyBase: + def __init__(self): + self.minutes_now = 0 + self.forecast_minutes = 2880 # 48 hours + self.soc_kw = 5.0 + self.soc_max = 10.0 + self.reserve = 1.0 + self.battery_loss = 0.97 + self.battery_loss_discharge = 0.97 + self.battery_rate_max_charge = 3.0 # kW + self.battery_rate_max_discharge = 3.0 # kW + self.battery_rate_min = 0.0 + self.battery_rate_max_scaling = 1.0 + self.battery_rate_max_scaling_discharge = 1.0 + self.inverter_loss = 0.96 + self.inverter_limit = 3.5 # kW + self.export_limit = 3.5 # kW + self.cost_today_sofar = 0.0 + self.import_today_now = 0.0 + self.export_today_now = 0.0 + + # Simple power curves (SOC % -> power factor) + self.battery_charge_power_curve = [ + [0, 1.0], [50, 1.0], [90, 0.8], [100, 0.3] + ] + self.battery_discharge_power_curve = [ + [0, 0.3], [10, 0.8], [50, 1.0], [100, 1.0] + ] + + def log(self, msg): + print("[LOG] {}".format(msg)) + + # Create vectorized predictor + base = DummyBase() + predictor = PredictionVectorized(base, step_minutes=30) + + # Create synthetic forecasts + pv_forecast = {} + load_forecast = {} + + for minute in range(0, 2880, 5): + hour = (minute // 60) % 24 + # Simple sinusoidal PV (peak at noon) + if 6 <= hour < 18: + pv_forecast[minute] = 0.5 * (1 + np.sin((hour - 6) * np.pi / 12)) + else: + pv_forecast[minute] = 0.0 + + # Simple load pattern + if 7 <= hour < 9 or 17 <= hour < 22: + load_forecast[minute] = 0.8 + else: + load_forecast[minute] = 0.3 + + predictor.prepare_forecast_arrays(pv_forecast, load_forecast) + + # Create synthetic rates + rate_import = {} + rate_export = {} + for minute in range(0, 2880, 5): + hour = (minute // 60) % 24 + # Cheap overnight, expensive peak + if 2 <= hour < 5: + rate_import[minute] = 0.075 # Cheap + elif 16 <= hour < 19: + rate_import[minute] = 0.30 # Expensive + else: + rate_import[minute] = 0.15 # Mid + + rate_export[minute] = 0.05 + + predictor.prepare_rate_arrays(rate_import, rate_export) + + # Create test windows + charge_windows = [ + {"start": 120, "end": 300}, # 02:00-05:00 + ] + export_windows = [ + {"start": 960, "end": 1140}, # 16:00-19:00 + ] + + charge_window_masks, export_window_masks = predictor.prepare_window_masks( + charge_windows, export_windows, num_scenarios=4 + ) + + # Create 4 test scenarios (combinations of windows on/off) + # Scenario 0: No windows + # Scenario 1: Charge only + # Scenario 2: Export only + # Scenario 3: Both windows + + num_scenarios = 4 + charge_enable = np.zeros((num_scenarios, predictor.num_steps), dtype=bool) + export_enable = np.zeros((num_scenarios, predictor.num_steps), dtype=bool) + + # Scenario 1: Charge window enabled + charge_enable[1, :] = charge_window_masks[0, :] + + # Scenario 2: Export window enabled + export_enable[2, :] = export_window_masks[0, :] + + # Scenario 3: Both enabled + charge_enable[3, :] = charge_window_masks[0, :] + export_enable[3, :] = export_window_masks[0, :] + + # Run batch prediction + print("\nRunning batch prediction for {} scenarios...".format(num_scenarios)) + results = predictor.run_prediction_batch(charge_enable, export_enable) + + # Display results + print("\nResults:") + print("-" * 60) + for i in range(num_scenarios): + scenario_name = [ + "ECO only (no windows)", + "Charge window only", + "Export window only", + "Both windows" + ][i] + + print("\nScenario {}: {}".format(i, scenario_name)) + print(" Final cost: £{:.2f}".format(results["final_cost"][i])) + print(" Final SOC: {:.2f} kWh".format(results["final_soc"][i])) + print(" Min SOC: {:.2f} kWh".format(results["soc_min"][i])) + print(" Import (total): {:.2f} kWh".format(results["import_kwh"][i])) + print(" Import (batt): {:.2f} kWh".format(results["import_kwh_battery"][i])) + print(" Import (house): {:.2f} kWh".format(results["import_kwh_house"][i])) + print(" Export: {:.2f} kWh".format(results["export_kwh"][i])) + print(" Battery cycle: {:.2f} kWh".format(results["battery_cycle"][i])) + + # Find best scenario + best_idx = np.argmin(results["final_cost"]) + print("\n" + "=" * 60) + print("Best scenario: {} (£{:.2f})".format( + ["ECO only", "Charge only", "Export only", "Both windows"][best_idx], + results["final_cost"][best_idx] + )) + print("=" * 60) diff --git a/apps/predbat/solax.py b/apps/predbat/solax.py index 736b68f44..9e50b2e37 100644 --- a/apps/predbat/solax.py +++ b/apps/predbat/solax.py @@ -303,7 +303,7 @@ class SolaxAPI(ComponentBase): Handles authentication and plant information retrieval """ - def initialize(self, client_id, client_secret, region="eu", plant_id=None, automatic=False, enable_controls=True): + def initialize(self, client_id, client_secret, region="eu", plant_id=None, automatic=False, enable_controls=True, plant_sn=None): """ Initialize the SolaX API component @@ -343,6 +343,14 @@ def initialize(self, client_id, client_secret, region="eu", plant_id=None, autom # Error tracking self.error_count = 0 + # Convert plant_sn to list + if plant_sn is None: + self.plant_sn_filter = [] + elif isinstance(plant_sn, str): + self.plant_sn_filter = [plant_sn] + else: + self.plant_sn_filter = plant_sn + self.log(f"SolaX API: Initialized with region={region}, base_url={self.base_url}") async def automatic_config(self): @@ -2380,6 +2388,8 @@ async def run(self, seconds, first): return False self.plant_list = [plant.get('plantId') for plant in self.plant_info] + if self.plant_sn_filter: + self.plant_list = [pid for pid in self.plant_list if pid in self.plant_sn_filter] self.log(f"SolaX API: Found {len(self.plant_list)} plants IDs: {self.plant_list}") # Check readonly mode diff --git a/apps/predbat/tests/test_execute.py b/apps/predbat/tests/test_execute.py index 6b14b48da..10512502d 100644 --- a/apps/predbat/tests/test_execute.py +++ b/apps/predbat/tests/test_execute.py @@ -274,7 +274,6 @@ def run_execute_test( my_predbat.charge_window_best = charge_window_best my_predbat.charge_limit_best = charge_limit_best - my_predbat.charge_limit_percent_best = [calc_percent_limit(x, my_predbat.soc_max) for x in charge_limit_best] my_predbat.export_window_best = export_window_best my_predbat.export_limits_best = export_limits_best my_predbat.set_charge_window = set_charge_window diff --git a/apps/predbat/tests/test_fox_api.py b/apps/predbat/tests/test_fox_api.py index fe2ba1191..4381124a2 100644 --- a/apps/predbat/tests/test_fox_api.py +++ b/apps/predbat/tests/test_fox_api.py @@ -27,6 +27,7 @@ def __init__(self): self.device_current_schedule = {} self.fdpwr_max = {} self.fdsoc_min = {} + self.inverter_sn_filter = [] def getMinSocOnGrid(self, deviceSN): """Mock implementation of getMinSocOnGrid""" @@ -59,6 +60,7 @@ def __init__(self): self.fdpwr_max = {} self.fdsoc_min = {} self.local_tz = pytz.timezone("Europe/London") + self.inverter_sn_filter = [] # Mock request responses - keyed by API path self.mock_responses = {} diff --git a/apps/predbat/tests/test_infra.py b/apps/predbat/tests/test_infra.py index 0c7450356..78addd8f7 100644 --- a/apps/predbat/tests/test_infra.py +++ b/apps/predbat/tests/test_infra.py @@ -491,7 +491,6 @@ def reset_inverter(my_predbat): my_predbat.charge_window_best = [] my_predbat.export_limits_best = [] my_predbat.export_window_best = [] - my_predbat.charge_limit_percent_best = [] my_predbat.manual_charge_times = [] my_predbat.manual_demand_times = [] my_predbat.manual_export_times = [] diff --git a/apps/predbat/tests/test_optimise_all_windows.py b/apps/predbat/tests/test_optimise_all_windows.py index 3971226d4..c38e95bbc 100644 --- a/apps/predbat/tests/test_optimise_all_windows.py +++ b/apps/predbat/tests/test_optimise_all_windows.py @@ -8,7 +8,6 @@ # pylint: disable=line-too-long # pylint: disable=attribute-defined-outside-init -from utils import calc_percent_limit from tests.test_infra import reset_rates, reset_inverter, update_rates_import, update_rates_export from prediction import Prediction from compare import Compare @@ -81,7 +80,6 @@ def run_optimise_all_windows( ) # Save plan my_predbat.charge_limit_best = charge_limit_best - my_predbat.charge_limit_percent_best = calc_percent_limit(charge_limit_best, my_predbat.soc_max) my_predbat.export_limits_best = export_limits_best my_predbat.charge_window_best = charge_window_best my_predbat.export_window_best = export_window_best @@ -100,7 +98,6 @@ def run_optimise_all_windows( # Save plan my_predbat.charge_limit_best = charge_limit_best - my_predbat.charge_limit_percent_best = calc_percent_limit(charge_limit_best, my_predbat.soc_max) my_predbat.export_limits_best = export_limits_best my_predbat.charge_window_best = charge_window_best my_predbat.export_window_best = export_window_best diff --git a/apps/predbat/tests/test_optimise_levels.py b/apps/predbat/tests/test_optimise_levels.py index 0afc2dad1..8be99c9e5 100644 --- a/apps/predbat/tests/test_optimise_levels.py +++ b/apps/predbat/tests/test_optimise_levels.py @@ -7,7 +7,6 @@ # pylint: disable=consider-using-f-string # pylint: disable=line-too-long # pylint: disable=attribute-defined-outside-init -from utils import calc_percent_limit from tests.test_infra import reset_rates, update_rates_import, update_rates_export, reset_inverter from prediction import Prediction @@ -130,7 +129,6 @@ def run_optimise_levels( # Save plan my_predbat.charge_limit_best = charge_limit_best - my_predbat.charge_limit_percent_best = calc_percent_limit(charge_limit_best, my_predbat.soc_max) my_predbat.export_limits_best = export_limits_best my_predbat.charge_window_best = charge_window_best my_predbat.export_window_best = export_window_best diff --git a/apps/predbat/tests/test_single_debug.py b/apps/predbat/tests/test_single_debug.py index bf1e4aa98..9a3e5192c 100644 --- a/apps/predbat/tests/test_single_debug.py +++ b/apps/predbat/tests/test_single_debug.py @@ -12,7 +12,6 @@ from compare import Compare from prediction import Prediction from tests.test_infra import reset_inverter -from utils import calc_percent_limit def run_single_debug(test_name, my_predbat, debug_file, expected_file=None, compare=False, debug=False): @@ -190,7 +189,6 @@ def run_single_debug(test_name, my_predbat, debug_file, expected_file=None, comp # Save plan # Pre-optimise all plan - my_predbat.charge_limit_percent_best = calc_percent_limit(my_predbat.charge_limit_best, my_predbat.soc_max) my_predbat.update_target_values() html_plan, raw_plan = my_predbat.publish_html_plan(pv_step, pv10_step, load_step, load10_step, my_predbat.end_record) filename = "plan_orig.html" From 8c32c6aef1f1878e8586e8e88c704b63e7f4af75 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sun, 25 Jan 2026 09:42:08 +0000 Subject: [PATCH 2/6] [pre-commit.ci lite] apply automatic fixes --- apps/predbat/prediction_vectorized.py | 240 +++++++++++--------------- 1 file changed, 103 insertions(+), 137 deletions(-) diff --git a/apps/predbat/prediction_vectorized.py b/apps/predbat/prediction_vectorized.py index 2afa6089f..fbc33cf67 100644 --- a/apps/predbat/prediction_vectorized.py +++ b/apps/predbat/prediction_vectorized.py @@ -31,7 +31,7 @@ class PredictionVectorized: def __init__(self, base, step_minutes=30): """ Initialize vectorized prediction from base Prediction object. - + Args: base: Prediction object with configuration and forecasts step_minutes: Time step size in minutes (default 30) @@ -40,7 +40,7 @@ def __init__(self, base, step_minutes=30): self.step_minutes = step_minutes self.minutes_now = base.minutes_now self.forecast_minutes = base.forecast_minutes - + # Battery parameters self.soc_kw = base.soc_kw self.soc_max = base.soc_max @@ -52,179 +52,179 @@ def __init__(self, base, step_minutes=30): self.battery_rate_min = base.battery_rate_min self.battery_rate_max_scaling = base.battery_rate_max_scaling self.battery_rate_max_scaling_discharge = base.battery_rate_max_scaling_discharge - + # Inverter parameters self.inverter_loss = base.inverter_loss self.inverter_limit = base.inverter_limit self.export_limit = base.export_limit - + # Cost tracking self.cost_today_sofar = base.cost_today_sofar self.import_today_now = base.import_today_now self.export_today_now = base.export_today_now - + # Power curves (simplified) self.battery_charge_power_curve = base.battery_charge_power_curve self.battery_discharge_power_curve = base.battery_discharge_power_curve - + # Prepare arrays self.pv_array = None self.load_array = None self.rate_import_array = None self.rate_export_array = None self.num_steps = 0 - + self.log("Vectorized prediction initialized with step_minutes={}".format(step_minutes)) - + def prepare_forecast_arrays(self, pv_forecast_minute_step, load_minutes_step): """ Convert forecast dictionaries to NumPy arrays aggregated to step_minutes. Merges car charging into load data. - + Args: pv_forecast_minute_step: Dict of {minute: kW} for PV forecast load_minutes_step: Dict of {minute: kW} for load forecast - + Returns: Tuple of (pv_array, load_array, num_steps) """ self.num_steps = int(self.forecast_minutes / self.step_minutes) self.pv_array = np.zeros(self.num_steps) self.load_array = np.zeros(self.num_steps) - + # Aggregate to larger time steps for step_idx in range(self.num_steps): minute_start = step_idx * self.step_minutes pv_sum = 0.0 load_sum = 0.0 - + for offset in range(0, self.step_minutes, PREDICT_STEP): minute = minute_start + offset if minute >= self.forecast_minutes: break pv_sum += pv_forecast_minute_step.get(minute, 0.0) load_sum += load_minutes_step.get(minute, 0.0) - + self.pv_array[step_idx] = pv_sum self.load_array[step_idx] = load_sum - + self.log("Prepared forecast arrays: {} steps of {} minutes".format(self.num_steps, self.step_minutes)) return self.pv_array, self.load_array, self.num_steps - + def prepare_rate_arrays(self, rate_import, rate_export): """ Convert rate dictionaries to NumPy arrays indexed by time step. - + Args: rate_import: Dict of {minute_absolute: £/kWh} rate_export: Dict of {minute_absolute: £/kWh} - + Returns: Tuple of (rate_import_array, rate_export_array) """ self.rate_import_array = np.zeros(self.num_steps) self.rate_export_array = np.zeros(self.num_steps) - + for step_idx in range(self.num_steps): minute_start = step_idx * self.step_minutes minute_absolute = self.minutes_now + minute_start - + # Use rate at start of step (simplified) self.rate_import_array[step_idx] = rate_import.get(minute_absolute, 0.0) self.rate_export_array[step_idx] = rate_export.get(minute_absolute, 0.0) - + return self.rate_import_array, self.rate_export_array - + def prepare_window_masks(self, charge_windows, export_windows, num_scenarios): """ Convert window lists and scenario bits into boolean masks. - + Args: charge_windows: List of {"start": minute, "end": minute} dicts export_windows: List of {"start": minute, "end": minute} dicts num_scenarios: Number of scenarios to generate - + Returns: Tuple of (charge_masks, export_masks) both shape (num_scenarios, num_steps) """ num_charge_windows = len(charge_windows) num_export_windows = len(export_windows) - + # Create masks for each window (num_windows, num_steps) charge_window_masks = np.zeros((num_charge_windows, self.num_steps), dtype=bool) export_window_masks = np.zeros((num_export_windows, self.num_steps), dtype=bool) - + for w_idx, window in enumerate(charge_windows): start_step = max(0, int((window["start"] - self.minutes_now) / self.step_minutes)) end_step = min(self.num_steps, int((window["end"] - self.minutes_now) / self.step_minutes)) if start_step < end_step: charge_window_masks[w_idx, start_step:end_step] = True - + for w_idx, window in enumerate(export_windows): start_step = max(0, int((window["start"] - self.minutes_now) / self.step_minutes)) end_step = min(self.num_steps, int((window["end"] - self.minutes_now) / self.step_minutes)) if start_step < end_step: export_window_masks[w_idx, start_step:end_step] = True - + # For now, return window masks - caller will combine based on scenario bit patterns return charge_window_masks, export_window_masks - + def get_charge_rate(self, soc_array): """ Get charge rate for given SOC values using simplified curve lookup. - + Args: soc_array: Array of SOC values in kWh (any shape) - + Returns: Array of charge rates in kW (same shape as input) """ if not self.battery_charge_power_curve: # No curve, use max rate return np.full_like(soc_array, self.battery_rate_max_charge * self.battery_rate_max_scaling) - + # Extract curve points soc_points = np.array([point[0] * self.soc_max / 100.0 for point in self.battery_charge_power_curve]) power_points = np.array([point[1] for point in self.battery_charge_power_curve]) - + # Interpolate charge_rates = np.interp(soc_array, soc_points, power_points) charge_rates = charge_rates * self.battery_rate_max_scaling - + return charge_rates - + def get_discharge_rate(self, soc_array): """ Get discharge rate for given SOC values using simplified curve lookup. - + Args: soc_array: Array of SOC values in kWh (any shape) - + Returns: Array of discharge rates in kW (same shape as input) """ if not self.battery_discharge_power_curve: # No curve, use max rate return np.full_like(soc_array, self.battery_rate_max_discharge * self.battery_rate_max_scaling_discharge) - + # Extract curve points soc_points = np.array([point[0] * self.soc_max / 100.0 for point in self.battery_discharge_power_curve]) power_points = np.array([point[1] for point in self.battery_discharge_power_curve]) - + # Interpolate discharge_rates = np.interp(soc_array, soc_points, power_points) discharge_rates = discharge_rates * self.battery_rate_max_scaling_discharge - + return discharge_rates - + def run_prediction_batch(self, charge_window_enable, export_window_enable): """ Run batch prediction for multiple scenarios. - + Args: charge_window_enable: Boolean array (num_scenarios, num_steps) - True where charging is forced export_window_enable: Boolean array (num_scenarios, num_steps) - True where discharging is forced - + Returns: Dict with keys: - final_cost: Array of final costs (num_scenarios,) @@ -237,7 +237,7 @@ def run_prediction_batch(self, charge_window_enable, export_window_enable): - soc_min: Array of minimum SOC reached (num_scenarios,) """ num_scenarios = charge_window_enable.shape[0] - + # Initialize state arrays (num_scenarios, num_steps+1) soc = np.full((num_scenarios, self.num_steps + 1), self.soc_kw) cost = np.full(num_scenarios, self.cost_today_sofar) @@ -246,129 +246,109 @@ def run_prediction_batch(self, charge_window_enable, export_window_enable): import_kwh_battery = np.zeros(num_scenarios) import_kwh_house = np.zeros(num_scenarios) battery_cycle = np.zeros(num_scenarios) - + # Inverter and battery limits (scaled to step size) inverter_limit_step = self.inverter_limit * self.step_minutes export_limit_step = self.export_limit * self.step_minutes inverter_loss = self.inverter_loss - + # Time loop (not vectorized over time, but vectorized over scenarios) for step_idx in range(self.num_steps): soc_current = soc[:, step_idx] - + # Get PV and load for this step pv_now = self.pv_array[step_idx] load_now = self.load_array[step_idx] - + # Get rates import_rate = self.rate_import_array[step_idx] export_rate = self.rate_export_array[step_idx] - + # Get charge/discharge windows for this step charge_active = charge_window_enable[:, step_idx] # (num_scenarios,) export_active = export_window_enable[:, step_idx] # (num_scenarios,) - + # Get SOC-dependent rates charge_rate = self.get_charge_rate(soc_current) # (num_scenarios,) discharge_rate = self.get_discharge_rate(soc_current) # (num_scenarios,) - + # Scale to step size charge_rate_step = charge_rate * self.step_minutes discharge_rate_step = discharge_rate * self.step_minutes - + # Calculate battery capacity limits battery_to_min = np.maximum(soc_current - self.reserve, 0) * self.battery_loss_discharge battery_to_max = np.maximum(self.soc_max - soc_current, 0) * self.battery_loss - + # Initialize battery draw battery_draw = np.zeros(num_scenarios) - + # Mode 1: Force discharge (export window active) force_discharge = export_active - battery_draw = np.where( - force_discharge, - np.minimum(discharge_rate_step, battery_to_min), - battery_draw - ) - + battery_draw = np.where(force_discharge, np.minimum(discharge_rate_step, battery_to_min), battery_draw) + # Mode 2: Force charge (charge window active, not discharge) force_charge = charge_active & ~export_active - battery_draw = np.where( - force_charge, - -np.minimum(charge_rate_step, battery_to_max), - battery_draw - ) - + battery_draw = np.where(force_charge, -np.minimum(charge_rate_step, battery_to_max), battery_draw) + # Mode 3: ECO mode (no windows active) eco_mode = ~charge_active & ~export_active - + # For ECO mode: calculate PV AC and determine battery action pv_ac = pv_now * inverter_loss # AC-only inverter diff_eco = load_now - pv_ac # Shortfall (positive) or excess (negative) - + # If shortfall, discharge to meet it battery_draw_eco = np.where( diff_eco > 0, np.minimum(np.minimum(diff_eco, discharge_rate_step), battery_to_min), # If excess, charge from it - np.maximum(np.maximum(diff_eco, -charge_rate_step), -battery_to_max) + np.maximum(np.maximum(diff_eco, -charge_rate_step), -battery_to_max), ) - + battery_draw = np.where(eco_mode, battery_draw_eco, battery_draw) - + # Apply inverter limit (AC-only, simplified) # Limit discharge - battery_draw = np.where( - battery_draw > 0, - np.minimum(battery_draw, inverter_limit_step), - battery_draw - ) + battery_draw = np.where(battery_draw > 0, np.minimum(battery_draw, inverter_limit_step), battery_draw) # Limit charge - battery_draw = np.where( - battery_draw < 0, - np.maximum(battery_draw, -inverter_limit_step), - battery_draw - ) - + battery_draw = np.where(battery_draw < 0, np.maximum(battery_draw, -inverter_limit_step), battery_draw) + # Update SOC with asymmetric losses - soc_delta = np.where( - battery_draw > 0, - -battery_draw / self.battery_loss_discharge, # Discharge - -battery_draw * self.battery_loss # Charge (battery_draw is negative) - ) - + soc_delta = np.where(battery_draw > 0, -battery_draw / self.battery_loss_discharge, -battery_draw * self.battery_loss) # Discharge # Charge (battery_draw is negative) + soc_next = soc_current + soc_delta soc_next = np.clip(soc_next, self.reserve, self.soc_max) soc[:, step_idx + 1] = soc_next - + # Calculate grid import/export (AC-only model) # Grid balance = load - pv - battery (positive battery_draw = discharge helps, negative = charge consumes) grid_balance = load_now - pv_ac - battery_draw / inverter_loss - + # Positive grid_balance = import, negative = export step_import = np.maximum(grid_balance, 0) step_export = np.maximum(-grid_balance, 0) - + # Limit export step_export = np.minimum(step_export, export_limit_step) - + # Update cumulative energy import_kwh += step_import export_kwh += step_export - + # Track battery vs house import import_kwh_battery += np.where(charge_active, step_import, 0) import_kwh_house += np.where(~charge_active, step_import, 0) - + # Update cost cost += step_import * import_rate - step_export * export_rate - + # Update battery cycles battery_cycle += np.abs(battery_draw) - + # Calculate minimum SOC soc_min = np.min(soc[:, :-1], axis=1) - + # Return results return { "final_cost": cost, @@ -387,7 +367,7 @@ def run_prediction_batch(self, charge_window_enable, export_window_enable): if __name__ == "__main__": print("Vectorized Prediction Test Harness") print("=" * 60) - + # Create a dummy base object class DummyBase: def __init__(self): @@ -409,26 +389,22 @@ def __init__(self): self.cost_today_sofar = 0.0 self.import_today_now = 0.0 self.export_today_now = 0.0 - + # Simple power curves (SOC % -> power factor) - self.battery_charge_power_curve = [ - [0, 1.0], [50, 1.0], [90, 0.8], [100, 0.3] - ] - self.battery_discharge_power_curve = [ - [0, 0.3], [10, 0.8], [50, 1.0], [100, 1.0] - ] - + self.battery_charge_power_curve = [[0, 1.0], [50, 1.0], [90, 0.8], [100, 0.3]] + self.battery_discharge_power_curve = [[0, 0.3], [10, 0.8], [50, 1.0], [100, 1.0]] + def log(self, msg): print("[LOG] {}".format(msg)) - + # Create vectorized predictor base = DummyBase() predictor = PredictionVectorized(base, step_minutes=30) - + # Create synthetic forecasts pv_forecast = {} load_forecast = {} - + for minute in range(0, 2880, 5): hour = (minute // 60) % 24 # Simple sinusoidal PV (peak at noon) @@ -436,15 +412,15 @@ def log(self, msg): pv_forecast[minute] = 0.5 * (1 + np.sin((hour - 6) * np.pi / 12)) else: pv_forecast[minute] = 0.0 - + # Simple load pattern if 7 <= hour < 9 or 17 <= hour < 22: load_forecast[minute] = 0.8 else: load_forecast[minute] = 0.3 - + predictor.prepare_forecast_arrays(pv_forecast, load_forecast) - + # Create synthetic rates rate_import = {} rate_export = {} @@ -457,11 +433,11 @@ def log(self, msg): rate_import[minute] = 0.30 # Expensive else: rate_import[minute] = 0.15 # Mid - + rate_export[minute] = 0.05 - + predictor.prepare_rate_arrays(rate_import, rate_export) - + # Create test windows charge_windows = [ {"start": 120, "end": 300}, # 02:00-05:00 @@ -469,46 +445,39 @@ def log(self, msg): export_windows = [ {"start": 960, "end": 1140}, # 16:00-19:00 ] - - charge_window_masks, export_window_masks = predictor.prepare_window_masks( - charge_windows, export_windows, num_scenarios=4 - ) - + + charge_window_masks, export_window_masks = predictor.prepare_window_masks(charge_windows, export_windows, num_scenarios=4) + # Create 4 test scenarios (combinations of windows on/off) # Scenario 0: No windows # Scenario 1: Charge only # Scenario 2: Export only # Scenario 3: Both windows - + num_scenarios = 4 charge_enable = np.zeros((num_scenarios, predictor.num_steps), dtype=bool) export_enable = np.zeros((num_scenarios, predictor.num_steps), dtype=bool) - + # Scenario 1: Charge window enabled charge_enable[1, :] = charge_window_masks[0, :] - + # Scenario 2: Export window enabled export_enable[2, :] = export_window_masks[0, :] - + # Scenario 3: Both enabled charge_enable[3, :] = charge_window_masks[0, :] export_enable[3, :] = export_window_masks[0, :] - + # Run batch prediction print("\nRunning batch prediction for {} scenarios...".format(num_scenarios)) results = predictor.run_prediction_batch(charge_enable, export_enable) - + # Display results print("\nResults:") print("-" * 60) for i in range(num_scenarios): - scenario_name = [ - "ECO only (no windows)", - "Charge window only", - "Export window only", - "Both windows" - ][i] - + scenario_name = ["ECO only (no windows)", "Charge window only", "Export window only", "Both windows"][i] + print("\nScenario {}: {}".format(i, scenario_name)) print(" Final cost: £{:.2f}".format(results["final_cost"][i])) print(" Final SOC: {:.2f} kWh".format(results["final_soc"][i])) @@ -518,12 +487,9 @@ def log(self, msg): print(" Import (house): {:.2f} kWh".format(results["import_kwh_house"][i])) print(" Export: {:.2f} kWh".format(results["export_kwh"][i])) print(" Battery cycle: {:.2f} kWh".format(results["battery_cycle"][i])) - + # Find best scenario best_idx = np.argmin(results["final_cost"]) print("\n" + "=" * 60) - print("Best scenario: {} (£{:.2f})".format( - ["ECO only", "Charge only", "Export only", "Both windows"][best_idx], - results["final_cost"][best_idx] - )) + print("Best scenario: {} (£{:.2f})".format(["ECO only", "Charge only", "Export only", "Both windows"][best_idx], results["final_cost"][best_idx])) print("=" * 60) From 49df519bcd736038478c3aa20291efabba027152 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sun, 25 Jan 2026 09:51:10 +0000 Subject: [PATCH 3/6] Catch potential solis mode crash: https://github.com/springfall2008/batpred/issues/3281 --- apps/predbat/inverter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/predbat/inverter.py b/apps/predbat/inverter.py index fe352c6b5..6a29c0cae 100644 --- a/apps/predbat/inverter.py +++ b/apps/predbat/inverter.py @@ -2257,7 +2257,7 @@ def alt_charge_discharge_enable(self, direction, enable): solax_modes = SOLAX_SOLIS_MODES_NEW if self.base.get_arg("solax_modbus_new", True) else SOLAX_SOLIS_MODES entity_id = self.base.get_arg("energy_control_switch", indirect=False, index=self.id) - switch = solax_modes.get(self.base.get_state_wrapper(entity_id), 0) + switch = solax_modes.get(str(self.base.get_state_wrapper(entity_id, "")), 0) if direction == "charge": if enable: From 1b88380af62552deb8f83584c554e98fc82e35ac Mon Sep 17 00:00:00 2001 From: Trefor Southwell <48591903+springfall2008@users.noreply.github.com> Date: Sun, 25 Jan 2026 09:52:52 +0000 Subject: [PATCH 4/6] Update apps/predbat/fox.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- apps/predbat/fox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/predbat/fox.py b/apps/predbat/fox.py index 708ac6236..b7872bbcf 100644 --- a/apps/predbat/fox.py +++ b/apps/predbat/fox.py @@ -1014,7 +1014,7 @@ async def get_device_list(self): devices = [] if result is not None: devices = result.get("data", []) - # Filter by self.inverter_sn_filter if its not + # If self.inverter_sn_filter is set, keep only devices whose deviceSN is in that filter if self.inverter_sn_filter: devices = [device for device in devices if device.get("deviceSN", "") in self.inverter_sn_filter] self.device_list = devices From fa2f9a21dbdfabfc63e8e3ca347ef0be1ab42ada Mon Sep 17 00:00:00 2001 From: Trefor Southwell <48591903+springfall2008@users.noreply.github.com> Date: Sun, 25 Jan 2026 09:54:24 +0000 Subject: [PATCH 5/6] Delete apps/predbat/prediction_vectorized.py --- apps/predbat/prediction_vectorized.py | 495 -------------------------- 1 file changed, 495 deletions(-) delete mode 100644 apps/predbat/prediction_vectorized.py diff --git a/apps/predbat/prediction_vectorized.py b/apps/predbat/prediction_vectorized.py deleted file mode 100644 index fbc33cf67..000000000 --- a/apps/predbat/prediction_vectorized.py +++ /dev/null @@ -1,495 +0,0 @@ -# ----------------------------------------------------------------------------- -# Predbat Home Battery System -# Copyright Trefor Southwell 2024 - 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 - -""" -Vectorized batch prediction engine for fast simulation of multiple scenarios. -Uses NumPy array operations to evaluate hundreds of charge/discharge window -combinations simultaneously. Designed for levels pass optimization. -""" - -import numpy as np -from const import PREDICT_STEP - - -class PredictionVectorized: - """ - Vectorized prediction engine that runs multiple scenarios in parallel using NumPy. - Simplified physics model: - - AC-only inverter (no hybrid DC path) - - Single SOC lookup per time step for charge curves - - No iboost, temperature effects, or other advanced features - - Suitable for initial filtering in levels pass optimization - """ - - def __init__(self, base, step_minutes=30): - """ - Initialize vectorized prediction from base Prediction object. - - Args: - base: Prediction object with configuration and forecasts - step_minutes: Time step size in minutes (default 30) - """ - self.log = base.log - self.step_minutes = step_minutes - self.minutes_now = base.minutes_now - self.forecast_minutes = base.forecast_minutes - - # Battery parameters - self.soc_kw = base.soc_kw - self.soc_max = base.soc_max - self.reserve = base.reserve - self.battery_loss = base.battery_loss - self.battery_loss_discharge = base.battery_loss_discharge - self.battery_rate_max_charge = base.battery_rate_max_charge - self.battery_rate_max_discharge = base.battery_rate_max_discharge - self.battery_rate_min = base.battery_rate_min - self.battery_rate_max_scaling = base.battery_rate_max_scaling - self.battery_rate_max_scaling_discharge = base.battery_rate_max_scaling_discharge - - # Inverter parameters - self.inverter_loss = base.inverter_loss - self.inverter_limit = base.inverter_limit - self.export_limit = base.export_limit - - # Cost tracking - self.cost_today_sofar = base.cost_today_sofar - self.import_today_now = base.import_today_now - self.export_today_now = base.export_today_now - - # Power curves (simplified) - self.battery_charge_power_curve = base.battery_charge_power_curve - self.battery_discharge_power_curve = base.battery_discharge_power_curve - - # Prepare arrays - self.pv_array = None - self.load_array = None - self.rate_import_array = None - self.rate_export_array = None - self.num_steps = 0 - - self.log("Vectorized prediction initialized with step_minutes={}".format(step_minutes)) - - def prepare_forecast_arrays(self, pv_forecast_minute_step, load_minutes_step): - """ - Convert forecast dictionaries to NumPy arrays aggregated to step_minutes. - Merges car charging into load data. - - Args: - pv_forecast_minute_step: Dict of {minute: kW} for PV forecast - load_minutes_step: Dict of {minute: kW} for load forecast - - Returns: - Tuple of (pv_array, load_array, num_steps) - """ - self.num_steps = int(self.forecast_minutes / self.step_minutes) - self.pv_array = np.zeros(self.num_steps) - self.load_array = np.zeros(self.num_steps) - - # Aggregate to larger time steps - for step_idx in range(self.num_steps): - minute_start = step_idx * self.step_minutes - pv_sum = 0.0 - load_sum = 0.0 - - for offset in range(0, self.step_minutes, PREDICT_STEP): - minute = minute_start + offset - if minute >= self.forecast_minutes: - break - pv_sum += pv_forecast_minute_step.get(minute, 0.0) - load_sum += load_minutes_step.get(minute, 0.0) - - self.pv_array[step_idx] = pv_sum - self.load_array[step_idx] = load_sum - - self.log("Prepared forecast arrays: {} steps of {} minutes".format(self.num_steps, self.step_minutes)) - return self.pv_array, self.load_array, self.num_steps - - def prepare_rate_arrays(self, rate_import, rate_export): - """ - Convert rate dictionaries to NumPy arrays indexed by time step. - - Args: - rate_import: Dict of {minute_absolute: £/kWh} - rate_export: Dict of {minute_absolute: £/kWh} - - Returns: - Tuple of (rate_import_array, rate_export_array) - """ - self.rate_import_array = np.zeros(self.num_steps) - self.rate_export_array = np.zeros(self.num_steps) - - for step_idx in range(self.num_steps): - minute_start = step_idx * self.step_minutes - minute_absolute = self.minutes_now + minute_start - - # Use rate at start of step (simplified) - self.rate_import_array[step_idx] = rate_import.get(minute_absolute, 0.0) - self.rate_export_array[step_idx] = rate_export.get(minute_absolute, 0.0) - - return self.rate_import_array, self.rate_export_array - - def prepare_window_masks(self, charge_windows, export_windows, num_scenarios): - """ - Convert window lists and scenario bits into boolean masks. - - Args: - charge_windows: List of {"start": minute, "end": minute} dicts - export_windows: List of {"start": minute, "end": minute} dicts - num_scenarios: Number of scenarios to generate - - Returns: - Tuple of (charge_masks, export_masks) both shape (num_scenarios, num_steps) - """ - num_charge_windows = len(charge_windows) - num_export_windows = len(export_windows) - - # Create masks for each window (num_windows, num_steps) - charge_window_masks = np.zeros((num_charge_windows, self.num_steps), dtype=bool) - export_window_masks = np.zeros((num_export_windows, self.num_steps), dtype=bool) - - for w_idx, window in enumerate(charge_windows): - start_step = max(0, int((window["start"] - self.minutes_now) / self.step_minutes)) - end_step = min(self.num_steps, int((window["end"] - self.minutes_now) / self.step_minutes)) - if start_step < end_step: - charge_window_masks[w_idx, start_step:end_step] = True - - for w_idx, window in enumerate(export_windows): - start_step = max(0, int((window["start"] - self.minutes_now) / self.step_minutes)) - end_step = min(self.num_steps, int((window["end"] - self.minutes_now) / self.step_minutes)) - if start_step < end_step: - export_window_masks[w_idx, start_step:end_step] = True - - # For now, return window masks - caller will combine based on scenario bit patterns - return charge_window_masks, export_window_masks - - def get_charge_rate(self, soc_array): - """ - Get charge rate for given SOC values using simplified curve lookup. - - Args: - soc_array: Array of SOC values in kWh (any shape) - - Returns: - Array of charge rates in kW (same shape as input) - """ - if not self.battery_charge_power_curve: - # No curve, use max rate - return np.full_like(soc_array, self.battery_rate_max_charge * self.battery_rate_max_scaling) - - # Extract curve points - soc_points = np.array([point[0] * self.soc_max / 100.0 for point in self.battery_charge_power_curve]) - power_points = np.array([point[1] for point in self.battery_charge_power_curve]) - - # Interpolate - charge_rates = np.interp(soc_array, soc_points, power_points) - charge_rates = charge_rates * self.battery_rate_max_scaling - - return charge_rates - - def get_discharge_rate(self, soc_array): - """ - Get discharge rate for given SOC values using simplified curve lookup. - - Args: - soc_array: Array of SOC values in kWh (any shape) - - Returns: - Array of discharge rates in kW (same shape as input) - """ - if not self.battery_discharge_power_curve: - # No curve, use max rate - return np.full_like(soc_array, self.battery_rate_max_discharge * self.battery_rate_max_scaling_discharge) - - # Extract curve points - soc_points = np.array([point[0] * self.soc_max / 100.0 for point in self.battery_discharge_power_curve]) - power_points = np.array([point[1] for point in self.battery_discharge_power_curve]) - - # Interpolate - discharge_rates = np.interp(soc_array, soc_points, power_points) - discharge_rates = discharge_rates * self.battery_rate_max_scaling_discharge - - return discharge_rates - - def run_prediction_batch(self, charge_window_enable, export_window_enable): - """ - Run batch prediction for multiple scenarios. - - Args: - charge_window_enable: Boolean array (num_scenarios, num_steps) - True where charging is forced - export_window_enable: Boolean array (num_scenarios, num_steps) - True where discharging is forced - - Returns: - Dict with keys: - - final_cost: Array of final costs (num_scenarios,) - - final_soc: Array of final SOC in kWh (num_scenarios,) - - import_kwh: Array of total import (num_scenarios,) - - export_kwh: Array of total export (num_scenarios,) - - import_kwh_battery: Array of import for charging (num_scenarios,) - - import_kwh_house: Array of import for load (num_scenarios,) - - battery_cycle: Array of total throughput (num_scenarios,) - - soc_min: Array of minimum SOC reached (num_scenarios,) - """ - num_scenarios = charge_window_enable.shape[0] - - # Initialize state arrays (num_scenarios, num_steps+1) - soc = np.full((num_scenarios, self.num_steps + 1), self.soc_kw) - cost = np.full(num_scenarios, self.cost_today_sofar) - import_kwh = np.full(num_scenarios, self.import_today_now) - export_kwh = np.full(num_scenarios, self.export_today_now) - import_kwh_battery = np.zeros(num_scenarios) - import_kwh_house = np.zeros(num_scenarios) - battery_cycle = np.zeros(num_scenarios) - - # Inverter and battery limits (scaled to step size) - inverter_limit_step = self.inverter_limit * self.step_minutes - export_limit_step = self.export_limit * self.step_minutes - inverter_loss = self.inverter_loss - - # Time loop (not vectorized over time, but vectorized over scenarios) - for step_idx in range(self.num_steps): - soc_current = soc[:, step_idx] - - # Get PV and load for this step - pv_now = self.pv_array[step_idx] - load_now = self.load_array[step_idx] - - # Get rates - import_rate = self.rate_import_array[step_idx] - export_rate = self.rate_export_array[step_idx] - - # Get charge/discharge windows for this step - charge_active = charge_window_enable[:, step_idx] # (num_scenarios,) - export_active = export_window_enable[:, step_idx] # (num_scenarios,) - - # Get SOC-dependent rates - charge_rate = self.get_charge_rate(soc_current) # (num_scenarios,) - discharge_rate = self.get_discharge_rate(soc_current) # (num_scenarios,) - - # Scale to step size - charge_rate_step = charge_rate * self.step_minutes - discharge_rate_step = discharge_rate * self.step_minutes - - # Calculate battery capacity limits - battery_to_min = np.maximum(soc_current - self.reserve, 0) * self.battery_loss_discharge - battery_to_max = np.maximum(self.soc_max - soc_current, 0) * self.battery_loss - - # Initialize battery draw - battery_draw = np.zeros(num_scenarios) - - # Mode 1: Force discharge (export window active) - force_discharge = export_active - battery_draw = np.where(force_discharge, np.minimum(discharge_rate_step, battery_to_min), battery_draw) - - # Mode 2: Force charge (charge window active, not discharge) - force_charge = charge_active & ~export_active - battery_draw = np.where(force_charge, -np.minimum(charge_rate_step, battery_to_max), battery_draw) - - # Mode 3: ECO mode (no windows active) - eco_mode = ~charge_active & ~export_active - - # For ECO mode: calculate PV AC and determine battery action - pv_ac = pv_now * inverter_loss # AC-only inverter - diff_eco = load_now - pv_ac # Shortfall (positive) or excess (negative) - - # If shortfall, discharge to meet it - battery_draw_eco = np.where( - diff_eco > 0, - np.minimum(np.minimum(diff_eco, discharge_rate_step), battery_to_min), - # If excess, charge from it - np.maximum(np.maximum(diff_eco, -charge_rate_step), -battery_to_max), - ) - - battery_draw = np.where(eco_mode, battery_draw_eco, battery_draw) - - # Apply inverter limit (AC-only, simplified) - # Limit discharge - battery_draw = np.where(battery_draw > 0, np.minimum(battery_draw, inverter_limit_step), battery_draw) - # Limit charge - battery_draw = np.where(battery_draw < 0, np.maximum(battery_draw, -inverter_limit_step), battery_draw) - - # Update SOC with asymmetric losses - soc_delta = np.where(battery_draw > 0, -battery_draw / self.battery_loss_discharge, -battery_draw * self.battery_loss) # Discharge # Charge (battery_draw is negative) - - soc_next = soc_current + soc_delta - soc_next = np.clip(soc_next, self.reserve, self.soc_max) - soc[:, step_idx + 1] = soc_next - - # Calculate grid import/export (AC-only model) - # Grid balance = load - pv - battery (positive battery_draw = discharge helps, negative = charge consumes) - grid_balance = load_now - pv_ac - battery_draw / inverter_loss - - # Positive grid_balance = import, negative = export - step_import = np.maximum(grid_balance, 0) - step_export = np.maximum(-grid_balance, 0) - - # Limit export - step_export = np.minimum(step_export, export_limit_step) - - # Update cumulative energy - import_kwh += step_import - export_kwh += step_export - - # Track battery vs house import - import_kwh_battery += np.where(charge_active, step_import, 0) - import_kwh_house += np.where(~charge_active, step_import, 0) - - # Update cost - cost += step_import * import_rate - step_export * export_rate - - # Update battery cycles - battery_cycle += np.abs(battery_draw) - - # Calculate minimum SOC - soc_min = np.min(soc[:, :-1], axis=1) - - # Return results - return { - "final_cost": cost, - "final_soc": soc[:, -1], - "import_kwh": import_kwh, - "export_kwh": export_kwh, - "import_kwh_battery": import_kwh_battery, - "import_kwh_house": import_kwh_house, - "battery_cycle": battery_cycle, - "soc_min": soc_min, - "soc_trajectories": soc, # For debugging - } - - -# Test harness -if __name__ == "__main__": - print("Vectorized Prediction Test Harness") - print("=" * 60) - - # Create a dummy base object - class DummyBase: - def __init__(self): - self.minutes_now = 0 - self.forecast_minutes = 2880 # 48 hours - self.soc_kw = 5.0 - self.soc_max = 10.0 - self.reserve = 1.0 - self.battery_loss = 0.97 - self.battery_loss_discharge = 0.97 - self.battery_rate_max_charge = 3.0 # kW - self.battery_rate_max_discharge = 3.0 # kW - self.battery_rate_min = 0.0 - self.battery_rate_max_scaling = 1.0 - self.battery_rate_max_scaling_discharge = 1.0 - self.inverter_loss = 0.96 - self.inverter_limit = 3.5 # kW - self.export_limit = 3.5 # kW - self.cost_today_sofar = 0.0 - self.import_today_now = 0.0 - self.export_today_now = 0.0 - - # Simple power curves (SOC % -> power factor) - self.battery_charge_power_curve = [[0, 1.0], [50, 1.0], [90, 0.8], [100, 0.3]] - self.battery_discharge_power_curve = [[0, 0.3], [10, 0.8], [50, 1.0], [100, 1.0]] - - def log(self, msg): - print("[LOG] {}".format(msg)) - - # Create vectorized predictor - base = DummyBase() - predictor = PredictionVectorized(base, step_minutes=30) - - # Create synthetic forecasts - pv_forecast = {} - load_forecast = {} - - for minute in range(0, 2880, 5): - hour = (minute // 60) % 24 - # Simple sinusoidal PV (peak at noon) - if 6 <= hour < 18: - pv_forecast[minute] = 0.5 * (1 + np.sin((hour - 6) * np.pi / 12)) - else: - pv_forecast[minute] = 0.0 - - # Simple load pattern - if 7 <= hour < 9 or 17 <= hour < 22: - load_forecast[minute] = 0.8 - else: - load_forecast[minute] = 0.3 - - predictor.prepare_forecast_arrays(pv_forecast, load_forecast) - - # Create synthetic rates - rate_import = {} - rate_export = {} - for minute in range(0, 2880, 5): - hour = (minute // 60) % 24 - # Cheap overnight, expensive peak - if 2 <= hour < 5: - rate_import[minute] = 0.075 # Cheap - elif 16 <= hour < 19: - rate_import[minute] = 0.30 # Expensive - else: - rate_import[minute] = 0.15 # Mid - - rate_export[minute] = 0.05 - - predictor.prepare_rate_arrays(rate_import, rate_export) - - # Create test windows - charge_windows = [ - {"start": 120, "end": 300}, # 02:00-05:00 - ] - export_windows = [ - {"start": 960, "end": 1140}, # 16:00-19:00 - ] - - charge_window_masks, export_window_masks = predictor.prepare_window_masks(charge_windows, export_windows, num_scenarios=4) - - # Create 4 test scenarios (combinations of windows on/off) - # Scenario 0: No windows - # Scenario 1: Charge only - # Scenario 2: Export only - # Scenario 3: Both windows - - num_scenarios = 4 - charge_enable = np.zeros((num_scenarios, predictor.num_steps), dtype=bool) - export_enable = np.zeros((num_scenarios, predictor.num_steps), dtype=bool) - - # Scenario 1: Charge window enabled - charge_enable[1, :] = charge_window_masks[0, :] - - # Scenario 2: Export window enabled - export_enable[2, :] = export_window_masks[0, :] - - # Scenario 3: Both enabled - charge_enable[3, :] = charge_window_masks[0, :] - export_enable[3, :] = export_window_masks[0, :] - - # Run batch prediction - print("\nRunning batch prediction for {} scenarios...".format(num_scenarios)) - results = predictor.run_prediction_batch(charge_enable, export_enable) - - # Display results - print("\nResults:") - print("-" * 60) - for i in range(num_scenarios): - scenario_name = ["ECO only (no windows)", "Charge window only", "Export window only", "Both windows"][i] - - print("\nScenario {}: {}".format(i, scenario_name)) - print(" Final cost: £{:.2f}".format(results["final_cost"][i])) - print(" Final SOC: {:.2f} kWh".format(results["final_soc"][i])) - print(" Min SOC: {:.2f} kWh".format(results["soc_min"][i])) - print(" Import (total): {:.2f} kWh".format(results["import_kwh"][i])) - print(" Import (batt): {:.2f} kWh".format(results["import_kwh_battery"][i])) - print(" Import (house): {:.2f} kWh".format(results["import_kwh_house"][i])) - print(" Export: {:.2f} kWh".format(results["export_kwh"][i])) - print(" Battery cycle: {:.2f} kWh".format(results["battery_cycle"][i])) - - # Find best scenario - best_idx = np.argmin(results["final_cost"]) - print("\n" + "=" * 60) - print("Best scenario: {} (£{:.2f})".format(["ECO only", "Charge only", "Export only", "Both windows"][best_idx], results["final_cost"][best_idx])) - print("=" * 60) From 61f694ea6f58aa977517ac702bfc5a9470b1d3b4 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Fri, 30 Jan 2026 08:20:52 +0000 Subject: [PATCH 6/6] Saving total not updating https://github.com/springfall2008/batpred/issues/3292 --- apps/predbat/predbat.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 7607f3dc0..46dbb3f75 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -27,7 +27,7 @@ import requests import asyncio -THIS_VERSION = "v8.32.12" +THIS_VERSION = "v8.32.13" # fmt: off PREDBAT_FILES = ["predbat.py", "const.py", "hass.py", "config.py", "prediction.py", "gecloud.py", "utils.py", "inverter.py", "ha.py", "download.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", "component_base.py", "axle.py", "solax.py", "solis.py", "unit_test.py"] @@ -832,12 +832,12 @@ def update_pred(self, scheduled=True): savings_total_last_updated = self.load_previous_value_from_ha(self.prefix + ".savings_total_predbat", attribute="last_updated") savings_total_start_date = self.load_previous_value_from_ha(self.prefix + ".savings_total_predbat", attribute="start_date") todays_date = self.now_utc_real.strftime("%Y-%m-%d") - if not savings_total_start_date: - savings_total_start_date = todays_date - # As last updated date is new we assume if we already have data then its been updated for today - if not savings_total_last_updated and savings_total_predbat > 0.0: + # If there is no date its a new install so we will save tomorrow as the first point + if not savings_total_last_updated: savings_total_last_updated = todays_date + if not savings_total_start_date: + savings_total_start_date = todays_date savings_total_pvbat = self.load_previous_value_from_ha(self.prefix + ".savings_total_pvbat") try: