Skip to content

Commit

Permalink
feat: Added support for applying weightings to target rate slots
Browse files Browse the repository at this point in the history
  • Loading branch information
BottlecapDave committed May 8, 2024
1 parent c583b8b commit 80bbb52
Show file tree
Hide file tree
Showing 13 changed files with 364 additions and 23 deletions.
1 change: 1 addition & 0 deletions _docs/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ For updating a given [target rate's](./setup/target_rate.md) config. This allows
| `data.target_offset` | `yes` | The optional offset to apply to the target rate when it starts. Must be in the format `(+/-)HH:MM:SS`. |
| `data.target_minimum_rate` | `yes` | The optional minimum rate the selected rates should not go below. |
| `data.target_maximum_rate` | `yes` | The optional maximum rate the selected rates should not go above. |
| `data.target_weighting` | `yes` | The optional weighting that should be applied to the selected rates. |

### Automation Example

Expand Down
17 changes: 17 additions & 0 deletions _docs/setup/target_rate.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,23 @@ If this is checked, then the normal behaviour of the sensor will be reversed. Th

There may be times that you want the target rate sensors to not take into account rates that are above or below a certain value (e.g. you don't want the sensor to turn on when rates go crazy or where it would be more beneficial to export).

### Weighting

!!! info

This is only available for continuous target rate sensors

There may be times when the device you're wanting the target rate sensor to turn on doesn't have a consistent power draw. You can specify a weighting which can be applied to each discovered 30 minute slot. This can be specified in a few different ways. Take the following example weighting for a required 2 hours.

* `1,1,2,1` - This applies a weighting of 1 to the first, second and forth slot and a weighting of 2 to the third slot. This will try and make the cheapest slot fall on the third slot, as long as the surrounding slots are cheaper than other continuous slots.
* `*,2,1` - This applies a weighting of 1 to the first, second and forth slot and a weighting of 2 to the third slot. The `*` can be used as a placeholder for the standard weighting of 1 for all slots before the ones specified.
* `1,1,2,*` - This applies a weighting of 1 to the first, second and forth slot and a weighting of 2 to the third slot. The `*` can be used as a placeholder for the standard weighting of 1 for all slots after the ones specified.
* `2,*,2` - This applies a weighting of 2 to the first and forth slot and a weighting of 1 to all slots in between. The `*` can be used as a placeholder for the standard weighting of 1 for all slots in between the specified slots.

Each slot weighting must be a whole number and positive.

You can also use weightings to ignore slots. This can be done by assigning a value of 0 for the desired slot.

## Attributes

The following attributes are available on each sensor
Expand Down
1 change: 1 addition & 0 deletions custom_components/octopus_energy/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ async def async_setup_entry(hass, entry, async_add_entities):
vol.Optional("target_offset"): str,
vol.Optional("target_minimum_rate"): str,
vol.Optional("target_maximum_rate"): str,
vol.Optional("target_weighting"): str,
},
extra=vol.ALLOW_EXTRA,
),
Expand Down
24 changes: 23 additions & 1 deletion custom_components/octopus_energy/config/target_rates.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,20 @@
CONFIG_TARGET_OLD_TYPE,
CONFIG_TARGET_START_TIME,
CONFIG_TARGET_TYPE,
CONFIG_TARGET_TYPE_CONTINUOUS,
CONFIG_TARGET_WEIGHTING,
DOMAIN,
REGEX_ENTITY_NAME,
REGEX_HOURS,
REGEX_OFFSET_PARTS,
REGEX_PRICE,
REGEX_TIME
REGEX_TIME,
REGEX_WEIGHTING
)

from . import get_meter_tariffs
from ..utils.tariff_check import is_agile_tariff
from ..target_rates import create_weighting

async def async_migrate_target_config(version: int, data: {}, get_entries):
new_data = {**data}
Expand Down Expand Up @@ -96,6 +100,9 @@ def merge_target_rate_config(data: dict, options: dict, updated_config: dict = N
if CONFIG_TARGET_MAX_RATE not in updated_config and CONFIG_TARGET_MAX_RATE in config:
config[CONFIG_TARGET_MAX_RATE] = None

if CONFIG_TARGET_WEIGHTING not in updated_config and CONFIG_TARGET_WEIGHTING in config:
config[CONFIG_TARGET_WEIGHTING] = None

return config

def is_time_frame_long_enough(hours, start_time, end_time):
Expand Down Expand Up @@ -171,6 +178,21 @@ def validate_target_rate_config(data, account_info, now):
else:
data[CONFIG_TARGET_MAX_RATE] = float(data[CONFIG_TARGET_MAX_RATE])

if CONFIG_TARGET_WEIGHTING in data and data[CONFIG_TARGET_WEIGHTING] is not None:
matches = re.search(REGEX_WEIGHTING, data[CONFIG_TARGET_WEIGHTING])
if matches is None:
errors[CONFIG_TARGET_WEIGHTING] = "invalid_weighting"

if CONFIG_TARGET_WEIGHTING not in errors:
number_of_slots = int(data[CONFIG_TARGET_HOURS] * 2)
weighting = create_weighting(data[CONFIG_TARGET_WEIGHTING], number_of_slots)

if (len(weighting) != number_of_slots):
errors[CONFIG_TARGET_WEIGHTING] = "invalid_weighting_slots"

if data[CONFIG_TARGET_TYPE] != CONFIG_TARGET_TYPE_CONTINUOUS:
errors[CONFIG_TARGET_WEIGHTING] = "weighting_not_supported"

start_time = data[CONFIG_TARGET_START_TIME] if CONFIG_TARGET_START_TIME in data else "00:00"
end_time = data[CONFIG_TARGET_END_TIME] if CONFIG_TARGET_END_TIME in data else "00:00"

Expand Down
20 changes: 13 additions & 7 deletions custom_components/octopus_energy/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
CONFIG_MAIN_PREVIOUS_GAS_CONSUMPTION_DAYS_OFFSET,
CONFIG_TARGET_MAX_RATE,
CONFIG_TARGET_MIN_RATE,
CONFIG_TARGET_TYPE_CONTINUOUS,
CONFIG_TARGET_TYPE_INTERMITTENT,
CONFIG_TARGET_WEIGHTING,
CONFIG_VERSION,
DATA_ACCOUNT,
DOMAIN,
Expand Down Expand Up @@ -155,11 +158,11 @@ async def __async_setup_target_rate_schema__(self, account_id: str):
return vol.Schema({
vol.Required(CONFIG_TARGET_NAME): str,
vol.Required(CONFIG_TARGET_HOURS): str,
vol.Required(CONFIG_TARGET_TYPE, default="Continuous"): selector.SelectSelector(
vol.Required(CONFIG_TARGET_TYPE, default=CONFIG_TARGET_TYPE_CONTINUOUS): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(value="Continuous", label="Continuous"),
selector.SelectOptionDict(value="Intermittent", label="Intermittent"),
selector.SelectOptionDict(value=CONFIG_TARGET_TYPE_CONTINUOUS, label="Continuous"),
selector.SelectOptionDict(value=CONFIG_TARGET_TYPE_INTERMITTENT, label="Intermittent"),
],
mode=selector.SelectSelectorMode.DROPDOWN,
)
Expand All @@ -178,6 +181,7 @@ async def __async_setup_target_rate_schema__(self, account_id: str):
vol.Optional(CONFIG_TARGET_INVERT_TARGET_RATES, default=False): bool,
vol.Optional(CONFIG_TARGET_MIN_RATE): float,
vol.Optional(CONFIG_TARGET_MAX_RATE): float,
vol.Optional(CONFIG_TARGET_WEIGHTING): str,
})

async def __async_setup_cost_tracker_schema__(self, account_id: str):
Expand Down Expand Up @@ -382,11 +386,11 @@ async def __async_setup_target_rate_schema__(self, config, errors):
vol.Schema({
vol.Required(CONFIG_TARGET_NAME): str,
vol.Required(CONFIG_TARGET_HOURS): str,
vol.Required(CONFIG_TARGET_TYPE, default="Continuous"): selector.SelectSelector(
vol.Required(CONFIG_TARGET_TYPE, default=CONFIG_TARGET_TYPE_CONTINUOUS): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(value="Continuous", label="Continuous"),
selector.SelectOptionDict(value="Intermittent", label="Intermittent"),
selector.SelectOptionDict(value=CONFIG_TARGET_TYPE_CONTINUOUS, label="Continuous"),
selector.SelectOptionDict(value=CONFIG_TARGET_TYPE_INTERMITTENT, label="Intermittent"),
],
mode=selector.SelectSelectorMode.DROPDOWN,
)
Expand All @@ -405,6 +409,7 @@ async def __async_setup_target_rate_schema__(self, config, errors):
vol.Optional(CONFIG_TARGET_INVERT_TARGET_RATES): bool,
vol.Optional(CONFIG_TARGET_MIN_RATE): float,
vol.Optional(CONFIG_TARGET_MAX_RATE): float,
vol.Optional(CONFIG_TARGET_WEIGHTING): str,
}),
{
CONFIG_TARGET_NAME: config[CONFIG_TARGET_NAME],
Expand All @@ -415,7 +420,8 @@ async def __async_setup_target_rate_schema__(self, config, errors):
CONFIG_TARGET_LAST_RATES: find_last_rates,
CONFIG_TARGET_INVERT_TARGET_RATES: invert_target_rates,
CONFIG_TARGET_MIN_RATE: config[CONFIG_TARGET_MIN_RATE] if CONFIG_TARGET_MIN_RATE in config else None,
CONFIG_TARGET_MAX_RATE: config[CONFIG_TARGET_MAX_RATE] if CONFIG_TARGET_MAX_RATE in config else None
CONFIG_TARGET_MAX_RATE: config[CONFIG_TARGET_MAX_RATE] if CONFIG_TARGET_MAX_RATE in config else None,
CONFIG_TARGET_WEIGHTING: config[CONFIG_TARGET_WEIGHTING] if CONFIG_TARGET_WEIGHTING in config else None,
}
),
errors=errors
Expand Down
9 changes: 9 additions & 0 deletions custom_components/octopus_energy/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
CONFIG_TARGET_NAME = "name"
CONFIG_TARGET_HOURS = "hours"
CONFIG_TARGET_TYPE = "type"
CONFIG_TARGET_TYPE_CONTINUOUS = "Continuous"
CONFIG_TARGET_TYPE_INTERMITTENT = "Intermittent"
CONFIG_TARGET_START_TIME = "start_time"
CONFIG_TARGET_END_TIME = "end_time"
CONFIG_TARGET_MPAN = "mpan"
Expand All @@ -58,6 +60,7 @@
CONFIG_TARGET_INVERT_TARGET_RATES = "target_invert_target_rates"
CONFIG_TARGET_MIN_RATE = "minimum_rate"
CONFIG_TARGET_MAX_RATE = "maximum_rate"
CONFIG_TARGET_WEIGHTING = "weighting"

CONFIG_COST_NAME = "name"
CONFIG_COST_MPAN = "mpan"
Expand Down Expand Up @@ -111,6 +114,12 @@
REGEX_DATE = "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
REGEX_PRICE = "^(-)?[0-9]+(\\.[0-9]+)*$"

REGEX_WEIGHTING_NUMBERS = "([0-9](,[0-9]+)*)"
REGEX_WEIGHTING_START = "(\\*(,[0-9]+)+)"
REGEX_WEIGHTING_MIDDLE = "([0-9](,[0-9]+)*(,\\*)(,[0-9]+)+)"
REGEX_WEIGHTING_END = "([0-9](,[0-9]+)*(,\\*))"
REGEX_WEIGHTING = f"^({REGEX_WEIGHTING_NUMBERS}|{REGEX_WEIGHTING_START}|{REGEX_WEIGHTING_MIDDLE}|{REGEX_WEIGHTING_END})$"

DATA_SCHEMA_ACCOUNT = vol.Schema({
vol.Required(CONFIG_ACCOUNT_ID): str,
vol.Required(CONFIG_MAIN_API_KEY): str,
Expand Down
7 changes: 7 additions & 0 deletions custom_components/octopus_energy/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ update_target_config:
example: '0.10'
selector:
text:
target_weighting:
name: Weighting
description:
The optional weighting that should be applied to the selected rates
example: '1,2,1'
selector:
text:

purge_invalid_external_statistic_ids:
name: Purge invalid external statistics
Expand Down
42 changes: 37 additions & 5 deletions custom_components/octopus_energy/target_rates/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from homeassistant.util.dt import (as_utc, parse_datetime)

from ..utils.conversions import value_inc_vat_to_pounds
from ..const import REGEX_OFFSET_PARTS
from ..const import REGEX_OFFSET_PARTS, REGEX_WEIGHTING

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -87,7 +87,8 @@ def calculate_continuous_times(
search_for_highest_rate = False,
find_last_rates = False,
min_rate = None,
max_rate = None
max_rate = None,
weighting: list = None
):
if (applicable_rates is None):
return []
Expand All @@ -96,6 +97,9 @@ def calculate_continuous_times(
applicable_rates_count = len(applicable_rates)
total_required_rates = math.ceil(target_hours * 2)

if weighting is not None and len(weighting) != total_required_rates:
raise ValueError("Weighting does not match target hours")

best_continuous_rates = None
best_continuous_rates_total = None

Expand All @@ -111,7 +115,7 @@ def calculate_continuous_times(
continue

continuous_rates = [rate]
continuous_rates_total = rate["value_inc_vat"]
continuous_rates_total = rate["value_inc_vat"] * (weighting[0] if weighting is not None else 1)

for offset in range(1, total_required_rates):
if (index + offset) < applicable_rates_count:
Expand All @@ -124,7 +128,7 @@ def calculate_continuous_times(
break

continuous_rates.append(offset_rate)
continuous_rates_total += offset_rate["value_inc_vat"]
continuous_rates_total += offset_rate["value_inc_vat"] * (weighting[offset] if weighting is not None else 1)
else:
break

Expand Down Expand Up @@ -289,4 +293,32 @@ def get_target_rate_info(current_date: datetime, applicable_rates, offset: str =
"next_average_cost": next_average_cost,
"next_min_cost": next_min_cost,
"next_max_cost": next_max_cost,
}
}

def create_weighting(config: str, number_of_slots: int):
if config is None or config == "":
weighting = []
for index in range(number_of_slots):
weighting.append(1)

return weighting

matches = re.search(REGEX_WEIGHTING, config)
if matches is None:
raise ValueError("Invalid config")

parts = config.split(',')
parts_length = len(parts)
weighting = []
for index in range(parts_length):
if (parts[index] == "*"):
# +1 to account for the current part
target_number_of_slots = number_of_slots - parts_length + 1
for index in range(target_number_of_slots):
weighting.append(1)

continue

weighting.append(int(parts[index]))

return weighting
24 changes: 20 additions & 4 deletions custom_components/octopus_energy/target_rates/target_rate.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
from datetime import timedelta
import math

import voluptuous as vol

Expand Down Expand Up @@ -39,13 +40,17 @@
CONFIG_TARGET_LAST_RATES,
CONFIG_TARGET_INVERT_TARGET_RATES,
CONFIG_TARGET_OFFSET,
CONFIG_TARGET_TYPE_CONTINUOUS,
CONFIG_TARGET_TYPE_INTERMITTENT,
CONFIG_TARGET_WEIGHTING,
DATA_ACCOUNT,
DOMAIN,
)

from . import (
calculate_continuous_times,
calculate_intermittent_times,
create_weighting,
get_applicable_rates,
get_target_rate_info
)
Expand Down Expand Up @@ -190,17 +195,21 @@ def _handle_coordinator_update(self) -> None:
all_rates,
is_rolling_target
)

number_of_slots = math.ceil(target_hours * 2)
weighting = create_weighting(self._config[CONFIG_TARGET_WEIGHTING] if CONFIG_TARGET_WEIGHTING in self._config else None, number_of_slots)

if (self._config[CONFIG_TARGET_TYPE] == "Continuous"):
if (self._config[CONFIG_TARGET_TYPE] == CONFIG_TARGET_TYPE_CONTINUOUS):
self._target_rates = calculate_continuous_times(
applicable_rates,
target_hours,
find_highest_rates,
find_last_rates,
min_rate,
max_rate
max_rate,
weighting
)
elif (self._config[CONFIG_TARGET_TYPE] == "Intermittent"):
elif (self._config[CONFIG_TARGET_TYPE] == CONFIG_TARGET_TYPE_INTERMITTENT):
self._target_rates = calculate_intermittent_times(
applicable_rates,
target_hours,
Expand Down Expand Up @@ -259,7 +268,7 @@ async def async_added_to_hass(self):
_LOGGER.debug(f'Restored OctopusEnergyTargetRate state: {self._state}')

@callback
async def async_update_config(self, target_start_time=None, target_end_time=None, target_hours=None, target_offset=None, target_minimum_rate=None, target_maximum_rate=None):
async def async_update_config(self, target_start_time=None, target_end_time=None, target_hours=None, target_offset=None, target_minimum_rate=None, target_maximum_rate=None, target_weighting=None):
"""Update sensors config"""

config = dict(self._config)
Expand Down Expand Up @@ -305,6 +314,13 @@ async def async_update_config(self, target_start_time=None, target_end_time=None
CONFIG_TARGET_MAX_RATE: trimmed_target_maximum_rate if trimmed_target_maximum_rate != "" else None
})

if target_weighting is not None:
# Inputs from automations can include quotes, so remove these
trimmed_target_weighting = target_weighting.strip('\"')
config.update({
CONFIG_TARGET_WEIGHTING: trimmed_target_weighting if trimmed_target_weighting != "" else None
})

account_result = self._hass.data[DOMAIN][self._account_id][DATA_ACCOUNT]
account_info = account_result.account if account_result is not None else None

Expand Down
Loading

0 comments on commit 80bbb52

Please sign in to comment.