From a3bf52363ce1bd1047ca691c71540ac518b7e3a8 Mon Sep 17 00:00:00 2001 From: Craig Callender <125502786+CraigCallender@users.noreply.github.com> Date: Thu, 27 Jun 2024 15:31:12 +0100 Subject: [PATCH 1/8] adding solis_cloud integrations. --- apps/pv_opt/config/config.yaml | 36 ++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/pv_opt/config/config.yaml b/apps/pv_opt/config/config.yaml index eb84d15..6da9173 100644 --- a/apps/pv_opt/config/config.yaml +++ b/apps/pv_opt/config/config.yaml @@ -3,8 +3,8 @@ pvpy: module: pvpy global: true -solis: - module: solis +solis_cloud: + module: solis_cloud global: true inverters: @@ -16,7 +16,7 @@ pv_opt: class: PVOpt dependencies: - pvpy - - solis + - solis_cloud log: pv_opt_log prefix: pvopt @@ -131,10 +131,10 @@ pv_opt: # update_cycle_seconds: 15 # maximum_dod_percent: number.{device_name}_battery_minimum_soc - id_consumption_today: sensor.{device_name}_consumption_today - id_consumption: - - sensor.{device_name}_house_load - - sensor.{device_name}_bypass_load + # id_consumption_today: sensor.{device_name}_consumption_today + # id_consumption: + # - sensor.{device_name}_house_load + # - sensor.{device_name}_bypass_load # id_grid_import_today: sensor.{device_name}_grid_import_today # id_grid_export_today: sensor.{device_name}_grid_export_today @@ -226,6 +226,26 @@ pv_opt: # id_inverter_mode: sensor.{device_name}_storage_control_mode + # =============================================================================================================== + # Brand / Integration Specific Config: SOLIS_SOLIS_CLOUD: https://github.com/hultenvp/solis-sensor & https://github.com/stevegal/solis_control + # =============================================================================================================== + # + # These are the default entities used with the Solis Solis Cloud integration. You can change them here and over-ride the defaults + + # inverter_type: SOLIS_SOLIS_CLOUD + # device_name: solis + + # battery_voltage: sensor.{device_name}_battery_voltage + # update_cycle_seconds: 300 + # maximum_dod_percent: number.{device_name}_battery_minimum_soc + + id_consumption_today: sensor.{device_name}_energy_today + + id_grid_import_today: sensor.{device_name}_daily_grid_energy_purchased + id_grid_export_today: sensor.{device_name}_daily_on_grid_energy + + id_battery_soc: sensor.{device_name}_remaining_battery_capacity + # =============================================================================================================== # Brand / Integration Specific Config: SUNSYNK_SOLARSYNK2: # =============================================================================================================== @@ -264,4 +284,4 @@ pv_opt: - name: Flux octopus_import_tariff_code: E-1R-FLUX-IMPORT-23-02-14-G - octopus_export_tariff_code: E-1R-FLUX-EXPORT-23-02-14-G \ No newline at end of file + octopus_export_tariff_code: E-1R-FLUX-EXPORT-23-02-14-G From e76e4e0397e3c9974ec1575535c97461bda08e1f Mon Sep 17 00:00:00 2001 From: Craig Callender <125502786+CraigCallender@users.noreply.github.com> Date: Tue, 2 Jul 2024 08:19:38 +0000 Subject: [PATCH 2/8] Adding battery_current_limit_amps config to BatteryModel and respecting it for discharge and charge power. --- apps/pv_opt/pv_opt.py | 1 + apps/pv_opt/pvpy.py | 27 ++++++++++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/pv_opt/pv_opt.py b/apps/pv_opt/pv_opt.py index 5006b79..adf2f3c 100644 --- a/apps/pv_opt/pv_opt.py +++ b/apps/pv_opt/pv_opt.py @@ -717,6 +717,7 @@ def _load_pv_system_model(self): self.battery_model = pv.BatteryModel( capacity=self.get_config("battery_capacity_wh"), max_dod=self.get_config("maximum_dod_percent") / 100, + current_limit_amps=self.get_config("battery_current_limit_amps"), ) self.pv_system = pv.PVsystemModel("PV_Opt", self.inverter_model, self.battery_model, host=self) diff --git a/apps/pv_opt/pvpy.py b/apps/pv_opt/pvpy.py index afdba05..eb0e93c 100644 --- a/apps/pv_opt/pvpy.py +++ b/apps/pv_opt/pvpy.py @@ -371,12 +371,33 @@ def __init__( class BatteryModel: - def __init__(self, capacity: int, max_dod: float = 0.15) -> None: + """Describes the battery system attached to the inverter + + Attributes: + __voltage: An int describing the voltage of the battery system. + capacity: An integer describing the Wh capacity of the battery. + max_dod: A float describing the maximum depth of discharge of the battery. + current_limit_amps: An int describing the maximum amps at which the battery can charge/discharge. + """ + + def __init__(self, capacity: int, max_dod: float = 0.15, current_limit_amps: int = 100) -> None: self.capacity = capacity self.max_dod = max_dod + self.current_limit_amps = current_limit_amps + self.__voltage : int = 48 def __str__(self): pass + + @property + def max_charge_power(self): + """ returns the maximum watts at which the battery can charge. """ + return self.current_limit_amps * self.__voltage # probably shouldn't use magic numbers here. + + @property + def max_discharge_power(self): + """ returns the maximum watts at which the battery can discharge. """ + return self.max_charge_power class OctopusAccount: @@ -904,7 +925,7 @@ def optimised_force(self, initial_soc, static_flows, contract: Contract, **kwarg str_log += f"SOC: {x.loc[start_window]['soc']:5.1f}%->{x.loc[start_window]['soc_end']:5.1f}% " forced_charge = min( - self.inverter.charger_power + self.battery.max_charge_power - x["forced"].loc[start_window] - x[cols["solar"]].loc[start_window], ((100 - x["soc_end"].loc[start_window]) / 100 * self.battery.capacity) * 2 * factor, @@ -1016,7 +1037,7 @@ def optimised_force(self, initial_soc, static_flows, contract: Contract, **kwarg slot = ( start_window, -min( - self.inverter.inverter_power - x[kwargs.get("solar", "solar")].loc[start_window], + self.battery.max_discharge_power - x[kwargs.get("solar", "solar")].loc[start_window], ((x["soc_end"].loc[start_window] - self.battery.max_dod) / 100 * self.battery.capacity) * 2 * factor, From 103211921d62c0b10cee7ef35e639f74093bf8d8 Mon Sep 17 00:00:00 2001 From: Craig Callender <125502786+CraigCallender@users.noreply.github.com> Date: Tue, 2 Jul 2024 08:24:39 +0000 Subject: [PATCH 3/8] Resetting config.yaml to source --- apps/pv_opt/config/config.yaml | 36 ++++++++-------------------------- 1 file changed, 8 insertions(+), 28 deletions(-) diff --git a/apps/pv_opt/config/config.yaml b/apps/pv_opt/config/config.yaml index 6da9173..eb84d15 100644 --- a/apps/pv_opt/config/config.yaml +++ b/apps/pv_opt/config/config.yaml @@ -3,8 +3,8 @@ pvpy: module: pvpy global: true -solis_cloud: - module: solis_cloud +solis: + module: solis global: true inverters: @@ -16,7 +16,7 @@ pv_opt: class: PVOpt dependencies: - pvpy - - solis_cloud + - solis log: pv_opt_log prefix: pvopt @@ -131,10 +131,10 @@ pv_opt: # update_cycle_seconds: 15 # maximum_dod_percent: number.{device_name}_battery_minimum_soc - # id_consumption_today: sensor.{device_name}_consumption_today - # id_consumption: - # - sensor.{device_name}_house_load - # - sensor.{device_name}_bypass_load + id_consumption_today: sensor.{device_name}_consumption_today + id_consumption: + - sensor.{device_name}_house_load + - sensor.{device_name}_bypass_load # id_grid_import_today: sensor.{device_name}_grid_import_today # id_grid_export_today: sensor.{device_name}_grid_export_today @@ -226,26 +226,6 @@ pv_opt: # id_inverter_mode: sensor.{device_name}_storage_control_mode - # =============================================================================================================== - # Brand / Integration Specific Config: SOLIS_SOLIS_CLOUD: https://github.com/hultenvp/solis-sensor & https://github.com/stevegal/solis_control - # =============================================================================================================== - # - # These are the default entities used with the Solis Solis Cloud integration. You can change them here and over-ride the defaults - - # inverter_type: SOLIS_SOLIS_CLOUD - # device_name: solis - - # battery_voltage: sensor.{device_name}_battery_voltage - # update_cycle_seconds: 300 - # maximum_dod_percent: number.{device_name}_battery_minimum_soc - - id_consumption_today: sensor.{device_name}_energy_today - - id_grid_import_today: sensor.{device_name}_daily_grid_energy_purchased - id_grid_export_today: sensor.{device_name}_daily_on_grid_energy - - id_battery_soc: sensor.{device_name}_remaining_battery_capacity - # =============================================================================================================== # Brand / Integration Specific Config: SUNSYNK_SOLARSYNK2: # =============================================================================================================== @@ -284,4 +264,4 @@ pv_opt: - name: Flux octopus_import_tariff_code: E-1R-FLUX-IMPORT-23-02-14-G - octopus_export_tariff_code: E-1R-FLUX-EXPORT-23-02-14-G + octopus_export_tariff_code: E-1R-FLUX-EXPORT-23-02-14-G \ No newline at end of file From 8a4454cb695eb962c1794597320a629d3fdd2b10 Mon Sep 17 00:00:00 2001 From: Craig Callender <125502786+CraigCallender@users.noreply.github.com> Date: Tue, 2 Jul 2024 08:26:02 +0000 Subject: [PATCH 4/8] Adding types to BatteryModel properties. --- apps/pv_opt/pvpy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/pv_opt/pvpy.py b/apps/pv_opt/pvpy.py index eb0e93c..7186130 100644 --- a/apps/pv_opt/pvpy.py +++ b/apps/pv_opt/pvpy.py @@ -390,12 +390,12 @@ def __str__(self): pass @property - def max_charge_power(self): + def max_charge_power(self) -> int: """ returns the maximum watts at which the battery can charge. """ return self.current_limit_amps * self.__voltage # probably shouldn't use magic numbers here. @property - def max_discharge_power(self): + def max_discharge_power(self) -> int: """ returns the maximum watts at which the battery can discharge. """ return self.max_charge_power From b856749be4c34f6916bd040650f55066c4ddb08a Mon Sep 17 00:00:00 2001 From: fboundy Date: Wed, 3 Jul 2024 11:21:07 +0100 Subject: [PATCH 5/8] 3.15.3 --- README.md | 2 +- apps/pv_opt/pv_opt.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8923b03..bd9692c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# PV Opt: Home Assistant Solar/Battery Optimiser v3.15.3 +# PV Opt: Home Assistant Solar/Battery Optimiser v3.15.4 Solar / Battery Charging Optimisation for Home Assistant. This appDaemon application attempts to optimise charging and discharging of a home solar/battery system to minimise cost electricity cost on a daily basis using freely available solar forecast data from SolCast. This is particularly beneficial for Octopus Agile but is also benefeficial for other time-of-use tariffs such as Octopus Flux or simple Economy 7. diff --git a/apps/pv_opt/pv_opt.py b/apps/pv_opt/pv_opt.py index 5006b79..5a21522 100644 --- a/apps/pv_opt/pv_opt.py +++ b/apps/pv_opt/pv_opt.py @@ -12,7 +12,7 @@ from numpy import nan import re -VERSION = "3.15.3" +VERSION = "3.15.4" OCTOPUS_PRODUCT_URL = r"https://api.octopus.energy/v1/products/" From 152ea5e808083d24f6d04cb8201f478488c2dae0 Mon Sep 17 00:00:00 2001 From: Craig Callender <125502786+CraigCallender@users.noreply.github.com> Date: Wed, 3 Jul 2024 10:40:53 +0000 Subject: [PATCH 6/8] Implementing PR comments. --- apps/pv_opt/pv_opt.py | 3 ++- apps/pv_opt/pvpy.py | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/pv_opt/pv_opt.py b/apps/pv_opt/pv_opt.py index adf2f3c..2c969ce 100644 --- a/apps/pv_opt/pv_opt.py +++ b/apps/pv_opt/pv_opt.py @@ -717,7 +717,8 @@ def _load_pv_system_model(self): self.battery_model = pv.BatteryModel( capacity=self.get_config("battery_capacity_wh"), max_dod=self.get_config("maximum_dod_percent") / 100, - current_limit_amps=self.get_config("battery_current_limit_amps"), + current_limit_amps=self.get_config("battery_current_limit_amps", default=100), + voltage=self.get_config("battery_voltage", default=50), ) self.pv_system = pv.PVsystemModel("PV_Opt", self.inverter_model, self.battery_model, host=self) diff --git a/apps/pv_opt/pvpy.py b/apps/pv_opt/pvpy.py index 7186130..1c01af5 100644 --- a/apps/pv_opt/pvpy.py +++ b/apps/pv_opt/pvpy.py @@ -374,17 +374,17 @@ class BatteryModel: """Describes the battery system attached to the inverter Attributes: - __voltage: An int describing the voltage of the battery system. capacity: An integer describing the Wh capacity of the battery. max_dod: A float describing the maximum depth of discharge of the battery. current_limit_amps: An int describing the maximum amps at which the battery can charge/discharge. + voltage: An int describing the voltage of the battery system. """ - def __init__(self, capacity: int, max_dod: float = 0.15, current_limit_amps: int = 100) -> None: + def __init__(self, capacity: int, max_dod: float = 0.15, current_limit_amps: int = 100, voltage: int = 50) -> None: self.capacity = capacity self.max_dod = max_dod self.current_limit_amps = current_limit_amps - self.__voltage : int = 48 + self.voltage = voltage def __str__(self): pass @@ -392,7 +392,7 @@ def __str__(self): @property def max_charge_power(self) -> int: """ returns the maximum watts at which the battery can charge. """ - return self.current_limit_amps * self.__voltage # probably shouldn't use magic numbers here. + return self.current_limit_amps * self.voltage @property def max_discharge_power(self) -> int: From 2f7d9582cffbe95f7fa0af76771f4527e0238bde Mon Sep 17 00:00:00 2001 From: fboundy Date: Wed, 3 Jul 2024 12:42:14 +0100 Subject: [PATCH 7/8] Add minimum to power limits. --- apps/pv_opt/pvpy.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/pv_opt/pvpy.py b/apps/pv_opt/pvpy.py index 1c01af5..ec0c732 100644 --- a/apps/pv_opt/pvpy.py +++ b/apps/pv_opt/pvpy.py @@ -388,15 +388,15 @@ def __init__(self, capacity: int, max_dod: float = 0.15, current_limit_amps: int def __str__(self): pass - + @property def max_charge_power(self) -> int: - """ returns the maximum watts at which the battery can charge. """ + """returns the maximum watts at which the battery can charge.""" return self.current_limit_amps * self.voltage - + @property def max_discharge_power(self) -> int: - """ returns the maximum watts at which the battery can discharge. """ + """returns the maximum watts at which the battery can discharge.""" return self.max_charge_power @@ -925,7 +925,7 @@ def optimised_force(self, initial_soc, static_flows, contract: Contract, **kwarg str_log += f"SOC: {x.loc[start_window]['soc']:5.1f}%->{x.loc[start_window]['soc_end']:5.1f}% " forced_charge = min( - self.battery.max_charge_power + min(self.battery.max_charge_power, self.inverter.charger_power) - x["forced"].loc[start_window] - x[cols["solar"]].loc[start_window], ((100 - x["soc_end"].loc[start_window]) / 100 * self.battery.capacity) * 2 * factor, @@ -1037,7 +1037,8 @@ def optimised_force(self, initial_soc, static_flows, contract: Contract, **kwarg slot = ( start_window, -min( - self.battery.max_discharge_power - x[kwargs.get("solar", "solar")].loc[start_window], + min(self.battery.max_discharge_power, self.inverter.inverter_power), + -x[kwargs.get("solar", "solar")].loc[start_window], ((x["soc_end"].loc[start_window] - self.battery.max_dod) / 100 * self.battery.capacity) * 2 * factor, From e29b19c57cd4767e27df031eec72b916e6b6b5f8 Mon Sep 17 00:00:00 2001 From: fboundy Date: Wed, 3 Jul 2024 14:32:21 +0100 Subject: [PATCH 8/8] Add Agile Predict and fix TZ error in Day Ahead --- apps/pv_opt/pvpy.py | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/pv_opt/pvpy.py b/apps/pv_opt/pvpy.py index ec0c732..d60cdb8 100644 --- a/apps/pv_opt/pvpy.py +++ b/apps/pv_opt/pvpy.py @@ -8,6 +8,8 @@ from datetime import datetime OCTOPUS_PRODUCT_URL = r"https://api.octopus.energy/v1/products/" +AGILE_PREDICT_URL = r"https://agilepredict.com/api/" + TIME_FORMAT = "%d/%m %H:%M %Z" MAX_ITERS = 3 @@ -76,6 +78,7 @@ def __init__( self.eco7 = eco7 self.area = kwargs.get("area", None) self.day_ahead = None + self.agile_predict = None self.eco7_start = pd.Timestamp(eco7_start, tz="UTC") if octopus: @@ -200,12 +203,12 @@ def to_df(self, start=None, end=None, **kwargs): if "AGILE" in self.name and use_day_ahead: if self.day_ahead is not None and df.index[-1].day == end.day: # reset the day ahead forecasts if we've got a forecast going into tomorrow + self.agile_predict = None self.day_ahead = None self.log("") self.log(f"Cleared day ahead forecast for tariff {self.name}") if pd.Timestamp.now(tz=self.tz).hour > 11 and df.index[-1].day != end.day: - # if it is after 11 but we don't have new Agile prices yet, check for a day-ahead forecast if self.day_ahead is None: self.day_ahead = self.get_day_ahead(df.index[0]) @@ -222,7 +225,9 @@ def to_df(self, start=None, end=None, **kwargs): else: factors = AGILE_FACTORS["import"][self.area] - mask = (self.day_ahead.index.hour >= 16) & (self.day_ahead.index.hour < 19) + mask = (self.day_ahead.index.tz_convert("GB").hour >= 16) & ( + self.day_ahead.index.tz_convert("GB").hour < 19 + ) agile = ( pd.concat( @@ -237,7 +242,13 @@ def to_df(self, start=None, end=None, **kwargs): ) df = pd.concat([df, agile]) - # self.log(df) + else: + # Otherwise download the latest forecast from AgilePredict + if self.agile_predict is None: + self.agile_predict = self._get_agile_predict() + + if self.agile_predict is not None: + df = pd.concat([df, self.agile_predict.loc[df.index[-1] + pd.Timedelta("30min") : end]]) # If the index frequency >30 minutes so we need to just extend it: if (len(df) > 1 and ((df.index[-1] - df.index[-2]).total_seconds() / 60) > 30) or len(df) == 1: @@ -285,6 +296,19 @@ def to_df(self, start=None, end=None, **kwargs): return df + def _get_agile_predict(self): + url = f"{AGILE_PREDICT_URL}{self.area}?days=2&high_low=false" + try: + r = requests.get(url) + r.raise_for_status() # Raise an exception for unsuccessful HTTP status codes + + except requests.exceptions.RequestException as e: + return + + df = pd.DataFrame(r.json()[0]["prices"]).set_index("date_time") + df.index = pd.to_datetime(df.index).tz_convert("UTC") + return df["agile_pred"] + def get_day_ahead(self, start): url = "https://www.nordpoolgroup.com/api/marketdata/page/325?currency=GBP"