From d8b880797c3262b151f56781bacd30b931f30813 Mon Sep 17 00:00:00 2001 From: fboundy Date: Sun, 10 Nov 2024 18:41:33 +0000 Subject: [PATCH] Fix Issues with Day Ahead Pricing and Add Manual Tariffs (#211) --- README.md | 46 ++++++++- apps/pv_opt/config/config.yaml | 34 ++++++- apps/pv_opt/pv_opt.py | 95 ++++++++++++++----- apps/pv_opt/pvpy.py | 167 +++++++++++++++++++++------------ 4 files changed, 251 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index 7471520..db21d58 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# PV Opt: Home Assistant Solar/Battery Optimiser v3.16.1 +# PV Opt: Home Assistant Solar/Battery Optimiser v3.17.0

This documentation needs updating!

@@ -498,6 +498,50 @@ These parameters will define how PV Opt estimates daily consumption: | Daily Consumption | kWh | `number.pvopt_daily_consumption_kwh` | 17 | Estimated daily consumption to use when predicting future load | | Shape Consumption Profile | `on`/`off` | `switch.pvopt_shape_consumption_profile` | On | Defines whether to shapoe the consumption to a typical daily profile (`on`) or to assume constant usage (`off`) | +

Pricing Parameters

+These parameters set the price that PV Opt uses: + +

Octopus Tariffs (usinng the Octopus API)

+ +| Parameter | Units | Entity | Default | Description | +|:--|:--:| :-- | :--:|:--| +| Octopus Auto|`on`/`off` | `octopus_auto` | On | Read tariffs from the Octopus Energy integration. If successful this over-rides the following parameters +| Octopus Account | `string` | `octopus_account` | | Octopus Account ID (Axxxxxxxx) - not required if Octopus Auto is set| +| Octopus API Key | `string` | `octopus_api_key` | | Octopus API Key - not required if Octopus Auto is set | +| Octopus Import Tariff Code| fraction | `octopus_import_tariff_code` | | Import Tariff Code (eg `E-1R-AGILE-23-12-06-G`) | +| Octopus Export Tariff Code| fraction | `octopus_export_tariff_code` | | Export Tariff Code (eg `E-1R-AGILE-OUTGOING-19-05-13-G`)| + +

Manual Tariffs

+ +Import and/or export tarifs can be set manually as follows. These can be combined with Octopus Account Codes (ie you could set Octopus Agile for input using `octopus_import_tariff_code` and a manual export). Manual tariffs will not work with either `Octopus Auto` or `Octopus Account`. + + manual_import_tariff: True + manual_import_tariff_name: Test Importe + manual_import_tariff_tz: GB + manual_import_tariff_standing: 43 + manual_import_tariff_unit: + - period_start: "00:00" + price: 4.2 + - period_start: "05:00" + price: 9.7 + - period_start: "16:00" + price: 77.0 + - period_start: "19:00" + price: -2.0 + + manual_export_tariff: True + manual_export_tariff_name: Test Export + manual_export_tariff_tz: GB + manual_export_tariff_unit: + - period_start: "01:00" + price: 14.2 + - period_start: "03:00" + price: 19.7 + - period_start: "16:00" + price: 50.0 + - period_start: "14:00" + price: 0.0 +

Tuning Parameters

These parameters will tweak how PV Opt runs: diff --git a/apps/pv_opt/config/config.yaml b/apps/pv_opt/config/config.yaml index ebe51e8..bbff045 100644 --- a/apps/pv_opt/config/config.yaml +++ b/apps/pv_opt/config/config.yaml @@ -29,7 +29,7 @@ pv_opt: # If true the current config in HA will be over-written with that in the config.yaml. - overwrite_ha_on_restart: false + overwrite_ha_on_restart: true list_entities: true # If true the personal data will be redacted from the log files. @@ -119,12 +119,38 @@ pv_opt: # ======================================== # Octopus account parameters # ======================================== - - # octopus_auto: False # Read tariffs from the Octopus Energy integration. If successful this over-rides the following parameters - + octopus_auto: False # Read tariffs from the Octopus Energy integration. If successful this over-rides the following parameters # octopus_account: !secret octopus_account # octopus_api_key: !secret octopus_api_key + manual_import_tariff: True + manual_import_tariff_name: Test Importe + manual_import_tariff_tz: GB + manual_import_tariff_standing: 43 + manual_import_tariff_unit: + - period_start: "00:00" + price: 4.2 + - period_start: "05:00" + price: 9.7 + - period_start: "16:00" + price: 77.0 + - period_start: "19:00" + price: -2.0 + + manual_export_tariff: True + manual_export_tariff_name: Test Export + manual_export_tariff_tz: GB + manual_export_tariff_unit: + - period_start: "01:00" + price: 14.2 + - period_start: "03:00" + price: 19.7 + - period_start: "16:00" + price: 50.0 + - period_start: "14:00" + price: 0.0 + + # The following Can be omitted if either of the above options is working correctly: # octopus_import_tariff_code: E-2R-VAR-22-11-01-G diff --git a/apps/pv_opt/pv_opt.py b/apps/pv_opt/pv_opt.py index 094e3e5..a685d24 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.16.1" +VERSION = "3.17.0" OCTOPUS_PRODUCT_URL = r"https://api.octopus.energy/v1/products/" @@ -921,35 +921,43 @@ def _load_contract(self): ) if self.contract is None: + tariffs = {x: None for x in IMPEXP} + if ( "octopus_import_tariff_code" in self.config and self.config["octopus_import_tariff_code"] is not None ): - try: - str = f"Trying to load tariff codes: Import: {self.config['octopus_import_tariff_code']}" - - if "octopus_export_tariff_code" in self.config: - str += f" Export: {self.config['octopus_export_tariff_code']}" - self.rlog(str) - - tariffs = {x: None for x in IMPEXP} - for imp_exp in IMPEXP: - if f"octopus_{imp_exp}_tariff_code" in self.config: - tariffs[imp_exp] = pv.Tariff( - self.config[f"octopus_{imp_exp}_tariff_code"], - export=(imp_exp == "export"), - host=self, - ) - - self.contract = pv.Contract( - "current", - imp=tariffs["import"], - exp=tariffs["export"], + self.rlog(f"Trying to load tariff codes: Import: {self.config['octopus_import_tariff_code']}") + # try: + # First load the import as we always need that + tariffs["import"] = pv.Tariff( + self.config[f"octopus_import_tariff_code"], + export=False, + host=self, + ) + elif self.get_config("manual_import_tariff", False): + tariffs["import"] = self._manual_tariff("import") + + if tariffs["import"] is not None: + if "octopus_export_tariff_code" in self.config: + self.rlog(f"Trying to load tariff codes: Export: {self.config['octopus_export_tariff_code']}") + tariffs["export"] = pv.Tariff( + self.config[f"octopus_export_tariff_code"], + export=False, host=self, ) - self.rlog("Contract tariffs loaded OK from Tariff Codes") - except Exception as e: - self.rlog(f"Unable to load Tariff Codes {e}", level="ERROR") + elif self.get_config("manual_export_tariff", False): + tariffs["export"] = self._manual_tariff("export") + + self.contract = pv.Contract( + "current", + imp=tariffs["import"], + exp=tariffs["export"], + host=self, + ) + self.rlog("Contract tariffs loaded OK from Tariff Codes / Manual Spec") + # except Exception as e: + # self.rlog(f"Unable to load Tariff Codes {e}", level="ERROR") if self.contract is None: i += 1 @@ -976,6 +984,26 @@ def _load_contract(self): self.rlog("Finished loading contract") + def _manual_tariff(self, direction="import"): + name = self.get_config(f"manual_{direction}_tariff_name") + self.log(f"Trying to load manual {direction} tariff {name}") + tz = self.get_config(f"manual_{direction}_tariff_tz") + if direction == "import": + fixed = self.get_config(f"manual_{direction}_tariff_standing", 0.0) + else: + fixed = None + unit = self.get_config(f"manual_{direction}_tariff_unit") + + return pv.Tariff( + name=name, + octopus=False, + export=(direction == "export"), + fixed=fixed, + unit=unit, + host=self, + manual=True, + ) + def _check_tariffs(self): if self.bottlecap_entities["import"] is not None: self._check_tariffs_vs_bottlecap() @@ -1004,6 +1032,7 @@ def _check_tariffs(self): self.log( f" {direction.title()}: {tariff.name:40s} Start: {tariff.start().strftime(DATE_TIME_FORMAT_LONG)} End: {z} " ) + self.log(tariff.to_df().to_string()) if "AGILE" in tariff.name: self.agile = True if "INTELLI" in tariff.name: @@ -1184,6 +1213,24 @@ def _load_args(self, items=None): self.rlog(f" {str1:34s} {str2} {str3} {x['hour']:5.2f} {str4} {x['consumption']:5.0f} W") self.yaml_config[item] = self.config[item] + elif re.match("^manual_..port_tariff_unit$", item): + self.config[item] = values + for i, x in enumerate(values): + if i == 0: + str1 = item + str2 = "=" + str3 = "Period Start:" + str4 = "Price:" + else: + str1 = "" + str2 = " " + str3 = " " + str4 = " " + self.rlog( + f" {str1:34s} {str2} {str3} {pd.Timestamp(x['period_start']).strftime('%H:%M')} {str4} {x['price']:5.0f} p/kWh" + ) + self.yaml_config[item] = self.config[item] + elif "id_" in item: self.log(f">>> Test: {self.entity_exists('update.home_assistant_core_update')}") for v in values: diff --git a/apps/pv_opt/pvpy.py b/apps/pv_opt/pvpy.py index c9dd91b..afe5bab 100644 --- a/apps/pv_opt/pvpy.py +++ b/apps/pv_opt/pvpy.py @@ -63,6 +63,7 @@ def __init__( octopus=True, eco7_start="01:00", host=None, + manual=False, **kwargs, ) -> None: self.name = name @@ -80,16 +81,22 @@ def __init__( self.day_ahead = None self.agile_predict = None self.eco7_start = pd.Timestamp(eco7_start, tz="UTC") + self.manual = manual if octopus: self.get_octopus(**kwargs) else: - self.fixed = [{"value_inc_vat": fixed, "valid_from": valid_from}] - self.unit = [{"value_inc_vat": unit, "valid_from": valid_from}] - if eco7: - self.day = [{"value_inc_vat": day, "valid_from": valid_from}] - self.night = [{"value_inc_vat": night, "valid_from": valid_from}] + + if self.manual: + self.unit = unit + self.fixed = fixed + else: + self.fixed = [{"value_inc_vat": fixed, "valid_from": valid_from}] + self.unit = [{"value_inc_vat": unit, "valid_from": valid_from}] + if eco7: + self.day = [{"value_inc_vat": day, "valid_from": valid_from}] + self.night = [{"value_inc_vat": night, "valid_from": valid_from}] def _oct_time(self, d): # print(d) @@ -150,10 +157,16 @@ def __str__(self): return str def start(self): - return min([pd.Timestamp(x["valid_from"]) for x in self.unit]) + if self.manual: + return pd.Timestamp("2020-01-01", tz=self.tz) + else: + return min([pd.Timestamp(x["valid_from"]) for x in self.unit]) def end(self): - return max([pd.Timestamp(x["valid_to"]) for x in self.unit]) + if self.manual: + return pd.Timestamp.now(tz=self.tz) + else: + return max([pd.Timestamp(x["valid_to"]) for x in self.unit]) def to_df(self, start=None, end=None, **kwargs): if self.host.debug: @@ -161,19 +174,28 @@ def to_df(self, start=None, end=None, **kwargs): self.log(f">>> Start: {start.strftime(TIME_FORMAT)} End: {end.strftime(TIME_FORMAT)}") time_now = pd.Timestamp.now(tz="UTC") - use_day_ahead = kwargs.get("day_ahead", ((start > time_now) or (end > time_now))) if start is None: if self.eco7: start = min([pd.Timestamp(x["valid_from"]) for x in self.day]) + elif self.manual: + start = pd.Timestamp.now(tz=self.tz).floor("1D") + else: start = min([pd.Timestamp(x["valid_from"]) for x in self.unit]) if end is None: end = pd.Timestamp.now(tz=start.tzinfo).ceil("30min") + use_day_ahead = kwargs.get("day_ahead", ((start > time_now) or (end > time_now))) + # self.get_octopus(area=self.area, period_from=start, period_to=end) + # self.log(f">>> Name: {self.name}") + # self.log(f">>> Export: {self.export}") + # self.log(f">>> Manual: {self.manual}") + # self.log(f">>> Start: {start}") + # self.log(f">>> End: {end}") if self.eco7: df = pd.concat( @@ -195,60 +217,78 @@ def to_df(self, start=None, end=None, **kwargs): df.loc[mask, "unit"] = df.loc[mask, "Night"] df = df["unit"].loc[start:end] + elif self.manual: + df = ( + pd.concat( + [ + pd.DataFrame( + index=[midnight + pd.Timedelta(f"{x['period_start']}:00") for x in self.unit], + data=[{"unit": x["price"]} for x in self.unit], + ).sort_index() + for midnight in pd.date_range( + start.floor("1D") - pd.Timedelta("1D"), end.ceil("1D"), freq="1D" + ) + ] + ) + .resample("30min") + .ffill() + .loc[start:end] + ) + else: df = pd.DataFrame(self.unit).set_index("valid_from")["value_inc_vat"] df.index = pd.to_datetime(df.index) df = df.sort_index() 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]) - if self.day_ahead is not None: - self.day_ahead = self.day_ahead.sort_index() - self.log("") - self.log( - f"Retrieved day ahead forecast for period {self.day_ahead.index[0].strftime(TIME_FORMAT)} - {self.day_ahead.index[-1].strftime(TIME_FORMAT)} for tariff {self.name}" - ) - - if self.day_ahead is not None: - if self.export: - factors = AGILE_FACTORS["export"][self.area] - else: - factors = AGILE_FACTORS["import"][self.area] - - mask = (self.day_ahead.index.tz_convert("GB").hour >= 16) & ( - self.day_ahead.index.tz_convert("GB").hour < 19 - ) - - agile = ( - pd.concat( - [ - (self.day_ahead[mask] * factors[0] + factors[1] + factors[2]), - (self.day_ahead[~mask] * factors[0] + factors[1]), - ] - ) - .sort_index() - .loc[df.index[-1] :] - .iloc[1:] - ) - - df = pd.concat([df, agile]) - 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 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]) + # if self.day_ahead is not None: + # self.day_ahead = self.day_ahead.sort_index() + # self.log("") + # self.log( + # f"Retrieved day ahead forecast for period {self.day_ahead.index[0].strftime(TIME_FORMAT)} - {self.day_ahead.index[-1].strftime(TIME_FORMAT)} for tariff {self.name}" + # ) + + # if self.day_ahead is not None: + # if self.export: + # factors = AGILE_FACTORS["export"][self.area] + # else: + # factors = AGILE_FACTORS["import"][self.area] + + # mask = (self.day_ahead.index.tz_convert("GB").hour >= 16) & ( + # self.day_ahead.index.tz_convert("GB").hour < 19 + # ) + + # agile = ( + # pd.concat( + # [ + # (self.day_ahead[mask] * factors[0] + factors[1] + factors[2]), + # (self.day_ahead[~mask] * factors[0] + factors[1]), + # ] + # ) + # .sort_index() + # .loc[df.index[-1] :] + # .iloc[1:] + # ) + + # df = pd.concat([df, agile]) + # 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: @@ -270,13 +310,16 @@ def to_df(self, start=None, end=None, **kwargs): df.name = "unit" if not self.export: - x = pd.DataFrame(self.fixed).set_index("valid_from")["value_inc_vat"].sort_index() - x.index = pd.to_datetime(x.index) - newindex = pd.date_range(x.index[0], df.index[-1], freq="30min") - x = x.reindex(newindex).sort_index() - x = x.ffill().loc[df.index[0] :] - df = pd.concat([df, x], axis=1).set_axis(["unit", "fixed"], axis=1) + if not self.manual: + x = pd.DataFrame(self.fixed).set_index("valid_from")["value_inc_vat"].sort_index() + x.index = pd.to_datetime(x.index) + newindex = pd.date_range(x.index[0], df.index[-1], freq="30min") + x = x.reindex(newindex).sort_index() + x = x.ffill().loc[df.index[0] :] + else: + x = pd.DataFrame(index=df.index, data={"fixed": self.fixed}) + df = pd.concat([df, x], axis=1).set_axis(["unit", "fixed"], axis=1) mask = df.index.time != pd.Timestamp("00:00", tz="UTC").time() df.loc[mask, "fixed"] = 0