From 588e7ed6d3e4bf3dd54a6b974674af0e98211355 Mon Sep 17 00:00:00 2001 From: BottlecapDave Date: Sun, 1 Oct 2023 11:01:51 +0100 Subject: [PATCH] feat: Updated gas standing charge coordinator to focus on single meter --- custom_components/octopus_energy/const.py | 3 +- .../electricity_standing_charges.py | 2 - .../coordinators/gas_standing_charges.py | 98 ++++--- .../gas/current_accumulative_consumption.py | 4 +- .../gas/current_accumulative_cost.py | 4 +- .../octopus_energy/gas/standing_charge.py | 2 +- custom_components/octopus_energy/sensor.py | 3 +- ...efresh_electricity_standing_charge_data.py | 3 - ..._async_refresh_gas_standing_charge_data.py | 244 ++++++++++++++++++ 9 files changed, 308 insertions(+), 55 deletions(-) create mode 100644 tests/unit/coordinators/test_async_refresh_gas_standing_charge_data.py diff --git a/custom_components/octopus_energy/const.py b/custom_components/octopus_energy/const.py index 631805dc..74737ca5 100644 --- a/custom_components/octopus_energy/const.py +++ b/custom_components/octopus_energy/const.py @@ -52,8 +52,7 @@ DATA_ELECTRICITY_STANDING_CHARGE_KEY = "ELECTRICITY_STANDING_CHARGES_{}_{}" -DATA_GAS_STANDING_CHARGES_COORDINATOR = "GAS_STANDING_CHARGES_COORDINATOR" -DATA_GAS_STANDING_CHARGES = "GAS_STANDING_CHARGES" +DATA_GAS_STANDING_CHARGE_KEY = "GAS_STANDING_CHARGES_{}_{}" STORAGE_COMPLETED_DISPATCHES_NAME = "octopus_energy.{}-completed-intelligent-dispatches.json" diff --git a/custom_components/octopus_energy/coordinators/electricity_standing_charges.py b/custom_components/octopus_energy/coordinators/electricity_standing_charges.py index ee3e3ed3..068d6d85 100644 --- a/custom_components/octopus_energy/coordinators/electricity_standing_charges.py +++ b/custom_components/octopus_energy/coordinators/electricity_standing_charges.py @@ -17,8 +17,6 @@ from ..api_client import OctopusEnergyApiClient -from . import get_current_electricity_agreement_tariff_codes - _LOGGER = logging.getLogger(__name__) class ElectricityStandingChargeCoordinatorResult: diff --git a/custom_components/octopus_energy/coordinators/gas_standing_charges.py b/custom_components/octopus_energy/coordinators/gas_standing_charges.py index fdcbefb1..997d0ad1 100644 --- a/custom_components/octopus_energy/coordinators/gas_standing_charges.py +++ b/custom_components/octopus_energy/coordinators/gas_standing_charges.py @@ -1,5 +1,6 @@ import logging from datetime import datetime, timedelta +from custom_components.octopus_energy.utils import get_active_tariff_code from homeassistant.util.dt import (now, as_utc) from homeassistant.helpers.update_coordinator import ( @@ -7,85 +8,100 @@ ) from ..const import ( + COORDINATOR_REFRESH_IN_SECONDS, + DATA_GAS_STANDING_CHARGE_KEY, DOMAIN, DATA_CLIENT, - DATA_GAS_STANDING_CHARGES, DATA_ACCOUNT, ) from ..api_client import OctopusEnergyApiClient -from . import get_current_gas_agreement_tariff_codes - _LOGGER = logging.getLogger(__name__) +class GasStandingChargeCoordinatorResult: + last_retrieved: datetime + standing_charge: {} + + def __init__(self, last_retrieved: datetime, standing_charge: {}): + self.last_retrieved = last_retrieved + self.standing_charge = standing_charge + +def get_tariff_code(current: datetime, account_info, target_mprn: str, target_serial_number: str): + if len(account_info["gas_meter_points"]) > 0: + for point in account_info["gas_meter_points"]: + active_tariff_code = get_active_tariff_code(current, point["agreements"]) + # The type of meter (ie smart vs dumb) can change the tariff behaviour, so we + # have to enumerate the different meters being used for each tariff as well. + for meter in point["meters"]: + if active_tariff_code is not None and point["mprn"] == target_mprn and meter["serial_number"] == target_serial_number: + return active_tariff_code + async def async_refresh_gas_standing_charges_data( current: datetime, client: OctopusEnergyApiClient, account_info, - existing_standing_charges: list + target_mprn: str, + target_serial_number: str, + existing_standing_charges_result: GasStandingChargeCoordinatorResult ): - if (account_info is not None): - tariff_codes = get_current_gas_agreement_tariff_codes(current, account_info) + period_from = as_utc(current.replace(hour=0, minute=0, second=0, microsecond=0)) + period_to = period_from + timedelta(days=1) - period_from = as_utc(current.replace(hour=0, minute=0, second=0, microsecond=0)) - period_to = period_from + timedelta(days=1) - - standing_charges = {} - for ((meter_point, is_smart_meter), tariff_code) in tariff_codes.items(): - key = meter_point - - new_standing_charges = None - if ((current.minute % 30) == 0 or - existing_standing_charges is None or - key not in existing_standing_charges or - (existing_standing_charges[key]["valid_from"] is not None and existing_standing_charges[key]["valid_from"] < period_from)): - try: - new_standing_charges = await client.async_get_gas_standing_charge(tariff_code, period_from, period_to) - _LOGGER.debug(f'Gas standing charges retrieved for {tariff_code}') - except: - _LOGGER.debug(f'Failed to retrieve gas standing charges for {tariff_code}') - else: - new_standing_charges = existing_standing_charges[key] - - if new_standing_charges is not None: - standing_charges[key] = new_standing_charges - elif (existing_standing_charges is not None and key in existing_standing_charges): - _LOGGER.debug(f"Failed to retrieve new gas standing charges for {tariff_code}, so using cached standing charges") - standing_charges[key] = existing_standing_charges[key] - - return standing_charges + if (account_info is not None): + tariff_code = get_tariff_code(current, account_info, target_mprn, target_serial_number) + if tariff_code is None: + return None + + new_standing_charge = None + if ((current.minute % 30) == 0 or + existing_standing_charges_result is None or + (existing_standing_charges_result.standing_charge["valid_from"] is not None and existing_standing_charges_result.standing_charge["valid_from"] < period_from)): + try: + new_standing_charge = await client.async_get_gas_standing_charge(tariff_code, period_from, period_to) + _LOGGER.debug(f'Gas standing charges retrieved for {target_mprn}/{target_serial_number} ({tariff_code})') + except: + _LOGGER.debug(f'Failed to retrieve gas standing charges for {target_mprn}/{target_serial_number} ({tariff_code})') + + if new_standing_charge is not None: + return GasStandingChargeCoordinatorResult(current, new_standing_charge) + elif (existing_standing_charges_result is not None): + _LOGGER.debug(f"Failed to retrieve new gas standing charges for {target_mprn}/{target_serial_number} ({tariff_code}), so using cached standing charges") - return existing_standing_charges + return existing_standing_charges_result -async def async_setup_gas_standing_charges_coordinator(hass, account_id: str): +async def async_setup_gas_standing_charges_coordinator(hass, target_mprn: str, target_serial_number: str): + key = DATA_GAS_STANDING_CHARGE_KEY.format(target_mprn, target_serial_number) + # Reset data rates as we might have new information - hass.data[DOMAIN][DATA_GAS_STANDING_CHARGES] = [] + hass.data[DOMAIN][key] = None async def async_update_gas_standing_charges_data(): """Fetch data from API endpoint.""" current = now() client: OctopusEnergyApiClient = hass.data[DOMAIN][DATA_CLIENT] account_info = hass.data[DOMAIN][DATA_ACCOUNT] if DATA_ACCOUNT in hass.data[DOMAIN] else None - standing_charges = hass.data[DOMAIN][DATA_GAS_STANDING_CHARGES] if DATA_GAS_STANDING_CHARGES in hass.data[DOMAIN] else {} + standing_charges: GasStandingChargeCoordinatorResult = hass.data[DOMAIN][key] if key in hass.data[DOMAIN] else None - hass.data[DOMAIN][DATA_GAS_STANDING_CHARGES] = await async_refresh_gas_standing_charges_data( + hass.data[DOMAIN][key] = await async_refresh_gas_standing_charges_data( current, client, account_info, + target_mprn, + target_serial_number, standing_charges, ) - return hass.data[DOMAIN][DATA_GAS_STANDING_CHARGES] + return hass.data[DOMAIN][key] coordinator = DataUpdateCoordinator( hass, _LOGGER, - name="gas_standing_charges", + name=key, update_method=async_update_gas_standing_charges_data, # Because of how we're using the data, we'll update every minute, but we will only actually retrieve # data every 30 minutes - update_interval=timedelta(minutes=1), + update_interval=timedelta(minutes=COORDINATOR_REFRESH_IN_SECONDS), ) await coordinator.async_config_entry_first_refresh() diff --git a/custom_components/octopus_energy/gas/current_accumulative_consumption.py b/custom_components/octopus_energy/gas/current_accumulative_consumption.py index 333dccab..4f4ef616 100644 --- a/custom_components/octopus_energy/gas/current_accumulative_consumption.py +++ b/custom_components/octopus_energy/gas/current_accumulative_consumption.py @@ -82,8 +82,8 @@ def state(self): """Retrieve the current days accumulative consumption""" consumption_data = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None rate_data = self._rates_coordinator.data.rates if self._rates_coordinator is not None and self._rates_coordinator.data is not None else None - standing_charge = self._standing_charge_coordinator.data[self._mprn]["value_inc_vat"] if self._standing_charge_coordinator is not None and self._standing_charge_coordinator.data is not None and self._mprn in self._standing_charge_coordinator.data and "value_inc_vat" in self._standing_charge_coordinator.data[self._mprn] else None - + standing_charge = self._standing_charge_coordinator.data.standing_charge["value_inc_vat"] if self._standing_charge_coordinator is not None and self._standing_charge_coordinator.data is not None else None + consumption_and_cost = calculate_gas_consumption_and_cost( consumption_data, rate_data, diff --git a/custom_components/octopus_energy/gas/current_accumulative_cost.py b/custom_components/octopus_energy/gas/current_accumulative_cost.py index 080bb947..00c605dc 100644 --- a/custom_components/octopus_energy/gas/current_accumulative_cost.py +++ b/custom_components/octopus_energy/gas/current_accumulative_cost.py @@ -91,8 +91,8 @@ def state(self): """Retrieve the currently calculated state""" consumption_data = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None rate_data = self._rates_coordinator.data.rates if self._rates_coordinator is not None and self._rates_coordinator.data is not None else None - standing_charge = self._standing_charge_coordinator.data[self._mprn]["value_inc_vat"] if self._standing_charge_coordinator is not None and self._standing_charge_coordinator.data is not None and self._mprn in self._standing_charge_coordinator.data and "value_inc_vat" in self._standing_charge_coordinator.data[self._mprn] else None - + standing_charge = self._standing_charge_coordinator.data.standing_charge["value_inc_vat"] if self._standing_charge_coordinator is not None and self._standing_charge_coordinator.data is not None else None + consumption_and_cost = calculate_gas_consumption_and_cost( consumption_data, rate_data, diff --git a/custom_components/octopus_energy/gas/standing_charge.py b/custom_components/octopus_energy/gas/standing_charge.py index 081dae8e..0843f8e2 100644 --- a/custom_components/octopus_energy/gas/standing_charge.py +++ b/custom_components/octopus_energy/gas/standing_charge.py @@ -68,7 +68,7 @@ def state(self): """Retrieve the latest gas standing charge""" _LOGGER.debug('Updating OctopusEnergyGasCurrentStandingCharge') - standard_charge_result = self.coordinator.data[self._mprn] if self.coordinator is not None and self.coordinator.data is not None and self._mprn in self.coordinator.data else None + standard_charge_result = self.coordinator.data.standing_charge if self.coordinator is not None and self.coordinator.data is not None else None if standard_charge_result is not None: self._latest_date = standard_charge_result["valid_from"] diff --git a/custom_components/octopus_energy/sensor.py b/custom_components/octopus_energy/sensor.py index 7afcdb72..190c2ad8 100644 --- a/custom_components/octopus_energy/sensor.py +++ b/custom_components/octopus_energy/sensor.py @@ -183,8 +183,6 @@ async def async_setup_default_sensors(hass: HomeAssistant, entry, async_add_enti if CONFIG_MAIN_GAS_PRICE_CAP in config: gas_price_cap = config[CONFIG_MAIN_GAS_PRICE_CAP] - gas_standing_charges_coordinator = await async_setup_gas_standing_charges_coordinator(hass, config[CONFIG_MAIN_ACCOUNT_ID]) - previous_gas_consumption_days_offset = CONFIG_DEFAULT_PREVIOUS_CONSUMPTION_OFFSET_IN_DAYS if CONFIG_MAIN_PREVIOUS_GAS_CONSUMPTION_DAYS_OFFSET in config: previous_gas_consumption_days_offset = config[CONFIG_MAIN_PREVIOUS_GAS_CONSUMPTION_DAYS_OFFSET] @@ -200,6 +198,7 @@ async def async_setup_default_sensors(hass: HomeAssistant, entry, async_add_enti _LOGGER.info(f'Adding gas meter; mprn: {mprn}; serial number: {serial_number}') gas_rate_coordinator = await async_setup_gas_rates_coordinator(hass, client, mprn, serial_number) + gas_standing_charges_coordinator = await async_setup_gas_standing_charges_coordinator(hass, mprn, serial_number) entities.append(OctopusEnergyGasCurrentRate(hass, gas_rate_coordinator, gas_tariff_code, meter, point, gas_price_cap)) entities.append(OctopusEnergyGasPreviousRate(hass, gas_rate_coordinator, meter, point)) diff --git a/tests/unit/coordinators/test_async_refresh_electricity_standing_charge_data.py b/tests/unit/coordinators/test_async_refresh_electricity_standing_charge_data.py index 99741d71..2d549831 100644 --- a/tests/unit/coordinators/test_async_refresh_electricity_standing_charge_data.py +++ b/tests/unit/coordinators/test_async_refresh_electricity_standing_charge_data.py @@ -6,7 +6,6 @@ from custom_components.octopus_energy.api_client import OctopusEnergyApiClient from custom_components.octopus_energy.coordinators.electricity_standing_charges import ElectricityStandingChargeCoordinatorResult, async_refresh_electricity_standing_charges_data -from custom_components.octopus_energy.const import EVENT_ELECTRICITY_CURRENT_DAY_RATES, EVENT_ELECTRICITY_NEXT_DAY_RATES, EVENT_ELECTRICITY_PREVIOUS_DAY_RATES current = datetime.strptime("2023-07-14T10:30:01+01:00", "%Y-%m-%dT%H:%M:%S%z") period_from = datetime.strptime("2023-07-14T00:00:00+01:00", "%Y-%m-%dT%H:%M:%S%z") @@ -187,8 +186,6 @@ async def async_mocked_get_electricity_standing_charge(*args, **kwargs): @pytest.mark.asyncio async def test_when_existing_standing_charge_is_old_then_standing_charge_retrieved(): - expected_period_from = (current - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) - expected_period_to = (current + timedelta(days=2)).replace(hour=0, minute=0, second=0, microsecond=0) expected_standing_charge = { "valid_from": period_from, "valid_to": period_to, diff --git a/tests/unit/coordinators/test_async_refresh_gas_standing_charge_data.py b/tests/unit/coordinators/test_async_refresh_gas_standing_charge_data.py new file mode 100644 index 00000000..51382f13 --- /dev/null +++ b/tests/unit/coordinators/test_async_refresh_gas_standing_charge_data.py @@ -0,0 +1,244 @@ +from datetime import datetime, timedelta +import pytest +import mock + +from unit import (create_rate_data) + +from custom_components.octopus_energy.api_client import OctopusEnergyApiClient +from custom_components.octopus_energy.coordinators.gas_standing_charges import GasStandingChargeCoordinatorResult, async_refresh_gas_standing_charges_data + +current = datetime.strptime("2023-07-14T10:30:01+01:00", "%Y-%m-%dT%H:%M:%S%z") +period_from = datetime.strptime("2023-07-14T00:00:00+01:00", "%Y-%m-%dT%H:%M:%S%z") +period_to = datetime.strptime("2023-07-15T00:00:00+01:00", "%Y-%m-%dT%H:%M:%S%z") + +tariff_code = "E-1R-SUPER-GREEN-24M-21-07-30-A" +mprn = "1234567890" +serial_number = "abcdefgh" + +def get_account_info(is_active_agreement = True): + return { + "gas_meter_points": [ + { + "mprn": mprn, + "meters": [ + { + "serial_number": serial_number, + "is_export": False, + "is_smart_meter": True, + "device_id": "", + "manufacturer": "", + "model": "", + "firmware": "" + } + ], + "agreements": [ + { + "valid_from": "2023-07-01T00:00:00+01:00" if is_active_agreement else "2023-08-01T00:00:00+01:00", + "valid_to": "2023-08-01T00:00:00+01:00" if is_active_agreement else "2023-09-01T00:00:00+01:00", + "tariff_code": tariff_code, + "product": "SUPER-GREEN-24M-21-07-30" + } + ] + } + ] + } + +@pytest.mark.asyncio +async def test_when_account_info_is_none_then_existing_standing_charge_returned(): + expected_standing_charge = { + "valid_from": period_from, + "valid_to": period_to, + "value_inc_vat": 0.30 + } + standing_charge_returned = False + async def async_mocked_get_gas_standing_charge(*args, **kwargs): + nonlocal standing_charge_returned + standing_charge_returned = True + return expected_standing_charge + + account_info = None + existing_standing_charge = GasStandingChargeCoordinatorResult(period_from, create_rate_data(period_from, period_to, [2, 4])) + + with mock.patch.multiple(OctopusEnergyApiClient, async_get_gas_standing_charge=async_mocked_get_gas_standing_charge): + client = OctopusEnergyApiClient("NOT_REAL") + retrieved_standing_charge: GasStandingChargeCoordinatorResult = await async_refresh_gas_standing_charges_data( + current, + client, + account_info, + mprn, + serial_number, + existing_standing_charge + ) + + assert retrieved_standing_charge == existing_standing_charge + assert standing_charge_returned == False + +@pytest.mark.asyncio +async def test_when_no_active_standing_charge_then_none_returned(): + expected_standing_charge = { + "valid_from": period_from, + "valid_to": period_to, + "value_inc_vat": 0.30 + } + standing_charge_returned = False + async def async_mocked_get_gas_standing_charge(*args, **kwargs): + nonlocal standing_charge_returned + standing_charge_returned = True + return expected_standing_charge + + account_info = get_account_info(False) + existing_standing_charge = GasStandingChargeCoordinatorResult(period_from, create_rate_data(period_from, period_to, [2, 4])) + + with mock.patch.multiple(OctopusEnergyApiClient, async_get_gas_standing_charge=async_mocked_get_gas_standing_charge): + client = OctopusEnergyApiClient("NOT_REAL") + retrieved_standing_charge: GasStandingChargeCoordinatorResult = await async_refresh_gas_standing_charges_data( + current, + client, + account_info, + mprn, + serial_number, + existing_standing_charge + ) + + assert retrieved_standing_charge is None + assert standing_charge_returned == False + +@pytest.mark.asyncio +async def test_when_current_is_not_thirty_minutes_then_existing_standing_charge_returned(): + for minute in range(60): + if minute == 0 or minute == 30: + continue + + current = datetime.strptime("2023-07-14T10:30:01+01:00", "%Y-%m-%dT%H:%M:%S%z").replace(minute=minute) + expected_standing_charge = { + "valid_from": period_from, + "valid_to": period_to, + "value_inc_vat": 0.30 + } + standing_charge_returned = False + async def async_mocked_get_gas_standing_charge(*args, **kwargs): + nonlocal standing_charge_returned + standing_charge_returned = True + return expected_standing_charge + + account_info = get_account_info() + existing_standing_charge = GasStandingChargeCoordinatorResult(period_from, { + "valid_from": period_from, + "valid_to": period_to, + "value_inc_vat": 0.10 + }) + + with mock.patch.multiple(OctopusEnergyApiClient, async_get_gas_standing_charge=async_mocked_get_gas_standing_charge): + client = OctopusEnergyApiClient("NOT_REAL") + retrieved_standing_charge: GasStandingChargeCoordinatorResult = await async_refresh_gas_standing_charges_data( + current, + client, + account_info, + mprn, + serial_number, + existing_standing_charge + ) + + assert retrieved_standing_charge == existing_standing_charge + assert standing_charge_returned == False + + +@pytest.mark.asyncio +async def test_when_existing_standing_charge_is_none_then_standing_charge_retrieved(): + expected_period_from = current.replace(hour=0, minute=0, second=0, microsecond=0) + expected_period_to = expected_period_from + timedelta(days=1) + expected_standing_charge = { + "valid_from": period_from, + "valid_to": period_to, + "value_inc_vat": 0.30 + } + standing_charge_returned = False + requested_period_from = None + requested_period_to = None + async def async_mocked_get_gas_standing_charge(*args, **kwargs): + nonlocal requested_period_from, requested_period_to, standing_charge_returned, expected_standing_charge + + requested_client, requested_tariff_code, requested_period_from, requested_period_to = args + standing_charge_returned = True + return expected_standing_charge + + account_info = get_account_info() + existing_standing_charge = None + expected_retrieved_standing_charge = GasStandingChargeCoordinatorResult(current, expected_standing_charge) + + with mock.patch.multiple(OctopusEnergyApiClient, async_get_gas_standing_charge=async_mocked_get_gas_standing_charge): + client = OctopusEnergyApiClient("NOT_REAL") + retrieved_standing_charge: GasStandingChargeCoordinatorResult = await async_refresh_gas_standing_charges_data( + current, + client, + account_info, + mprn, + serial_number, + existing_standing_charge + ) + + assert retrieved_standing_charge is not None + assert retrieved_standing_charge.last_retrieved == expected_retrieved_standing_charge.last_retrieved + assert retrieved_standing_charge.standing_charge == expected_retrieved_standing_charge.standing_charge + assert standing_charge_returned == True + assert requested_period_from == expected_period_from + assert requested_period_to == expected_period_to + +@pytest.mark.asyncio +async def test_when_existing_standing_charge_is_old_then_standing_charge_retrieved(): + expected_standing_charge = { + "valid_from": period_from, + "valid_to": period_to, + "value_inc_vat": 0.30 + } + standing_charge_returned = False + async def async_mocked_get_gas_standing_charge(*args, **kwargs): + nonlocal standing_charge_returned + standing_charge_returned = True + return expected_standing_charge + + account_info = get_account_info() + existing_standing_charge = GasStandingChargeCoordinatorResult(period_to - timedelta(days=60), create_rate_data(period_from - timedelta(days=60), period_to - timedelta(days=60), [2, 4])) + expected_retrieved_standing_charge = GasStandingChargeCoordinatorResult(current, expected_standing_charge) + + with mock.patch.multiple(OctopusEnergyApiClient, async_get_gas_standing_charge=async_mocked_get_gas_standing_charge): + client = OctopusEnergyApiClient("NOT_REAL") + retrieved_standing_charge: GasStandingChargeCoordinatorResult = await async_refresh_gas_standing_charges_data( + current, + client, + account_info, + mprn, + serial_number, + existing_standing_charge + ) + + assert retrieved_standing_charge is not None + assert retrieved_standing_charge.last_retrieved == expected_retrieved_standing_charge.last_retrieved + assert retrieved_standing_charge.standing_charge == expected_retrieved_standing_charge.standing_charge + assert standing_charge_returned == True + +@pytest.mark.asyncio +async def test_when_standing_charge_not_retrieved_then_existing_standing_charge_returned(): + expected_standing_charge = create_rate_data(period_from, period_to, [1, 2, 3, 4]) + standing_charge_returned = False + async def async_mocked_get_gas_standing_charge(*args, **kwargs): + nonlocal standing_charge_returned + standing_charge_returned = True + return None + + account_info = get_account_info() + existing_standing_charge = GasStandingChargeCoordinatorResult(period_from, expected_standing_charge) + + with mock.patch.multiple(OctopusEnergyApiClient, async_get_gas_standing_charge=async_mocked_get_gas_standing_charge): + client = OctopusEnergyApiClient("NOT_REAL") + retrieved_standing_charge: GasStandingChargeCoordinatorResult = await async_refresh_gas_standing_charges_data( + current, + client, + account_info, + mprn, + serial_number, + existing_standing_charge + ) + + assert retrieved_standing_charge == existing_standing_charge + assert standing_charge_returned == True \ No newline at end of file