Skip to content

Commit

Permalink
fix(binary-sensor): Updated target rate sensors to work with export b…
Browse files Browse the repository at this point in the history
…ased meters
  • Loading branch information
BottlecapDave committed Dec 3, 2022
1 parent 49616ae commit 0d4757b
Show file tree
Hide file tree
Showing 7 changed files with 176 additions and 28 deletions.
25 changes: 17 additions & 8 deletions custom_components/octopus_energy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import logging
from datetime import timedelta
from homeassistant.util.dt import (now, as_utc)
import asyncio

from homeassistant.util.dt import (now, as_utc)
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator
)

from .const import (
DOMAIN,

Expand All @@ -14,15 +19,12 @@
DATA_CLIENT,
DATA_ELECTRICITY_RATES_COORDINATOR,
DATA_RATES,
DATA_ACCOUNT_ID
DATA_ACCOUNT_ID,
DATA_ACCOUNT
)

from .api_client import OctopusEnergyApiClient

from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator
)

from .utils import (
get_active_tariff_code
)
Expand All @@ -34,7 +36,7 @@ async def async_setup_entry(hass, entry):
hass.data.setdefault(DOMAIN, {})

if CONFIG_MAIN_API_KEY in entry.data:
setup_dependencies(hass, entry.data)
await async_setup_dependencies(hass, entry.data)

# Forward our entry to setup our default sensors
hass.async_create_task(
Expand All @@ -45,6 +47,9 @@ async def async_setup_entry(hass, entry):
hass.config_entries.async_forward_entry_setup(entry, "binary_sensor")
)
elif CONFIG_TARGET_NAME in entry.data:
if DOMAIN not in hass.data or DATA_ELECTRICITY_RATES_COORDINATOR not in hass.data[DOMAIN] or DATA_ACCOUNT not in hass.data[DOMAIN]:
raise ConfigEntryNotReady

# Forward our entry to setup our target rate sensors
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, "binary_sensor")
Expand Down Expand Up @@ -73,7 +78,7 @@ async def async_get_current_electricity_agreement_tariff_codes(client, config):

return tariff_codes

def setup_dependencies(hass, config):
async def async_setup_dependencies(hass, config):
"""Setup the coordinator and api client which will be shared by various entities"""

if DATA_CLIENT not in hass.data[DOMAIN]:
Expand Down Expand Up @@ -117,6 +122,10 @@ async def async_update_electricity_rates_data():
update_interval=timedelta(minutes=1),
)

account_info = await client.async_get_account(config[CONFIG_MAIN_ACCOUNT_ID])

hass.data[DOMAIN][DATA_ACCOUNT] = account_info

async def options_update_listener(hass, entry):
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
Expand Down
35 changes: 23 additions & 12 deletions custom_components/octopus_energy/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
from datetime import timedelta
import math
import logging
from custom_components.octopus_energy.utils import apply_offset

from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.util.dt import (utcnow, now, as_utc, parse_datetime)
from homeassistant.util.dt import (utcnow, now)
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity
)
Expand All @@ -28,7 +26,8 @@
CONFIG_TARGET_ROLLING_TARGET,

DATA_ELECTRICITY_RATES_COORDINATOR,
DATA_CLIENT
DATA_CLIENT,
DATA_ACCOUNT
)

from .target_sensor_utils import (
Expand All @@ -47,9 +46,6 @@ async def async_setup_entry(hass, entry, async_add_entities):
if CONFIG_MAIN_API_KEY in entry.data:
await async_setup_season_sensors(hass, entry, async_add_entities)
elif CONFIG_TARGET_NAME in entry.data:
if DOMAIN not in hass.data or DATA_ELECTRICITY_RATES_COORDINATOR not in hass.data[DOMAIN]:
raise ConfigEntryNotReady

await async_setup_target_sensors(hass, entry, async_add_entities)

return True
Expand All @@ -73,18 +69,31 @@ async def async_setup_target_sensors(hass, entry, async_add_entities):

coordinator = hass.data[DOMAIN][DATA_ELECTRICITY_RATES_COORDINATOR]

async_add_entities([OctopusEnergyTargetRate(coordinator, config)], True)
account_info = hass.data[DOMAIN][DATA_ACCOUNT]

mpan = config[CONFIG_TARGET_MPAN]

is_export = False
for point in account_info["electricity_meter_points"]:
if point["mpan"] == mpan:
for meter in point["meters"]:
is_export = meter["is_export"]

entities = [OctopusEnergyTargetRate(coordinator, config, is_export)]
async_add_entities(entities, True)

class OctopusEnergyTargetRate(CoordinatorEntity, BinarySensorEntity):
"""Sensor for calculating when a target should be turned on or off."""

def __init__(self, coordinator, config):
def __init__(self, coordinator, config, is_export):
"""Init sensor."""
# Pass coordinator to base class
super().__init__(coordinator)

self._config = config
self._is_export = is_export
self._attributes = self._config.copy()
self._attributes["is_target_export"] = is_export
self._target_rates = []

@property
Expand All @@ -95,7 +104,7 @@ def unique_id(self):
@property
def name(self):
"""Name of the sensor."""
return f"Octopus Energy Target {self._config[CONFIG_TARGET_NAME]}"
return f"Octopus Energy Target Export {self._config[CONFIG_TARGET_NAME]}"

@property
def icon(self):
Expand Down Expand Up @@ -168,7 +177,8 @@ def is_on(self):
target_hours,
all_rates,
offset,
is_rolling_target
is_rolling_target,
self._is_export
)
elif (self._config[CONFIG_TARGET_TYPE] == "Intermittent"):
self._target_rates = calculate_intermittent_times(
Expand All @@ -178,7 +188,8 @@ def is_on(self):
target_hours,
all_rates,
offset,
is_rolling_target
is_rolling_target,
self._is_export
)
else:
_LOGGER.error(f"Unexpected target type: {self._config[CONFIG_TARGET_TYPE]}")
Expand Down
1 change: 1 addition & 0 deletions custom_components/octopus_energy/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
DATA_RATES = "RATES"
DATA_GAS_TARIFF_CODE = "GAS_TARIFF_CODE"
DATA_ACCOUNT_ID = "ACCOUNT_ID"
DATA_ACCOUNT = "ACCOUNT"

REGEX_HOURS = "^[0-9]+(\\.[0-9]+)*$"
REGEX_TIME = "^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$"
Expand Down
7 changes: 3 additions & 4 deletions custom_components/octopus_energy/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@
async_calculate_gas_cost
)

from typing import Generic, TypeVar

from .utils import (get_active_tariff_code)
from .const import (
DOMAIN,
Expand All @@ -37,7 +35,8 @@
CONFIG_SMETS1,

DATA_ELECTRICITY_RATES_COORDINATOR,
DATA_CLIENT
DATA_CLIENT,
DATA_ACCOUNT
)

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -113,7 +112,7 @@ async def async_setup_default_sensors(hass, entry, async_add_entities):

entities = []

account_info = await client.async_get_account(config[CONFIG_MAIN_ACCOUNT_ID])
account_info = hass.data[DOMAIN][DATA_ACCOUNT]

now = utcnow()

Expand Down
8 changes: 4 additions & 4 deletions custom_components/octopus_energy/target_sensor_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def __get_rate(rate):
def __get_valid_to(rate):
return rate["valid_to"]

def calculate_continuous_times(current_date, target_start_time, target_end_time, target_hours, rates, target_start_offset = None, is_rolling_target = True):
def calculate_continuous_times(current_date, target_start_time, target_end_time, target_hours, rates, target_start_offset = None, is_rolling_target = True, search_for_highest_rate = False):
applicable_rates = __get_applicable_rates(current_date, target_start_time, target_end_time, rates, target_start_offset, is_rolling_target)
applicable_rates_count = len(applicable_rates)
total_required_rates = math.ceil(target_hours * 2)
Expand All @@ -84,7 +84,7 @@ def calculate_continuous_times(current_date, target_start_time, target_end_time,
else:
break

if ((best_continuous_rates == None or continuous_rates_total < best_continuous_rates_total) and len(continuous_rates) == total_required_rates):
if ((best_continuous_rates == None or (search_for_highest_rate == False and continuous_rates_total < best_continuous_rates_total) or (search_for_highest_rate and continuous_rates_total > best_continuous_rates_total)) and len(continuous_rates) == total_required_rates):
best_continuous_rates = continuous_rates
best_continuous_rates_total = continuous_rates_total
else:
Expand All @@ -97,11 +97,11 @@ def calculate_continuous_times(current_date, target_start_time, target_end_time,

return []

def calculate_intermittent_times(current_date, target_start_time, target_end_time, target_hours, rates, target_start_offset = None, is_rolling_target = True):
def calculate_intermittent_times(current_date, target_start_time, target_end_time, target_hours, rates, target_start_offset = None, is_rolling_target = True, search_for_highest_rate = False):
applicable_rates = __get_applicable_rates(current_date, target_start_time, target_end_time, rates, target_start_offset, is_rolling_target)
total_required_rates = math.ceil(target_hours * 2)

applicable_rates.sort(key=__get_rate)
applicable_rates.sort(key=__get_rate, reverse=search_for_highest_rate)
applicable_rates = applicable_rates[:total_required_rates]

_LOGGER.debug(f'{len(applicable_rates)} applicable rates found')
Expand Down
64 changes: 64 additions & 0 deletions tests/unit/test_calculate_continuous_times.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,70 @@ async def test_when_continuous_times_present_then_next_continuous_times_returned
assert result[1]["valid_to"] == expected_first_valid_from + timedelta(hours=1)
assert result[1]["value_inc_vat"] == 0.1

@pytest.mark.asyncio
@pytest.mark.parametrize("current_date,target_start_time,target_end_time,expected_first_valid_from,is_rolling_target",[
(datetime.strptime("2022-02-09T00:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), "10:00", "18:00", datetime.strptime("2022-02-09T11:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), True),
(datetime.strptime("2022-02-09T12:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), "10:00", "18:00", datetime.strptime("2022-02-09T12:30:00Z", "%Y-%m-%dT%H:%M:%S%z"), True),
(datetime.strptime("2022-02-09T19:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), "10:00", "18:00", datetime.strptime("2022-02-10T11:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), True),
(datetime.strptime("2022-02-09T00:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), "10:00", "18:00", datetime.strptime("2022-02-09T11:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), False),
(datetime.strptime("2022-02-09T12:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), "10:00", "18:00", datetime.strptime("2022-02-09T11:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), False),
(datetime.strptime("2022-02-09T19:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), "10:00", "18:00", datetime.strptime("2022-02-09T11:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), False),
# No start set
(datetime.strptime("2022-02-09T00:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), None, "18:00", datetime.strptime("2022-02-09T00:30:00Z", "%Y-%m-%dT%H:%M:%S%z"), True),
(datetime.strptime("2022-02-09T12:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), None, "18:00", datetime.strptime("2022-02-09T12:30:00Z", "%Y-%m-%dT%H:%M:%S%z"), True),
(datetime.strptime("2022-02-09T19:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), None, "18:00", datetime.strptime("2022-02-10T00:30:00Z", "%Y-%m-%dT%H:%M:%S%z"), True),
(datetime.strptime("2022-02-09T00:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), None, "18:00", datetime.strptime("2022-02-09T00:30:00Z", "%Y-%m-%dT%H:%M:%S%z"), False),
(datetime.strptime("2022-02-09T12:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), None, "18:00", datetime.strptime("2022-02-09T00:30:00Z", "%Y-%m-%dT%H:%M:%S%z"), False),
(datetime.strptime("2022-02-09T19:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), None, "18:00", datetime.strptime("2022-02-09T00:30:00Z", "%Y-%m-%dT%H:%M:%S%z"), False),
# No end set
(datetime.strptime("2022-02-09T00:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), "10:00", None, datetime.strptime("2022-02-09T11:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), True),
(datetime.strptime("2022-02-09T12:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), "10:00", None, datetime.strptime("2022-02-09T12:30:00Z", "%Y-%m-%dT%H:%M:%S%z"), True),
(datetime.strptime("2022-02-09T00:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), "10:00", None, datetime.strptime("2022-02-09T11:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), False),
(datetime.strptime("2022-02-09T12:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), "10:00", None, datetime.strptime("2022-02-09T11:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), False),
# No start or end set
(datetime.strptime("2022-02-09T00:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), None, None, datetime.strptime("2022-02-09T00:30:00Z", "%Y-%m-%dT%H:%M:%S%z"), True),
(datetime.strptime("2022-02-09T12:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), None, None, datetime.strptime("2022-02-09T12:30:00Z", "%Y-%m-%dT%H:%M:%S%z"), True),
(datetime.strptime("2022-02-09T00:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), None, None, datetime.strptime("2022-02-09T00:30:00Z", "%Y-%m-%dT%H:%M:%S%z"), False),
(datetime.strptime("2022-02-09T12:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), None, None, datetime.strptime("2022-02-09T00:30:00Z", "%Y-%m-%dT%H:%M:%S%z"), False),
])
async def test_when_continuous_times_present_and_highest_price_required_then_next_continuous_times_returned(current_date, target_start_time, target_end_time, expected_first_valid_from, is_rolling_target):
# Arrange
period_from = datetime.strptime("2022-02-09T00:00:00Z", "%Y-%m-%dT%H:%M:%S%z")
period_to = datetime.strptime("2022-02-11T00:00:00Z", "%Y-%m-%dT%H:%M:%S%z")
expected_rates = [0.1, 0.2, 0.3]

rates = create_rate_data(
period_from,
period_to,
expected_rates
)

# Restrict our time block
target_hours = 1

# Act
result = calculate_continuous_times(
current_date,
target_start_time,
target_end_time,
target_hours,
rates,
None,
is_rolling_target,
True
)

# Assert
assert result != None
assert len(result) == 2
assert result[0]["valid_from"] == expected_first_valid_from
assert result[0]["valid_to"] == expected_first_valid_from + timedelta(minutes=30)
assert result[0]["value_inc_vat"] == 0.2

assert result[1]["valid_from"] == expected_first_valid_from + timedelta(minutes=30)
assert result[1]["valid_to"] == expected_first_valid_from + timedelta(hours=1)
assert result[1]["value_inc_vat"] == 0.3

@pytest.mark.asyncio
async def test_when_current_time_has_not_enough_time_left_then_no_continuous_times_returned():
# Arrange
Expand Down
64 changes: 64 additions & 0 deletions tests/unit/test_calculate_intermittent_times.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,70 @@ async def test_when_intermittent_times_present_then_next_intermittent_times_retu
assert result[1]["valid_to"] == expected_first_valid_from + timedelta(hours=2)
assert result[1]["value_inc_vat"] == 0.1

@pytest.mark.asyncio
@pytest.mark.parametrize("current_date,target_start_time,target_end_time,expected_first_valid_from,is_rolling_target",[
(datetime.strptime("2022-02-09T00:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), "10:00", "18:00", datetime.strptime("2022-02-09T10:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), True),
(datetime.strptime("2022-02-09T12:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), "10:00", "18:00", datetime.strptime("2022-02-09T13:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), True),
(datetime.strptime("2022-02-09T19:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), "10:00", "18:00", datetime.strptime("2022-02-10T10:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), True),
(datetime.strptime("2022-02-09T00:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), "10:00", "18:00", datetime.strptime("2022-02-09T10:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), False),
(datetime.strptime("2022-02-09T12:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), "10:00", "18:00", datetime.strptime("2022-02-09T10:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), False),
(datetime.strptime("2022-02-09T19:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), "10:00", "18:00", datetime.strptime("2022-02-09T10:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), False),
# # No start set
(datetime.strptime("2022-02-09T00:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), None, "18:00", datetime.strptime("2022-02-09T01:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), True),
(datetime.strptime("2022-02-09T12:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), None, "18:00", datetime.strptime("2022-02-09T13:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), True),
(datetime.strptime("2022-02-09T19:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), None, "18:00", datetime.strptime("2022-02-10T01:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), True),
(datetime.strptime("2022-02-09T00:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), None, "18:00", datetime.strptime("2022-02-09T01:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), False),
(datetime.strptime("2022-02-09T12:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), None, "18:00", datetime.strptime("2022-02-09T01:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), False),
(datetime.strptime("2022-02-09T19:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), None, "18:00", datetime.strptime("2022-02-09T01:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), False),
# # No end set
(datetime.strptime("2022-02-09T00:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), "10:00", None, datetime.strptime("2022-02-09T10:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), True),
(datetime.strptime("2022-02-09T12:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), "10:00", None, datetime.strptime("2022-02-09T13:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), True),
(datetime.strptime("2022-02-09T00:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), "10:00", None, datetime.strptime("2022-02-09T10:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), False),
(datetime.strptime("2022-02-09T12:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), "10:00", None, datetime.strptime("2022-02-09T10:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), False),
# # No start or end set
(datetime.strptime("2022-02-09T00:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), None, None, datetime.strptime("2022-02-09T01:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), True),
(datetime.strptime("2022-02-09T12:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), None, None, datetime.strptime("2022-02-09T13:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), True),
(datetime.strptime("2022-02-09T00:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), None, None, datetime.strptime("2022-02-09T01:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), False),
(datetime.strptime("2022-02-09T12:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), None, None, datetime.strptime("2022-02-09T01:00:00Z", "%Y-%m-%dT%H:%M:%S%z"), False),
])
async def test_when_intermittent_times_present_and_highest_prices_are_true_then_next_intermittent_times_returned(current_date, target_start_time, target_end_time, expected_first_valid_from, is_rolling_target):
# Arrange
period_from = datetime.strptime("2022-02-09T00:00:00Z", "%Y-%m-%dT%H:%M:%S%z")
period_to = datetime.strptime("2022-02-11T00:00:00Z", "%Y-%m-%dT%H:%M:%S%z")
expected_rates = [0.1, 0.2, 0.3]

rates = create_rate_data(
period_from,
period_to,
expected_rates
)

# Restrict our time block
target_hours = 1

# Act
result = calculate_intermittent_times(
current_date,
target_start_time,
target_end_time,
target_hours,
rates,
None,
is_rolling_target,
True
)

# Assert
assert result != None
assert len(result) == 2
assert result[0]["valid_from"] == expected_first_valid_from
assert result[0]["valid_to"] == expected_first_valid_from + timedelta(minutes=30)
assert result[0]["value_inc_vat"] == 0.3

assert result[1]["valid_from"] == expected_first_valid_from + timedelta(hours=1, minutes=30)
assert result[1]["valid_to"] == expected_first_valid_from + timedelta(hours=2)
assert result[1]["value_inc_vat"] == 0.3

@pytest.mark.asyncio
async def test_when_current_time_has_not_enough_time_left_then_no_intermittent_times_returned():
# Arrange
Expand Down

0 comments on commit 0d4757b

Please sign in to comment.