Skip to content

Commit

Permalink
Add optional parameters on consumption APIs
Browse files Browse the repository at this point in the history
This includes the following options for sorting/filtering etc...

* Grouping
* Paging
* Filtering
* Sorting
  • Loading branch information
markallanson committed Jan 11, 2021
1 parent 4d30ae6 commit cacdb09
Show file tree
Hide file tree
Showing 8 changed files with 198 additions and 22 deletions.
13 changes: 12 additions & 1 deletion octopus_energy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
"""Python client for the Octopus Energy RESTful API"""

from .rest_client import OctopusEnergyRestClient
from .models import Consumption, IntervalConsumption, MeterType, UnitType
from .models import (
Consumption,
IntervalConsumption,
MeterType,
UnitType,
Aggregate,
SortOrder,
RateType,
)
from .exceptions import ApiAuthenticationError, ApiError, ApiNotFoundError, ApiBadRequestError

__all__ = [
Expand All @@ -14,4 +22,7 @@
"IntervalConsumption",
"MeterType",
"UnitType",
"Aggregate",
"SortOrder",
"RateType",
]
16 changes: 15 additions & 1 deletion octopus_energy/mappers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from datetime import datetime

from dateutil.parser import isoparse

from octopus_energy.models import IntervalConsumption, UnitType, Consumption, MeterType
from .models import IntervalConsumption, UnitType, Consumption, MeterType

_CUBIC_METERS_TO_KWH_MULTIPLIER = 11.1868
_KWH_TO_KWH_MULTIPLIER = 1
Expand Down Expand Up @@ -43,6 +45,18 @@ def consumption_from_response(
)


def to_timestamp_str(timestamp: datetime) -> str:
"""Convert a datetime to an iso timestamp string expected by the octopus energy APIs.
Args:
timestamp: The timestamp to convert.
Returns:
The timestamp in a iso format required by the octopus energy APIs
"""
return timestamp.replace(microsecond=0).isoformat() if timestamp else None


def _calculate_unit(consumption, actual_unit, desired_unit):
"""Converts unit values from one unit to another unit.
Expand Down
39 changes: 39 additions & 0 deletions octopus_energy/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,42 @@ class RateType(_DocEnum):
"night-unit-rates",
"Represents the rate charged during the night for dual rate tariffs.",
)


class SortOrder(_DocEnum):
"""The order to use when sorting results from search type API alls"""

NEWEST_FIRST = (
"-period",
"Newest result first",
)
OLDEST_FIRST = (
"period",
"Oldest result first",
)


class Aggregate(_DocEnum):
"""How to aggregate data returned from consumption based APIs"""

HALF_HOURLY = None, "Aggregate consumption half hourly (the default)"
HOUR = (
"hour",
"Aggregate consumption by hours",
)
DAY = (
"day",
"Aggregate consumption by days",
)
WEEK = (
"week",
"Aggregate consumption by weeks",
)
MONTH = (
"month",
"Aggregate consumption by months",
)
QUARTER = (
"quarter",
"Aggregate consumption quarterly",
)
83 changes: 70 additions & 13 deletions octopus_energy/rest_client.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
from datetime import datetime
from functools import partial
from http import HTTPStatus
from typing import Optional, Callable

from aiohttp import BasicAuth, ClientSession
from furl import furl

from octopus_energy.exceptions import (
from .mappers import to_timestamp_str
from .exceptions import (
ApiError,
ApiAuthenticationError,
ApiNotFoundError,
ApiBadRequestError,
)
from octopus_energy.models import RateType, EnergyTariffType
from .models import RateType, EnergyTariffType, Aggregate, SortOrder

_API_BASE = "https://api.octopus.energy"

Expand Down Expand Up @@ -106,19 +108,44 @@ async def get_account_details(self, account_number: str) -> dict:
"""
return await self._get(["v1", "accounts", account_number])

async def get_electricity_consumption_v1(self, mpan: str, serial_number: str) -> dict:
async def get_electricity_consumption_v1(
self,
mpan: str,
serial_number: str,
page_num: int = None,
page_size: int = None,
period_from: datetime = None,
period_to: datetime = None,
order: SortOrder = None,
aggregate: Aggregate = None,
) -> dict:
"""Gets the consumption of electricity from a specific meter.
Args:
mpan: The MPAN (Meter Point Administration Number) of the location to query.
serial_number: The serial number of the meter to query.
page_num: (Optional) The page number to load.
page_size: (Optional) How many results per page.
period_from: (Optional) The timestamp from where to begin returning results.
period_to: (Optional) The timestamp at which to end returning results.
order: (Optional) The ordering to apply to the results.
aggregate: (Optional) Over what period to aggregate the results. By default consumption
results are aggregated half hourly. You can override this setting by
explicitly stating an alternate aggregate.
Returns:
A dictionary containing the electricity consumption response.
"""
return await self._get(
["v1", "electricity-meter-points", mpan, "meters", serial_number, "consumption"]
["v1", "electricity-meter-points", mpan, "meters", serial_number, "consumption"],
{
"page": page_num,
"page_size": page_size,
"period_from": to_timestamp_str(period_from),
"period_to": to_timestamp_str(period_to),
"order": order.value if order is not None else None,
"group_by": aggregate.value if aggregate is not None else None,
},
)

async def get_electricity_meter_points_v1(self, mpan: str) -> dict:
Expand All @@ -133,19 +160,44 @@ async def get_electricity_meter_points_v1(self, mpan: str) -> dict:
"""
return await self._get(["v1", "electricity-meter-points", mpan])

async def get_gas_consumption_v1(self, mprn: str, serial_number: str) -> dict:
async def get_gas_consumption_v1(
self,
mprn: str,
serial_number: str,
page_num: int = None,
page_size: int = None,
period_from: datetime = None,
period_to: datetime = None,
order: SortOrder = None,
aggregate: Aggregate = None,
) -> dict:
"""Gets the consumption of gas from a specific meter.
Args:
mprn: The MPRN (Meter Point Reference Number) of the location to query.
serial_number: The serial number of the meter to query.
page_num: (Optional) The page number to load.
page_size: (Optional) How many results per page.
period_from: (Optional) The timestamp from where to begin returning results.
period_to: (Optional) The timestamp at which to end returning results.
order: (Optional) The ordering to apply to the results.
aggregate: (Optional) Over what period to aggregate the results. By default consumption
results are aggregated half hourly. You can override this setting by
explicitly stating an alternate aggregate.
Returns:
A dictionary containing the gas consumption response.
"""
return await self._get(
["v1", "gas-meter-points", mprn, "meters", serial_number, "consumption"]
["v1", "gas-meter-points", mprn, "meters", serial_number, "consumption"],
{
"page": page_num,
"page_size": page_size,
"period_from": to_timestamp_str(period_from),
"period_to": to_timestamp_str(period_to),
"order": order.value if order is not None else None,
"group_by": aggregate.value if aggregate is not None else None,
},
)

async def get_products_v1(self) -> dict:
Expand Down Expand Up @@ -209,16 +261,21 @@ async def renew_business_tariff(self, account_number: str, renewal_data: dict) -
"""
return await self._post(["v1", "accounts", account_number, "tariff-renewal"], renewal_data)

async def _get(self, url_parts: list, **kwargs) -> dict:
return await self._execute(self.session.get, url_parts, **kwargs)
async def _get(self, url_parts: list, query_params: dict = {}, **kwargs) -> dict:
return await self._execute(self.session.get, url_parts, query_params, **kwargs)

async def _post(self, url_parts: list, data: dict, **kwargs) -> dict:
return await self._execute(partial(self.session.post, data=data), url_parts, **kwargs)
async def _post(self, url_parts: list, data: dict, query_params: dict = {}, **kwargs) -> dict:
return await self._execute(
partial(self.session.post, data=data), url_parts, query_params, **kwargs
)

async def _execute(self, func: Callable, url_parts: list, **kwargs) -> dict:
async def _execute(
self, func: Callable, url_parts: list, query_params: dict = {}, **kwargs
) -> dict:
"""Executes an API call to Octopus energy and maps the response."""
url = self.base_url.copy()
url.path.segments.extend(url_parts)
url.query.params.update(query_params)
response = await func(url=str(url), **kwargs)
if response.status > 399:
if response.status == HTTPStatus.UNAUTHORIZED:
Expand Down
17 changes: 16 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "octopus-energy"
version = "0.1.6"
version = "0.1.7"
description = "Python client for the Octopus Energy RESTful API"
authors = ["Mark Allanson <[email protected]>"]
license = "MIT"
Expand All @@ -23,6 +23,7 @@ tox = "^3.20.1"
aioresponses = "^0.7.1"
pytest-asyncio = "^0.14.0"
pytest-subtests = "^0.4.0"
freezegun = "^1.0.0"

[build-system]
requires = ["poetry-core>=1.0.0"]
Expand Down
37 changes: 32 additions & 5 deletions tests/test_e2e.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from datetime import datetime
from typing import Callable

import pytest
from aioresponses import aioresponses
from dateutil.tz import tzoffset
from freezegun import freeze_time

from octopus_energy import OctopusEnergyRestClient
from octopus_energy.models import EnergyTariffType, RateType
from octopus_energy.models import EnergyTariffType, RateType, SortOrder, Aggregate
from octopus_energy.rest_client import _API_BASE
from tests import load_fixture_json

Expand Down Expand Up @@ -55,13 +58,25 @@ async def get(client):
)


@freeze_time("2021-01-01 01:11:11")
@pytest.mark.asyncio
async def test_get_elec_consumption_v1(mock_aioresponses: aioresponses):
async def get(client):
return await client.get_electricity_consumption_v1("mpan", "serial_number")
return await client.get_electricity_consumption_v1(
"mpan",
"serial_number",
page_num=1,
page_size=10,
period_from=datetime.now(tz=tzoffset("CEST", 2)),
period_to=datetime.now(tz=tzoffset("CEST", 2)),
order=SortOrder.NEWEST_FIRST,
aggregate=Aggregate.DAY,
)

await _run_get_test(
"v1/electricity-meter-points/mpan/meters/serial_number/consumption",
"v1/electricity-meter-points/mpan/meters/serial_number/consumption?page=1&page_size=10"
"&period_from=2021-01-01T01%3A11%3A13%2B00%3A00%3A02"
"&period_to=2021-01-01T01%3A11%3A13%2B00%3A00%3A02&order=-period&group_by=day",
"consumption_response.json",
get,
mock_aioresponses,
Expand All @@ -81,13 +96,25 @@ async def get(client):
)


@freeze_time("2021-01-01 01:11:11")
@pytest.mark.asyncio
async def test_get_gas_consumption_v1(mock_aioresponses: aioresponses):
async def get(client):
return await client.get_gas_consumption_v1("mprn", "serial_number")
return await client.get_gas_consumption_v1(
"mprn",
"serial_number",
page_num=1,
page_size=10,
period_from=datetime.now(tz=tzoffset("CEST", 2)),
period_to=datetime.now(tz=tzoffset("CEST", 2)),
order=SortOrder.NEWEST_FIRST,
aggregate=Aggregate.DAY,
)

await _run_get_test(
"v1/gas-meter-points/mprn/meters/serial_number/consumption",
"v1/gas-meter-points/mprn/meters/serial_number/consumption?page=1&page_size=10"
"&period_from=2021-01-01T01%3A11%3A13%2B00%3A00%3A02"
"&period_to=2021-01-01T01%3A11%3A13%2B00%3A00%3A02&order=-period&group_by=day",
"consumption_response.json",
get,
mock_aioresponses,
Expand Down
12 changes: 12 additions & 0 deletions tests/test_mappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from octopus_energy.mappers import (
_calculate_unit,
consumption_from_response,
to_timestamp_str,
)
from octopus_energy.models import UnitType, MeterType
from tests import load_fixture_json
Expand Down Expand Up @@ -138,6 +139,17 @@ def test_consumption_missing_intervals(self):
self.assertEqual(consumption.unit_type, UnitType.KWH)


def test_timestamp_format():
"""Verifies timestamps.
* microseconds are stripped
* formatted as iso8601
"""
timestamp = datetime.utcnow().replace(microsecond=0)
str = to_timestamp_str(timestamp)
assert str == timestamp.isoformat()


@pytest.mark.parametrize(
"source_unit_type,desired_unit_type,source_unit,desired_unit",
[
Expand Down

0 comments on commit cacdb09

Please sign in to comment.