From d31b5f50f37a42049b25d2976710b6675a37d98d Mon Sep 17 00:00:00 2001 From: markallanson Date: Sat, 6 Feb 2021 14:38:27 +0000 Subject: [PATCH] Add support for getting the tariff rate at a specific timestamp --- octopus_energy/__init__.py | 2 + octopus_energy/client.py | 56 +++- octopus_energy/mappers.py | 28 ++ octopus_energy/models.py | 8 + pyproject.toml | 2 +- .../mapping_results/tariff_rate_mapping.json | 288 ++++++++++++++++++ tests/test_client.py | 45 ++- tests/test_mappers.py | 20 ++ 8 files changed, 429 insertions(+), 20 deletions(-) create mode 100644 tests/mapping_results/tariff_rate_mapping.json diff --git a/octopus_energy/__init__.py b/octopus_energy/__init__.py index 985bb70..7d37a98 100644 --- a/octopus_energy/__init__.py +++ b/octopus_energy/__init__.py @@ -18,6 +18,7 @@ ElectricityMeter, GasMeter, PageReference, + TariffRate, ) from .exceptions import ApiAuthenticationError, ApiError, ApiNotFoundError, ApiBadRequestError from .rest_client import OctopusEnergyRestClient @@ -47,4 +48,5 @@ "ElectricityMeter", "GasMeter", "PageReference", + "TariffRate", ] diff --git a/octopus_energy/client.py b/octopus_energy/client.py index 98e5d37..b07c073 100644 --- a/octopus_energy/client.py +++ b/octopus_energy/client.py @@ -1,7 +1,7 @@ -from datetime import datetime -from typing import List +from datetime import datetime, timedelta +from typing import List, Optional -from .mappers import meters_from_response, consumption_from_response +from .mappers import meters_from_response, consumption_from_response, tariff_rates_from_response from octopus_energy import ( Meter, OctopusEnergyRestClient, @@ -9,6 +9,9 @@ EnergyType, SortOrder, PageReference, + EnergyTariffType, + RateType, + TariffRate, ) @@ -25,14 +28,12 @@ class OctopusEnergyConsumerClient: This client uses async i/o. """ - def __init__(self, account_number: str, api_token: str): + def __init__(self, api_token: Optional[str] = None): """Initializes the Octopus Energy Consumer Client. Args: - account_number: Your Octopus Energy Account Number. api_token: Your Octopus Energy API Key. """ - self.account_number = account_number self.rest_client = OctopusEnergyRestClient(api_token) def __enter__(self): @@ -54,9 +55,13 @@ async def close(self): """ await self.rest_client.close() - async def get_meters(self) -> List[Meter]: - """Gets all meters associated with your account.""" - return meters_from_response(await self.rest_client.get_account_details(self.account_number)) + async def get_meters(self, account_number: str) -> List[Meter]: + """Gets all meters associated with your account. + + Args: + account_number: Your Octopus Energy Account Number. + """ + return meters_from_response(await self.rest_client.get_account_details(account_number)) async def get_consumption( self, @@ -99,3 +104,36 @@ async def get_consumption( response = await func(meter.meter_point.id, meter.serial_number, **params) return consumption_from_response(response, meter) + + async def get_tariff_cost( + self, + product_code: str, + tariff_code: str, + tariff_type: EnergyTariffType, + rate_type: RateType, + timestamp: datetime, + ) -> Optional[TariffRate]: + """Gets the cost of a tariff cost at a point in time. + + Args: + product_code: The product code. + tariff_code: The tariff code. + tariff_type: The type of energy within the tariff. + rate_type: The type of rate. + timestamp: The timestamp + + Returns: + The cost per unit of energy for the requested rate at a point in time. + + """ + response = await self.rest_client.get_tariff_v1( + product_code, + tariff_type, + tariff_code, + rate_type, + period_from=timestamp, + # Add a millisecond as the API doesn't like requests with the same start and end + period_to=timestamp + timedelta(seconds=1), + ) + rates = tariff_rates_from_response(response) + return None if not rates else rates[0] diff --git a/octopus_energy/mappers.py b/octopus_energy/mappers.py index 959416c..1350eca 100644 --- a/octopus_energy/mappers.py +++ b/octopus_energy/mappers.py @@ -1,4 +1,5 @@ from datetime import datetime +from decimal import Decimal, ROUND_DOWN from typing import List, Optional import dateutil @@ -21,6 +22,7 @@ PageReference, SortOrder, Aggregate, + TariffRate, ) _CUBIC_METERS_TO_KWH_MULTIPLIER = 11.1868 @@ -29,6 +31,7 @@ (UnitType.KWH.value, UnitType.CUBIC_METERS.value): (1 / _CUBIC_METERS_TO_KWH_MULTIPLIER), (UnitType.CUBIC_METERS.value, UnitType.KWH.value): _CUBIC_METERS_TO_KWH_MULTIPLIER, } +_QUANT_3DP = Decimal("0.001") def to_timestamp_str(timestamp: datetime) -> str: @@ -52,6 +55,8 @@ def from_timestamp_str(timestamp: str) -> Optional[datetime]: Returns: The timestamp as a datetime object. """ + if timestamp is None: + return None return dateutil.parser.isoparse(timestamp) if timestamp else None @@ -140,6 +145,29 @@ def consumption_from_response( ) +def tariff_rates_from_response(response: dict) -> List[TariffRate]: + """Generates the list of tariff rates from an octopus energy API response. + + Args: + response: The API response object. + + Returns: + The List containing the rates for a specific tariff + + """ + if "results" not in response: + return [] + return [ + TariffRate( + Decimal(result["value_exc_vat"]).quantize(_QUANT_3DP, rounding=ROUND_DOWN), + Decimal(result["value_inc_vat"]).quantize(_QUANT_3DP, rounding=ROUND_DOWN), + from_timestamp_str(result["valid_from"]), + from_timestamp_str(result.get("valid_to", None)), + ) + for result in response["results"] + ] + + def _get_page_reference(response: dict, page: str): if page not in response: return None diff --git a/octopus_energy/models.py b/octopus_energy/models.py index 73fc84c..01290fa 100644 --- a/octopus_energy/models.py +++ b/octopus_energy/models.py @@ -30,6 +30,14 @@ class Tariff: valid_to: Optional[datetime] +@dataclass +class TariffRate: + cost_inc_vat: Decimal + cost_exc_vat: Decimal + valid_from: datetime + valid_to: Optional[datetime] + + class UnitType(Enum): """Units of energy measurement.""" diff --git a/pyproject.toml b/pyproject.toml index c093f9c..853f376 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "octopus-energy" -version = "0.1.11" +version = "0.1.12" description = "Python client for the Octopus Energy RESTful API" authors = ["Mark Allanson "] license = "MIT" diff --git a/tests/mapping_results/tariff_rate_mapping.json b/tests/mapping_results/tariff_rate_mapping.json new file mode 100644 index 0000000..0a7dae9 --- /dev/null +++ b/tests/mapping_results/tariff_rate_mapping.json @@ -0,0 +1,288 @@ +[ + { + "py/object": "octopus_energy.models.TariffRate", + "cost_inc_vat": { + "py/reduce": [ + { + "py/type": "decimal.Decimal" + }, + { + "py/tuple": [ + "20.640" + ] + } + ] + }, + "cost_exc_vat": { + "py/reduce": [ + { + "py/type": "decimal.Decimal" + }, + { + "py/tuple": [ + "21.672" + ] + } + ] + }, + "valid_from": { + "py/object": "datetime.datetime", + "__reduce__": [ + { + "py/type": "datetime.datetime" + }, + [ + "B+QLAQAAAAAAAA==", + { + "py/reduce": [ + { + "py/function": "copyreg._reconstructor" + }, + { + "py/tuple": [ + { + "py/type": "dateutil.tz.tz.tzutc" + }, + { + "py/type": "datetime.tzinfo" + }, + { + "py/reduce": [ + { + "py/type": "datetime.tzinfo" + }, + { + "py/tuple": [] + } + ] + } + ] + } + ] + } + ] + ] + }, + "valid_to": null + }, + { + "py/object": "octopus_energy.models.TariffRate", + "cost_inc_vat": { + "py/reduce": [ + { + "py/type": "decimal.Decimal" + }, + { + "py/tuple": [ + "18.460" + ] + } + ] + }, + "cost_exc_vat": { + "py/reduce": [ + { + "py/type": "decimal.Decimal" + }, + { + "py/tuple": [ + "19.382" + ] + } + ] + }, + "valid_from": { + "py/object": "datetime.datetime", + "__reduce__": [ + { + "py/type": "datetime.datetime" + }, + [ + "B+MEHhcAAAAAAA==", + { + "py/id": 5 + } + ] + ] + }, + "valid_to": { + "py/object": "datetime.datetime", + "__reduce__": [ + { + "py/type": "datetime.datetime" + }, + [ + "B+QLAQAAAAAAAA==", + { + "py/id": 5 + } + ] + ] + } + }, + { + "py/object": "octopus_energy.models.TariffRate", + "cost_inc_vat": { + "py/reduce": [ + { + "py/type": "decimal.Decimal" + }, + { + "py/tuple": [ + "18.460" + ] + } + ] + }, + "cost_exc_vat": { + "py/reduce": [ + { + "py/type": "decimal.Decimal" + }, + { + "py/tuple": [ + "19.382" + ] + } + ] + }, + "valid_from": { + "py/object": "datetime.datetime", + "__reduce__": [ + { + "py/type": "datetime.datetime" + }, + [ + "B+ILFAAAAAAAAA==", + { + "py/id": 5 + } + ] + ] + }, + "valid_to": { + "py/object": "datetime.datetime", + "__reduce__": [ + { + "py/type": "datetime.datetime" + }, + [ + "B+MEHhcAAAAAAA==", + { + "py/id": 5 + } + ] + ] + } + }, + { + "py/object": "octopus_energy.models.TariffRate", + "cost_inc_vat": { + "py/reduce": [ + { + "py/type": "decimal.Decimal" + }, + { + "py/tuple": [ + "18.460" + ] + } + ] + }, + "cost_exc_vat": { + "py/reduce": [ + { + "py/type": "decimal.Decimal" + }, + { + "py/tuple": [ + "19.382" + ] + } + ] + }, + "valid_from": { + "py/object": "datetime.datetime", + "__reduce__": [ + { + "py/type": "datetime.datetime" + }, + [ + "B+IIBRcAAAAAAA==", + { + "py/id": 5 + } + ] + ] + }, + "valid_to": { + "py/object": "datetime.datetime", + "__reduce__": [ + { + "py/type": "datetime.datetime" + }, + [ + "B+ILFAAAAAAAAA==", + { + "py/id": 5 + } + ] + ] + } + }, + { + "py/object": "octopus_energy.models.TariffRate", + "cost_inc_vat": { + "py/reduce": [ + { + "py/type": "decimal.Decimal" + }, + { + "py/tuple": [ + "18.000" + ] + } + ] + }, + "cost_exc_vat": { + "py/reduce": [ + { + "py/type": "decimal.Decimal" + }, + { + "py/tuple": [ + "18.899" + ] + } + ] + }, + "valid_from": { + "py/object": "datetime.datetime", + "__reduce__": [ + { + "py/type": "datetime.datetime" + }, + [ + "B+EBCwoAAAAAAA==", + { + "py/id": 5 + } + ] + ] + }, + "valid_to": { + "py/object": "datetime.datetime", + "__reduce__": [ + { + "py/type": "datetime.datetime" + }, + [ + "B+IIBRcAAAAAAA==", + { + "py/id": 5 + } + ] + ] + } + } +] diff --git a/tests/test_client.py b/tests/test_client.py index 5324917..920c0b3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,3 +1,4 @@ +from datetime import datetime, timedelta from unittest import TestCase from unittest.mock import patch, Mock @@ -8,6 +9,8 @@ SortOrder, PageReference, Aggregate, + EnergyTariffType, + RateType, ) from tests import does_asyncio @@ -15,17 +18,14 @@ class ClientTestCase(TestCase): def test_cannot_use_client_without_async(self): with self.assertRaises(TypeError): - with OctopusEnergyConsumerClient("", ""): + with OctopusEnergyConsumerClient(""): pass @does_asyncio @patch("octopus_energy.client.OctopusEnergyRestClient", autospec=True) async def test_init(self, mock_rest_client: Mock): - account_number = "A_BVC" api_token = "A_BVC" - async with OctopusEnergyConsumerClient(account_number, api_token) as client: - with self.subTest("remembers account number"): - self.assertEqual(client.account_number, account_number) + async with OctopusEnergyConsumerClient(api_token): with self.subTest("passes api token to rest client"): mock_rest_client.assert_called_once_with(api_token) @@ -34,8 +34,8 @@ async def test_init(self, mock_rest_client: Mock): @patch("octopus_energy.client.meters_from_response", autospec=True) async def test_get_meters(self, mock_mapper: Mock, mock_rest_client: Mock): account = "Account" - async with OctopusEnergyConsumerClient(account, "") as client: - response = await client.get_meters() + async with OctopusEnergyConsumerClient("") as client: + response = await client.get_meters(account) with self.subTest("calls get account details on rest client"): mock_rest_client.return_value.get_account_details.assert_called_with(account) with self.subTest("returns the result of mapping"): @@ -52,7 +52,7 @@ async def test_get_consumption(self, mock_mapper: Mock, mock_rest_client: Mock): meter.serial_number = sn with self.subTest("electricity meter"): - async with OctopusEnergyConsumerClient("", "") as client: + async with OctopusEnergyConsumerClient("") as client: meter.energy_type = EnergyType.ELECTRICITY response = await client.get_consumption(meter) with self.subTest("calls rest client with expected parameters"): @@ -63,7 +63,7 @@ async def test_get_consumption(self, mock_mapper: Mock, mock_rest_client: Mock): self.assertIsNotNone(response) with self.subTest("gas meter"): - async with OctopusEnergyConsumerClient("", "") as client: + async with OctopusEnergyConsumerClient("") as client: meter.energy_type = EnergyType.GAS response = await client.get_consumption(meter) with self.subTest("calls rest client with expected parameters"): @@ -74,7 +74,7 @@ async def test_get_consumption(self, mock_mapper: Mock, mock_rest_client: Mock): self.assertIsNotNone(response) with self.subTest("paging support"): - async with OctopusEnergyConsumerClient("", "") as client: + async with OctopusEnergyConsumerClient("") as client: meter.energy_type = EnergyType.GAS page_reference = PageReference( { @@ -96,3 +96,28 @@ async def test_get_consumption(self, mock_mapper: Mock, mock_rest_client: Mock): ) with self.subTest("returns the result of mapping"): self.assertIsNotNone(response) + + @does_asyncio + @patch("octopus_energy.client.OctopusEnergyRestClient", autospec=True) + @patch("octopus_energy.client.tariff_rates_from_response", autospec=True) + async def test_get_tariff_cost(self, mock_mapper: Mock, mock_rest_client: Mock): + product_code = "pc" + tariff_code = "tc" + tariff_type = EnergyTariffType.ELECTRICITY + rate_type = RateType.STANDARD_UNIT_RATES + timestamp = datetime.utcnow() + async with OctopusEnergyConsumerClient("") as client: + response = await client.get_tariff_cost( + product_code, tariff_code, tariff_type, rate_type, timestamp + ) + with self.subTest("calls get_tariff_v1 on rest client"): + mock_rest_client.return_value.get_tariff_v1.assert_called_with( + product_code, + tariff_type, + tariff_code, + rate_type, + period_from=timestamp, + period_to=timestamp + timedelta(seconds=1), + ) + with self.subTest("returns the result of mapping"): + self.assertIsNotNone(response) diff --git a/tests/test_mappers.py b/tests/test_mappers.py index eee1567..067a585 100644 --- a/tests/test_mappers.py +++ b/tests/test_mappers.py @@ -12,6 +12,7 @@ to_timestamp_str, meters_from_response, _get_page_reference, + tariff_rates_from_response, ) from octopus_energy.models import UnitType, MeterGeneration, SortOrder, Aggregate from tests import load_fixture_json, load_json @@ -21,6 +22,10 @@ def load_mapping_response_json(filename: str) -> dict: return jsonpickle.decode(load_json(os.path.join("mapping_results", filename))) +def _gen_mapping_response_json(obj) -> dict: + print(jsonpickle.encode(obj)) + + class TestAccountMappers(TestCase): def __init__(self, methodName: str = ...) -> None: super().__init__(methodName) @@ -33,6 +38,21 @@ def test_account_mapping(self): self.assertCountEqual(meters, expected_response) +class TestTariffMappers(TestCase): + def __init__(self, methodName: str = ...) -> None: + super().__init__(methodName) + self.maxDiff = None + + def test_rate_mapping(self): + """Verifies that the result of mapping a known input produces a known output.""" + rates = tariff_rates_from_response(load_fixture_json("get_tariff_response.json")) + expected_response = load_mapping_response_json("tariff_rate_mapping.json") + self.assertCountEqual(rates, expected_response) + + def test_no_results(self): + self.assertEqual([], tariff_rates_from_response({})) + + class TestConsumptionMappers(TestCase): def test_smets1_gas_mapping_kwh(self): response = load_fixture_json("consumption_response.json")