diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b539f90 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +aiohttp==.3.9.5 +pyproj==3.6.1 +pytest +async-timeout==4.0.3 +pytest-asyncio==0.23.7 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..8bd0e5f --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +[tool:pytest] +testpaths = tests +norecursedirs = .git +addopts = -s -v +asyncio_mode = auto \ No newline at end of file diff --git a/src/open_irceline/__init__.py b/src/open_irceline/__init__.py new file mode 100644 index 0000000..a791cf4 --- /dev/null +++ b/src/open_irceline/__init__.py @@ -0,0 +1,4 @@ +from pyproj import Transformer + +project_transform = Transformer.from_crs('EPSG:4326', 'EPSG:31370', always_xy=True) +rio_wfs_base_url = 'https://geo.irceline.be/rio/wfs' diff --git a/src/open_irceline/api.py b/src/open_irceline/api.py new file mode 100644 index 0000000..678259a --- /dev/null +++ b/src/open_irceline/api.py @@ -0,0 +1,107 @@ +import asyncio +import socket +from datetime import datetime, timedelta +from typing import Tuple, List + +import aiohttp +from aiohttp import ClientResponse +import async_timeout + +from . import project_transform, rio_wfs_base_url +from .data import RioFeature, FeatureValue + + +class IrcelineApiError(Exception): + """Exception to indicate a general API error.""" + + +class IrcelineApiCommunicationError(IrcelineApiError): + """Exception to indicate a communication error.""" + + +class IrcelineApiParametersError(IrcelineApiError): + """Exception to indicate a parameter error.""" + + +class IrcelineClient: + """API client for IRCEL - CELINE open data""" + + def __init__(self, session: aiohttp.ClientSession) -> None: + self._session = session + + @staticmethod + def epsg_transform(position: Tuple[float, float]) -> tuple: + """ + Convert 'EPSG:4326' coordinates to 'EPSG:31370' coordinates + :param position: (x, y) coordinates + :return: tuple of int in the EPSG:31370 system + """ + result = project_transform.transform(position[0], position[1]) + return round(result[0]), round(result[1]) + + async def get_rio_value(self, + timestamp: datetime, + features: List[RioFeature], + position: Tuple[float, float] + ) -> dict: + coord = self.epsg_transform(position) + # Remove one hour from timestamp to handle case where the hour just passed but the data is not yet there + # (e.g. 5.01 PM, but the most recent data is for 4.00 PM) + timestamp = timestamp.replace(microsecond=0, second=0, minute=0) - timedelta(hours=1) + + querystring = {"service": "WFS", + "version": "1.3.0", + "request": "GetFeature", + "outputFormat": "application/json", + "typeName": ",".join(features), + "cql_filter": f"timestamp>='{timestamp.isoformat()}' AND " + f"INTERSECTS(the_geom, POINT ({coord[0]} {coord[1]}))"} + + r: ClientResponse = await self._api_wrapper(rio_wfs_base_url, querystring) + return self.format_result('rio', await r.json()) + + @staticmethod + def format_result(prefix: str, data: dict) -> dict: + if data.get('type', None) != 'FeatureCollection' or not isinstance(data.get('features', None), list): + return dict() + features = data.get('features', []) + result = dict() + for f in features: + if (f.get('id', None) is None or + f.get('properties', {}).get('value', None) is None or + f.get('properties', {}).get('timestamp', None) is None): + continue + + try: + timestamp = datetime.fromisoformat(f.get('properties', {}).get('timestamp')) + value = float(f.get('properties', {}).get('value')) + except (TypeError, ValueError): + continue + + name = f"{prefix}:{f.get('id').split('.')[0]}" + if name not in result or result[name]['timestamp'] < timestamp: + result[name] = FeatureValue(timestamp=timestamp, value=value) + + return result + + async def _api_wrapper(self, url: str, querystring: dict): + + headers = {'User-Agent': 'github.com/jdejaegh/python-irceline'} + + try: + async with async_timeout.timeout(60): + response = await self._session.request( + method='GET', + url=url, + params=querystring, + headers=headers + ) + response.raise_for_status() + return response + + except asyncio.TimeoutError as exception: + raise IrcelineApiCommunicationError("Timeout error fetching information") from exception + except (aiohttp.ClientError, socket.gaierror) as exception: + raise IrcelineApiCommunicationError("Error fetching information") from exception + except Exception as exception: # pylint: disable=broad-except + raise IrcelineApiError(f"Something really wrong happened! {exception}") from exception diff --git a/src/open_irceline/data.py b/src/open_irceline/data.py new file mode 100644 index 0000000..93533a2 --- /dev/null +++ b/src/open_irceline/data.py @@ -0,0 +1,27 @@ +from enum import StrEnum +from typing import TypedDict +from datetime import datetime + + +class RioFeature(StrEnum): + BC_HMEAN = 'rio:bc_hmean' + BC_24HMEAN = 'rio:bc_24hmean' + BC_DMEAN = 'rio:bc_dmean' + NO2_HMEAN = 'rio:no2_hmean' + NO2_DMEAN = 'rio:no2_dmean' + O3_HMEAN = 'rio:o3_hmean' + O3_MAXHMEAN = 'rio:o3_maxhmean' + O3_8HMEAN = 'rio:o3_8hmean' + O3_MAX8HMEAN = 'rio:o3_max8hmean' + PM10_HMEAN = 'rio:pm10_hmean' + PM10_24HMEAN = 'rio:pm10_24hmean' + PM10_DMEAN = 'rio:pm10_dmean' + PM25_HMEAN = 'rio:pm25_hmean' + PM25_24HMEAN = 'rio:pm25_24hmean' + PM25_DMEAN = 'rio:pm25_dmean' + SO2_HMEAN = 'rio:so2_hmean' + + +class FeatureValue(TypedDict): + timestamp: datetime | None + value: int | float | None diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29