From 64aedf330d5936dd536cbdb79e313cf7469da7e7 Mon Sep 17 00:00:00 2001 From: fboundy Date: Sat, 24 Feb 2024 09:42:23 +0000 Subject: [PATCH 1/3] Fix 30T -> 30min --- apps/pv_opt/pvpy.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/pv_opt/pvpy.py b/apps/pv_opt/pvpy.py index e4aaf7e..e86e818 100644 --- a/apps/pv_opt/pvpy.py +++ b/apps/pv_opt/pvpy.py @@ -140,7 +140,7 @@ def to_df(self, start=None, end=None, **kwargs): start = min([pd.Timestamp(x["valid_from"]) for x in self.unit]) if end is None: - end = pd.Timestamp.now(tz=start.tzinfo).ceil("30T") + end = pd.Timestamp.now(tz=start.tzinfo).ceil("30min") # self.get_octopus(area=self.area, period_from=start, period_to=end) @@ -158,7 +158,7 @@ def to_df(self, start=None, end=None, **kwargs): index=pd.date_range( min([pd.Timestamp(x["valid_from"]) for x in self.day]), end, - freq="30T", + freq="30min", ) ).ffill() mask = (df.index.time >= self.eco7_start.time()) & ( @@ -215,7 +215,7 @@ def to_df(self, start=None, end=None, **kwargs): len(df) > 1 and ((df.index[-1] - df.index[-2]).total_seconds() / 60) > 30 ) or len(df) == 1: - newindex = pd.date_range(df.index[0], end, freq="30T") + newindex = pd.date_range(df.index[0], end, freq="30min") df = df.reindex(index=newindex).ffill().loc[start:] else: if self.host.debug: @@ -227,7 +227,7 @@ def to_df(self, start=None, end=None, **kwargs): extended_index = pd.date_range( df.index[-1] + pd.Timedelta(30, "minutes"), df.index[-1] + pd.Timedelta(24, "hours"), - freq="30T", + freq="30min", ) dfx = ( pd.concat([df, pd.DataFrame(index=extended_index)]) @@ -246,7 +246,7 @@ def to_df(self, start=None, end=None, **kwargs): .sort_index() ) x.index = pd.to_datetime(x.index) - newindex = pd.date_range(x.index[0], df.index[-1], freq="30T") + 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) @@ -313,7 +313,7 @@ def get_day_ahead(self, start): price.index = price.index.tz_localize("CET") price.index = price.index.tz_convert("UTC") price = price[~price.index.duplicated()] - return price.resample("30T").ffill().loc[start:] + return price.resample("30min").ffill().loc[start:] class InverterModel: From 17c25bf09cc60996504c6158c9862a8c06af262d Mon Sep 17 00:00:00 2001 From: fboundy Date: Sat, 24 Feb 2024 11:03:05 +0000 Subject: [PATCH 2/3] Bug Fix --- README.md | 2 +- apps/pv_opt/config/config.yaml | 7 +-- apps/pv_opt/pv_opt.py | 4 +- apps/pv_opt/pvpy.py | 9 +--- optimiser.md | 80 ++++++++++++++++++++++++++++++++++ 5 files changed, 89 insertions(+), 13 deletions(-) create mode 100644 optimiser.md diff --git a/README.md b/README.md index 255541a..e224297 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# PV Opt: Home Assistant Solar/Battery Optimiser v3.9.2 +# PV Opt: Home Assistant Solar/Battery Optimiser v3.9.3 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/config/config.yaml b/apps/pv_opt/config/config.yaml index e613d94..f82d1be 100644 --- a/apps/pv_opt/config/config.yaml +++ b/apps/pv_opt/config/config.yaml @@ -81,7 +81,7 @@ 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 @@ -91,8 +91,9 @@ pv_opt: # octopus_import_tariff_code: E-2R-VAR-22-11-01-G # octopus_export_tariff_code: E-1R-AGILE-OUTGOING-19-05-13-G - octopus_import_tariff_code: E-1R-AGILE-23-12-06-G - # octopus_export_tariff_code: E-1R-OUTGOING-LITE-FIX-12M-23-09-12-G + # octopus_import_tariff_code: E-1R-AGILE-23-12-06-G + # # octopus_export_tariff_code: E-1R-OUTGOING-LITE-FIX-12M-23-09-12-G + # octopus_export_tariff_code: E-1R-OUTGOING-FIX-12M-19-05-13-G # octopus_import_tariff_code: E-1R-FLUX-IMPORT-23-02-14-G # octopus_export_tariff_code: E-1R-FLUX-EXPORT-23-02-14-G diff --git a/apps/pv_opt/pv_opt.py b/apps/pv_opt/pv_opt.py index 79e348a..473cde5 100644 --- a/apps/pv_opt/pv_opt.py +++ b/apps/pv_opt/pv_opt.py @@ -20,7 +20,7 @@ # USE_TARIFF = True -VERSION = "3.9.2" +VERSION = "3.9.3" DEBUG = False DATE_TIME_FORMAT_LONG = "%Y-%m-%d %H:%M:%S%z" @@ -1523,7 +1523,7 @@ def optimise(self): # Next slot starts before the next optimiser run. This implies we are not currently in # a charge or discharge slot - if len(self.windows > 0): + if len(self.windows) > 0: self.log( f"Next charge/discharge window starts in {time_to_slot_start:0.1f} minutes." ) diff --git a/apps/pv_opt/pvpy.py b/apps/pv_opt/pvpy.py index e86e818..e70274d 100644 --- a/apps/pv_opt/pvpy.py +++ b/apps/pv_opt/pvpy.py @@ -671,9 +671,7 @@ def optimised_force(self, initial_soc, static_flows, contract: Contract, **kwarg done = True if len(x) > 0: min_price = x["import"].min() - # self.log( - # f">>> {min_price} {x.index[0].strftime(TIME_FORMAT)} - {x.index[-1].strftime(TIME_FORMAT)}" - # ) + window = x[x["import"] == min_price].index start_window = window[0] @@ -817,6 +815,7 @@ def optimised_force(self, initial_soc, static_flows, contract: Contract, **kwarg slots_added = 999 # Only do the rest if there is an export tariff: + # self.log(f">>>{prices['export'].sum()}") if prices['export'].sum() >0: j = 0 else: @@ -845,9 +844,6 @@ def optimised_force(self, initial_soc, static_flows, contract: Contract, **kwarg self.log( f"Max export price when there is no forced charge: {max_export_price:0.2f}p/kWh." ) - # self.log( - # f">>> Charger power: {self.inverter.charger_power}. Inverter power: {self.inverter.inverter_power}" - # ) i = 0 available = ( @@ -906,7 +902,6 @@ def optimised_force(self, initial_soc, static_flows, contract: Contract, **kwarg * factor, ) - # self.log(f">>> {forced_charge} {factor}") slot = ( start_window, forced_charge, diff --git a/optimiser.md b/optimiser.md new file mode 100644 index 0000000..cbf3937 --- /dev/null +++ b/optimiser.md @@ -0,0 +1,80 @@ +# PV Opt Optimiser - How it Works + +For those who are interested this document gives a brief overview of the logic behind the algorythm that PV_Opt uses. It may help users determine if it is doing what is expected or to better tune the Optimiser. + +

Input Data

+ +

The PV Model

+ +At the heart of the system is the model of the PV system. This comprises a number of classes defined in `pvpy.py`: + +| Component | Class | Descriptions| +|:--|:--|:--| +| Battery | BatteryModel | Describes the battery. Its two attributes are its capacity in kWh and the maxmimum permissible depth of discharge in % SOC. +| + +

Forecast Period

+ +All calculations are done over a 48 hour period starting at the most recent midnight (UTC). Resolution is 30 minutes since this is the standard pricing interval for all variable rate tariffs. The objective of the optimiser is to minimise the net cost at the end of this period. + + +

Static Data

+ +Static data are defined as the data that do not change during the optimisation process. These are the expected solar power profile and the expected load profile. These are defined at 30 minute intervals to the end of the following day (in UTC) from the selected Solcast forecast and the expected consumption respectively. + +

Prices

+ +These are also static and are defined from the available Octopus API data. If Agile is required and is not available for the full duration it is predicted from published Day-Ahead wholesale pricing. + +

The Base Forecast

+ +The Base Forecast simply predicts battery SOC and grid power (in and out) for every 30 minutes from now forward using the solar and load forecasts. It allows for conversion efficiencies and the limits of the inverter and charger as defined in the PV Model. The grid power flows are then combined with the import and export prices to generate a Base Net Cost for the 48 hour period. + +

Optimisation

+ +There are three stages to the optimisation algorithm. Stage 1 is always run. Stages 2 and 3 are only relevant if there is an Export tariff. + +

Stage 1: High Cost Usage Swaps

+ +The basic algorythm is as follows: + +1. Find the 30 minute period with the highest cost in the Base Forecast (`max_cost_slot`) +2. Find the cheapest period where you could buy the same amount of energy (allowing for efficiencies) before `max_cost_slot` and after the last time the battery is full (`min_price_slot`) and when there is available forced charge capacity. If several slots have the same price then spread the charging equally over these slots. +3. Force the battery to charge by the necessary power during this slow. +4. Recalculate the Latest Forecast and find the new `max_cost_slot`. In practice this may well be the same slot as before. +5. Repeat (2) - (4) until there are no slots left to buy cheaper. + +An example of this phase is shown below. + +In this example teh algoryth 1st deals with the expensive slot at 18:30 on 25/02 which it is able to swap for slots at 14:00 - 15:00 on 24/02. Once enough charging has been added to keep the system sunning on battery at 18:30 on 25/02, the next high cost is at 08:30 on 25/02. This is dealt with by buying more power between 20:30 and 03:30. +``` +10:10:00 INFO: High Cost Usage Swaps +10:10:00 INFO: --------------------- +10:10:00 INFO: +10:10:00 INFO: 25/02 18:30: 1.07 kWh at 27.91p. <==> 24/02 14:30: 11.25p/kWh 12.01p SOC: 61.1%-> 15.0% New SOC: 61.1%-> 65.9% Net: 522.2 +10:10:00 INFO: 25/02 18:30: 1.07 kWh at 27.91p. <==> 24/02 14:30: 11.25p/kWh 12.01p SOC: 61.1%-> 19.9% New SOC: 61.1%-> 70.8% Net: 517.8 +10:10:00 INFO: 25/02 18:30: 1.07 kWh at 27.91p. <==> 24/02 14:30: 11.25p/kWh 12.01p SOC: 61.1%-> 24.7% New SOC: 61.1%-> 74.7% Net: 511.0 +10:10:00 INFO: 25/02 18:30: 1.07 kWh at 27.91p. <==> 24/02 14:00: 11.47p/kWh 12.25p SOC: 63.5%-> 15.0% New SOC: 63.5%-> 68.4% Net: 501.8 +10:10:00 INFO: 25/02 18:30: 1.07 kWh at 27.91p. <==> 24/02 14:00: 11.47p/kWh 12.25p SOC: 63.5%-> 20.0% New SOC: 63.5%-> 73.2% Net: 495.1 +10:10:00 INFO: 25/02 18:30: 1.07 kWh at 27.91p. <==> 24/02 14:00: 11.47p/kWh 12.25p SOC: 63.5%-> 24.9% New SOC: 63.5%-> 77.2% Net: 487.9 +10:10:00 INFO: 25/02 18:30: 1.07 kWh at 27.91p. <==> 24/02 15:00: 11.84p/kWh 12.65p SOC: 90.8%-> 34.9% New SOC: 90.8%-> 92.0% Net: 469.8 +10:10:00 INFO: 25/02 18:30: 0.80 kWh at 20.79p. <==> 24/02 15:00: 11.84p/kWh 9.43p SOC: 90.8%-> 44.9% New SOC: 90.8%-> 92.9% Net: 467.3 +10:10:00 INFO: 25/02 08:30: 1.53 kWh at 20.67p. <==> 24/02 15:00: 11.84p/kWh 18.07p SOC: 90.8%-> 17.1% New SOC: 90.8%-> 94.0% Net: 467.8 +10:10:00 INFO: 25/02 08:30: 1.53 kWh at 20.67p. <==> 24/02 15:00: 11.84p/kWh 18.07p SOC: 90.8%-> 19.4% New SOC: 90.8%-> 94.9% Net: 467.8 +10:10:00 INFO: 25/02 08:30: 1.53 kWh at 20.67p. <==> 24/02 20:30: 11.84p/kWh 18.07p SOC: 40.8%-> 21.7% New SOC: 40.8%-> 51.0% Net: 467.6 +10:10:00 INFO: 25/02 08:30: 1.53 kWh at 20.67p. <==> 24/02 20:30: 11.84p/kWh 18.07p SOC: 40.8%-> 25.2% New SOC: 40.8%-> 54.5% Net: 468.6 +10:10:00 INFO: 25/02 08:30: 1.53 kWh at 20.67p. <==> 25/02 03:30: 11.87p/kWh 18.10p SOC: 15.0%-> 15.0% New SOC: 15.0%-> 21.9% Net: 467.2 +10:10:00 INFO: 25/02 08:30: 1.53 kWh at 20.67p. <==> 25/02 03:30: 11.87p/kWh 18.10p SOC: 15.0%-> 28.9% New SOC: 15.0%-> 28.6% Net: 465.3 +10:10:00 INFO: 25/02 08:30: 1.53 kWh at 20.67p. <==> 25/02 02:00: 12.06p/kWh 18.40p SOC: 26.5%-> 15.0% New SOC: 26.5%-> 31.2% Net: 459.4 +10:10:00 INFO: 25/02 19:00: 1.06 kWh at 15.67p. <==> 25/02 15:00: 11.84p/kWh 12.55p SOC: 42.4%-> 46.7% New SOC: 42.4%-> 49.4% Net: 449.5 +10:10:00 INFO: 25/02 19:30: 0.79 kWh at 11.41p. <==> 25/02 15:00: 11.84p/kWh 9.39p SOC: 42.4%-> 56.3% New SOC: 42.4%-> 53.0% Net: 447.3 +10:10:00 INFO: 25/02 19:30: 0.60 kWh at 8.61p. <==> 25/02 15:00: 11.84p/kWh 7.08p SOC: 42.4%-> 63.5% New SOC: 42.4%-> 55.7% Net: 445.7 +10:10:00 INFO: 25/02 20:00: 0.64 kWh at 8.51p. <==> 25/02 15:00: 11.84p/kWh 7.58p SOC: 42.4%-> 69.0% New SOC: 42.4%-> 56.1% Net: 445.6 +10:10:00 INFO: 25/02 21:00: 0.65 kWh at 8.05p. <==> 25/02 20:30: 11.84p/kWh 7.70p SOC: 15.0%-> 15.0% New SOC: 15.0%-> 20.9% Net: 445.3 +10:10:00 INFO: 25/02 20:00: 0.56 kWh at 7.38p. <==> 25/02 13:30: 11.97p/kWh 6.64p SOC: 16.0%-> 15.1% New SOC: 16.0%-> 21.0% Net: 444.5 +10:10:00 INFO: 25/02 09:00: 0.31 kWh at 4.20p. <==> 25/02 02:00: 12.06p/kWh 3.72p SOC: 26.5%-> 40.4% New SOC: 26.5%-> 32.1% Net: 444.0 +10:10:00 INFO: 25/02 09:30: 0.22 kWh at 2.99p. <==> 25/02 02:00: 12.06p/kWh 2.64p SOC: 26.5%-> 43.2% New SOC: 26.5%-> 32.8% Net: 443.7 +``` + +Once this is done the Net Cost saving is checked against the "Pass Threshold". In this case the saving is 80p which is well above the theshold of 4p and so the slots are kept. + From 6a00b0e414e2f8d53b7b03adf08c05053be0961c Mon Sep 17 00:00:00 2001 From: fboundy Date: Sun, 25 Feb 2024 11:45:59 +0000 Subject: [PATCH 3/3] 3.9.3 --- apps/pv_opt/pv_opt.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/pv_opt/pv_opt.py b/apps/pv_opt/pv_opt.py index 473cde5..79c47ad 100644 --- a/apps/pv_opt/pv_opt.py +++ b/apps/pv_opt/pv_opt.py @@ -16,8 +16,6 @@ # import pvpy as pv OCTOPUS_PRODUCT_URL = r"https://api.octopus.energy/v1/products/" -# %% -# USE_TARIFF = True VERSION = "3.9.3"