mirror of
https://github.com/jdejaegh/python-irceline.git
synced 2025-06-27 03:35:56 +02:00
Compare commits
19 commits
Author | SHA1 | Date | |
---|---|---|---|
7d38b44eb6 | |||
9b01553e7f | |||
52550a33b1 | |||
08e3424d69 | |||
3d4a7c3720 | |||
86addac7cf | |||
5b899da28e | |||
3b2e88213e | |||
c878bf8f81 | |||
bb7eb712e3 | |||
74dfd6b8aa | |||
7e5a1da012 | |||
98a7853604 | |||
3860c90fac | |||
7fe28b7783 | |||
169bcd2da3 | |||
45a72b9794 | |||
fc308021e8 | |||
5bed043c9d |
25 changed files with 4586 additions and 765 deletions
28
README.md
28
README.md
|
@ -24,9 +24,8 @@ pip install open-irceline
|
||||||
```python
|
```python
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime, date
|
from datetime import datetime
|
||||||
|
from open_irceline import IrcelineRioClient, IrcelineForecastClient, ForecastFeature, RioFeature
|
||||||
from open_irceline import IrcelineRioClient, RioFeature, IrcelineForecastClient, ForecastFeature, belaqi_index_actual
|
|
||||||
|
|
||||||
|
|
||||||
async def get_rio_interpolated_data():
|
async def get_rio_interpolated_data():
|
||||||
|
@ -43,12 +42,11 @@ async def get_rio_interpolated_data():
|
||||||
print(f"PM10 {result[RioFeature.PM10_HMEAN]['value']} µg/m³")
|
print(f"PM10 {result[RioFeature.PM10_HMEAN]['value']} µg/m³")
|
||||||
|
|
||||||
|
|
||||||
async def get_forecast():
|
async def get_o3_forecast():
|
||||||
"""Get forecast for O3 concentration for Brussels for the next days"""
|
"""Get forecast for O3 concentration for Brussels for the next days"""
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
client = IrcelineForecastClient(session)
|
client = IrcelineForecastClient(session)
|
||||||
result = await client.get_data(
|
result = await client.get_data(
|
||||||
timestamp=date.today(),
|
|
||||||
features=[ForecastFeature.O3_MAXHMEAN],
|
features=[ForecastFeature.O3_MAXHMEAN],
|
||||||
position=(50.85, 4.35) # (lat, lon) for Brussels
|
position=(50.85, 4.35) # (lat, lon) for Brussels
|
||||||
)
|
)
|
||||||
|
@ -57,28 +55,28 @@ async def get_forecast():
|
||||||
print(f"{feature} {day} {v['value']} µg/m³")
|
print(f"{feature} {day} {v['value']} µg/m³")
|
||||||
|
|
||||||
|
|
||||||
async def get_current_belaqi():
|
async def get_belaqi_forecast():
|
||||||
"""Get current BelAQI index from RIO interpolated values"""
|
"""Get current BelAQI index from RIO interpolated values"""
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
client = IrcelineRioClient(session)
|
client = IrcelineForecastClient(session)
|
||||||
result = await belaqi_index_actual(
|
result = await client.get_data(
|
||||||
rio_client=client,
|
features=[ForecastFeature.BELAQI],
|
||||||
timestamp=datetime.utcnow(), # must be timezone aware
|
|
||||||
position=(50.85, 4.35) # (lat, lon) for Brussels
|
position=(50.85, 4.35) # (lat, lon) for Brussels
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f"Current BelAQI index for Brussels: {result}")
|
for (_, day), value in result.items():
|
||||||
|
print(day, value['value'])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
print("RIO interpolated data")
|
print("\nInterpolated data")
|
||||||
asyncio.run(get_rio_interpolated_data())
|
asyncio.run(get_rio_interpolated_data())
|
||||||
|
|
||||||
print("\nO3 forecast for Brussels")
|
print("\nO3 forecast for Brussels")
|
||||||
asyncio.run(get_forecast())
|
asyncio.run(get_o3_forecast())
|
||||||
|
|
||||||
print("\nCurrent BelAQI index")
|
print("\nForecast BelAQI index")
|
||||||
asyncio.run(get_current_belaqi())
|
asyncio.run(get_belaqi_forecast())
|
||||||
```
|
```
|
||||||
|
|
||||||
## Attribution
|
## Attribution
|
||||||
|
|
|
@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "open-irceline"
|
name = "open-irceline"
|
||||||
version = "0.1.0"
|
version = "3.0.2"
|
||||||
description = "Get IRCEL - CELINE air quality data 🍃 🇧🇪"
|
description = "Get IRCEL - CELINE air quality data 🍃 🇧🇪"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [{ name = "Jules Dejaeghere", email = "curable.grass491@mailer.me" }]
|
authors = [{ name = "Jules Dejaeghere", email = "curable.grass491@mailer.me" }]
|
||||||
|
@ -27,7 +27,7 @@ requires-python = ">=3.11"
|
||||||
Homepage = "https://github.com/jdejaegh/python-irceline"
|
Homepage = "https://github.com/jdejaegh/python-irceline"
|
||||||
|
|
||||||
[tool.bumpver]
|
[tool.bumpver]
|
||||||
current_version = "0.1.0"
|
current_version = "3.0.2"
|
||||||
version_pattern = "MAJOR.MINOR.PATCH"
|
version_pattern = "MAJOR.MINOR.PATCH"
|
||||||
commit_message = "bump version {old_version} -> {new_version}"
|
commit_message = "bump version {old_version} -> {new_version}"
|
||||||
tag_message = "{new_version}"
|
tag_message = "{new_version}"
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
aiohttp==3.9.5
|
aiohttp==3.11.9
|
||||||
pyproj==3.6.1
|
pyproj==3.6.1
|
||||||
pytest
|
pytest
|
||||||
async-timeout==4.0.3
|
async-timeout==4.0.3
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
from .api import IrcelineRioClient, IrcelineForecastClient, IrcelineApiError
|
from .api import IrcelineApiError
|
||||||
from .belaqi import belaqi_index, belaqi_index_actual, belaqi_index_forecast
|
from .data import RioFeature, ForecastFeature, FeatureValue, RioIfdmFeature
|
||||||
from .data import RioFeature, ForecastFeature, FeatureValue, BelAqiIndex
|
from .forecast import IrcelineForecastClient
|
||||||
|
from .rio import IrcelineRioClient, IrcelineRioIfdmClient
|
||||||
|
|
||||||
__version__ = '0.1.0'
|
__version__ = '3.0.2'
|
||||||
|
|
|
@ -1,42 +1,39 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import csv
|
|
||||||
import socket
|
import socket
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from datetime import datetime, timedelta, date
|
from typing import Tuple, List, Set
|
||||||
from io import StringIO
|
|
||||||
from itertools import product
|
|
||||||
from typing import Tuple, List, Dict, Set
|
|
||||||
from xml.etree import ElementTree
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import async_timeout
|
import async_timeout
|
||||||
from aiohttp import ClientResponse
|
from aiohttp import ClientResponse
|
||||||
|
|
||||||
from .data import RioFeature, FeatureValue, ForecastFeature, IrcelineFeature
|
from .data import IrcelineFeature
|
||||||
from .utils import SizedDict, epsg_transform, round_coordinates
|
|
||||||
|
|
||||||
_rio_wfs_base_url = 'https://geo.irceline.be/wfs'
|
_rio_wfs_base_url = 'https://geo.irceline.be/wfs'
|
||||||
# noinspection HttpUrlsUsage
|
_forecast_wms_base_url = 'https://geo.irceline.be/forecast/wms'
|
||||||
# There is not HTTPS version of this endpoint
|
_rio_ifdm_wms_base_url = 'https://geobelair.irceline.be/rioifdm/wms'
|
||||||
_forecast_base_url = 'http://ftp.irceline.be/forecast'
|
|
||||||
_user_agent = 'github.com/jdejaegh/python-irceline'
|
_user_agent = 'github.com/jdejaegh/python-irceline'
|
||||||
|
|
||||||
|
|
||||||
class IrcelineApiError(Exception):
|
class IrcelineApiError(Exception):
|
||||||
"""Exception to indicate an API error."""
|
"""Exception to indicate an API error."""
|
||||||
|
|
||||||
|
|
||||||
class IrcelineBaseClient(ABC):
|
class IrcelineBaseClient(ABC):
|
||||||
def __init__(self, session: aiohttp.ClientSession, cache_size: int = 20) -> None:
|
def __init__(self, session: aiohttp.ClientSession) -> None:
|
||||||
self._session = session
|
self._session = session
|
||||||
self._cache = SizedDict(cache_size)
|
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_data(self,
|
async def get_data(self,
|
||||||
timestamp: datetime | date,
|
|
||||||
features: List[IrcelineFeature],
|
features: List[IrcelineFeature],
|
||||||
position: Tuple[float, float]) -> dict:
|
position: Tuple[float, float]) -> dict:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_capabilities(self) -> Set[str]:
|
||||||
|
pass
|
||||||
|
|
||||||
async def _api_wrapper(self, url: str, querystring: dict = None, headers: dict = None, method: str = 'GET'):
|
async def _api_wrapper(self, url: str, querystring: dict = None, headers: dict = None, method: str = 'GET'):
|
||||||
"""
|
"""
|
||||||
Call the URL with the specified query string. Raises exception for >= 400 response code
|
Call the URL with the specified query string. Raises exception for >= 400 response code
|
||||||
|
@ -48,7 +45,6 @@ class IrcelineBaseClient(ABC):
|
||||||
headers = dict()
|
headers = dict()
|
||||||
if 'User-Agent' not in headers:
|
if 'User-Agent' not in headers:
|
||||||
headers |= {'User-Agent': _user_agent}
|
headers |= {'User-Agent': _user_agent}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with async_timeout.timeout(60):
|
async with async_timeout.timeout(60):
|
||||||
response = await self._session.request(
|
response = await self._session.request(
|
||||||
|
@ -67,200 +63,39 @@ class IrcelineBaseClient(ABC):
|
||||||
except Exception as exception: # pylint: disable=broad-except
|
except Exception as exception: # pylint: disable=broad-except
|
||||||
raise IrcelineApiError(f"Something really wrong happened! {exception}") from exception
|
raise IrcelineApiError(f"Something really wrong happened! {exception}") from exception
|
||||||
|
|
||||||
async def _api_cached_wrapper(self, url: str, method: str = 'GET'):
|
|
||||||
"""
|
|
||||||
Call the API but uses cache based on the ETag value to avoid repeated calls for the same ressource
|
|
||||||
:param url: url to fetch
|
|
||||||
:param method: HTTP method (default to GET)
|
|
||||||
:return: response from the client
|
|
||||||
"""
|
|
||||||
if url in self._cache:
|
|
||||||
headers = {"If-None-Match": f'{self._cache.get(url, {}).get("etag")}'}
|
|
||||||
else:
|
|
||||||
headers = None
|
|
||||||
|
|
||||||
r: ClientResponse = await self._api_wrapper(url, headers=headers, method=method)
|
class IrcelineBaseWmsClient(IrcelineBaseClient, ABC):
|
||||||
if r.status == 304:
|
_default_querystring = {"service": "WMS",
|
||||||
return self._cache.get(url, {}).get("response")
|
"version": "1.1.1",
|
||||||
elif 'ETag' in r.headers:
|
"request": "GetFeatureInfo",
|
||||||
self._cache[url] = {'etag': r.headers['ETag'],
|
"info_format": "application/json",
|
||||||
'response': r}
|
"width": "1",
|
||||||
return r
|
"height": "1",
|
||||||
|
"srs": "EPSG:4326",
|
||||||
|
"X": "1",
|
||||||
class IrcelineRioClient(IrcelineBaseClient):
|
"Y": "1"}
|
||||||
"""API client for RIO interpolated IRCEL - CELINE open data"""
|
_epsilon = 0.00001
|
||||||
|
_base_url = None
|
||||||
async def get_data(self,
|
|
||||||
timestamp: datetime | date,
|
|
||||||
features: List[RioFeature],
|
|
||||||
position: Tuple[float, float]
|
|
||||||
) -> Dict[RioFeature, FeatureValue]:
|
|
||||||
"""
|
|
||||||
Call the WFS API to get the interpolated level of RioFeature. Raises exception upon API error
|
|
||||||
:param timestamp: datetime for which to get the data for
|
|
||||||
:param features: list of RioFeature to fetch from the API
|
|
||||||
:param position: decimal degrees pair of coordinates
|
|
||||||
:return: dict with the response (key is RioFeature, value is FeatureValue with actual value and timestamp)
|
|
||||||
"""
|
|
||||||
# Remove one hour/day 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)
|
|
||||||
if isinstance(timestamp, datetime):
|
|
||||||
timestamp = timestamp.replace(microsecond=0, second=0, minute=0) - timedelta(hours=1)
|
|
||||||
timestamp = timestamp.isoformat()
|
|
||||||
key = 'timestamp'
|
|
||||||
elif isinstance(timestamp, date):
|
|
||||||
timestamp = timestamp - timedelta(days=1)
|
|
||||||
timestamp = timestamp.isoformat()
|
|
||||||
key = 'date'
|
|
||||||
else:
|
|
||||||
raise IrcelineApiError(f"Wrong parameter type for timestamp: {type(timestamp)}")
|
|
||||||
|
|
||||||
lat, lon = epsg_transform(position)
|
|
||||||
querystring = {"service": "WFS",
|
|
||||||
"version": "1.3.0",
|
|
||||||
"request": "GetFeature",
|
|
||||||
"outputFormat": "application/json",
|
|
||||||
"typeName": ",".join(features),
|
|
||||||
"cql_filter":
|
|
||||||
f"{key}>='{timestamp}'"
|
|
||||||
f" AND "
|
|
||||||
f"INTERSECTS(the_geom, POINT ({lat} {lon}))"}
|
|
||||||
r: ClientResponse = await self._api_wrapper(_rio_wfs_base_url, querystring)
|
|
||||||
return self._format_result('rio', await r.json(), features)
|
|
||||||
|
|
||||||
async def get_rio_capabilities(self) -> Set[str]:
|
|
||||||
"""
|
|
||||||
Fetch the list of possible features from the WFS server
|
|
||||||
:return: set of features available on the WFS server
|
|
||||||
"""
|
|
||||||
querystring = {"service": "WFS",
|
|
||||||
"version": "1.3.0",
|
|
||||||
"request": "GetCapabilities"}
|
|
||||||
r: ClientResponse = await self._api_wrapper(_rio_wfs_base_url, querystring)
|
|
||||||
|
|
||||||
return self._parse_capabilities(await r.text())
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_capabilities(xml_string: str) -> Set[str]:
|
def _parse_capabilities(xml_string: str) -> Set[str]:
|
||||||
"""
|
|
||||||
From an XML string obtained with GetCapabilities, generate a set of feature names
|
|
||||||
:param xml_string: XML string to parse
|
|
||||||
:return: set of FeatureType Names found in the XML document
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
root = ElementTree.fromstring(xml_string)
|
root = ElementTree.fromstring(xml_string)
|
||||||
except ElementTree.ParseError:
|
except ElementTree.ParseError:
|
||||||
return set()
|
return set()
|
||||||
# noinspection HttpUrlsUsage
|
|
||||||
# We never connect to the URL, it is just the namespace in the XML
|
path = './/Capability/Layer/Layer/Name'
|
||||||
namespaces = {
|
feature_type_names = {t.text for t in root.findall(path)}
|
||||||
'wfs': 'http://www.opengis.net/wfs',
|
|
||||||
}
|
|
||||||
path = './/wfs:FeatureTypeList/wfs:FeatureType/wfs:Name'
|
|
||||||
feature_type_names = {t.text for t in root.findall(path, namespaces)}
|
|
||||||
return feature_type_names
|
return feature_type_names
|
||||||
|
|
||||||
@staticmethod
|
async def get_capabilities(self) -> Set[str]:
|
||||||
def _format_result(prefix: str, data: dict, features: List[RioFeature]) -> dict:
|
|
||||||
"""
|
"""
|
||||||
Format the JSON dict returned by the WFS service into a more practical dict to use with only the latest measure
|
Fetch the list of possible features from the WMS server
|
||||||
for each feature requested
|
:return: set of features available on the WMS server
|
||||||
:param prefix: namespace of the feature (e.g. rio), without the colon
|
|
||||||
:param data: JSON dict value as returned by the API
|
|
||||||
:param features: RioFeatures wanted in the final dict
|
|
||||||
:return: reduced dict, key is RioFeature, value is FeatureValue
|
|
||||||
"""
|
"""
|
||||||
if data.get('type', None) != 'FeatureCollection' or not isinstance(data.get('features', None), list):
|
querystring = {"service": "WMS",
|
||||||
return dict()
|
"version": "1.1.1",
|
||||||
features_api = data.get('features', [])
|
"request": "GetCapabilities"}
|
||||||
result = dict()
|
r: ClientResponse = await self._api_wrapper(self._base_url, querystring)
|
||||||
for f in features_api:
|
|
||||||
props = f.get('properties', {})
|
|
||||||
if (f.get('id', None) is None or
|
|
||||||
props.get('value', None) is None):
|
|
||||||
continue
|
|
||||||
if (props.get('timestamp', None) is None and
|
|
||||||
props.get('date', None) is None):
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
return self._parse_capabilities(await r.text())
|
||||||
if 'timestamp' in props.keys():
|
|
||||||
timestamp = datetime.fromisoformat(props.get('timestamp'))
|
|
||||||
else:
|
|
||||||
# Cut last character as the date is written '2024-06-15Z' which is not ISO compliant
|
|
||||||
timestamp = date.fromisoformat(props.get('date')[:-1])
|
|
||||||
value = float(props.get('value'))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
name = f"{prefix}:{f.get('id').split('.')[0]}"
|
|
||||||
if name not in [f'{f}' for f in features]:
|
|
||||||
continue
|
|
||||||
if name not in result or result[name]['timestamp'] < timestamp:
|
|
||||||
result[name] = FeatureValue(timestamp=timestamp, value=value)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class IrcelineForecastClient(IrcelineBaseClient):
|
|
||||||
"""API client for forecast IRCEL - CELINE open data"""
|
|
||||||
|
|
||||||
async def get_data(self,
|
|
||||||
timestamp: date,
|
|
||||||
features: List[ForecastFeature],
|
|
||||||
position: Tuple[float, float]
|
|
||||||
) -> Dict[Tuple[ForecastFeature, date], FeatureValue]:
|
|
||||||
"""
|
|
||||||
Get forecasted concentrations for the given features at the given position. The forecasts are downloaded for
|
|
||||||
the specified day and the 4 next days as well
|
|
||||||
:param timestamp: date at which the forecast are computed (generally today). If unavailable, the day before will be
|
|
||||||
tried as well
|
|
||||||
:param features: pollutants to get the forecasts for
|
|
||||||
:param position: (lat, long)
|
|
||||||
:return: dict where key is (ForecastFeature, date of the forecast) and value is a FeatureValue
|
|
||||||
"""
|
|
||||||
x, y = round_coordinates(position[0], position[1])
|
|
||||||
result = dict()
|
|
||||||
|
|
||||||
for feature, d in product(features, range(5)):
|
|
||||||
url = f"{_forecast_base_url}/BE_{feature}_{timestamp.strftime('%Y%m%d')}_d{d}.csv"
|
|
||||||
try:
|
|
||||||
r: ClientResponse = await self._api_cached_wrapper(url)
|
|
||||||
ts = timestamp
|
|
||||||
except IrcelineApiError:
|
|
||||||
# retry for the day before
|
|
||||||
yesterday = timestamp - timedelta(days=1)
|
|
||||||
url = f"{_forecast_base_url}/BE_{feature}_{yesterday.strftime('%Y%m%d')}_d{d}.csv"
|
|
||||||
try:
|
|
||||||
r: ClientResponse = await self._api_cached_wrapper(url)
|
|
||||||
ts = yesterday
|
|
||||||
except IrcelineApiError:
|
|
||||||
# if it fails twice, just set None and go to the next
|
|
||||||
result[(feature, timestamp + timedelta(days=d))] = FeatureValue(value=None, timestamp=timestamp)
|
|
||||||
continue
|
|
||||||
|
|
||||||
result[(feature, ts + timedelta(days=d))] = FeatureValue(
|
|
||||||
value=self.extract_result_from_csv(x, y, await r.text()),
|
|
||||||
timestamp=ts)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def extract_result_from_csv(x: float, y: float, csv_text: str) -> float | None:
|
|
||||||
"""
|
|
||||||
Find the value of the forecast for the given (x, y) position in the csv text.
|
|
||||||
x, y should already be rounded to match the positions found in the csv
|
|
||||||
:param x: latitude (rounded)
|
|
||||||
:param y: longitude (rounded)
|
|
||||||
:param csv_text: text of the CSV file
|
|
||||||
:return: value matching the position if found, else None
|
|
||||||
"""
|
|
||||||
f = StringIO(csv_text)
|
|
||||||
for row in csv.reader(f, delimiter=';'):
|
|
||||||
try:
|
|
||||||
if x == float(row[1]) and y == float(row[2]):
|
|
||||||
return float(row[3])
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
continue
|
|
||||||
return None
|
|
||||||
|
|
|
@ -1,127 +0,0 @@
|
||||||
"""
|
|
||||||
Compute the BelAQI index from concentrations of PM10, PM2.5, O3 and NO2, based on
|
|
||||||
https://www.irceline.be/en/air-quality/measurements/belaqi-air-quality-index/information
|
|
||||||
|
|
||||||
> to calculate the actual (hour per hour varying) sub-indexes and the global index, the concentration scales of Table 4
|
|
||||||
> are applied to the latest hourly mean O3 and NO2 concentrations and the running 24-hourly mean PM2.5 and PM10
|
|
||||||
> concentrations.
|
|
||||||
"""
|
|
||||||
from datetime import datetime, date
|
|
||||||
from typing import Tuple, Dict
|
|
||||||
|
|
||||||
from .api import IrcelineRioClient, IrcelineForecastClient
|
|
||||||
from .data import BelAqiIndex, RioFeature, ForecastFeature
|
|
||||||
|
|
||||||
|
|
||||||
def belaqi_index(pm10: float, pm25: float, o3: float, no2: float) -> BelAqiIndex:
|
|
||||||
"""
|
|
||||||
Computes the BelAQI index based on the components
|
|
||||||
Raise ValueError if a component is < 0
|
|
||||||
:param pm10: PM10 daily mean (or running 24-hourly mean for real-time) (µg/m³)
|
|
||||||
:param pm25: PM2.5 daily mean (or running 24-hourly mean for real-time) (µg/m³)
|
|
||||||
:param o3: O3 max 1-hourly mean per day (or latest hourly mean for real-time) (µg/m³)
|
|
||||||
:param no2: NO2 max 1-hourly mean per day (or latest hourly mean for real-time) (µg/m³)
|
|
||||||
:return: BelAQI index from 1 to 10 (Value of BelAqiIndex enum)
|
|
||||||
"""
|
|
||||||
if pm10 is None or pm25 is None or o3 is None or no2 is None:
|
|
||||||
raise ValueError("All the components should be valued (at lest one is None here)")
|
|
||||||
|
|
||||||
if pm10 < 0 or pm25 < 0 or o3 < 0 or no2 < 0:
|
|
||||||
raise ValueError("All the components should have a positive value")
|
|
||||||
|
|
||||||
elif pm10 > 100 or pm25 > 70 or o3 > 320 or no2 > 300:
|
|
||||||
return BelAqiIndex.HORRIBLE
|
|
||||||
|
|
||||||
elif pm10 > 80 or pm25 > 60 or o3 > 280 or no2 > 250:
|
|
||||||
return BelAqiIndex.VERY_BAD
|
|
||||||
|
|
||||||
elif pm10 > 70 or pm25 > 50 or o3 > 240 or no2 > 200:
|
|
||||||
return BelAqiIndex.BAD
|
|
||||||
|
|
||||||
elif pm10 > 60 or pm25 > 40 or o3 > 180 or no2 > 180:
|
|
||||||
return BelAqiIndex.VERY_POOR
|
|
||||||
|
|
||||||
elif pm10 > 50 or pm25 > 35 or o3 > 160 or no2 > 150:
|
|
||||||
return BelAqiIndex.POOR
|
|
||||||
|
|
||||||
elif pm10 > 40 or pm25 > 25 or o3 > 120 or no2 > 120:
|
|
||||||
return BelAqiIndex.MODERATE
|
|
||||||
|
|
||||||
elif pm10 > 30 or pm25 > 15 or o3 > 70 or no2 > 70:
|
|
||||||
return BelAqiIndex.FAIRLY_GOOD
|
|
||||||
|
|
||||||
elif pm10 > 20 or pm25 > 10 or o3 > 50 or no2 > 50:
|
|
||||||
return BelAqiIndex.GOOD
|
|
||||||
|
|
||||||
elif pm10 > 10 or pm25 > 5 or o3 > 25 or no2 > 20:
|
|
||||||
return BelAqiIndex.VERY_GOOD
|
|
||||||
|
|
||||||
elif pm10 >= 0 or pm25 >= 0 or o3 >= 0 or no2 >= 0:
|
|
||||||
return BelAqiIndex.EXCELLENT
|
|
||||||
|
|
||||||
|
|
||||||
async def belaqi_index_actual(rio_client: IrcelineRioClient, position: Tuple[float, float],
|
|
||||||
timestamp: datetime | None = None) -> BelAqiIndex:
|
|
||||||
"""
|
|
||||||
Get current BelAQI index value for the given position using the rio_client
|
|
||||||
Raise ValueError if one or more components are not available
|
|
||||||
:param rio_client: client for the RIO WFS service
|
|
||||||
:param position: position for which to get the data
|
|
||||||
:param timestamp: desired time for the data (now if None)
|
|
||||||
:return: BelAQI index value for the position at the time
|
|
||||||
"""
|
|
||||||
if timestamp is None:
|
|
||||||
timestamp = datetime.utcnow()
|
|
||||||
components = await rio_client.get_data(
|
|
||||||
timestamp=timestamp,
|
|
||||||
features=[RioFeature.PM10_24HMEAN,
|
|
||||||
RioFeature.PM25_24HMEAN,
|
|
||||||
RioFeature.O3_HMEAN,
|
|
||||||
RioFeature.NO2_HMEAN],
|
|
||||||
position=position
|
|
||||||
)
|
|
||||||
|
|
||||||
return belaqi_index(
|
|
||||||
components.get(RioFeature.PM10_24HMEAN, {}).get('value', -1),
|
|
||||||
components.get(RioFeature.PM25_24HMEAN, {}).get('value', -1),
|
|
||||||
components.get(RioFeature.O3_HMEAN, {}).get('value', -1),
|
|
||||||
components.get(RioFeature.NO2_HMEAN, {}).get('value', -1)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def belaqi_index_forecast(forecast_client: IrcelineForecastClient, position: Tuple[float, float],
|
|
||||||
timestamp: date | None = None) -> Dict[date, BelAqiIndex | None]:
|
|
||||||
"""
|
|
||||||
Get forecasted BelAQI index value for the given position using the forecast_client.
|
|
||||||
Data is downloaded for the given day and the four next days
|
|
||||||
Value is None for the date if one or more components cannot be downloaded
|
|
||||||
:param forecast_client: client for the forecast data
|
|
||||||
:param position: position for which to get the data
|
|
||||||
:param timestamp: day at which the forecast are issued
|
|
||||||
:return: dict mapping a day to the forecasted BelAQI index
|
|
||||||
"""
|
|
||||||
if timestamp is None:
|
|
||||||
timestamp = date.today()
|
|
||||||
components = await forecast_client.get_data(
|
|
||||||
timestamp=timestamp,
|
|
||||||
features=[ForecastFeature.PM10_DMEAN,
|
|
||||||
ForecastFeature.PM25_DMEAN,
|
|
||||||
ForecastFeature.O3_MAXHMEAN,
|
|
||||||
ForecastFeature.NO2_MAXHMEAN],
|
|
||||||
position=position
|
|
||||||
)
|
|
||||||
|
|
||||||
result = dict()
|
|
||||||
|
|
||||||
for _, day in components.keys():
|
|
||||||
try:
|
|
||||||
result[day] = belaqi_index(
|
|
||||||
components.get((ForecastFeature.PM10_DMEAN, day), {}).get('value', -1),
|
|
||||||
components.get((ForecastFeature.PM25_DMEAN, day), {}).get('value', -1),
|
|
||||||
components.get((ForecastFeature.O3_MAXHMEAN, day), {}).get('value', -1),
|
|
||||||
components.get((ForecastFeature.NO2_MAXHMEAN, day), {}).get('value', -1)
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
result[day] = None
|
|
||||||
|
|
||||||
return result
|
|
|
@ -1,5 +1,5 @@
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from enum import StrEnum, Enum
|
from enum import StrEnum
|
||||||
from typing import TypedDict
|
from typing import TypedDict
|
||||||
|
|
||||||
|
|
||||||
|
@ -30,27 +30,25 @@ class RioFeature(IrcelineFeature):
|
||||||
SO2_HMEAN = 'rio:so2_hmean'
|
SO2_HMEAN = 'rio:so2_hmean'
|
||||||
|
|
||||||
|
|
||||||
|
class RioIfdmFeature(IrcelineFeature):
|
||||||
|
PM25_HMEAN = 'rioifdm:pm25_hmean'
|
||||||
|
NO2_HMEAN = 'rioifdm:no2_hmean'
|
||||||
|
PM10_HMEAN = 'rioifdm:pm10_hmean'
|
||||||
|
O3_HMEAN = 'rioifdm:o3_hmean'
|
||||||
|
BELAQI = 'rioifdm:belaqi'
|
||||||
|
|
||||||
|
|
||||||
class ForecastFeature(IrcelineFeature):
|
class ForecastFeature(IrcelineFeature):
|
||||||
NO2_MAXHMEAN = 'chimere_no2_maxhmean'
|
NO2_MAXHMEAN = 'forecast:no2_maxhmean'
|
||||||
O3_MAXHMEAN = 'chimere_o3_maxhmean'
|
NO2_DMEAN = 'forecast:no2_dmean'
|
||||||
PM10_DMEAN = 'chimere_pm10_dmean'
|
O3_MAXHMEAN = 'forecast:o3_maxhmean'
|
||||||
PM25_DMEAN = 'chimere_pm25_dmean'
|
O3_MAX8HMEAN = 'forecast:o3_max8hmean'
|
||||||
|
PM10_DMEAN = 'forecast:pm10_dmean'
|
||||||
|
PM25_DMEAN = 'forecast:pm25_dmean'
|
||||||
|
BELAQI = 'forecast:belaqi'
|
||||||
|
|
||||||
|
|
||||||
class FeatureValue(TypedDict):
|
class FeatureValue(TypedDict):
|
||||||
# Timestamp at which the value was computed
|
# Timestamp at which the value was computed
|
||||||
timestamp: datetime | date
|
timestamp: datetime | date | None
|
||||||
value: int | float | None
|
value: int | float | None
|
||||||
|
|
||||||
|
|
||||||
class BelAqiIndex(Enum):
|
|
||||||
EXCELLENT = 1
|
|
||||||
VERY_GOOD = 2
|
|
||||||
GOOD = 3
|
|
||||||
FAIRLY_GOOD = 4
|
|
||||||
MODERATE = 5
|
|
||||||
POOR = 6
|
|
||||||
VERY_POOR = 7
|
|
||||||
BAD = 8
|
|
||||||
VERY_BAD = 9
|
|
||||||
HORRIBLE = 10
|
|
||||||
|
|
55
src/open_irceline/forecast.py
Normal file
55
src/open_irceline/forecast.py
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
import asyncio
|
||||||
|
from datetime import date, timedelta, datetime
|
||||||
|
from itertools import product
|
||||||
|
from typing import List, Tuple, Dict
|
||||||
|
|
||||||
|
from aiohttp import ClientResponse, ClientResponseError
|
||||||
|
|
||||||
|
from .api import IrcelineApiError, IrcelineBaseWmsClient, _forecast_wms_base_url
|
||||||
|
from .data import ForecastFeature, FeatureValue
|
||||||
|
|
||||||
|
|
||||||
|
class IrcelineForecastClient(IrcelineBaseWmsClient):
|
||||||
|
_base_url = _forecast_wms_base_url
|
||||||
|
|
||||||
|
async def get_data(self,
|
||||||
|
features: List[ForecastFeature],
|
||||||
|
position: Tuple[float, float]
|
||||||
|
) -> Dict[Tuple[ForecastFeature, date], FeatureValue]:
|
||||||
|
"""
|
||||||
|
Get forecasted concentrations for the given features at the given position. The forecasts are downloaded for
|
||||||
|
the specified day and the 3 next days as well
|
||||||
|
:param features: pollutants to get the forecasts for
|
||||||
|
:param position: (lat, long)
|
||||||
|
:return: dict where key is (ForecastFeature, date of the forecast) and value is a FeatureValue
|
||||||
|
"""
|
||||||
|
timestamp = date.today()
|
||||||
|
result = dict()
|
||||||
|
lat, lon = position
|
||||||
|
base_querystring = (self._default_querystring |
|
||||||
|
{"bbox": f"{lon},{lat},{lon + self._epsilon},{lat + self._epsilon}"})
|
||||||
|
|
||||||
|
tasks = [asyncio.create_task(self._get_single_feature(base_querystring, d, feature, timestamp))
|
||||||
|
for feature, d in product(features, range(4))]
|
||||||
|
results = await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
for r in results:
|
||||||
|
result |= r
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _get_single_feature(self, base_querystring: dict, d: int, feature: ForecastFeature,
|
||||||
|
timestamp: date) -> dict:
|
||||||
|
result = dict()
|
||||||
|
|
||||||
|
querystring = base_querystring | {"layers": f"{feature}_d{d}",
|
||||||
|
"query_layers": f"{feature}_d{d}"}
|
||||||
|
try:
|
||||||
|
r: ClientResponse = await self._api_wrapper(self._base_url, querystring)
|
||||||
|
r: dict = await r.json()
|
||||||
|
result[(feature, timestamp + timedelta(days=d))] = FeatureValue(
|
||||||
|
value=r.get('features', [{}])[0].get('properties', {}).get('GRAY_INDEX'),
|
||||||
|
timestamp=datetime.fromisoformat(r.get('timeStamp')) if 'timeStamp' in r else None)
|
||||||
|
except (IrcelineApiError, ClientResponseError, IndexError):
|
||||||
|
result[(feature, timestamp + timedelta(days=d))] = FeatureValue(value=None, timestamp=None)
|
||||||
|
return result
|
176
src/open_irceline/rio.py
Normal file
176
src/open_irceline/rio.py
Normal file
|
@ -0,0 +1,176 @@
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, date, UTC, timedelta
|
||||||
|
from typing import List, Tuple, Dict, Set
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
|
from aiohttp import ClientResponse, ClientResponseError
|
||||||
|
|
||||||
|
from .api import IrcelineBaseClient, _rio_wfs_base_url, IrcelineApiError, _rio_ifdm_wms_base_url, IrcelineBaseWmsClient
|
||||||
|
from .data import RioFeature, FeatureValue, RioIfdmFeature
|
||||||
|
from .utils import epsg_transform
|
||||||
|
|
||||||
|
|
||||||
|
class IrcelineRioClient(IrcelineBaseClient):
|
||||||
|
"""
|
||||||
|
API client for RIO interpolated IRCEL - CELINE open data
|
||||||
|
RIO is more coarse grained for interpolation than RIO IFDM and allows to request multiple features in the same
|
||||||
|
request, which may be faster.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def get_data(self,
|
||||||
|
features: List[RioFeature],
|
||||||
|
position: Tuple[float, float],
|
||||||
|
timestamp: datetime | date | None = None
|
||||||
|
) -> Dict[RioFeature, FeatureValue]:
|
||||||
|
"""
|
||||||
|
Call the WFS API to get the interpolated level of RioFeature. Raises exception upon API error
|
||||||
|
:param timestamp: datetime for which to get the data for
|
||||||
|
:param features: list of RioFeature to fetch from the API
|
||||||
|
:param position: decimal degrees pair of coordinates
|
||||||
|
:return: dict with the response (key is RioFeature, value is FeatureValue with actual value and timestamp)
|
||||||
|
"""
|
||||||
|
if timestamp is None:
|
||||||
|
timestamp = datetime.now(UTC)
|
||||||
|
# Remove one hour/day 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)
|
||||||
|
if isinstance(timestamp, datetime):
|
||||||
|
timestamp = timestamp.replace(microsecond=0, second=0, minute=0) - timedelta(hours=1)
|
||||||
|
timestamp = timestamp.isoformat()
|
||||||
|
key = 'timestamp'
|
||||||
|
elif isinstance(timestamp, date):
|
||||||
|
timestamp = timestamp - timedelta(days=1)
|
||||||
|
timestamp = timestamp.isoformat()
|
||||||
|
key = 'date'
|
||||||
|
else:
|
||||||
|
raise IrcelineApiError(f"Wrong parameter type for timestamp: {type(timestamp)}")
|
||||||
|
|
||||||
|
lat, lon = epsg_transform(position)
|
||||||
|
querystring = {"service": "WFS",
|
||||||
|
"version": "1.3.0",
|
||||||
|
"request": "GetFeature",
|
||||||
|
"outputFormat": "application/json",
|
||||||
|
"typeName": ",".join(features),
|
||||||
|
"cql_filter":
|
||||||
|
f"{key}>='{timestamp}'"
|
||||||
|
f" AND "
|
||||||
|
f"INTERSECTS(the_geom, POINT ({lat} {lon}))"}
|
||||||
|
r: ClientResponse = await self._api_wrapper(_rio_wfs_base_url, querystring)
|
||||||
|
return self._format_result('rio', await r.json(), features)
|
||||||
|
|
||||||
|
async def get_capabilities(self) -> Set[str]:
|
||||||
|
"""
|
||||||
|
Fetch the list of possible features from the WFS server
|
||||||
|
:return: set of features available on the WFS server
|
||||||
|
"""
|
||||||
|
querystring = {"service": "WFS",
|
||||||
|
"version": "1.3.0",
|
||||||
|
"request": "GetCapabilities"}
|
||||||
|
r: ClientResponse = await self._api_wrapper(_rio_wfs_base_url, querystring)
|
||||||
|
|
||||||
|
return self._parse_capabilities(await r.text())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_capabilities(xml_string: str) -> Set[str]:
|
||||||
|
"""
|
||||||
|
From an XML string obtained with GetCapabilities, generate a set of feature names
|
||||||
|
:param xml_string: XML string to parse
|
||||||
|
:return: set of FeatureType Names found in the XML document
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
root = ElementTree.fromstring(xml_string)
|
||||||
|
except ElementTree.ParseError:
|
||||||
|
return set()
|
||||||
|
# noinspection HttpUrlsUsage
|
||||||
|
# We never connect to the URL, it is just the namespace in the XML
|
||||||
|
namespaces = {
|
||||||
|
'wfs': 'http://www.opengis.net/wfs',
|
||||||
|
}
|
||||||
|
path = './/wfs:FeatureTypeList/wfs:FeatureType/wfs:Name'
|
||||||
|
feature_type_names = {t.text for t in root.findall(path, namespaces)}
|
||||||
|
return feature_type_names
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_result(prefix: str, data: dict, features: List[RioFeature]) -> dict:
|
||||||
|
"""
|
||||||
|
Format the JSON dict returned by the WFS service into a more practical dict to use with only the latest measure
|
||||||
|
for each feature requested
|
||||||
|
:param prefix: namespace of the feature (e.g. rio), without the colon
|
||||||
|
:param data: JSON dict value as returned by the API
|
||||||
|
:param features: RioFeatures wanted in the final dict
|
||||||
|
:return: reduced dict, key is RioFeature, value is FeatureValue
|
||||||
|
"""
|
||||||
|
if data.get('type', None) != 'FeatureCollection' or not isinstance(data.get('features', None), list):
|
||||||
|
return dict()
|
||||||
|
features_api = data.get('features', [])
|
||||||
|
result = dict()
|
||||||
|
for f in features_api:
|
||||||
|
props = f.get('properties', {})
|
||||||
|
if (f.get('id', None) is None or
|
||||||
|
props.get('value', None) is None):
|
||||||
|
continue
|
||||||
|
if (props.get('timestamp', None) is None and
|
||||||
|
props.get('date', None) is None):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
if 'timestamp' in props.keys():
|
||||||
|
timestamp = datetime.fromisoformat(props.get('timestamp'))
|
||||||
|
else:
|
||||||
|
# Cut last character as the date is written '2024-06-15Z' which is not ISO compliant
|
||||||
|
timestamp = date.fromisoformat(props.get('date')[:-1])
|
||||||
|
value = float(props.get('value'))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = f"{prefix}:{f.get('id').split('.')[0]}"
|
||||||
|
if name not in [f'{f}' for f in features]:
|
||||||
|
continue
|
||||||
|
if name not in result or result[name]['timestamp'] < timestamp:
|
||||||
|
result[name] = FeatureValue(timestamp=timestamp, value=value)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class IrcelineRioIfdmClient(IrcelineBaseWmsClient):
|
||||||
|
"""
|
||||||
|
API client for RIO IFDM interpolated IRCEL - CELINE open data
|
||||||
|
RIO IFDM is more fine-grained for interpolation than RIO but only allows one feature to be request at a time, which
|
||||||
|
may be slower
|
||||||
|
"""
|
||||||
|
_base_url = _rio_ifdm_wms_base_url
|
||||||
|
|
||||||
|
async def get_data(self,
|
||||||
|
features: List[RioIfdmFeature],
|
||||||
|
position: Tuple[float, float]
|
||||||
|
) -> Dict[RioIfdmFeature, FeatureValue]:
|
||||||
|
"""
|
||||||
|
Get interpolated concentrations for the given features at the given position.
|
||||||
|
:param features: pollutants to get the forecasts for
|
||||||
|
:param position: (lat, long)
|
||||||
|
:return: dict where key is RioIfdmFeature and value is a FeatureValue
|
||||||
|
"""
|
||||||
|
result = dict()
|
||||||
|
lat, lon = position
|
||||||
|
base_querystring = (self._default_querystring |
|
||||||
|
{"bbox": f"{lon},{lat},{lon + self._epsilon},{lat + self._epsilon}"})
|
||||||
|
|
||||||
|
tasks = [asyncio.create_task(self._get_single_feature(base_querystring, feature)) for feature in features]
|
||||||
|
results = await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
for r in results:
|
||||||
|
result |= r
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _get_single_feature(self, base_querystring: dict, feature: RioIfdmFeature) -> dict:
|
||||||
|
result = dict()
|
||||||
|
querystring = base_querystring | {"layers": f"{feature}", "query_layers": f"{feature}"}
|
||||||
|
try:
|
||||||
|
r: ClientResponse = await self._api_wrapper(self._base_url, querystring)
|
||||||
|
r: dict = await r.json()
|
||||||
|
result[feature] = FeatureValue(
|
||||||
|
value=r.get('features', [{}])[0].get('properties', {}).get('GRAY_INDEX'),
|
||||||
|
timestamp=datetime.fromisoformat(r.get('timeStamp')) if 'timeStamp' in r else None)
|
||||||
|
except (IrcelineApiError, ClientResponseError, IndexError):
|
||||||
|
result[feature] = FeatureValue(value=None, timestamp=None)
|
||||||
|
return result
|
|
@ -1,4 +1,3 @@
|
||||||
from collections import OrderedDict
|
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
from pyproj import Transformer
|
from pyproj import Transformer
|
||||||
|
@ -6,31 +5,6 @@ from pyproj import Transformer
|
||||||
_project_transform = Transformer.from_crs('EPSG:4326', 'EPSG:31370', always_xy=False)
|
_project_transform = Transformer.from_crs('EPSG:4326', 'EPSG:31370', always_xy=False)
|
||||||
|
|
||||||
|
|
||||||
class SizedDict(OrderedDict):
|
|
||||||
"""Dictionary with a maximum size. When more items are added, the least recently accessed element is evicted"""
|
|
||||||
|
|
||||||
def __init__(self, size: int):
|
|
||||||
super().__init__()
|
|
||||||
self._size = size
|
|
||||||
|
|
||||||
def __setitem__(self, key, value):
|
|
||||||
super().__setitem__(key, value)
|
|
||||||
self.move_to_end(key)
|
|
||||||
if len(self) > self._size:
|
|
||||||
self.popitem(False)
|
|
||||||
|
|
||||||
def __getitem__(self, key):
|
|
||||||
self.move_to_end(key)
|
|
||||||
return super().__getitem__(key)
|
|
||||||
|
|
||||||
def get(self, __key, __default=None):
|
|
||||||
self.move_to_end(__key)
|
|
||||||
return super().get(__key, __default)
|
|
||||||
|
|
||||||
def update(self, __m, **kwargs):
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
|
|
||||||
def epsg_transform(position: Tuple[float, float]) -> Tuple[int, int]:
|
def epsg_transform(position: Tuple[float, float]) -> Tuple[int, int]:
|
||||||
"""
|
"""
|
||||||
Convert 'EPSG:4326' coordinates to 'EPSG:31370' coordinates
|
Convert 'EPSG:4326' coordinates to 'EPSG:31370' coordinates
|
||||||
|
@ -39,15 +13,3 @@ def epsg_transform(position: Tuple[float, float]) -> Tuple[int, int]:
|
||||||
"""
|
"""
|
||||||
result = _project_transform.transform(position[0], position[1])
|
result = _project_transform.transform(position[0], position[1])
|
||||||
return round(result[0]), round(result[1])
|
return round(result[0]), round(result[1])
|
||||||
|
|
||||||
|
|
||||||
def round_coordinates(x: float, y: float, step=.05) -> Tuple[float, float]:
|
|
||||||
"""
|
|
||||||
Round the coordinate to the precision given by step
|
|
||||||
:param x: latitude
|
|
||||||
:param y: longitude
|
|
||||||
:param step: precision of the rounding
|
|
||||||
:return: x and y round to the closest step increment
|
|
||||||
"""
|
|
||||||
n = 1 / step
|
|
||||||
return round(x * n) / n, round(y * n) / n
|
|
||||||
|
|
|
@ -27,28 +27,3 @@ def get_mock_session(json_file=None, text_file=None):
|
||||||
mock_session.request = AsyncMock(return_value=mock_response)
|
mock_session.request = AsyncMock(return_value=mock_response)
|
||||||
return mock_session
|
return mock_session
|
||||||
|
|
||||||
|
|
||||||
def create_mock_response(*args, **kwargs):
|
|
||||||
etag = 'my-etag-here'
|
|
||||||
mock_response = Mock()
|
|
||||||
if '20240619' not in kwargs.get('url', ''):
|
|
||||||
mock_response.status = 404
|
|
||||||
mock_response.raise_for_status = Mock(side_effect=aiohttp.ClientResponseError(Mock(), Mock()))
|
|
||||||
elif etag in kwargs.get('headers', {}).get('If-None-Match', ''):
|
|
||||||
mock_response.text = AsyncMock(return_value='')
|
|
||||||
mock_response.status = 304
|
|
||||||
else:
|
|
||||||
mock_response.text = AsyncMock(return_value=get_api_data('forecast.csv', plain=True))
|
|
||||||
mock_response.status = 200
|
|
||||||
|
|
||||||
if '20240619' in kwargs.get('url', ''):
|
|
||||||
mock_response.headers = {'ETag': etag}
|
|
||||||
else:
|
|
||||||
mock_response.headers = dict()
|
|
||||||
return mock_response
|
|
||||||
|
|
||||||
|
|
||||||
def get_mock_session_many_csv():
|
|
||||||
mock_session = Mock(aiohttp.ClientSession)
|
|
||||||
mock_session.request = AsyncMock(side_effect=create_mock_response)
|
|
||||||
return mock_session
|
|
||||||
|
|
3291
tests/fixtures/forecast_wms_capabilities.xml
vendored
Normal file
3291
tests/fixtures/forecast_wms_capabilities.xml
vendored
Normal file
File diff suppressed because it is too large
Load diff
17
tests/fixtures/forecast_wms_feature_info.json
vendored
Normal file
17
tests/fixtures/forecast_wms_feature_info.json
vendored
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"id": "",
|
||||||
|
"geometry": null,
|
||||||
|
"properties": {
|
||||||
|
"GRAY_INDEX": 10.853286743164062
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalFeatures": "unknown",
|
||||||
|
"numberReturned": 1,
|
||||||
|
"timeStamp": "2024-06-30T13:00:21.520Z",
|
||||||
|
"crs": null
|
||||||
|
}
|
17
tests/fixtures/forecast_wms_feature_info_invalid.json
vendored
Normal file
17
tests/fixtures/forecast_wms_feature_info_invalid.json
vendored
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"id": "",
|
||||||
|
"geometry": null,
|
||||||
|
"properties": {
|
||||||
|
"WRONG": 10.853286743164062
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalFeatures": "unknown",
|
||||||
|
"numberReturned": 1,
|
||||||
|
"timeStamp": "2024-06-30T13:00:21.520Z",
|
||||||
|
"crs": null
|
||||||
|
}
|
8
tests/fixtures/forecast_wms_feature_info_no_field.json
vendored
Normal file
8
tests/fixtures/forecast_wms_feature_info_no_field.json
vendored
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": [],
|
||||||
|
"totalFeatures": "unknown",
|
||||||
|
"numberReturned": 1,
|
||||||
|
"timeStamp": "2024-06-30T13:00:21.520Z",
|
||||||
|
"crs": null
|
||||||
|
}
|
17
tests/fixtures/ifdm_interpolation_feature_info.json
vendored
Normal file
17
tests/fixtures/ifdm_interpolation_feature_info.json
vendored
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"id": "",
|
||||||
|
"geometry": null,
|
||||||
|
"properties": {
|
||||||
|
"GRAY_INDEX": 84.33950805664062
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalFeatures": "unknown",
|
||||||
|
"numberReturned": 1,
|
||||||
|
"timeStamp": "2024-06-30T15:43:07.222Z",
|
||||||
|
"crs": null
|
||||||
|
}
|
694
tests/fixtures/rio_ifdm_capabilities.xml
vendored
Normal file
694
tests/fixtures/rio_ifdm_capabilities.xml
vendored
Normal file
|
@ -0,0 +1,694 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE WMT_MS_Capabilities SYSTEM "https://geobelair.irceline.be/schemas/wms/1.1.1/WMS_MS_Capabilities.dtd">
|
||||||
|
<WMT_MS_Capabilities version="1.1.1" updateSequence="4129">
|
||||||
|
<Service>
|
||||||
|
<Name>OGC:WMS</Name>
|
||||||
|
<Title>IRCEL - CELINE - Web Map Service</Title>
|
||||||
|
<Abstract>A compliant implementation of WMS plus most of the SLD extension (dynamic styling). Can also generate
|
||||||
|
PDF, SVG, KML, GeoRSS
|
||||||
|
</Abstract>
|
||||||
|
<KeywordList>
|
||||||
|
<Keyword>WFS</Keyword>
|
||||||
|
<Keyword>WMS</Keyword>
|
||||||
|
<Keyword>GEOSERVER</Keyword>
|
||||||
|
</KeywordList>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="http://geo.irceline.be/rioifdm/wms"/>
|
||||||
|
<ContactInformation>
|
||||||
|
<ContactPersonPrimary>
|
||||||
|
<ContactPerson>IRCEL - CELINE</ContactPerson>
|
||||||
|
<ContactOrganization>Belgian Interregional Environment Agency</ContactOrganization>
|
||||||
|
</ContactPersonPrimary>
|
||||||
|
<ContactPosition/>
|
||||||
|
<ContactAddress>
|
||||||
|
<AddressType/>
|
||||||
|
<Address>Gaucheretstraat 92-94 Rue Gaucheret</Address>
|
||||||
|
<City>Brussels</City>
|
||||||
|
<StateOrProvince/>
|
||||||
|
<PostCode>1030</PostCode>
|
||||||
|
<Country>Belgium</Country>
|
||||||
|
</ContactAddress>
|
||||||
|
<ContactVoiceTelephone>+(32)(0)2 227 57 01</ContactVoiceTelephone>
|
||||||
|
<ContactFacsimileTelephone/>
|
||||||
|
<ContactElectronicMailAddress>info@irceline.be</ContactElectronicMailAddress>
|
||||||
|
</ContactInformation>
|
||||||
|
<Fees>NONE</Fees>
|
||||||
|
<AccessConstraints>NONE</AccessConstraints>
|
||||||
|
</Service>
|
||||||
|
<Capability>
|
||||||
|
<Request>
|
||||||
|
<GetCapabilities>
|
||||||
|
<Format>application/vnd.ogc.wms_xml</Format>
|
||||||
|
<Format>text/xml</Format>
|
||||||
|
<DCPType>
|
||||||
|
<HTTP>
|
||||||
|
<Get>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?SERVICE=WMS&"/>
|
||||||
|
</Get>
|
||||||
|
<Post>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?SERVICE=WMS&"/>
|
||||||
|
</Post>
|
||||||
|
</HTTP>
|
||||||
|
</DCPType>
|
||||||
|
</GetCapabilities>
|
||||||
|
<GetMap>
|
||||||
|
<Format>image/png</Format>
|
||||||
|
<Format>application/atom xml</Format>
|
||||||
|
<Format>application/atom+xml</Format>
|
||||||
|
<Format>application/json;type=utfgrid</Format>
|
||||||
|
<Format>application/openlayers</Format>
|
||||||
|
<Format>application/openlayers2</Format>
|
||||||
|
<Format>application/openlayers3</Format>
|
||||||
|
<Format>application/pdf</Format>
|
||||||
|
<Format>application/rss xml</Format>
|
||||||
|
<Format>application/rss+xml</Format>
|
||||||
|
<Format>application/vnd.google-earth.kml</Format>
|
||||||
|
<Format>application/vnd.google-earth.kml xml</Format>
|
||||||
|
<Format>application/vnd.google-earth.kml+xml</Format>
|
||||||
|
<Format>application/vnd.google-earth.kml+xml;mode=networklink</Format>
|
||||||
|
<Format>application/vnd.google-earth.kmz</Format>
|
||||||
|
<Format>application/vnd.google-earth.kmz xml</Format>
|
||||||
|
<Format>application/vnd.google-earth.kmz+xml</Format>
|
||||||
|
<Format>application/vnd.google-earth.kmz;mode=networklink</Format>
|
||||||
|
<Format>atom</Format>
|
||||||
|
<Format>image/geotiff</Format>
|
||||||
|
<Format>image/geotiff8</Format>
|
||||||
|
<Format>image/gif</Format>
|
||||||
|
<Format>image/gif;subtype=animated</Format>
|
||||||
|
<Format>image/jpeg</Format>
|
||||||
|
<Format>image/png8</Format>
|
||||||
|
<Format>image/png; mode=8bit</Format>
|
||||||
|
<Format>image/svg</Format>
|
||||||
|
<Format>image/svg xml</Format>
|
||||||
|
<Format>image/svg+xml</Format>
|
||||||
|
<Format>image/tiff</Format>
|
||||||
|
<Format>image/tiff8</Format>
|
||||||
|
<Format>image/vnd.jpeg-png</Format>
|
||||||
|
<Format>image/vnd.jpeg-png8</Format>
|
||||||
|
<Format>kml</Format>
|
||||||
|
<Format>kmz</Format>
|
||||||
|
<Format>openlayers</Format>
|
||||||
|
<Format>rss</Format>
|
||||||
|
<Format>text/html; subtype=openlayers</Format>
|
||||||
|
<Format>text/html; subtype=openlayers2</Format>
|
||||||
|
<Format>text/html; subtype=openlayers3</Format>
|
||||||
|
<Format>utfgrid</Format>
|
||||||
|
<DCPType>
|
||||||
|
<HTTP>
|
||||||
|
<Get>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?SERVICE=WMS&"/>
|
||||||
|
</Get>
|
||||||
|
</HTTP>
|
||||||
|
</DCPType>
|
||||||
|
</GetMap>
|
||||||
|
<GetFeatureInfo>
|
||||||
|
<Format>text/plain</Format>
|
||||||
|
<Format>application/vnd.ogc.gml</Format>
|
||||||
|
<Format>text/xml</Format>
|
||||||
|
<Format>application/vnd.ogc.gml/3.1.1</Format>
|
||||||
|
<Format>text/xml; subtype=gml/3.1.1</Format>
|
||||||
|
<Format>text/html</Format>
|
||||||
|
<Format>application/json</Format>
|
||||||
|
<DCPType>
|
||||||
|
<HTTP>
|
||||||
|
<Get>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?SERVICE=WMS&"/>
|
||||||
|
</Get>
|
||||||
|
<Post>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?SERVICE=WMS&"/>
|
||||||
|
</Post>
|
||||||
|
</HTTP>
|
||||||
|
</DCPType>
|
||||||
|
</GetFeatureInfo>
|
||||||
|
<DescribeLayer>
|
||||||
|
<Format>application/vnd.ogc.wms_xml</Format>
|
||||||
|
<DCPType>
|
||||||
|
<HTTP>
|
||||||
|
<Get>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?SERVICE=WMS&"/>
|
||||||
|
</Get>
|
||||||
|
</HTTP>
|
||||||
|
</DCPType>
|
||||||
|
</DescribeLayer>
|
||||||
|
<GetLegendGraphic>
|
||||||
|
<Format>image/png</Format>
|
||||||
|
<Format>image/jpeg</Format>
|
||||||
|
<Format>application/json</Format>
|
||||||
|
<Format>image/gif</Format>
|
||||||
|
<DCPType>
|
||||||
|
<HTTP>
|
||||||
|
<Get>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?SERVICE=WMS&"/>
|
||||||
|
</Get>
|
||||||
|
</HTTP>
|
||||||
|
</DCPType>
|
||||||
|
</GetLegendGraphic>
|
||||||
|
<GetStyles>
|
||||||
|
<Format>application/vnd.ogc.sld+xml</Format>
|
||||||
|
<DCPType>
|
||||||
|
<HTTP>
|
||||||
|
<Get>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?SERVICE=WMS&"/>
|
||||||
|
</Get>
|
||||||
|
</HTTP>
|
||||||
|
</DCPType>
|
||||||
|
</GetStyles>
|
||||||
|
</Request>
|
||||||
|
<Exception>
|
||||||
|
<Format>application/vnd.ogc.se_xml</Format>
|
||||||
|
<Format>application/vnd.ogc.se_inimage</Format>
|
||||||
|
<Format>application/vnd.ogc.se_blank</Format>
|
||||||
|
<Format>application/json</Format>
|
||||||
|
</Exception>
|
||||||
|
<UserDefinedSymbolization SupportSLD="1" UserLayer="1" UserStyle="1" RemoteWFS="1"/>
|
||||||
|
<Layer>
|
||||||
|
<Title>IRCEL - CELINE - Web Map Service</Title>
|
||||||
|
<Abstract>A compliant implementation of WMS plus most of the SLD extension (dynamic styling). Can also
|
||||||
|
generate PDF, SVG, KML, GeoRSS
|
||||||
|
</Abstract>
|
||||||
|
<!--Limited list of EPSG projections:-->
|
||||||
|
<SRS>EPSG:3857</SRS>
|
||||||
|
<SRS>EPSG:4258</SRS>
|
||||||
|
<SRS>EPSG:4326</SRS>
|
||||||
|
<SRS>EPSG:31370</SRS>
|
||||||
|
<SRS>EPSG:900913</SRS>
|
||||||
|
<LatLonBoundingBox minx="2.4804079470216474" miny="49.461523892203324" maxx="6.494748595696256"
|
||||||
|
maxy="51.54189845090732"/>
|
||||||
|
<AuthorityURL name="IRCEL - CELINE">
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="http://geo.irceline.be"/>
|
||||||
|
</AuthorityURL>
|
||||||
|
<Identifier authority="IRCEL - CELINE">http://geo.irceline.be</Identifier>
|
||||||
|
<Layer queryable="1" opaque="0">
|
||||||
|
<Name>belaqi</Name>
|
||||||
|
<Title>belaqi</Title>
|
||||||
|
<Abstract/>
|
||||||
|
<KeywordList>
|
||||||
|
<Keyword>belaqi</Keyword>
|
||||||
|
<Keyword>WCS</Keyword>
|
||||||
|
<Keyword>GeoTIFF</Keyword>
|
||||||
|
</KeywordList>
|
||||||
|
<SRS>EPSG:31370</SRS>
|
||||||
|
<LatLonBoundingBox minx="2.4804079470216474" miny="49.461523892203324" maxx="6.494748595696256"
|
||||||
|
maxy="51.54189845090732"/>
|
||||||
|
<BoundingBox SRS="EPSG:31370" minx="18950.0" miny="18650.0" maxx="297550.0" maxy="248050.0"/>
|
||||||
|
<Style>
|
||||||
|
<Name>belaqi_raster_discrete_belair</Name>
|
||||||
|
<Title>AQ index for raster (discrete colour schale)</Title>
|
||||||
|
<Abstract>BelAQI index colour scale. Classic concentration color progression.</Abstract>
|
||||||
|
<LegendURL width="36" height="250">
|
||||||
|
<Format>image/png</Format>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?request=GetLegendGraphic&version=1.1.1&format=image%2Fpng&width=20&height=20&layer=belaqi"/>
|
||||||
|
</LegendURL>
|
||||||
|
</Style>
|
||||||
|
</Layer>
|
||||||
|
<Layer queryable="1" opaque="0">
|
||||||
|
<Name>belaqi_dm1</Name>
|
||||||
|
<Title>belaqi_dm1</Title>
|
||||||
|
<Abstract/>
|
||||||
|
<KeywordList>
|
||||||
|
<Keyword>belaqi_dmean_dm1</Keyword>
|
||||||
|
<Keyword>WCS</Keyword>
|
||||||
|
<Keyword>GeoTIFF</Keyword>
|
||||||
|
</KeywordList>
|
||||||
|
<SRS>EPSG:31370</SRS>
|
||||||
|
<LatLonBoundingBox minx="2.4804079470216474" miny="49.461523892203324" maxx="6.494748595696256"
|
||||||
|
maxy="51.54189845090732"/>
|
||||||
|
<BoundingBox SRS="EPSG:31370" minx="18950.0" miny="18650.0" maxx="297550.0" maxy="248050.0"/>
|
||||||
|
<Style>
|
||||||
|
<Name>belaqi_raster_discrete_belair</Name>
|
||||||
|
<Title>AQ index for raster (discrete colour schale)</Title>
|
||||||
|
<Abstract>BelAQI index colour scale. Classic concentration color progression.</Abstract>
|
||||||
|
<LegendURL width="36" height="250">
|
||||||
|
<Format>image/png</Format>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?request=GetLegendGraphic&version=1.1.1&format=image%2Fpng&width=20&height=20&layer=belaqi_dm1"/>
|
||||||
|
</LegendURL>
|
||||||
|
</Style>
|
||||||
|
</Layer>
|
||||||
|
<Layer queryable="1" opaque="0">
|
||||||
|
<Name>belaqi_dm2</Name>
|
||||||
|
<Title>belaqi_dm2</Title>
|
||||||
|
<Abstract/>
|
||||||
|
<KeywordList>
|
||||||
|
<Keyword>belaqi_dmean_dm2</Keyword>
|
||||||
|
<Keyword>WCS</Keyword>
|
||||||
|
<Keyword>GeoTIFF</Keyword>
|
||||||
|
</KeywordList>
|
||||||
|
<SRS>EPSG:31370</SRS>
|
||||||
|
<LatLonBoundingBox minx="2.4804079470216474" miny="49.461523892203324" maxx="6.494748595696256"
|
||||||
|
maxy="51.54189845090732"/>
|
||||||
|
<BoundingBox SRS="EPSG:31370" minx="18950.0" miny="18650.0" maxx="297550.0" maxy="248050.0"/>
|
||||||
|
<Style>
|
||||||
|
<Name>belaqi_raster_discrete_belair</Name>
|
||||||
|
<Title>AQ index for raster (discrete colour schale)</Title>
|
||||||
|
<Abstract>BelAQI index colour scale. Classic concentration color progression.</Abstract>
|
||||||
|
<LegendURL width="36" height="250">
|
||||||
|
<Format>image/png</Format>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?request=GetLegendGraphic&version=1.1.1&format=image%2Fpng&width=20&height=20&layer=belaqi_dm2"/>
|
||||||
|
</LegendURL>
|
||||||
|
</Style>
|
||||||
|
</Layer>
|
||||||
|
<Layer queryable="1" opaque="0">
|
||||||
|
<Name>belaqi_dm3</Name>
|
||||||
|
<Title>belaqi_dm3</Title>
|
||||||
|
<Abstract/>
|
||||||
|
<KeywordList>
|
||||||
|
<Keyword>belaqi_dmean_dm3</Keyword>
|
||||||
|
<Keyword>WCS</Keyword>
|
||||||
|
<Keyword>GeoTIFF</Keyword>
|
||||||
|
</KeywordList>
|
||||||
|
<SRS>EPSG:31370</SRS>
|
||||||
|
<LatLonBoundingBox minx="2.4804079470216474" miny="49.461523892203324" maxx="6.494748595696256"
|
||||||
|
maxy="51.54189845090732"/>
|
||||||
|
<BoundingBox SRS="EPSG:31370" minx="18950.0" miny="18650.0" maxx="297550.0" maxy="248050.0"/>
|
||||||
|
<Style>
|
||||||
|
<Name>belaqi_raster_discrete_belair</Name>
|
||||||
|
<Title>AQ index for raster (discrete colour schale)</Title>
|
||||||
|
<Abstract>BelAQI index colour scale. Classic concentration color progression.</Abstract>
|
||||||
|
<LegendURL width="36" height="250">
|
||||||
|
<Format>image/png</Format>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?request=GetLegendGraphic&version=1.1.1&format=image%2Fpng&width=20&height=20&layer=belaqi_dm3"/>
|
||||||
|
</LegendURL>
|
||||||
|
</Style>
|
||||||
|
</Layer>
|
||||||
|
<Layer queryable="1" opaque="0">
|
||||||
|
<Name>no2_dm1</Name>
|
||||||
|
<Title>no2_dm1</Title>
|
||||||
|
<Abstract/>
|
||||||
|
<KeywordList>
|
||||||
|
<Keyword>no2_dmean_dm1</Keyword>
|
||||||
|
<Keyword>WCS</Keyword>
|
||||||
|
<Keyword>GeoTIFF</Keyword>
|
||||||
|
</KeywordList>
|
||||||
|
<SRS>EPSG:31370</SRS>
|
||||||
|
<LatLonBoundingBox minx="2.4804079470216474" miny="49.461523892203324" maxx="6.494748595696256"
|
||||||
|
maxy="51.54189845090732"/>
|
||||||
|
<BoundingBox SRS="EPSG:31370" minx="18950.0" miny="18650.0" maxx="297550.0" maxy="248050.0"/>
|
||||||
|
<Style>
|
||||||
|
<Name>no2_dmean_raster_discrete_belair</Name>
|
||||||
|
<Title>Nitrogen dioxide (NO2) daily mean concentrations</Title>
|
||||||
|
<Abstract>BelAQI index colour scale. Daily mean concentration classes for Nitrogen dioxide (NO2).
|
||||||
|
</Abstract>
|
||||||
|
<LegendURL width="80" height="252">
|
||||||
|
<Format>image/png</Format>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?request=GetLegendGraphic&version=1.1.1&format=image%2Fpng&width=20&height=20&layer=no2_dm1"/>
|
||||||
|
</LegendURL>
|
||||||
|
</Style>
|
||||||
|
</Layer>
|
||||||
|
<Layer queryable="1" opaque="0">
|
||||||
|
<Name>no2_dm2</Name>
|
||||||
|
<Title>no2_dm2</Title>
|
||||||
|
<Abstract/>
|
||||||
|
<KeywordList>
|
||||||
|
<Keyword>no2_dmean_dm2</Keyword>
|
||||||
|
<Keyword>WCS</Keyword>
|
||||||
|
<Keyword>GeoTIFF</Keyword>
|
||||||
|
</KeywordList>
|
||||||
|
<SRS>EPSG:31370</SRS>
|
||||||
|
<LatLonBoundingBox minx="2.4804079470216474" miny="49.461523892203324" maxx="6.494748595696256"
|
||||||
|
maxy="51.54189845090732"/>
|
||||||
|
<BoundingBox SRS="EPSG:31370" minx="18950.0" miny="18650.0" maxx="297550.0" maxy="248050.0"/>
|
||||||
|
<Style>
|
||||||
|
<Name>no2_dmean_raster_discrete_belair</Name>
|
||||||
|
<Title>Nitrogen dioxide (NO2) daily mean concentrations</Title>
|
||||||
|
<Abstract>BelAQI index colour scale. Daily mean concentration classes for Nitrogen dioxide (NO2).
|
||||||
|
</Abstract>
|
||||||
|
<LegendURL width="80" height="252">
|
||||||
|
<Format>image/png</Format>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?request=GetLegendGraphic&version=1.1.1&format=image%2Fpng&width=20&height=20&layer=no2_dm2"/>
|
||||||
|
</LegendURL>
|
||||||
|
</Style>
|
||||||
|
</Layer>
|
||||||
|
<Layer queryable="1" opaque="0">
|
||||||
|
<Name>no2_dm3</Name>
|
||||||
|
<Title>no2_dm3</Title>
|
||||||
|
<Abstract/>
|
||||||
|
<KeywordList>
|
||||||
|
<Keyword>no2_dmean_dm3</Keyword>
|
||||||
|
<Keyword>WCS</Keyword>
|
||||||
|
<Keyword>GeoTIFF</Keyword>
|
||||||
|
</KeywordList>
|
||||||
|
<SRS>EPSG:31370</SRS>
|
||||||
|
<LatLonBoundingBox minx="2.4804079470216474" miny="49.461523892203324" maxx="6.494748595696256"
|
||||||
|
maxy="51.54189845090732"/>
|
||||||
|
<BoundingBox SRS="EPSG:31370" minx="18950.0" miny="18650.0" maxx="297550.0" maxy="248050.0"/>
|
||||||
|
<Style>
|
||||||
|
<Name>no2_dmean_raster_discrete_belair</Name>
|
||||||
|
<Title>Nitrogen dioxide (NO2) daily mean concentrations</Title>
|
||||||
|
<Abstract>BelAQI index colour scale. Daily mean concentration classes for Nitrogen dioxide (NO2).
|
||||||
|
</Abstract>
|
||||||
|
<LegendURL width="80" height="252">
|
||||||
|
<Format>image/png</Format>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?request=GetLegendGraphic&version=1.1.1&format=image%2Fpng&width=20&height=20&layer=no2_dm3"/>
|
||||||
|
</LegendURL>
|
||||||
|
</Style>
|
||||||
|
</Layer>
|
||||||
|
<Layer queryable="1" opaque="0">
|
||||||
|
<Name>no2_hmean</Name>
|
||||||
|
<Title>no2_hmean</Title>
|
||||||
|
<Abstract/>
|
||||||
|
<KeywordList>
|
||||||
|
<Keyword>no2_hmean</Keyword>
|
||||||
|
<Keyword>WCS</Keyword>
|
||||||
|
<Keyword>GeoTIFF</Keyword>
|
||||||
|
</KeywordList>
|
||||||
|
<SRS>EPSG:31370</SRS>
|
||||||
|
<LatLonBoundingBox minx="2.4804079470216474" miny="49.461523892203324" maxx="6.494748595696256"
|
||||||
|
maxy="51.54189845090732"/>
|
||||||
|
<BoundingBox SRS="EPSG:31370" minx="18950.0" miny="18650.0" maxx="297550.0" maxy="248050.0"/>
|
||||||
|
<Style>
|
||||||
|
<Name>no2_hmean_raster_discrete_belair</Name>
|
||||||
|
<Title>Nitrogen dioxide (NO2) hourly mean concentrations</Title>
|
||||||
|
<Abstract>BelAQI index colour scale. Hourly mean concentration classes for Nitrogen dioxide (NO2).
|
||||||
|
</Abstract>
|
||||||
|
<LegendURL width="80" height="252">
|
||||||
|
<Format>image/png</Format>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?request=GetLegendGraphic&version=1.1.1&format=image%2Fpng&width=20&height=20&layer=no2_hmean"/>
|
||||||
|
</LegendURL>
|
||||||
|
</Style>
|
||||||
|
</Layer>
|
||||||
|
<Layer queryable="1" opaque="0">
|
||||||
|
<Name>o3_dm1</Name>
|
||||||
|
<Title>o3_dm1</Title>
|
||||||
|
<Abstract/>
|
||||||
|
<KeywordList>
|
||||||
|
<Keyword>o3_max8hmean_dm1</Keyword>
|
||||||
|
<Keyword>WCS</Keyword>
|
||||||
|
<Keyword>GeoTIFF</Keyword>
|
||||||
|
</KeywordList>
|
||||||
|
<SRS>EPSG:31370</SRS>
|
||||||
|
<LatLonBoundingBox minx="2.4804079470216474" miny="49.461523892203324" maxx="6.494748595696256"
|
||||||
|
maxy="51.54189845090732"/>
|
||||||
|
<BoundingBox SRS="EPSG:31370" minx="18950.0" miny="18650.0" maxx="297550.0" maxy="248050.0"/>
|
||||||
|
<Style>
|
||||||
|
<Name>o3_max8hmean_raster_discrete_belair</Name>
|
||||||
|
<Title>Ozone (O3) daily max 8-hourly mean concentrations</Title>
|
||||||
|
<Abstract>BelAQI index colour scale. Daily max 8-hourly mean concentration classes for Ozone (O3).
|
||||||
|
</Abstract>
|
||||||
|
<LegendURL width="87" height="252">
|
||||||
|
<Format>image/png</Format>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?request=GetLegendGraphic&version=1.1.1&format=image%2Fpng&width=20&height=20&layer=o3_dm1"/>
|
||||||
|
</LegendURL>
|
||||||
|
</Style>
|
||||||
|
</Layer>
|
||||||
|
<Layer queryable="1" opaque="0">
|
||||||
|
<Name>o3_dm2</Name>
|
||||||
|
<Title>o3_dm2</Title>
|
||||||
|
<Abstract/>
|
||||||
|
<KeywordList>
|
||||||
|
<Keyword>o3_max8hmean_dm2</Keyword>
|
||||||
|
<Keyword>WCS</Keyword>
|
||||||
|
<Keyword>GeoTIFF</Keyword>
|
||||||
|
</KeywordList>
|
||||||
|
<SRS>EPSG:31370</SRS>
|
||||||
|
<LatLonBoundingBox minx="2.4804079470216474" miny="49.461523892203324" maxx="6.494748595696256"
|
||||||
|
maxy="51.54189845090732"/>
|
||||||
|
<BoundingBox SRS="EPSG:31370" minx="18950.0" miny="18650.0" maxx="297550.0" maxy="248050.0"/>
|
||||||
|
<Style>
|
||||||
|
<Name>o3_max8hmean_raster_discrete_belair</Name>
|
||||||
|
<Title>Ozone (O3) daily max 8-hourly mean concentrations</Title>
|
||||||
|
<Abstract>BelAQI index colour scale. Daily max 8-hourly mean concentration classes for Ozone (O3).
|
||||||
|
</Abstract>
|
||||||
|
<LegendURL width="87" height="252">
|
||||||
|
<Format>image/png</Format>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?request=GetLegendGraphic&version=1.1.1&format=image%2Fpng&width=20&height=20&layer=o3_dm2"/>
|
||||||
|
</LegendURL>
|
||||||
|
</Style>
|
||||||
|
</Layer>
|
||||||
|
<Layer queryable="1" opaque="0">
|
||||||
|
<Name>o3_dm3</Name>
|
||||||
|
<Title>o3_dm3</Title>
|
||||||
|
<Abstract/>
|
||||||
|
<KeywordList>
|
||||||
|
<Keyword>o3_max8hmean_dm3</Keyword>
|
||||||
|
<Keyword>WCS</Keyword>
|
||||||
|
<Keyword>GeoTIFF</Keyword>
|
||||||
|
</KeywordList>
|
||||||
|
<SRS>EPSG:31370</SRS>
|
||||||
|
<LatLonBoundingBox minx="2.4804079470216474" miny="49.461523892203324" maxx="6.494748595696256"
|
||||||
|
maxy="51.54189845090732"/>
|
||||||
|
<BoundingBox SRS="EPSG:31370" minx="18950.0" miny="18650.0" maxx="297550.0" maxy="248050.0"/>
|
||||||
|
<Style>
|
||||||
|
<Name>o3_max8hmean_raster_discrete_belair</Name>
|
||||||
|
<Title>Ozone (O3) daily max 8-hourly mean concentrations</Title>
|
||||||
|
<Abstract>BelAQI index colour scale. Daily max 8-hourly mean concentration classes for Ozone (O3).
|
||||||
|
</Abstract>
|
||||||
|
<LegendURL width="87" height="252">
|
||||||
|
<Format>image/png</Format>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?request=GetLegendGraphic&version=1.1.1&format=image%2Fpng&width=20&height=20&layer=o3_dm3"/>
|
||||||
|
</LegendURL>
|
||||||
|
</Style>
|
||||||
|
</Layer>
|
||||||
|
<Layer queryable="1" opaque="0">
|
||||||
|
<Name>o3_hmean</Name>
|
||||||
|
<Title>o3_hmean</Title>
|
||||||
|
<Abstract/>
|
||||||
|
<KeywordList>
|
||||||
|
<Keyword>o3_hmean</Keyword>
|
||||||
|
<Keyword>WCS</Keyword>
|
||||||
|
<Keyword>GeoTIFF</Keyword>
|
||||||
|
</KeywordList>
|
||||||
|
<SRS>EPSG:31370</SRS>
|
||||||
|
<LatLonBoundingBox minx="2.4804079470216474" miny="49.461523892203324" maxx="6.494748595696256"
|
||||||
|
maxy="51.54189845090732"/>
|
||||||
|
<BoundingBox SRS="EPSG:31370" minx="18950.0" miny="18650.0" maxx="297550.0" maxy="248050.0"/>
|
||||||
|
<Style>
|
||||||
|
<Name>o3_hmean_raster_discrete_belair</Name>
|
||||||
|
<Title>Ozone (O3) hourly mean concentrations</Title>
|
||||||
|
<Abstract>BelAQI index colour scale. Annual mean concentration classes for Ozone (O3).</Abstract>
|
||||||
|
<LegendURL width="87" height="252">
|
||||||
|
<Format>image/png</Format>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?request=GetLegendGraphic&version=1.1.1&format=image%2Fpng&width=20&height=20&layer=o3_hmean"/>
|
||||||
|
</LegendURL>
|
||||||
|
</Style>
|
||||||
|
</Layer>
|
||||||
|
<Layer queryable="1" opaque="0">
|
||||||
|
<Name>pm10_dm1</Name>
|
||||||
|
<Title>pm10_dm1</Title>
|
||||||
|
<Abstract/>
|
||||||
|
<KeywordList>
|
||||||
|
<Keyword>pm10_dmean_dm1</Keyword>
|
||||||
|
<Keyword>WCS</Keyword>
|
||||||
|
<Keyword>GeoTIFF</Keyword>
|
||||||
|
</KeywordList>
|
||||||
|
<SRS>EPSG:31370</SRS>
|
||||||
|
<LatLonBoundingBox minx="2.4804079470216474" miny="49.461523892203324" maxx="6.494748595696256"
|
||||||
|
maxy="51.54189845090732"/>
|
||||||
|
<BoundingBox SRS="EPSG:31370" minx="18950.0" miny="18650.0" maxx="297550.0" maxy="248050.0"/>
|
||||||
|
<Style>
|
||||||
|
<Name>pm10_dmean_raster_discrete_belair</Name>
|
||||||
|
<Title>particulate matter (PM10) daily mean concentrations</Title>
|
||||||
|
<Abstract>BelAQI index colour scale. Daily mean concentration classes for particulate matter
|
||||||
|
(PM10).
|
||||||
|
</Abstract>
|
||||||
|
<LegendURL width="87" height="252">
|
||||||
|
<Format>image/png</Format>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?request=GetLegendGraphic&version=1.1.1&format=image%2Fpng&width=20&height=20&layer=pm10_dm1"/>
|
||||||
|
</LegendURL>
|
||||||
|
</Style>
|
||||||
|
</Layer>
|
||||||
|
<Layer queryable="1" opaque="0">
|
||||||
|
<Name>pm10_dm2</Name>
|
||||||
|
<Title>pm10_dm2</Title>
|
||||||
|
<Abstract/>
|
||||||
|
<KeywordList>
|
||||||
|
<Keyword>pm10_dmean_dm2</Keyword>
|
||||||
|
<Keyword>WCS</Keyword>
|
||||||
|
<Keyword>GeoTIFF</Keyword>
|
||||||
|
</KeywordList>
|
||||||
|
<SRS>EPSG:31370</SRS>
|
||||||
|
<LatLonBoundingBox minx="2.4804079470216474" miny="49.461523892203324" maxx="6.494748595696256"
|
||||||
|
maxy="51.54189845090732"/>
|
||||||
|
<BoundingBox SRS="EPSG:31370" minx="18950.0" miny="18650.0" maxx="297550.0" maxy="248050.0"/>
|
||||||
|
<Style>
|
||||||
|
<Name>pm10_dmean_raster_discrete_belair</Name>
|
||||||
|
<Title>particulate matter (PM10) daily mean concentrations</Title>
|
||||||
|
<Abstract>BelAQI index colour scale. Daily mean concentration classes for particulate matter
|
||||||
|
(PM10).
|
||||||
|
</Abstract>
|
||||||
|
<LegendURL width="87" height="252">
|
||||||
|
<Format>image/png</Format>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?request=GetLegendGraphic&version=1.1.1&format=image%2Fpng&width=20&height=20&layer=pm10_dm2"/>
|
||||||
|
</LegendURL>
|
||||||
|
</Style>
|
||||||
|
</Layer>
|
||||||
|
<Layer queryable="1" opaque="0">
|
||||||
|
<Name>pm10_dm3</Name>
|
||||||
|
<Title>pm10_dm3</Title>
|
||||||
|
<Abstract/>
|
||||||
|
<KeywordList>
|
||||||
|
<Keyword>pm10_dmean_dm3</Keyword>
|
||||||
|
<Keyword>WCS</Keyword>
|
||||||
|
<Keyword>GeoTIFF</Keyword>
|
||||||
|
</KeywordList>
|
||||||
|
<SRS>EPSG:31370</SRS>
|
||||||
|
<LatLonBoundingBox minx="2.4804079470216474" miny="49.461523892203324" maxx="6.494748595696256"
|
||||||
|
maxy="51.54189845090732"/>
|
||||||
|
<BoundingBox SRS="EPSG:31370" minx="18950.0" miny="18650.0" maxx="297550.0" maxy="248050.0"/>
|
||||||
|
<Style>
|
||||||
|
<Name>pm10_dmean_raster_discrete_belair</Name>
|
||||||
|
<Title>particulate matter (PM10) daily mean concentrations</Title>
|
||||||
|
<Abstract>BelAQI index colour scale. Daily mean concentration classes for particulate matter
|
||||||
|
(PM10).
|
||||||
|
</Abstract>
|
||||||
|
<LegendURL width="87" height="252">
|
||||||
|
<Format>image/png</Format>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?request=GetLegendGraphic&version=1.1.1&format=image%2Fpng&width=20&height=20&layer=pm10_dm3"/>
|
||||||
|
</LegendURL>
|
||||||
|
</Style>
|
||||||
|
</Layer>
|
||||||
|
<Layer queryable="1" opaque="0">
|
||||||
|
<Name>pm10_hmean</Name>
|
||||||
|
<Title>pm10_hmean</Title>
|
||||||
|
<Abstract/>
|
||||||
|
<KeywordList>
|
||||||
|
<Keyword>pm10_hmean</Keyword>
|
||||||
|
<Keyword>WCS</Keyword>
|
||||||
|
<Keyword>GeoTIFF</Keyword>
|
||||||
|
</KeywordList>
|
||||||
|
<SRS>EPSG:31370</SRS>
|
||||||
|
<LatLonBoundingBox minx="2.4804079470216474" miny="49.461523892203324" maxx="6.494748595696256"
|
||||||
|
maxy="51.54189845090732"/>
|
||||||
|
<BoundingBox SRS="EPSG:31370" minx="18950.0" miny="18650.0" maxx="297550.0" maxy="248050.0"/>
|
||||||
|
<Style>
|
||||||
|
<Name>pm10_hmean_raster_discrete_belair</Name>
|
||||||
|
<Title>particulate matter (PM10) hourly mean concentrations</Title>
|
||||||
|
<Abstract>BelAQI index colour scale. Hourly mean concentration classes for particulate matter
|
||||||
|
(PM10).
|
||||||
|
</Abstract>
|
||||||
|
<LegendURL width="87" height="252">
|
||||||
|
<Format>image/png</Format>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?request=GetLegendGraphic&version=1.1.1&format=image%2Fpng&width=20&height=20&layer=pm10_hmean"/>
|
||||||
|
</LegendURL>
|
||||||
|
</Style>
|
||||||
|
</Layer>
|
||||||
|
<Layer queryable="1" opaque="0">
|
||||||
|
<Name>pm25_dm1</Name>
|
||||||
|
<Title>pm25_dm1</Title>
|
||||||
|
<Abstract/>
|
||||||
|
<KeywordList>
|
||||||
|
<Keyword>pm25_dmean_dm1</Keyword>
|
||||||
|
<Keyword>WCS</Keyword>
|
||||||
|
<Keyword>GeoTIFF</Keyword>
|
||||||
|
</KeywordList>
|
||||||
|
<SRS>EPSG:31370</SRS>
|
||||||
|
<LatLonBoundingBox minx="2.4804079470216474" miny="49.461523892203324" maxx="6.494748595696256"
|
||||||
|
maxy="51.54189845090732"/>
|
||||||
|
<BoundingBox SRS="EPSG:31370" minx="18950.0" miny="18650.0" maxx="297550.0" maxy="248050.0"/>
|
||||||
|
<Style>
|
||||||
|
<Name>pm25_dmean_raster_discrete_belair</Name>
|
||||||
|
<Title>particulate matter (PM25) daily mean concentrations</Title>
|
||||||
|
<Abstract>BelAQI index colour scale. Daily mean concentration classes for particulate matter
|
||||||
|
(PM25).
|
||||||
|
</Abstract>
|
||||||
|
<LegendURL width="87" height="252">
|
||||||
|
<Format>image/png</Format>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?request=GetLegendGraphic&version=1.1.1&format=image%2Fpng&width=20&height=20&layer=pm25_dm1"/>
|
||||||
|
</LegendURL>
|
||||||
|
</Style>
|
||||||
|
</Layer>
|
||||||
|
<Layer queryable="1" opaque="0">
|
||||||
|
<Name>pm25_dm2</Name>
|
||||||
|
<Title>pm25_dm2</Title>
|
||||||
|
<Abstract/>
|
||||||
|
<KeywordList>
|
||||||
|
<Keyword>pm25_dmean_dm2</Keyword>
|
||||||
|
<Keyword>WCS</Keyword>
|
||||||
|
<Keyword>GeoTIFF</Keyword>
|
||||||
|
</KeywordList>
|
||||||
|
<SRS>EPSG:31370</SRS>
|
||||||
|
<LatLonBoundingBox minx="2.4804079470216474" miny="49.461523892203324" maxx="6.494748595696256"
|
||||||
|
maxy="51.54189845090732"/>
|
||||||
|
<BoundingBox SRS="EPSG:31370" minx="18950.0" miny="18650.0" maxx="297550.0" maxy="248050.0"/>
|
||||||
|
<Style>
|
||||||
|
<Name>pm25_dmean_raster_discrete_belair</Name>
|
||||||
|
<Title>particulate matter (PM25) daily mean concentrations</Title>
|
||||||
|
<Abstract>BelAQI index colour scale. Daily mean concentration classes for particulate matter
|
||||||
|
(PM25).
|
||||||
|
</Abstract>
|
||||||
|
<LegendURL width="87" height="252">
|
||||||
|
<Format>image/png</Format>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?request=GetLegendGraphic&version=1.1.1&format=image%2Fpng&width=20&height=20&layer=pm25_dm2"/>
|
||||||
|
</LegendURL>
|
||||||
|
</Style>
|
||||||
|
</Layer>
|
||||||
|
<Layer queryable="1" opaque="0">
|
||||||
|
<Name>pm25_dm3</Name>
|
||||||
|
<Title>pm25_dm3</Title>
|
||||||
|
<Abstract/>
|
||||||
|
<KeywordList>
|
||||||
|
<Keyword>pm25_dmean_dm3</Keyword>
|
||||||
|
<Keyword>WCS</Keyword>
|
||||||
|
<Keyword>GeoTIFF</Keyword>
|
||||||
|
</KeywordList>
|
||||||
|
<SRS>EPSG:31370</SRS>
|
||||||
|
<LatLonBoundingBox minx="2.4804079470216474" miny="49.461523892203324" maxx="6.494748595696256"
|
||||||
|
maxy="51.54189845090732"/>
|
||||||
|
<BoundingBox SRS="EPSG:31370" minx="18950.0" miny="18650.0" maxx="297550.0" maxy="248050.0"/>
|
||||||
|
<Style>
|
||||||
|
<Name>pm25_dmean_raster_discrete_belair</Name>
|
||||||
|
<Title>particulate matter (PM25) daily mean concentrations</Title>
|
||||||
|
<Abstract>BelAQI index colour scale. Daily mean concentration classes for particulate matter
|
||||||
|
(PM25).
|
||||||
|
</Abstract>
|
||||||
|
<LegendURL width="87" height="252">
|
||||||
|
<Format>image/png</Format>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?request=GetLegendGraphic&version=1.1.1&format=image%2Fpng&width=20&height=20&layer=pm25_dm3"/>
|
||||||
|
</LegendURL>
|
||||||
|
</Style>
|
||||||
|
</Layer>
|
||||||
|
<Layer queryable="1" opaque="0">
|
||||||
|
<Name>pm25_hmean</Name>
|
||||||
|
<Title>pm25_hmean</Title>
|
||||||
|
<Abstract/>
|
||||||
|
<KeywordList>
|
||||||
|
<Keyword>pm25_hmean</Keyword>
|
||||||
|
<Keyword>WCS</Keyword>
|
||||||
|
<Keyword>GeoTIFF</Keyword>
|
||||||
|
</KeywordList>
|
||||||
|
<SRS>EPSG:31370</SRS>
|
||||||
|
<LatLonBoundingBox minx="2.4804079470216474" miny="49.461523892203324" maxx="6.494748595696256"
|
||||||
|
maxy="51.54189845090732"/>
|
||||||
|
<BoundingBox SRS="EPSG:31370" minx="18950.0" miny="18650.0" maxx="297550.0" maxy="248050.0"/>
|
||||||
|
<Style>
|
||||||
|
<Name>pm25_hmean_raster_discrete_belair</Name>
|
||||||
|
<Title>particulate matter (PM25) hourly mean concentrations</Title>
|
||||||
|
<Abstract>BelAQI index colour scale. Hourly mean concentration classes for particulate matter
|
||||||
|
(PM25).
|
||||||
|
</Abstract>
|
||||||
|
<LegendURL width="87" height="252">
|
||||||
|
<Format>image/png</Format>
|
||||||
|
<OnlineResource xmlns:xlink="http://www.w3.org/1999/xlink" xlink:type="simple"
|
||||||
|
xlink:href="https://geobelair.irceline.be/rioifdm/wms?request=GetLegendGraphic&version=1.1.1&format=image%2Fpng&width=20&height=20&layer=pm25_hmean"/>
|
||||||
|
</LegendURL>
|
||||||
|
</Style>
|
||||||
|
</Layer>
|
||||||
|
</Layer>
|
||||||
|
</Capability>
|
||||||
|
</WMT_MS_Capabilities>
|
8
tests/fixtures/rio_wfs_for_belaqi.json
vendored
8
tests/fixtures/rio_wfs_for_belaqi.json
vendored
|
@ -3,7 +3,7 @@
|
||||||
"features": [
|
"features": [
|
||||||
{
|
{
|
||||||
"type": "Feature",
|
"type": "Feature",
|
||||||
"id": "pm10_24hmean.fid--d1ce43_19045107e20_1893",
|
"id": "pm10_hmean.fid--d1ce43_19045107e20_1893",
|
||||||
"geometry": {
|
"geometry": {
|
||||||
"type": "Polygon",
|
"type": "Polygon",
|
||||||
"coordinates": [
|
"coordinates": [
|
||||||
|
@ -41,7 +41,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "Feature",
|
"type": "Feature",
|
||||||
"id": "pm10_24hmean.fid--d1ce43_19045107e20_1894",
|
"id": "pm10_hmean.fid--d1ce43_19045107e20_1894",
|
||||||
"geometry": {
|
"geometry": {
|
||||||
"type": "Polygon",
|
"type": "Polygon",
|
||||||
"coordinates": [
|
"coordinates": [
|
||||||
|
@ -79,7 +79,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "Feature",
|
"type": "Feature",
|
||||||
"id": "pm25_24hmean.fid--d1ce43_19045107e20_1895",
|
"id": "pm25_hmean.fid--d1ce43_19045107e20_1895",
|
||||||
"geometry": {
|
"geometry": {
|
||||||
"type": "Polygon",
|
"type": "Polygon",
|
||||||
"coordinates": [
|
"coordinates": [
|
||||||
|
@ -117,7 +117,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "Feature",
|
"type": "Feature",
|
||||||
"id": "pm25_24hmean.fid--d1ce43_19045107e20_1896",
|
"id": "pm25_hmean.fid--d1ce43_19045107e20_1896",
|
||||||
"geometry": {
|
"geometry": {
|
||||||
"type": "Polygon",
|
"type": "Polygon",
|
||||||
"coordinates": [
|
"coordinates": [
|
||||||
|
|
128
tests/test_api_forecast.py
Normal file
128
tests/test_api_forecast.py
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from itertools import product
|
||||||
|
from unittest.mock import call
|
||||||
|
|
||||||
|
from freezegun import freeze_time
|
||||||
|
|
||||||
|
from src.open_irceline import IrcelineForecastClient, ForecastFeature, FeatureValue
|
||||||
|
from src.open_irceline.api import _user_agent, _forecast_wms_base_url
|
||||||
|
from tests.conftest import get_api_data, get_mock_session
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_capabilities():
|
||||||
|
data = get_api_data('forecast_wms_capabilities.xml', plain=True)
|
||||||
|
result = IrcelineForecastClient._parse_capabilities(data)
|
||||||
|
|
||||||
|
expected = {'o3_maxhmean_wl_d3', 'pm25_dmean_wl_d0', 'o3_max8hmean_chimv2022_d2', 'no2_maxhmean_tf_d2',
|
||||||
|
'belaqi_forecast_chimv2022_d2', 'pm25_dmean_chimv2022_d3', 'pm10_dmean_chimv2022_d0',
|
||||||
|
'no2_maxhmean_wl_d0', 'no2_maxhmean_d2', 'no2_dmean_chimv2022_d2', 'o3_maxhmean_chimv2022_d3',
|
||||||
|
'pm25_dmean_wl_d3', 'o3_maxhmean_chimv2022_d0', 'pm25_dmean', 'pm25_dmean_tf_d0', 'no2_dmean_wl_d2',
|
||||||
|
'o3_max8hmean_chimv2022_d3', 'pm25_dmean_d2', 'o3_max8hmean_chimv2022_d0', 'o3_maxhmean_wl_d2',
|
||||||
|
'no2_maxhmean_wl_d1', 'pm10_dmean_tf_d2', 'pm25_dmean_d1', 'o3_maxhmean_chimv2022_d2',
|
||||||
|
'pm10_dmean_chimv2022_d2', 'o3_maxhmean_vl', 'belaqi_wl_d2', 'pm10_dmean_wl', 'pm10_dmean_d2',
|
||||||
|
'no2_dmean_wl_d0', 'no2_dmean_d1', 'o3_maxhmean_d2', 'o3_maxhmean_wl', 'pm25_dmean_wl_d2',
|
||||||
|
'o3_maxhmean_d3', 'o3_max8hmean_wl_d3', 'belaqi_d0', 'no2_maxhmean_wl_d2', 'no2_maxhmean_wl',
|
||||||
|
'pm10_dmean_wl_d1', 'no2_dmean_chimv2022_d3', 'o3_maxhmean_tf_d1', 'pm25_dmean_vl', 'pm10_dmean_d0',
|
||||||
|
'o3_max8hmean_d0', 'o3_max8hmean_d2', 'no2_maxhmean_vl', 'o3_max8hmean_chimv2022_d1', 'pm10_dmean',
|
||||||
|
'pm10_dmean_wl_d2', 'euaqi_d3', 'belaqi_d1', 'o3_max8hmean_d1', 'o3_maxhmean_chimv2022_d1', 'belaqi_vl',
|
||||||
|
'belaqi_wl_d0', 'no2_dmean_chimv2022_d0', 'pm25_dmean_wl_d1', 'pm25_dmean_tf_d2', 'no2_dmean_d2',
|
||||||
|
'o3_maxhmean', 'belaqi_wl', 'no2_maxhmean_d0', 'no2_maxhmean_d3', 'o3_max8hmean_d3', 'euaqi_forecast',
|
||||||
|
'o3_max8hmean_wl_d1', 'pm10_dmean_chimv2022_d3', 'no2_maxhmean_wl_d3', 'o3_maxhmean_d1',
|
||||||
|
'no2_dmean_wl_d1', 'o3_maxhmean_wl_d1', 'no2_dmean_d3', 'belaqi_d3', 'belaqi', 'pm25_dmean_d3',
|
||||||
|
'belaqi_forecast', 'no2_dmean_d0', 'pm25_dmean_chimv2022_d1', 'belaqi_wl_d1', 'pm10_dmean_d3',
|
||||||
|
'no2_dmean_wl_d3', 'pm25_dmean_tf_d1', 'euaqi_d0', 'o3_maxhmean_wl_d0', 'belaqi_forecast_chimv2022_d3',
|
||||||
|
'no2_dmean_chimv2022_d1', 'o3_max8hmean_wl_d0', 'o3_max8hmean_wl_d2', 'pm10_dmean_chimv2022_d1',
|
||||||
|
'pm10_dmean_wl_d3', 'pm25_dmean_wl', 'belaqi_forecast_chimv2022_d1', 'euaqi_d2', 'pm10_dmean_d1',
|
||||||
|
'belaqi_wl_d3', 'belaqi_forecast_chimv2022_d0', 'o3_maxhmean_tf_d0', 'euaqi_d1', 'no2_maxhmean',
|
||||||
|
'pm25_dmean_chimv2022_d2', 'belaqi_d2', 'pm25_dmean_d0', 'no2_maxhmean_tf_d0', 'pm10_dmean_tf_d0',
|
||||||
|
'pm25_dmean_chimv2022_d0', 'o3_maxhmean_d0', 'pm10_dmean_tf_d1', 'pm10_dmean_vl', 'no2_maxhmean_tf_d1',
|
||||||
|
'o3_maxhmean_tf_d2', 'pm10_dmean_wl_d0', 'no2_maxhmean_d1'}
|
||||||
|
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
for f, d in product(ForecastFeature, range(4)):
|
||||||
|
assert f"{f.split(':')[1]}_d{d}" in result
|
||||||
|
|
||||||
|
|
||||||
|
async def test_aget_capabilities():
|
||||||
|
session = get_mock_session(text_file='forecast_wms_capabilities.xml')
|
||||||
|
|
||||||
|
client = IrcelineForecastClient(session)
|
||||||
|
_ = await client.get_capabilities()
|
||||||
|
|
||||||
|
session.request.assert_called_once_with(
|
||||||
|
method='GET',
|
||||||
|
url=_forecast_wms_base_url,
|
||||||
|
params={"service": "WMS",
|
||||||
|
"version": "1.1.1",
|
||||||
|
"request": "GetCapabilities"},
|
||||||
|
headers={'User-Agent': _user_agent}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time(datetime.fromisoformat("2024-06-30T13:00:21.520Z"))
|
||||||
|
async def test_api_forecast_error():
|
||||||
|
pos = (50.4657, 4.8647)
|
||||||
|
session = get_mock_session('forecast_wms_feature_info_invalid.json')
|
||||||
|
|
||||||
|
client = IrcelineForecastClient(session)
|
||||||
|
|
||||||
|
features = [ForecastFeature.NO2_DMEAN, ForecastFeature.O3_MAXHMEAN]
|
||||||
|
result = await client.get_data(features, pos)
|
||||||
|
|
||||||
|
for k, v in result.items():
|
||||||
|
assert v == FeatureValue(timestamp=datetime.fromisoformat("2024-06-30T13:00:21.520Z"), value=None)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_api_forecast_no_field():
|
||||||
|
pos = (50.4657, 4.8647)
|
||||||
|
session = get_mock_session('forecast_wms_feature_info_no_field.json')
|
||||||
|
|
||||||
|
client = IrcelineForecastClient(session)
|
||||||
|
|
||||||
|
features = [ForecastFeature.NO2_DMEAN, ForecastFeature.O3_MAXHMEAN]
|
||||||
|
result = await client.get_data(features, pos)
|
||||||
|
|
||||||
|
for k, v in result.items():
|
||||||
|
assert v == FeatureValue(timestamp=None, value=None)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_api_forecast():
|
||||||
|
pos = (50.4657, 4.8647)
|
||||||
|
lat, lon = pos
|
||||||
|
session = get_mock_session('forecast_wms_feature_info.json')
|
||||||
|
|
||||||
|
client = IrcelineForecastClient(session)
|
||||||
|
|
||||||
|
features = [ForecastFeature.NO2_DMEAN, ForecastFeature.O3_MAXHMEAN]
|
||||||
|
result = await client.get_data(features, pos)
|
||||||
|
|
||||||
|
base = {"service": "WMS",
|
||||||
|
"version": "1.1.1",
|
||||||
|
"request": "GetFeatureInfo",
|
||||||
|
"info_format": "application/json",
|
||||||
|
"width": "1",
|
||||||
|
"height": "1",
|
||||||
|
"srs": "EPSG:4326",
|
||||||
|
"bbox": f"{lon},{lat},{lon + 0.00001},{lat + 0.00001}",
|
||||||
|
"X": "1",
|
||||||
|
"Y": "1"}
|
||||||
|
|
||||||
|
calls = [call(
|
||||||
|
method='GET',
|
||||||
|
url=_forecast_wms_base_url,
|
||||||
|
params=base | {"layers": f"{feature}_d{d}",
|
||||||
|
"query_layers": f"{feature}_d{d}"},
|
||||||
|
headers={'User-Agent': _user_agent},
|
||||||
|
)
|
||||||
|
for feature, d in product(features, range(4))]
|
||||||
|
|
||||||
|
session.request.assert_has_calls(calls, any_order=True)
|
||||||
|
|
||||||
|
for k, v in result.items():
|
||||||
|
assert v['value'] == 10.853286743164062
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_capabilities_with_error():
|
||||||
|
result = IrcelineForecastClient._parse_capabilities("wow there no valid XML")
|
||||||
|
assert result == set()
|
|
@ -1,90 +0,0 @@
|
||||||
from datetime import date
|
|
||||||
from unittest.mock import call
|
|
||||||
|
|
||||||
from src.open_irceline.api import _forecast_base_url, _user_agent
|
|
||||||
from src.open_irceline.api import IrcelineForecastClient
|
|
||||||
from src.open_irceline.data import ForecastFeature
|
|
||||||
from tests.conftest import get_api_data, get_mock_session_many_csv
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_from_csv():
|
|
||||||
data = get_api_data('forecast.csv', plain=True)
|
|
||||||
x, y = 50.45, 4.85
|
|
||||||
|
|
||||||
result = IrcelineForecastClient.extract_result_from_csv(x, y, data)
|
|
||||||
assert result == 13.0844
|
|
||||||
|
|
||||||
result = IrcelineForecastClient.extract_result_from_csv(23, 4, data)
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_cached_calls():
|
|
||||||
session = get_mock_session_many_csv()
|
|
||||||
client = IrcelineForecastClient(session)
|
|
||||||
|
|
||||||
_ = await client.get_data(
|
|
||||||
timestamp=date(2024, 6, 19),
|
|
||||||
features=[ForecastFeature.NO2_MAXHMEAN],
|
|
||||||
position=(50.45, 4.85)
|
|
||||||
)
|
|
||||||
|
|
||||||
calls = [
|
|
||||||
call(method='GET',
|
|
||||||
url=f"{_forecast_base_url}/BE_{ForecastFeature.NO2_MAXHMEAN}_20240619_d{i}.csv",
|
|
||||||
params=None,
|
|
||||||
headers={'User-Agent': _user_agent}
|
|
||||||
) for i in range(5)
|
|
||||||
]
|
|
||||||
|
|
||||||
assert session.request.call_count == 5
|
|
||||||
session.request.assert_has_calls(calls)
|
|
||||||
|
|
||||||
_ = await client.get_data(
|
|
||||||
timestamp=date(2024, 6, 19),
|
|
||||||
features=[ForecastFeature.NO2_MAXHMEAN],
|
|
||||||
position=(50.45, 4.85)
|
|
||||||
)
|
|
||||||
|
|
||||||
calls += [
|
|
||||||
call(method='GET',
|
|
||||||
url=f"{_forecast_base_url}/BE_{ForecastFeature.NO2_MAXHMEAN}_20240619_d{i}.csv",
|
|
||||||
params=None,
|
|
||||||
headers={'User-Agent': _user_agent, 'If-None-Match': 'my-etag-here'}
|
|
||||||
) for i in range(5)
|
|
||||||
]
|
|
||||||
|
|
||||||
assert session.request.call_count == 10
|
|
||||||
session.request.assert_has_calls(calls)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_missed_cached_calls():
|
|
||||||
session = get_mock_session_many_csv()
|
|
||||||
client = IrcelineForecastClient(session)
|
|
||||||
|
|
||||||
r = await client.get_data(
|
|
||||||
timestamp=date(2024, 6, 21),
|
|
||||||
features=[ForecastFeature.NO2_MAXHMEAN],
|
|
||||||
position=(50.45, 4.85)
|
|
||||||
)
|
|
||||||
|
|
||||||
calls = list()
|
|
||||||
|
|
||||||
for i in range(5):
|
|
||||||
calls += [
|
|
||||||
call(method='GET',
|
|
||||||
url=f"{_forecast_base_url}/BE_{ForecastFeature.NO2_MAXHMEAN}_20240621_d{i}.csv",
|
|
||||||
params=None,
|
|
||||||
headers={'User-Agent': _user_agent}
|
|
||||||
),
|
|
||||||
call(method='GET',
|
|
||||||
url=f"{_forecast_base_url}/BE_{ForecastFeature.NO2_MAXHMEAN}_20240620_d{i}.csv",
|
|
||||||
params=None,
|
|
||||||
headers={'User-Agent': _user_agent}
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
assert session.request.call_count == 10
|
|
||||||
session.request.assert_has_calls(calls)
|
|
||||||
|
|
||||||
for value in r.values():
|
|
||||||
assert value['value'] is None
|
|
|
@ -2,7 +2,7 @@ from datetime import datetime, date
|
||||||
|
|
||||||
from freezegun import freeze_time
|
from freezegun import freeze_time
|
||||||
|
|
||||||
from src.open_irceline.api import IrcelineRioClient
|
from src.open_irceline import IrcelineRioClient
|
||||||
from src.open_irceline.api import _rio_wfs_base_url, _user_agent
|
from src.open_irceline.api import _rio_wfs_base_url, _user_agent
|
||||||
from src.open_irceline.data import RioFeature, FeatureValue
|
from src.open_irceline.data import RioFeature, FeatureValue
|
||||||
from src.open_irceline.utils import epsg_transform
|
from src.open_irceline.utils import epsg_transform
|
||||||
|
@ -64,7 +64,7 @@ async def test_format_result_dmean():
|
||||||
|
|
||||||
|
|
||||||
def test_parse_capabilities():
|
def test_parse_capabilities():
|
||||||
data = get_api_data('capabilities.xml', plain=True)
|
data = get_api_data('rio_capabilities.xml', plain=True)
|
||||||
result = IrcelineRioClient._parse_capabilities(data)
|
result = IrcelineRioClient._parse_capabilities(data)
|
||||||
|
|
||||||
expected = {'rio:so2_anmean_be', 'rio:o3_hmean', 'rio:bc_anmean_vl', 'rio:o3_anmean_be', 'rio:pm10_hmean_vl',
|
expected = {'rio:so2_anmean_be', 'rio:o3_hmean', 'rio:bc_anmean_vl', 'rio:o3_anmean_be', 'rio:pm10_hmean_vl',
|
||||||
|
@ -99,7 +99,7 @@ async def test_api_rio():
|
||||||
|
|
||||||
d = date(2024, 6, 18)
|
d = date(2024, 6, 18)
|
||||||
features = [RioFeature.NO2_HMEAN, RioFeature.O3_HMEAN]
|
features = [RioFeature.NO2_HMEAN, RioFeature.O3_HMEAN]
|
||||||
_ = await client.get_data(d, features, pos)
|
_ = await client.get_data(features, pos, d)
|
||||||
session.request.assert_called_once_with(
|
session.request.assert_called_once_with(
|
||||||
method='GET',
|
method='GET',
|
||||||
url=_rio_wfs_base_url,
|
url=_rio_wfs_base_url,
|
||||||
|
@ -117,10 +117,10 @@ async def test_api_rio():
|
||||||
|
|
||||||
|
|
||||||
async def test_api_rio_get_capabilities():
|
async def test_api_rio_get_capabilities():
|
||||||
session = get_mock_session(text_file='capabilities.xml')
|
session = get_mock_session(text_file='rio_capabilities.xml')
|
||||||
|
|
||||||
client = IrcelineRioClient(session)
|
client = IrcelineRioClient(session)
|
||||||
_ = await client.get_rio_capabilities()
|
_ = await client.get_capabilities()
|
||||||
|
|
||||||
session.request.assert_called_once_with(
|
session.request.assert_called_once_with(
|
||||||
method='GET',
|
method='GET',
|
||||||
|
|
102
tests/test_api_rio_ifdm.py
Normal file
102
tests/test_api_rio_ifdm.py
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import call
|
||||||
|
|
||||||
|
from freezegun import freeze_time
|
||||||
|
|
||||||
|
from src.open_irceline.api import _rio_ifdm_wms_base_url, _user_agent
|
||||||
|
from src.open_irceline.data import RioIfdmFeature, FeatureValue
|
||||||
|
from src.open_irceline.rio import IrcelineRioIfdmClient
|
||||||
|
from tests.conftest import get_api_data, get_mock_session
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_capabilities():
|
||||||
|
data = get_api_data('rio_ifdm_capabilities.xml', plain=True)
|
||||||
|
result = IrcelineRioIfdmClient._parse_capabilities(data)
|
||||||
|
|
||||||
|
expected = {'no2_dm3', 'belaqi_dm2', 'pm10_hmean', 'belaqi_dm1', 'pm25_dm3', 'pm25_dm2', 'pm10_dm1', 'o3_dm3',
|
||||||
|
'no2_dm1', 'pm10_dm3', 'pm25_dm1', 'belaqi', 'belaqi_dm3', 'pm10_dm2', 'o3_dm2', 'pm25_hmean', 'o3_dm1',
|
||||||
|
'o3_hmean', 'no2_dm2', 'no2_hmean'}
|
||||||
|
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
for f in RioIfdmFeature:
|
||||||
|
assert f"{f.split(':')[1]}" in result
|
||||||
|
|
||||||
|
|
||||||
|
async def test_aget_capabilities():
|
||||||
|
session = get_mock_session(text_file='rio_ifdm_capabilities.xml')
|
||||||
|
|
||||||
|
client = IrcelineRioIfdmClient(session)
|
||||||
|
_ = await client.get_capabilities()
|
||||||
|
|
||||||
|
session.request.assert_called_once_with(
|
||||||
|
method='GET',
|
||||||
|
url=_rio_ifdm_wms_base_url,
|
||||||
|
params={"service": "WMS",
|
||||||
|
"version": "1.1.1",
|
||||||
|
"request": "GetCapabilities"},
|
||||||
|
headers={'User-Agent': _user_agent}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time(datetime.fromisoformat("2024-06-30T13:00:21.520Z"))
|
||||||
|
async def test_api_forecast_error():
|
||||||
|
pos = (50.4657, 4.8647)
|
||||||
|
session = get_mock_session('forecast_wms_feature_info_invalid.json')
|
||||||
|
|
||||||
|
client = IrcelineRioIfdmClient(session)
|
||||||
|
|
||||||
|
features = [RioIfdmFeature.NO2_HMEAN, RioIfdmFeature.O3_HMEAN]
|
||||||
|
result = await client.get_data(features, pos)
|
||||||
|
|
||||||
|
for k, v in result.items():
|
||||||
|
assert v == FeatureValue(timestamp=datetime.fromisoformat("2024-06-30T13:00:21.520Z"), value=None)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_api_forecast():
|
||||||
|
pos = (50.4657, 4.8647)
|
||||||
|
lat, lon = pos
|
||||||
|
session = get_mock_session('forecast_wms_feature_info.json')
|
||||||
|
|
||||||
|
client = IrcelineRioIfdmClient(session)
|
||||||
|
|
||||||
|
features = [RioIfdmFeature.NO2_HMEAN, RioIfdmFeature.O3_HMEAN]
|
||||||
|
result = await client.get_data(features, pos)
|
||||||
|
|
||||||
|
base = {"service": "WMS",
|
||||||
|
"version": "1.1.1",
|
||||||
|
"request": "GetFeatureInfo",
|
||||||
|
"info_format": "application/json",
|
||||||
|
"width": "1",
|
||||||
|
"height": "1",
|
||||||
|
"srs": "EPSG:4326",
|
||||||
|
"bbox": f"{lon},{lat},{lon + 0.00001},{lat + 0.00001}",
|
||||||
|
"X": "1",
|
||||||
|
"Y": "1"}
|
||||||
|
|
||||||
|
calls = [call(
|
||||||
|
method='GET',
|
||||||
|
url=_rio_ifdm_wms_base_url,
|
||||||
|
params=base | {"layers": f"{feature}",
|
||||||
|
"query_layers": f"{feature}"},
|
||||||
|
headers={'User-Agent': _user_agent},
|
||||||
|
)
|
||||||
|
for feature in features]
|
||||||
|
|
||||||
|
session.request.assert_has_calls(calls, any_order=True)
|
||||||
|
|
||||||
|
for k, v in result.items():
|
||||||
|
assert v['value'] == 10.853286743164062
|
||||||
|
|
||||||
|
|
||||||
|
async def test_api_forecast_no_field():
|
||||||
|
pos = (50.4657, 4.8647)
|
||||||
|
session = get_mock_session('forecast_wms_feature_info_no_field.json')
|
||||||
|
|
||||||
|
client = IrcelineRioIfdmClient(session)
|
||||||
|
|
||||||
|
features = [RioIfdmFeature.NO2_HMEAN, RioIfdmFeature.O3_HMEAN]
|
||||||
|
result = await client.get_data(features, pos)
|
||||||
|
|
||||||
|
for k, v in result.items():
|
||||||
|
assert v == FeatureValue(timestamp=None, value=None)
|
|
@ -1,196 +0,0 @@
|
||||||
from datetime import date, timedelta, datetime
|
|
||||||
from random import randint, seed
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from freezegun import freeze_time
|
|
||||||
|
|
||||||
from src.open_irceline.api import IrcelineForecastClient, IrcelineRioClient
|
|
||||||
from src.open_irceline.belaqi import belaqi_index, belaqi_index_forecast, belaqi_index_actual
|
|
||||||
from src.open_irceline.data import BelAqiIndex
|
|
||||||
from tests.conftest import get_mock_session_many_csv, get_mock_session
|
|
||||||
|
|
||||||
|
|
||||||
def test_belaqi_index():
|
|
||||||
# Excellent
|
|
||||||
assert belaqi_index(5, 2, 10, 10) == BelAqiIndex.EXCELLENT
|
|
||||||
assert belaqi_index(0, 0, 0, 0) == BelAqiIndex.EXCELLENT
|
|
||||||
assert belaqi_index(10, 5, 25, 20) == BelAqiIndex.EXCELLENT
|
|
||||||
|
|
||||||
# Very good
|
|
||||||
assert belaqi_index(15, 8, 40, 35) == BelAqiIndex.VERY_GOOD
|
|
||||||
assert belaqi_index(11, 6, 26, 21) == BelAqiIndex.VERY_GOOD
|
|
||||||
assert belaqi_index(20, 10, 50, 50) == BelAqiIndex.VERY_GOOD
|
|
||||||
|
|
||||||
# Good
|
|
||||||
assert belaqi_index(25, 12, 60, 60) == BelAqiIndex.GOOD
|
|
||||||
assert belaqi_index(21, 11, 51, 51) == BelAqiIndex.GOOD
|
|
||||||
assert belaqi_index(30, 15, 70, 70) == BelAqiIndex.GOOD
|
|
||||||
|
|
||||||
# Fairly good
|
|
||||||
assert belaqi_index(35, 20, 100, 90) == BelAqiIndex.FAIRLY_GOOD
|
|
||||||
assert belaqi_index(31, 16, 71, 71) == BelAqiIndex.FAIRLY_GOOD
|
|
||||||
assert belaqi_index(40, 25, 120, 120) == BelAqiIndex.FAIRLY_GOOD
|
|
||||||
|
|
||||||
# Moderate
|
|
||||||
assert belaqi_index(45, 30, 140, 130) == BelAqiIndex.MODERATE
|
|
||||||
assert belaqi_index(41, 26, 121, 121) == BelAqiIndex.MODERATE
|
|
||||||
assert belaqi_index(50, 35, 160, 150) == BelAqiIndex.MODERATE
|
|
||||||
|
|
||||||
# Poor
|
|
||||||
assert belaqi_index(55, 38, 170, 160) == BelAqiIndex.POOR
|
|
||||||
assert belaqi_index(51, 36, 161, 151) == BelAqiIndex.POOR
|
|
||||||
assert belaqi_index(60, 40, 180, 180) == BelAqiIndex.POOR
|
|
||||||
|
|
||||||
# Very poor
|
|
||||||
assert belaqi_index(65, 45, 200, 190) == BelAqiIndex.VERY_POOR
|
|
||||||
assert belaqi_index(61, 41, 181, 181) == BelAqiIndex.VERY_POOR
|
|
||||||
assert belaqi_index(70, 50, 240, 200) == BelAqiIndex.VERY_POOR
|
|
||||||
|
|
||||||
# Bad
|
|
||||||
assert belaqi_index(75, 55, 260, 220) == BelAqiIndex.BAD
|
|
||||||
assert belaqi_index(71, 51, 241, 201) == BelAqiIndex.BAD
|
|
||||||
assert belaqi_index(80, 60, 280, 250) == BelAqiIndex.BAD
|
|
||||||
|
|
||||||
# Very bad
|
|
||||||
assert belaqi_index(85, 65, 300, 270) == BelAqiIndex.VERY_BAD
|
|
||||||
assert belaqi_index(81, 61, 281, 251) == BelAqiIndex.VERY_BAD
|
|
||||||
assert belaqi_index(100, 70, 320, 300) == BelAqiIndex.VERY_BAD
|
|
||||||
|
|
||||||
# Horrible
|
|
||||||
assert belaqi_index(110, 75, 330, 310) == BelAqiIndex.HORRIBLE
|
|
||||||
assert belaqi_index(101, 71, 321, 301) == BelAqiIndex.HORRIBLE
|
|
||||||
assert belaqi_index(150, 100, 400, 400) == BelAqiIndex.HORRIBLE
|
|
||||||
|
|
||||||
|
|
||||||
def test_belaqi_single_component():
|
|
||||||
# Tests with only PM10 varying
|
|
||||||
assert belaqi_index(5, 0, 0, 0) == BelAqiIndex.EXCELLENT
|
|
||||||
assert belaqi_index(15, 0, 0, 0) == BelAqiIndex.VERY_GOOD
|
|
||||||
assert belaqi_index(25, 0, 0, 0) == BelAqiIndex.GOOD
|
|
||||||
assert belaqi_index(35, 0, 0, 0) == BelAqiIndex.FAIRLY_GOOD
|
|
||||||
assert belaqi_index(45, 0, 0, 0) == BelAqiIndex.MODERATE
|
|
||||||
assert belaqi_index(55, 0, 0, 0) == BelAqiIndex.POOR
|
|
||||||
assert belaqi_index(65, 0, 0, 0) == BelAqiIndex.VERY_POOR
|
|
||||||
assert belaqi_index(75, 0, 0, 0) == BelAqiIndex.BAD
|
|
||||||
assert belaqi_index(85, 0, 0, 0) == BelAqiIndex.VERY_BAD
|
|
||||||
assert belaqi_index(110, 0, 0, 0) == BelAqiIndex.HORRIBLE
|
|
||||||
|
|
||||||
# Tests with only PM2.5 varying
|
|
||||||
assert belaqi_index(0, 2, 0, 0) == BelAqiIndex.EXCELLENT
|
|
||||||
assert belaqi_index(0, 8, 0, 0) == BelAqiIndex.VERY_GOOD
|
|
||||||
assert belaqi_index(0, 12, 0, 0) == BelAqiIndex.GOOD
|
|
||||||
assert belaqi_index(0, 20, 0, 0) == BelAqiIndex.FAIRLY_GOOD
|
|
||||||
assert belaqi_index(0, 30, 0, 0) == BelAqiIndex.MODERATE
|
|
||||||
assert belaqi_index(0, 38, 0, 0) == BelAqiIndex.POOR
|
|
||||||
assert belaqi_index(0, 45, 0, 0) == BelAqiIndex.VERY_POOR
|
|
||||||
assert belaqi_index(0, 55, 0, 0) == BelAqiIndex.BAD
|
|
||||||
assert belaqi_index(0, 65, 0, 0) == BelAqiIndex.VERY_BAD
|
|
||||||
assert belaqi_index(0, 75, 0, 0) == BelAqiIndex.HORRIBLE
|
|
||||||
|
|
||||||
# Tests with only O3 varying
|
|
||||||
assert belaqi_index(0, 0, 10, 0) == BelAqiIndex.EXCELLENT
|
|
||||||
assert belaqi_index(0, 0, 40, 0) == BelAqiIndex.VERY_GOOD
|
|
||||||
assert belaqi_index(0, 0, 60, 0) == BelAqiIndex.GOOD
|
|
||||||
assert belaqi_index(0, 0, 100, 0) == BelAqiIndex.FAIRLY_GOOD
|
|
||||||
assert belaqi_index(0, 0, 140, 0) == BelAqiIndex.MODERATE
|
|
||||||
assert belaqi_index(0, 0, 170, 0) == BelAqiIndex.POOR
|
|
||||||
assert belaqi_index(0, 0, 200, 0) == BelAqiIndex.VERY_POOR
|
|
||||||
assert belaqi_index(0, 0, 260, 0) == BelAqiIndex.BAD
|
|
||||||
assert belaqi_index(0, 0, 300, 0) == BelAqiIndex.VERY_BAD
|
|
||||||
assert belaqi_index(0, 0, 330, 0) == BelAqiIndex.HORRIBLE
|
|
||||||
|
|
||||||
# Tests with only NO2 varying
|
|
||||||
assert belaqi_index(0, 0, 0, 10) == BelAqiIndex.EXCELLENT
|
|
||||||
assert belaqi_index(0, 0, 0, 35) == BelAqiIndex.VERY_GOOD
|
|
||||||
assert belaqi_index(0, 0, 0, 60) == BelAqiIndex.GOOD
|
|
||||||
assert belaqi_index(0, 0, 0, 90) == BelAqiIndex.FAIRLY_GOOD
|
|
||||||
assert belaqi_index(0, 0, 0, 130) == BelAqiIndex.MODERATE
|
|
||||||
assert belaqi_index(0, 0, 0, 160) == BelAqiIndex.POOR
|
|
||||||
assert belaqi_index(0, 0, 0, 190) == BelAqiIndex.VERY_POOR
|
|
||||||
assert belaqi_index(0, 0, 0, 220) == BelAqiIndex.BAD
|
|
||||||
assert belaqi_index(0, 0, 0, 270) == BelAqiIndex.VERY_BAD
|
|
||||||
assert belaqi_index(0, 0, 0, 310) == BelAqiIndex.HORRIBLE
|
|
||||||
|
|
||||||
|
|
||||||
def test_belaqi_random():
|
|
||||||
seed(42)
|
|
||||||
# Generate random test values and their expected indices
|
|
||||||
test_cases = [
|
|
||||||
(randint(0, 10), randint(0, 5), randint(0, 25), randint(0, 20), BelAqiIndex.EXCELLENT),
|
|
||||||
(randint(11, 20), randint(6, 10), randint(26, 50), randint(21, 50), BelAqiIndex.VERY_GOOD),
|
|
||||||
(randint(21, 30), randint(11, 15), randint(51, 70), randint(51, 70), BelAqiIndex.GOOD),
|
|
||||||
(randint(31, 40), randint(16, 25), randint(71, 120), randint(71, 120), BelAqiIndex.FAIRLY_GOOD),
|
|
||||||
(randint(41, 50), randint(26, 35), randint(121, 160), randint(121, 150), BelAqiIndex.MODERATE),
|
|
||||||
(randint(51, 60), randint(36, 40), randint(161, 180), randint(151, 180), BelAqiIndex.POOR),
|
|
||||||
(randint(61, 70), randint(41, 50), randint(181, 240), randint(181, 200), BelAqiIndex.VERY_POOR),
|
|
||||||
(randint(71, 80), randint(51, 60), randint(241, 280), randint(201, 250), BelAqiIndex.BAD),
|
|
||||||
(randint(81, 100), randint(61, 70), randint(281, 320), randint(251, 300), BelAqiIndex.VERY_BAD),
|
|
||||||
(randint(101, 150), randint(71, 100), randint(321, 400), randint(301, 400), BelAqiIndex.HORRIBLE)
|
|
||||||
]
|
|
||||||
|
|
||||||
# Test each case
|
|
||||||
for pm10, pm25, o3, no2, expected in test_cases:
|
|
||||||
assert belaqi_index(pm10, pm25, o3, no2) == expected
|
|
||||||
|
|
||||||
|
|
||||||
def test_belaqi_value_error():
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
belaqi_index(-1, 0, 12, 8)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
belaqi_index(1, -20, 12, 8)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
belaqi_index(1, 0, -12, 8)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
belaqi_index(1, 0, 12, -8888)
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(datetime.fromisoformat("2024-06-19T19:30:09.581Z"))
|
|
||||||
async def test_belaqi_index_forecast():
|
|
||||||
session = get_mock_session_many_csv()
|
|
||||||
client = IrcelineForecastClient(session)
|
|
||||||
pos = (50.55, 4.85)
|
|
||||||
|
|
||||||
result = await belaqi_index_forecast(client, pos)
|
|
||||||
|
|
||||||
expected_days = {date(2024, 6, 19) + timedelta(days=i) for i in range(5)}
|
|
||||||
|
|
||||||
assert set(result.keys()) == expected_days
|
|
||||||
for v in result.values():
|
|
||||||
assert v == BelAqiIndex.GOOD
|
|
||||||
|
|
||||||
|
|
||||||
async def test_belaqi_index_forecast_missing_day():
|
|
||||||
session = get_mock_session_many_csv()
|
|
||||||
client = IrcelineForecastClient(session)
|
|
||||||
pos = (50.55, 4.85)
|
|
||||||
|
|
||||||
result = await belaqi_index_forecast(client, pos, date(2024, 6, 21))
|
|
||||||
|
|
||||||
expected_days = {date(2024, 6, 21) + timedelta(days=i) for i in range(5)}
|
|
||||||
assert set(result.keys()) == expected_days
|
|
||||||
for v in result.values():
|
|
||||||
assert v is None
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(datetime.fromisoformat("2024-06-23T12:30:09.581Z"))
|
|
||||||
async def test_belaqi_index_actual():
|
|
||||||
session = get_mock_session(json_file='rio_wfs_for_belaqi.json')
|
|
||||||
client = IrcelineRioClient(session)
|
|
||||||
pos = (50.55, 4.85)
|
|
||||||
|
|
||||||
result = await belaqi_index_actual(client, pos)
|
|
||||||
print(result)
|
|
||||||
assert result == BelAqiIndex.FAIRLY_GOOD
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(datetime.fromisoformat("2024-06-23T12:30:09.581Z"))
|
|
||||||
async def test_belaqi_index_actual_missing_value():
|
|
||||||
session = get_mock_session(json_file='rio_wfs.json')
|
|
||||||
client = IrcelineRioClient(session)
|
|
||||||
pos = (50.55, 4.85)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
_ = await belaqi_index_actual(client, pos)
|
|
|
@ -1,44 +1,4 @@
|
||||||
import pytest
|
from src.open_irceline.utils import epsg_transform
|
||||||
|
|
||||||
from src.open_irceline.utils import SizedDict, round_coordinates, epsg_transform
|
|
||||||
|
|
||||||
|
|
||||||
def test_sized_dict():
|
|
||||||
s_dict = SizedDict(5)
|
|
||||||
assert len(s_dict) == 0
|
|
||||||
|
|
||||||
s_dict['a'] = 1
|
|
||||||
s_dict['b'] = 2
|
|
||||||
s_dict['c'] = 3
|
|
||||||
s_dict['d'] = 4
|
|
||||||
s_dict['e'] = 5
|
|
||||||
assert len(s_dict) == 5
|
|
||||||
|
|
||||||
s_dict['f'] = 6
|
|
||||||
assert 'a' not in s_dict
|
|
||||||
assert s_dict['f'] == 6
|
|
||||||
assert len(s_dict) == 5
|
|
||||||
|
|
||||||
s_dict['b'] = 42
|
|
||||||
s_dict['g'] = 7
|
|
||||||
assert s_dict.get('f') == 6
|
|
||||||
assert s_dict['g'] == 7
|
|
||||||
assert s_dict['b'] == 42
|
|
||||||
assert 'c' not in s_dict
|
|
||||||
assert len(s_dict) == 5
|
|
||||||
|
|
||||||
del s_dict['b']
|
|
||||||
assert len(s_dict) == 4
|
|
||||||
assert 'b' not in s_dict
|
|
||||||
|
|
||||||
with pytest.raises(NotImplementedError):
|
|
||||||
s_dict.update({'a': 1})
|
|
||||||
|
|
||||||
|
|
||||||
def test_round_coord():
|
|
||||||
x, y = round_coordinates(50.4657, 4.8647)
|
|
||||||
assert x == 50.45
|
|
||||||
assert y == 4.85
|
|
||||||
|
|
||||||
|
|
||||||
def test_epsg_transform():
|
def test_epsg_transform():
|
||||||
|
|
Loading…
Add table
Reference in a new issue