diff --git a/README.md b/README.md
index 1ef574b..8617209 100644
--- a/README.md
+++ b/README.md
@@ -519,6 +519,50 @@ These parameters will define how PV Opt estimates daily consumption:
| Car Charge Slot max price | p | `number.max_ev_price_p` | 30 | Maximum 1/2 hour slot price per kWh in pence added to the candidate car charging plan. Disable by setting to 0. Note: setting a low value may mean the car will not charge to the required SOC if overnight Agile rates are high. |
| Car Charge Efficiency | % | `number.ev_charger_efficiency_percent` | 92 | Charging Efficiency for EV Charger/Car. 92% is average for most cars/chargers but adjust if the car is consistently undercharging or overcharging against its target. |
+
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 3a17091..c1b3792 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 5fc233d..ce8bcca 100644
--- a/apps/pv_opt/pv_opt.py
+++ b/apps/pv_opt/pv_opt.py
@@ -58,7 +58,6 @@
# 3.17.1 Beta changes merged back into 3.17.0-Beta-11.
-
OCTOPUS_PRODUCT_URL = r"https://api.octopus.energy/v1/products/"
DEBUG = False
@@ -1411,36 +1410,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
@@ -1474,6 +1480,26 @@ def _load_contract(self):
self.log("")
self.log("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()
@@ -1502,6 +1528,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:
@@ -1684,6 +1711,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:
@@ -4071,4 +4116,4 @@ def get_entity_default(self, entity_id):
return DEFAULT_CONFIG[item]["default"]
-# %%
+# %%
\ No newline at end of file
diff --git a/apps/pv_opt/pvpy.py b/apps/pv_opt/pvpy.py
index ab2016c..a9ba8e3 100644
--- a/apps/pv_opt/pvpy.py
+++ b/apps/pv_opt/pvpy.py
@@ -74,6 +74,7 @@ def __init__(
octopus=True,
eco7_start="01:00",
host=None,
+ manual=False,
**kwargs,
) -> None:
self.name = name
@@ -100,6 +101,7 @@ def __init__(
self.day_ahead = None
self.agile_predict = None
self.eco7_start = pd.Timestamp(eco7_start, tz="UTC")
+ self.manual = manual
self.io_prices = {}
@@ -108,11 +110,16 @@ def __init__(
# self.log("")
# self.log("Returned from get_octopus_from_website")
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}]
if "INTELLI" in name and not self.export:
if self.host.get_config("octopus_auto"):
@@ -256,10 +263,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):
@@ -268,19 +281,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")
- # self.get_octopus_from_website(area=self.area, period_from=start, period_to=end)
+ 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(
@@ -302,6 +324,24 @@ 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)
@@ -317,55 +357,54 @@ def to_df(self, start=None, end=None, **kwargs):
# self.log(df.to_string())
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:
@@ -422,13 +461,16 @@ def to_df(self, start=None, end=None, **kwargs):
# Add a column "fixed" for the standing charge.
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