From 38eb677bd4e2e7dd49dd6a4125ad77c4527c82b4 Mon Sep 17 00:00:00 2001 From: markallanson Date: Thu, 31 Dec 2020 12:18:47 +0000 Subject: [PATCH] Add API support for gas an electricity consumption --- octopus_energy/__init__.py | 5 + octopus_energy/client.py | 84 +++++++++ octopus_energy/exceptions.py | 15 ++ octopus_energy/mappers.py | 58 ++++++ octopus_energy/models.py | 68 +++++++ poetry.lock | 46 +++-- pyproject.toml | 5 + tests/__init__.py | 15 ++ .../consumption_missing_results_response.json | 6 + .../consumption_no_results_response.json | 6 + tests/fixtures/consumption_response.json | 22 +++ tests/test_client.py | 64 ++++++- tests/test_e2e.py | 38 ++++ tests/test_mappers.py | 168 ++++++++++++++++++ tox.ini | 7 +- 15 files changed, 588 insertions(+), 19 deletions(-) create mode 100644 octopus_energy/client.py create mode 100644 octopus_energy/exceptions.py create mode 100644 octopus_energy/mappers.py create mode 100644 octopus_energy/models.py create mode 100644 tests/fixtures/consumption_missing_results_response.json create mode 100644 tests/fixtures/consumption_no_results_response.json create mode 100644 tests/fixtures/consumption_response.json create mode 100644 tests/test_e2e.py create mode 100644 tests/test_mappers.py diff --git a/octopus_energy/__init__.py b/octopus_energy/__init__.py index e69de29..49e6b09 100644 --- a/octopus_energy/__init__.py +++ b/octopus_energy/__init__.py @@ -0,0 +1,5 @@ +"""Python client for the Octopus Energy RESTful API""" + +from .client import OctopusEnergyClient +from .models import Consumption, IntervalConsumption, MeterType, UnitType +from .exceptions import ApiError, ApiAuthenticationError diff --git a/octopus_energy/client.py b/octopus_energy/client.py new file mode 100644 index 0000000..2d64783 --- /dev/null +++ b/octopus_energy/client.py @@ -0,0 +1,84 @@ +from http import HTTPStatus +from typing import Any + +import requests +from requests.auth import HTTPBasicAuth + +from octopus_energy.models import UnitType, Consumption, MeterType +from octopus_energy.exceptions import ApiError, ApiAuthenticationError +from octopus_energy.mappers import consumption_from_response + +_API_BASE = "https://api.octopus.energy" + + +class OctopusEnergyClient: + """A client for interacting with the Octopus Energy RESTful API.""" + + def __init__(self, api_token, default_unit: UnitType = UnitType.KWH): + self.auth = HTTPBasicAuth(api_token, "") + self.default_unit = default_unit + + def get_gas_consumption_v1( + self, mprn, serial_number, meter_type: MeterType, desired_unit_type: UnitType = UnitType.KWH + ) -> Consumption: + """Gets the consumption of gas from a specific meter. + + Args: + mprn: The MPRN (Meter Point Reference Number) of the meter to query + serial_number: The serial number of the meter to query + meter_type: The type of the meter being queried. The octopus energy API does not tell + us what the type of meter is, so we need to define this in the request + to query. + desired_unit_type: The desired units you want the results in. This defaults to + Kilowatt Hours. + + Returns: + The consumption of gas for the meter. + + """ + return self._execute( + requests.get, + f"v1/gas-meter-points/{mprn}/meters/{serial_number}/consumption/", + consumption_from_response, + meter_type=meter_type, + desired_unit_type=desired_unit_type, + ) + + def get_electricity_consumption_v1( + self, + mpan: str, + serial_number: str, + meter_type: MeterType, + desired_unit_type: UnitType = UnitType.KWH, + ) -> Consumption: + """Gets the consumption of electricity from a specific meter. + + Args: + mpan: The MPAN (Meter Point Administration Number) of the meter to query + serial_number: The serial number of the meter to query + meter_type: The type of the meter being queried. The octopus energy API does not tell + us what the type of meter is, so we need to define this in the request to + query. + desired_unit_type: The desired units you want the results in. This defaults to + Kilowatt Hours. + + Returns: + The consumption of gas for the meter. + + """ + return self._execute( + requests.get, + f"v1/electricity-meter-points/{mpan}/meters/{serial_number}/consumption/", + consumption_from_response, + meter_type=meter_type, + desired_unit_type=desired_unit_type, + ) + + def _execute(self, func, url: str, response_mapper, **kwargs) -> Any: + """Executes an API call to Octopus energy and maps the response.""" + response = func(f"{_API_BASE}/{url}", auth=self.auth) + if not response.ok: + if response.status_code == HTTPStatus.UNAUTHORIZED: + raise ApiAuthenticationError() + raise ApiError("Error", response=response) + return response_mapper(response, **kwargs) diff --git a/octopus_energy/exceptions.py b/octopus_energy/exceptions.py new file mode 100644 index 0000000..5cd874e --- /dev/null +++ b/octopus_energy/exceptions.py @@ -0,0 +1,15 @@ +class ApiError(Exception): + """An error has occurred while calling the octopus energy API""" + + def __init__(self, *args: object, response) -> None: + super().__init__(*args) + self.response = response + + def __str__(self) -> str: + return f"{self.response.status} - {self.response.text}" + + +class ApiAuthenticationError(Exception): + """The credentials were rejected by Octopus.""" + + ... diff --git a/octopus_energy/mappers.py b/octopus_energy/mappers.py new file mode 100644 index 0000000..6530013 --- /dev/null +++ b/octopus_energy/mappers.py @@ -0,0 +1,58 @@ +from dateutil.parser import isoparse +from requests import Response + +from octopus_energy.models import IntervalConsumption, UnitType, Consumption, MeterType + +_CUBIC_METERS_TO_KWH_MULTIPLIER = 11.1868 +_KWH_TO_KWH_MULTIPLIER = 1 +_UNIT_MULTIPLIERS = { + (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, +} + + +def consumption_from_response( + response: Response, meter_type: MeterType, desired_unit_type: UnitType +) -> Consumption: + """Generates the Consumption model from an octopus energy API response. + + Args: + response: The API response object. + meter_type: The type of meter the reading is from. + desired_unit_type: The desired unit for the consumption intervals. The mapping will + convert from the meters units to the desired units. + + Returns: + The Consumption model for the period of time represented in the response. + + """ + response_json = response.json() + if "results" not in response_json: + return Consumption(unit=desired_unit_type, meter_type=meter_type) + return Consumption( + desired_unit_type, + meter_type, + [ + IntervalConsumption( + consumed_units=_calculate_unit( + result["consumption"], meter_type.unit_type, desired_unit_type + ), + interval_start=isoparse(result["interval_start"]), + interval_end=isoparse(result["interval_end"]), + ) + for result in response_json["results"] + ], + ) + + +def _calculate_unit(consumption, actual_unit, desired_unit): + """Converts unit values from one unit to another unit. + + If no mapping is available the value is returned unchanged. + + :param consumption: The consumption to convert. + :param actual_unit: The unit the supplied consumption is measured in. + :param desired_unit: The unit the convert the consumption to. + :return: The consumption converted to the desired unit. + """ + return consumption * _UNIT_MULTIPLIERS.get((actual_unit.value, desired_unit.value), 1) diff --git a/octopus_energy/models.py b/octopus_energy/models.py new file mode 100644 index 0000000..f857209 --- /dev/null +++ b/octopus_energy/models.py @@ -0,0 +1,68 @@ +from dataclasses import dataclass, field +from datetime import datetime +from decimal import Decimal +from enum import Enum +from typing import List + + +class UnitType(Enum): + """Units of energy measurement.""" + + KWH = ("kWh", "Kilowatt Hours") + CUBIC_METERS = ("m³", "Cubic Meters") + + @property + def description(self) -> str: + """A description, in english, of the unit type.""" + return self.value[1] + + def __eq__(self, other): + return self.value == other.value + + +class MeterType(Enum): + """Energy meter types, the units the measure in and description in english.""" + + SMETS1_GAS = ("SMETS1_GAS", UnitType.KWH, "1st Generation Smart Gas Meter") + SMETS2_GAS = ("SMETS2_GAS", UnitType.CUBIC_METERS, "2nd Generation Smart Gas Meter") + SMETS1_ELECTRICITY = ( + "SMETS1_ELECTRICITY", + UnitType.KWH, + "1st Generation Smart Electricity Meter", + ) + SMETS2_ELECTRICITY = ( + "SMETS2_ELECTRICITY", + UnitType.KWH, + "2nd Generation Smart Electricity Meter", + ) + + @property + def unit_type(self) -> UnitType: + """The type of unit the meter measures consumption in.""" + return self.value[1] + + @property + def description(self) -> str: + """A description, in english, of the meter.""" + return self.value[2] + + def __eq__(self, other): + return self.value == other.value + + +@dataclass +class IntervalConsumption: + """Represents the consumption of energy over a single interval of time.""" + + interval_start: datetime + interval_end: datetime + consumed_units: Decimal + + +@dataclass +class Consumption: + """Consumption of energy for a list of time intervals.""" + + unit: UnitType + meter: MeterType + intervals: List[IntervalConsumption] = field(default_factory=lambda: []) diff --git a/poetry.lock b/poetry.lock index 1eb2e73..20259b5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -10,7 +10,7 @@ python-versions = "*" name = "atomicwrites" version = "1.4.0" description = "Atomic file writes." -category = "main" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" @@ -18,7 +18,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" name = "attrs" version = "20.3.0" description = "Classes Without Boilerplate" -category = "main" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" @@ -78,7 +78,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" name = "colorama" version = "0.4.4" description = "Cross-platform colored terminal text." -category = "main" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" @@ -124,7 +124,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" name = "importlib-metadata" version = "2.1.1" description = "Read metadata from Python packages" -category = "main" +category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" @@ -139,7 +139,7 @@ testing = ["packaging", "pep517", "unittest2", "importlib-resources (>=1.3)"] name = "iniconfig" version = "1.1.1" description = "iniconfig: brain-dead simple config-ini parsing" -category = "main" +category = "dev" optional = false python-versions = "*" @@ -163,7 +163,7 @@ python-versions = "*" name = "packaging" version = "20.8" description = "Core utilities for Python packages" -category = "main" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" @@ -182,7 +182,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" name = "pluggy" version = "0.13.1" description = "plugin and hook calling mechanisms for python" -category = "main" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" @@ -196,7 +196,7 @@ dev = ["pre-commit", "tox"] name = "py" version = "1.10.0" description = "library with cross-python path, ini-parsing, io, code, log facilities" -category = "main" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" @@ -220,7 +220,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" name = "pyparsing" version = "2.4.7" description = "Python parsing module" -category = "main" +category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" @@ -228,7 +228,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" name = "pytest" version = "6.2.1" description = "pytest: simple powerful testing with Python" -category = "main" +category = "dev" optional = false python-versions = ">=3.6" @@ -246,6 +246,17 @@ toml = "*" [package.extras] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] +[[package]] +name = "python-dateutil" +version = "2.8.1" +description = "Extensions to the standard Python datetime module" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + +[package.dependencies] +six = ">=1.5" + [[package]] name = "regex" version = "2020.11.13" @@ -292,7 +303,7 @@ test = ["fixtures", "mock", "purl", "pytest", "sphinx", "testrepository (>=0.0.1 name = "six" version = "1.15.0" description = "Python 2 and 3 compatibility utilities" -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" @@ -300,7 +311,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" name = "toml" version = "0.10.2" description = "Python Library for Tom's Obvious, Minimal Language" -category = "main" +category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" @@ -379,7 +390,7 @@ testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", name = "zipp" version = "3.4.0" description = "Backport of pathlib-compatible object wrapper for zip files" -category = "main" +category = "dev" optional = false python-versions = ">=3.6" @@ -390,7 +401,7 @@ testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "4a10cfbc5330acef301869b774103b481038a744ee26de66331a29637d592014" +content-hash = "cca268d178a64121ce17c37d2722fa2643d52e2127e32fe470198f7bc028e85d" [metadata.files] appdirs = [ @@ -408,6 +419,9 @@ attrs = [ black = [ {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, ] +callee = [ + {file = "callee-0.3.1.tar.gz", hash = "sha256:056f95d7760c87ce470aa4ab83bab5f9bdf090d4ecf77d52efe9c4559c0aefd3"}, +] certifi = [ {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"}, @@ -488,6 +502,10 @@ pytest = [ {file = "pytest-6.2.1-py3-none-any.whl", hash = "sha256:1969f797a1a0dbd8ccf0fecc80262312729afea9c17f1d70ebf85c5e76c6f7c8"}, {file = "pytest-6.2.1.tar.gz", hash = "sha256:66e419b1899bc27346cb2c993e12c5e5e8daba9073c1fbce33b9807abc95c306"}, ] +python-dateutil = [ + {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, + {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, +] regex = [ {file = "regex-2020.11.13-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8b882a78c320478b12ff024e81dc7d43c1462aa4a3341c754ee65d857a521f85"}, {file = "regex-2020.11.13-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a63f1a07932c9686d2d416fb295ec2c01ab246e89b4d58e5fa468089cab44b70"}, diff --git a/pyproject.toml b/pyproject.toml index 0d24225..5b377de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ license = "MIT" [tool.poetry.dependencies] python = "^3.7" requests = "^2.25.1" +python-dateutil = "^2.8.1" [tool.poetry.dev-dependencies] pytest = "^6.2.1" @@ -19,3 +20,7 @@ tox = "^3.20.1" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" + +[tool.black] +line-length = 100 +target-version = ['py37'] diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..f859835 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,15 @@ +import os + + +def load_fixture(filename: str) -> str: + """Load a fixture for a test from the fixtures directory. + + Args: + filename: The name of the fixture file to load + + Returns: + The content of the file + """ + path = os.path.join(os.path.dirname(__file__), "fixtures", filename) + with open(path) as fptr: + return fptr.read() diff --git a/tests/fixtures/consumption_missing_results_response.json b/tests/fixtures/consumption_missing_results_response.json new file mode 100644 index 0000000..d2bde88 --- /dev/null +++ b/tests/fixtures/consumption_missing_results_response.json @@ -0,0 +1,6 @@ +{ + "count": 0, + "next": null, + "previous": null, + "results": [] +} \ No newline at end of file diff --git a/tests/fixtures/consumption_no_results_response.json b/tests/fixtures/consumption_no_results_response.json new file mode 100644 index 0000000..d2bde88 --- /dev/null +++ b/tests/fixtures/consumption_no_results_response.json @@ -0,0 +1,6 @@ +{ + "count": 0, + "next": null, + "previous": null, + "results": [] +} \ No newline at end of file diff --git a/tests/fixtures/consumption_response.json b/tests/fixtures/consumption_response.json new file mode 100644 index 0000000..303f32d --- /dev/null +++ b/tests/fixtures/consumption_response.json @@ -0,0 +1,22 @@ +{ + "count": 48, + "next": null, + "previous": null, + "results": [ + { + "consumption": 0.063, + "interval_start": "2018-05-19T00:30:00+0100", + "interval_end": "2018-05-19T01:00:00+0100" + }, + { + "consumption": 0.071, + "interval_start": "2018-05-19T00:00:00+0100", + "interval_end": "2018-05-19T00:30:00+0100" + }, + { + "consumption": 0.073, + "interval_start": "2018-05-18T23:30:00+0100", + "interval_end": "2018-05-18T00:00:00+0100" + } + ] +} \ No newline at end of file diff --git a/tests/test_client.py b/tests/test_client.py index 91a09ca..0afeae7 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,7 +1,65 @@ +import re +from http import HTTPStatus from unittest import TestCase +from requests_mock import Mocker + +from octopus_energy import OctopusEnergyClient, MeterType, ApiAuthenticationError, ApiError + +_MOCK_TOKEN = "sk_live_xxxxxx" + class ClientTest(TestCase): - def test_pass(self): - """Dummy test to satisfy poetry while getting project setup""" - self.assertTrue(True) + def setUp(self) -> None: + super().setUp() + self.client = OctopusEnergyClient(_MOCK_TOKEN) + + @Mocker() + def test_raises_api_auth_error_when_authentication_fails(self, requests_mock): + requests_mock.get(re.compile(".*"), status_code=HTTPStatus.UNAUTHORIZED.value) + with self.subTest("elec consumption v1"): + with self.assertRaises(ApiAuthenticationError): + self.client.get_electricity_consumption_v1( + "mpan", "serial_number", MeterType.SMETS1_ELECTRICITY + ) + with self.subTest("gas consumption v1"): + with self.assertRaises(ApiAuthenticationError): + self.client.get_gas_consumption_v1("mprn", "serial_number", MeterType.SMETS1_GAS) + + @Mocker() + def test_raises_api_error_when_not_ok(self, requests_mock): + requests_mock.get(re.compile(".*"), status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value) + with self.subTest("elec consumption v1"): + with self.assertRaises(ApiError): + self.client.get_electricity_consumption_v1( + "mpan", "serial_number", MeterType.SMETS1_ELECTRICITY + ) + with self.subTest("gas consumption v1"): + with self.assertRaises(ApiError): + self.client.get_gas_consumption_v1("mprn", "serial_number", MeterType.SMETS1_GAS) + + @Mocker() + def test_get_elec_consumption_v1(self, requests_mock): + mock = requests_mock.get( + re.compile(".*"), status_code=HTTPStatus.OK.value, json={"results": []} + ) + self.client.get_electricity_consumption_v1( + "mpan", "serial_number", MeterType.SMETS1_ELECTRICITY + ) + with self.subTest("call made"): + self.assertTrue(mock.called) + with self.subTest("auth header supplied"): + self.assertTrue("Authorization" in mock.last_request.headers, "has header") + self.assertIn("Basic", mock.last_request.headers["Authorization"], "uses basic auth") + + @Mocker() + def test_get_gas_consumption_v1(self, requests_mock): + mock = requests_mock.get( + re.compile(".*"), status_code=HTTPStatus.OK.value, json={"results": []} + ) + self.client.get_electricity_consumption_v1("mpan", "serial_number", MeterType.SMETS1_GAS) + with self.subTest("call made"): + self.assertTrue(mock.called) + with self.subTest("auth header supplied"): + self.assertTrue("Authorization" in mock.last_request.headers, "has header") + self.assertIn("Basic", mock.last_request.headers["Authorization"], "uses basic auth") diff --git a/tests/test_e2e.py b/tests/test_e2e.py new file mode 100644 index 0000000..7a5f68b --- /dev/null +++ b/tests/test_e2e.py @@ -0,0 +1,38 @@ +import re +from unittest import TestCase + +from requests_mock import Mocker + +from octopus_energy import OctopusEnergyClient, MeterType +from octopus_energy.client import _API_BASE +from tests import load_fixture + +_FAKE_API_TOKEN = "sk_live_xxxxxxxxxxxx" + + +class E2eTests(TestCase): + def setUp(self) -> None: + super().setUp() + self.client = OctopusEnergyClient(_FAKE_API_TOKEN) + + @Mocker() + def test_get_gas_consumption_v1(self, requests_mock: Mocker): + requests_mock.get( + re.compile(f"{_API_BASE}/v1/gas-meter-points/mprn/meters/serial_number/consumption/"), + text=load_fixture("consumption_response.json"), + ) + response = self.client.get_gas_consumption_v1("mprn", "serial_number", MeterType.SMETS1_GAS) + self.assertEqual(len(response.intervals), 3) + + @Mocker() + def test_get_elec_consumption_v1(self, requests_mock: Mocker): + requests_mock.get( + re.compile( + f"{_API_BASE}/v1/electricity-meter-points/mpan/meters/serial_number/consumption/" + ), + text=load_fixture("consumption_response.json"), + ) + response = self.client.get_electricity_consumption_v1( + "mpan", "serial_number", MeterType.SMETS1_ELECTRICITY + ) + self.assertEqual(len(response.intervals), 3) diff --git a/tests/test_mappers.py b/tests/test_mappers.py new file mode 100644 index 0000000..59da1c8 --- /dev/null +++ b/tests/test_mappers.py @@ -0,0 +1,168 @@ +import json +from datetime import datetime +from unittest import TestCase +from unittest.mock import Mock + +from dateutil.tz import tzoffset + +from octopus_energy.mappers import ( + _calculate_unit, + consumption_from_response, +) +from octopus_energy.models import UnitType, MeterType +from tests import load_fixture + + +class TestMappers(TestCase): + def test_smets1_gas_mapping_kwh(self): + response = Mock() + response.json.return_value = json.loads(load_fixture("consumption_response.json")) + consumption = consumption_from_response(response, MeterType.SMETS1_GAS, UnitType.KWH) + with self.subTest("interval count"): + self.assertEqual(len(consumption.intervals), 3, "Contains 3 periods of consumption") + with self.subTest("units"): + self.assertEqual(consumption.unit, UnitType.KWH) + with self.subTest("first interval consumption"): + self.assertEqual(consumption.intervals[0].consumed_units, 0.063) + with self.subTest("first interval start"): + self.assertEqual( + consumption.intervals[0].interval_start, + datetime(2018, 5, 19, 0, 30, tzinfo=tzoffset(None, 3600)), + ) + with self.subTest("first interval end"): + self.assertEqual( + consumption.intervals[0].interval_end, + datetime(2018, 5, 19, 1, 0, tzinfo=tzoffset(None, 3600)), + ) + + def test_smets1_gas_mapping_cubic_meters(self): + response = Mock() + response.json.return_value = json.loads(load_fixture("consumption_response.json")) + consumption = consumption_from_response( + response, MeterType.SMETS1_GAS, UnitType.CUBIC_METERS + ) + with self.subTest("units"): + self.assertEqual(consumption.unit, UnitType.CUBIC_METERS) + with self.subTest("first interval consumption (in cubic meters)"): + self.assertEqual(consumption.intervals[0].consumed_units, 0.0056316372868023025) + + def test_smets2_gas_mapping_kwh(self): + response = Mock() + response.json.return_value = json.loads(load_fixture("consumption_response.json")) + consumption = consumption_from_response(response, MeterType.SMETS2_GAS, UnitType.KWH) + with self.subTest("interval count"): + self.assertEqual(len(consumption.intervals), 3, "Contains 3 periods of consumption") + with self.subTest("units"): + self.assertEqual(consumption.unit, UnitType.KWH) + with self.subTest("first interval consumption (converted to kwh)"): + self.assertEqual(consumption.intervals[0].consumed_units, 0.7047684) + with self.subTest("first interval start"): + self.assertEqual( + consumption.intervals[0].interval_start, + datetime(2018, 5, 19, 0, 30, tzinfo=tzoffset(None, 3600)), + ) + with self.subTest("first interval end"): + self.assertEqual( + consumption.intervals[0].interval_end, + datetime(2018, 5, 19, 1, 0, tzinfo=tzoffset(None, 3600)), + ) + + def test_smets2_gas_mapping_cubic_meters(self): + response = Mock() + response.json.return_value = json.loads(load_fixture("consumption_response.json")) + consumption = consumption_from_response( + response, MeterType.SMETS2_GAS, UnitType.CUBIC_METERS + ) + with self.subTest("units"): + self.assertEqual(consumption.unit, UnitType.CUBIC_METERS) + with self.subTest("first interval consumption (converted to kwh)"): + self.assertEqual(consumption.intervals[0].consumed_units, 0.063) + + def test_smets1_elec_mapping(self): + response = Mock() + response.json.return_value = json.loads(load_fixture("consumption_response.json")) + consumption = consumption_from_response( + response, MeterType.SMETS1_ELECTRICITY, UnitType.KWH + ) + with self.subTest("interval count"): + self.assertEqual(len(consumption.intervals), 3, "Contains 3 periods of consumption") + with self.subTest("units"): + self.assertEqual(consumption.unit, UnitType.KWH) + with self.subTest("first interval consumption (converted to kwh)"): + self.assertEqual(consumption.intervals[0].consumed_units, 0.063) + with self.subTest("first interval start"): + self.assertEqual( + consumption.intervals[0].interval_start, + datetime(2018, 5, 19, 0, 30, tzinfo=tzoffset(None, 3600)), + ) + with self.subTest("first interval end"): + self.assertEqual( + consumption.intervals[0].interval_end, + datetime(2018, 5, 19, 1, 0, tzinfo=tzoffset(None, 3600)), + ) + + def test_smets2_elec_mapping(self): + response = Mock() + response.json.return_value = json.loads(load_fixture("consumption_response.json")) + consumption = consumption_from_response( + response, MeterType.SMETS2_ELECTRICITY, UnitType.KWH + ) + with self.subTest("interval count"): + self.assertEqual(len(consumption.intervals), 3, "Contains 3 periods of consumption") + with self.subTest("units"): + self.assertEqual(consumption.unit, UnitType.KWH) + with self.subTest("first interval consumption (converted to kwh)"): + self.assertEqual(consumption.intervals[0].consumed_units, 0.063) + with self.subTest("first interval start"): + self.assertEqual( + consumption.intervals[0].interval_start, + datetime(2018, 5, 19, 0, 30, tzinfo=tzoffset(None, 3600)), + ) + with self.subTest("first interval end"): + self.assertEqual( + consumption.intervals[0].interval_end, + datetime(2018, 5, 19, 1, 0, tzinfo=tzoffset(None, 3600)), + ) + + def test_consumption_no_intervals(self): + response = Mock() + response.json.return_value = json.loads( + load_fixture("consumption_no_results_response.json") + ) + consumption = consumption_from_response( + response, meter_type=MeterType.SMETS1_GAS, desired_unit_type=UnitType.KWH + ) + with self.subTest("interval count"): + self.assertEqual(len(consumption.intervals), 0, "Contains 0 periods of consumption") + with self.subTest("units"): + self.assertEqual(consumption.unit, UnitType.KWH) + + def test_consumption_missing_intervals(self): + response = Mock() + response.json.return_value = json.loads( + load_fixture("consumption_missing_results_response.json") + ) + consumption = consumption_from_response( + response, meter_type=MeterType.SMETS1_GAS, desired_unit_type=UnitType.KWH + ) + with self.subTest("interval count"): + self.assertEqual(len(consumption.intervals), 0, "Contains 0 periods of consumption") + with self.subTest("units"): + self.assertEqual(consumption.unit, UnitType.KWH) + + +class TestUnitConversion(TestCase): + def test_conversions(self): + """Tests the unit conversions supported by the library.""" + conversion_tests = [ + (UnitType.CUBIC_METERS, UnitType.KWH, 100, 1118.68), + (UnitType.KWH, UnitType.CUBIC_METERS, 100, 8.9391068044481), + (UnitType.KWH, UnitType.KWH, 100, 100), + (UnitType.CUBIC_METERS, UnitType.CUBIC_METERS, 100, 100), + ] + for conversion_test in conversion_tests: + with self.subTest(f"{conversion_test[0]} to {conversion_test[1]}"): + self.assertEqual( + _calculate_unit(conversion_test[2], conversion_test[0], conversion_test[1]), + conversion_test[3], + ) diff --git a/tox.ini b/tox.ini index 9bbdc35..250db7c 100644 --- a/tox.ini +++ b/tox.ini @@ -16,8 +16,7 @@ whitelist_externals = poetry deps=poetry commands= poetry install -v - black --check --line-length 100 octopus_energy tests - + black --check octopus_energy tests [testenv:flake8] whitelist_externals = poetry @@ -26,3 +25,7 @@ commands= poetry install -v flake8 octopus_energy tests +[flake8] +max_line_length = 100 +per_file_ignores = + __init__.py: F401 \ No newline at end of file