Skip to content

Commit

Permalink
feat: Added ability to configure optional minimum/maximum rates for t…
Browse files Browse the repository at this point in the history
…arget rate sensors
  • Loading branch information
BottlecapDave committed Apr 17, 2024
1 parent aac8dcd commit 95ac448
Show file tree
Hide file tree
Showing 12 changed files with 310 additions and 13 deletions.
2 changes: 2 additions & 0 deletions _docs/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ For updating a given [target rate's](./setup/target_rate.md) config. This allows
| `data.target_start_time` | `yes` | The optional time the evaluation period should start. Must be in the format of `HH:MM`. |
| `data.target_end_time` | `yes` | The optional time the evaluation period should end. Must be in the format of `HH:MM`. |
| `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. |

### Automation Example

Expand Down
4 changes: 4 additions & 0 deletions _docs/setup/target_rate.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ This feature is toggled on by the `Find last applicable rates` checkbox.

If this is checked, then the normal behaviour of the sensor will be reversed. This means if you target an **import** sensor, with this checked it will find the most expensive rates. Similarly if you target an **export** meter, with this checked it will find the cheapest rates.

### Minimum/Maximum Rates

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).

## Attributes

The following attributes are available on each sensor
Expand Down
6 changes: 4 additions & 2 deletions custom_components/octopus_energy/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,17 @@ async def async_setup_entry(hass, entry, async_add_entities):
vol.All(
vol.Schema(
{
vol.Required("target_hours"): str,
vol.Optional("target_hours"): str,
vol.Optional("target_start_time"): str,
vol.Optional("target_end_time"): str,
vol.Optional("target_offset"): str,
vol.Optional("target_minimum_rate"): str,
vol.Optional("target_maximum_rate"): str,
},
extra=vol.ALLOW_EXTRA,
),
cv.has_at_least_one_key(
"target_hours", "target_start_time", "target_end_time", "target_offset"
"target_hours", "target_start_time", "target_end_time", "target_offset", "target_minimum_rate", "target_maximum_rate"
),
),
"async_update_config",
Expand Down
34 changes: 34 additions & 0 deletions custom_components/octopus_energy/config/target_rates.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
CONFIG_ACCOUNT_ID,
CONFIG_TARGET_END_TIME,
CONFIG_TARGET_HOURS,
CONFIG_TARGET_MAX_RATE,
CONFIG_TARGET_MIN_RATE,
CONFIG_TARGET_MPAN,
CONFIG_TARGET_NAME,
CONFIG_TARGET_OFFSET,
Expand All @@ -24,6 +26,7 @@
REGEX_ENTITY_NAME,
REGEX_HOURS,
REGEX_OFFSET_PARTS,
REGEX_PRICE,
REGEX_TIME
)

Expand Down Expand Up @@ -78,6 +81,21 @@ def merge_target_rate_config(data: dict, options: dict, updated_config: dict = N
if updated_config is not None:
config.update(updated_config)

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

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

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

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

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

return config

def is_time_frame_long_enough(hours, start_time, end_time):
Expand Down Expand Up @@ -137,6 +155,22 @@ def validate_target_rate_config(data, account_info, now):
if matches is None:
errors[CONFIG_TARGET_OFFSET] = "invalid_offset"

if CONFIG_TARGET_MIN_RATE in data and data[CONFIG_TARGET_MIN_RATE] is not None:
if isinstance(data[CONFIG_TARGET_MIN_RATE], float) == False:
matches = re.search(REGEX_PRICE, data[CONFIG_TARGET_MIN_RATE])
if matches is None:
errors[CONFIG_TARGET_MIN_RATE] = "invalid_price"
else:
data[CONFIG_TARGET_MIN_RATE] = float(data[CONFIG_TARGET_MIN_RATE])

if CONFIG_TARGET_MAX_RATE in data and data[CONFIG_TARGET_MAX_RATE] is not None:
if isinstance(data[CONFIG_TARGET_MAX_RATE], float) == False:
matches = re.search(REGEX_PRICE, data[CONFIG_TARGET_MAX_RATE])
if matches is None:
errors[CONFIG_TARGET_MAX_RATE] = "invalid_price"
else:
data[CONFIG_TARGET_MAX_RATE] = float(data[CONFIG_TARGET_MAX_RATE])

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
8 changes: 8 additions & 0 deletions custom_components/octopus_energy/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
CONFIG_MAIN_LIVE_GAS_CONSUMPTION_REFRESH_IN_MINUTES,
CONFIG_MAIN_PREVIOUS_ELECTRICITY_CONSUMPTION_DAYS_OFFSET,
CONFIG_MAIN_PREVIOUS_GAS_CONSUMPTION_DAYS_OFFSET,
CONFIG_TARGET_MAX_RATE,
CONFIG_TARGET_MIN_RATE,
CONFIG_VERSION,
DATA_ACCOUNT,
DOMAIN,
Expand Down Expand Up @@ -174,6 +176,8 @@ async def __async_setup_target_rate_schema__(self, account_id: str):
vol.Optional(CONFIG_TARGET_ROLLING_TARGET, default=False): bool,
vol.Optional(CONFIG_TARGET_LAST_RATES, default=False): bool,
vol.Optional(CONFIG_TARGET_INVERT_TARGET_RATES, default=False): bool,
vol.Optional(CONFIG_TARGET_MIN_RATE): float,
vol.Optional(CONFIG_TARGET_MAX_RATE): float,
})

async def __async_setup_cost_tracker_schema__(self, account_id: str):
Expand Down Expand Up @@ -399,6 +403,8 @@ async def __async_setup_target_rate_schema__(self, config, errors):
vol.Optional(CONFIG_TARGET_ROLLING_TARGET): bool,
vol.Optional(CONFIG_TARGET_LAST_RATES): bool,
vol.Optional(CONFIG_TARGET_INVERT_TARGET_RATES): bool,
vol.Optional(CONFIG_TARGET_MIN_RATE): float,
vol.Optional(CONFIG_TARGET_MAX_RATE): float,
}),
{
CONFIG_TARGET_NAME: config[CONFIG_TARGET_NAME],
Expand All @@ -408,6 +414,8 @@ async def __async_setup_target_rate_schema__(self, config, errors):
CONFIG_TARGET_ROLLING_TARGET: is_rolling_target,
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
}
),
errors=errors
Expand Down
3 changes: 3 additions & 0 deletions custom_components/octopus_energy/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
CONFIG_TARGET_ROLLING_TARGET = "rolling_target"
CONFIG_TARGET_LAST_RATES = "last_rates"
CONFIG_TARGET_INVERT_TARGET_RATES = "target_invert_target_rates"
CONFIG_TARGET_MIN_RATE = "minimum_rate"
CONFIG_TARGET_MAX_RATE = "maximum_rate"

CONFIG_COST_NAME = "name"
CONFIG_COST_MPAN = "mpan"
Expand Down Expand Up @@ -106,6 +108,7 @@
REGEX_TARIFF_PARTS = "^((?P<energy>[A-Z])-(?P<rate>[0-9A-Z]+)-)?(?P<product_code>[A-Z0-9-]+)-(?P<region>[A-Z])$"
REGEX_OFFSET_PARTS = "^(-)?([0-1]?[0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])$"
REGEX_DATE = "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
REGEX_PRICE = "^(-)?[0-9]+(\\.[0-9]+)*$"

DATA_SCHEMA_ACCOUNT = vol.Schema({
vol.Required(CONFIG_ACCOUNT_ID): str,
Expand Down
14 changes: 14 additions & 0 deletions custom_components/octopus_energy/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@ update_target_config:
The optional offset to apply to the target rate when it starts
selector:
text:
target_minimum_rate:
name: Minimum rate
description:
The optional minimum rate the selected rates should not go below
example: '0.10'
selector:
text:
target_maximum_rate:
name: Maximum rate
description:
The optional maximum rate the selected rates should not go above
example: '0.10'
selector:
text:

purge_invalid_external_statistic_ids:
name: Purge invalid external statistics
Expand Down
22 changes: 20 additions & 2 deletions custom_components/octopus_energy/target_rates/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@ def calculate_continuous_times(
applicable_rates: list,
target_hours: float,
search_for_highest_rate = False,
find_last_rates = False
find_last_rates = False,
min_rate = None,
max_rate = None
):
if (applicable_rates is None):
return []
Expand All @@ -102,12 +104,25 @@ def calculate_continuous_times(
# Loop through our rates and try and find the block of time that meets our desired
# hours and has the lowest combined rates
for index, rate in enumerate(applicable_rates):
if (min_rate is not None and rate["value_inc_vat"] < min_rate):
continue

if (max_rate is not None and rate["value_inc_vat"] > max_rate):
continue

continuous_rates = [rate]
continuous_rates_total = rate["value_inc_vat"]

for offset in range(1, total_required_rates):
if (index + offset) < applicable_rates_count:
offset_rate = applicable_rates[(index + offset)]

if (min_rate is not None and offset_rate["value_inc_vat"] < min_rate):
break

if (max_rate is not None and offset_rate["value_inc_vat"] > max_rate):
break

continuous_rates.append(offset_rate)
continuous_rates_total += offset_rate["value_inc_vat"]
else:
Expand All @@ -130,7 +145,9 @@ def calculate_intermittent_times(
applicable_rates: list,
target_hours: float,
search_for_highest_rate = False,
find_last_rates = False
find_last_rates = False,
min_rate = None,
max_rate = None
):
if (applicable_rates is None):
return []
Expand All @@ -148,6 +165,7 @@ def calculate_intermittent_times(
else:
applicable_rates.sort(key= lambda rate: (rate["value_inc_vat"], rate["end"]))

applicable_rates = list(filter(lambda rate: (min_rate is None or rate["value_inc_vat"] >= min_rate) and (max_rate is None or rate["value_inc_vat"] <= max_rate), applicable_rates))
applicable_rates = applicable_rates[:total_required_rates]

_LOGGER.debug(f'{len(applicable_rates)} applicable rates found')
Expand Down
34 changes: 31 additions & 3 deletions custom_components/octopus_energy/target_rates/target_rate.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
from homeassistant.helpers import translation

from ..const import (
CONFIG_TARGET_MAX_RATE,
CONFIG_TARGET_MIN_RATE,
CONFIG_TARGET_NAME,
CONFIG_TARGET_HOURS,
CONFIG_TARGET_OLD_END_TIME,
Expand Down Expand Up @@ -171,6 +173,14 @@ def _handle_coordinator_update(self) -> None:
if (CONFIG_TARGET_INVERT_TARGET_RATES in self._config):
invert_target_rates = self._config[CONFIG_TARGET_INVERT_TARGET_RATES]

min_rate = None
if CONFIG_TARGET_MIN_RATE in self._config:
min_rate = self._config[CONFIG_TARGET_MIN_RATE]

max_rate = None
if CONFIG_TARGET_MAX_RATE in self._config:
max_rate = self._config[CONFIG_TARGET_MAX_RATE]

find_highest_rates = (self._is_export and invert_target_rates == False) or (self._is_export == False and invert_target_rates)

applicable_rates = get_applicable_rates(
Expand All @@ -186,14 +196,18 @@ def _handle_coordinator_update(self) -> None:
applicable_rates,
target_hours,
find_highest_rates,
find_last_rates
find_last_rates,
min_rate,
max_rate
)
elif (self._config[CONFIG_TARGET_TYPE] == "Intermittent"):
self._target_rates = calculate_intermittent_times(
applicable_rates,
target_hours,
find_highest_rates,
find_last_rates
find_last_rates,
min_rate,
max_rate
)
else:
_LOGGER.error(f"Unexpected target type: {self._config[CONFIG_TARGET_TYPE]}")
Expand Down Expand Up @@ -244,7 +258,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):
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):
"""Update sensors config"""

config = dict(self._config)
Expand Down Expand Up @@ -276,6 +290,20 @@ async def async_update_config(self, target_start_time=None, target_end_time=None
CONFIG_TARGET_OFFSET: trimmed_target_offset
})

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

if target_maximum_rate is not None:
# Inputs from automations can include quotes, so remove these
trimmed_target_maximum_rate = target_maximum_rate.strip('\"')
config.update({
CONFIG_TARGET_MAX_RATE: trimmed_target_maximum_rate if trimmed_target_maximum_rate != "" 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
14 changes: 10 additions & 4 deletions custom_components/octopus_energy/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@
"offset": "The offset to apply to the scheduled block to be considered active",
"rolling_target": "Re-evaluate multiple times a day",
"last_rates": "Find last applicable rates",
"target_invert_target_rates": "Invert targeted rates"
"target_invert_target_rates": "Invert targeted rates",
"minimum_rate": "The optional minimum rate for target hours",
"maximum_rate": "The optional maximum rate for target hours"
}
},
"target_rate_account": {
Expand Down Expand Up @@ -75,7 +77,8 @@
"invalid_end_time_agile": "Target time not fit for agile tariffs. Please consult target rate documentation for more information.",
"duplicate_account": "Account has already been configured",
"invalid_week_day": "Week reset day must be between 0 and 6 (inclusively)",
"invalid_month_day": "Month reset day must be between 1 and 28 (inclusively)"
"invalid_month_day": "Month reset day must be between 1 and 28 (inclusively)",
"invalid_price": "Price must be in the form pounds and pence (e.g. 0.10)"
},
"abort": {
"not_supported": "Configuration for target rates is not supported at the moment.",
Expand Down Expand Up @@ -114,7 +117,9 @@
"offset": "The offset to apply to the scheduled block to be considered active",
"rolling_target": "Re-evaluate multiple times a day",
"last_rates": "Find last applicable rates",
"target_invert_target_rates": "Invert targeted rates"
"target_invert_target_rates": "Invert targeted rates",
"minimum_rate": "The optional minimum rate for target hours",
"maximum_rate": "The optional maximum rate for target hours"
}
},
"cost_tracker": {
Expand All @@ -140,7 +145,8 @@
"invalid_mpan": "Meter not found in account with an active tariff",
"invalid_end_time_agile": "Target time not fit for agile tariffs. Please consult target rate documentation for more information.",
"invalid_week_day": "Week reset day must be between 0 and 6 (inclusively)",
"invalid_month_day": "Month reset day must be between 1 and 28 (inclusively)"
"invalid_month_day": "Month reset day must be between 1 and 28 (inclusively)",
"invalid_price": "Price must be in the form pounds and pence (e.g. 0.10)"
},
"abort": {
"not_supported": "Configuration for target rates is not supported at the moment.",
Expand Down
Loading

0 comments on commit 95ac448

Please sign in to comment.