From d0d1a19fda24cab66a90efc1a086ac8cfaca58ad Mon Sep 17 00:00:00 2001 From: Emlyn Price Date: Mon, 12 Feb 2024 21:26:55 +0000 Subject: [PATCH 01/51] Move to modern package structure --- .travis.yml | 27 - MANIFEST.in | 2 - datapoint/Day.py | 21 - datapoint/Element.py | 12 - datapoint/Forecast.py | 105 - datapoint/Manager.py | 685 ------ datapoint/Observation.py | 20 - datapoint/Site.py | 18 - datapoint/Timestep.py | 36 - datapoint/_version.py | 683 ------ datapoint/regions/RegionManager.py | 66 - datapoint/regions/__init__.py | 0 datapoint/regions/region_names.py | 19 - examples/simple_forecast/simple_forecast.py | 33 +- pyproject.toml | 45 +- setup.cfg | 5 - setup.py | 87 - src/datapoint/Forecast.py | 212 ++ src/datapoint/Manager.py | 148 ++ {datapoint => src/datapoint}/__init__.py | 0 {datapoint => src/datapoint}/exceptions.py | 0 {datapoint => src/datapoint}/profile.py | 0 src/datapoint/weather_codes.py | 36 + versioneer.py | 2277 ------------------- 24 files changed, 455 insertions(+), 4082 deletions(-) delete mode 100644 .travis.yml delete mode 100644 MANIFEST.in delete mode 100644 datapoint/Day.py delete mode 100644 datapoint/Element.py delete mode 100644 datapoint/Forecast.py delete mode 100644 datapoint/Manager.py delete mode 100644 datapoint/Observation.py delete mode 100644 datapoint/Site.py delete mode 100644 datapoint/Timestep.py delete mode 100644 datapoint/_version.py delete mode 100644 datapoint/regions/RegionManager.py delete mode 100644 datapoint/regions/__init__.py delete mode 100644 datapoint/regions/region_names.py delete mode 100644 setup.cfg delete mode 100644 setup.py create mode 100644 src/datapoint/Forecast.py create mode 100644 src/datapoint/Manager.py rename {datapoint => src/datapoint}/__init__.py (100%) rename {datapoint => src/datapoint}/exceptions.py (100%) rename {datapoint => src/datapoint}/profile.py (100%) create mode 100644 src/datapoint/weather_codes.py delete mode 100644 versioneer.py diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 4d7b72b..0000000 --- a/.travis.yml +++ /dev/null @@ -1,27 +0,0 @@ -language: python -sudo: false -dist: xenial -python: - - '3.8' - - '3.9' - - '3.10' - - '3.11' - - '3.12' -install: -- pip install codecov -- pip install coverage -- pip install . -- pip install -r requirements.txt -script: -- coverage run -m unittest discover -s tests/unit && codecov -F 'unit' -- if [[ "$API_KEY" != "" ]]; then coverage run -m unittest discover -s tests/integration; fi && codecov -F 'integration' -deploy: - provider: pypi - user: EJEP - skip_cleanup: true - password: "$PYPI_PASSWORD" - on: - tags: true - all_branches: true - repo: EJEP/datapoint-python - python: '3.8' diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index e490274..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -include versioneer.py -include datapoint/_version.py diff --git a/datapoint/Day.py b/datapoint/Day.py deleted file mode 100644 index c15c2a9..0000000 --- a/datapoint/Day.py +++ /dev/null @@ -1,21 +0,0 @@ -class Day(): - def __init__(self): - self.date = None - self.timesteps = [] - - def __str__(self): - day_str = '' - - date_part = 'Date: ' + str(self.date) + '\n\n' - day_str += date_part - - day_str += 'Timesteps: \n\n' - try: - for timestep in self.timesteps: - day_str += str(timestep) - day_str += '\n' - - except TypeError: - day_str += 'No timesteps' - - return day_str diff --git a/datapoint/Element.py b/datapoint/Element.py deleted file mode 100644 index 71ec77b..0000000 --- a/datapoint/Element.py +++ /dev/null @@ -1,12 +0,0 @@ -class Element(): - def __init__(self, field_code=None, value=None, units=None): - - self.field_code = field_code - self.value = value - self.units = units - - # For elements which can also have a text value - self.text = None - - def __str__(self): - return str(self.value) + ' ' + str(self.units) diff --git a/datapoint/Forecast.py b/datapoint/Forecast.py deleted file mode 100644 index 8e4ef8f..0000000 --- a/datapoint/Forecast.py +++ /dev/null @@ -1,105 +0,0 @@ -import datetime -from datapoint.exceptions import APIException - - -class Forecast(): - def __init__(self, frequency=""): - self.frequency = frequency - self.data_date = None - self.continent = None - self.country = None - self.name = None - self.longitude = None - self.latitude = None - self.location_id = None - self.elevation = None - self.days = [] - - def timedelta_total_seconds(self, timedelta): - return ( - timedelta.microseconds + 0.0 + - (timedelta.seconds + timedelta.days * 24 * 3600) * 10 ** 6) / 10 ** 6 - - def at_datetime(self, target): - """ Return the timestep closest to the target datetime""" - - # Convert target to offset aware datetime - if target.tzinfo is None: - target = datetime.datetime.combine(target.date(), target.time(), self.days[0].date.tzinfo) - - num_timesteps = len(self.days[1].timesteps) - # First check that the target is at most 1.5 hours before the first timestep - if target < self.days[0].timesteps[0].date - datetime.timedelta(hours=1, minutes=30) and num_timesteps == 8: - err_str = 'There is no forecast available for the requested time. ' + \ - 'The requested time is more than 1.5 hours before the first available forecast' - raise APIException(err_str) - - elif target < self.days[0].timesteps[0].date - datetime.timedelta(hours=6) and num_timesteps == 2: - - err_str = 'There is no forecast available for the requested time. ' + \ - 'The requested time is more than 6 hours before the first available forecast' - - raise APIException(err_str) - - # Ensure that the target is less than 1 hour 30 minutes after the final - # timestep. - # Logic is correct - # If there are 8 timesteps per day, then the target must be within 1.5 - # hours of the last timestep - if target > (self.days[-1].timesteps[-1].date + datetime.timedelta(hours=1, minutes=30)) and num_timesteps == 8: - - err_str = 'There is no forecast available for the requested time. The requested time is more than 1.5 hours after the first available forecast' - - raise APIException(err_str) - - # If there are 2 timesteps per day, then the target must be within 6 - # hours of the last timestep - if target > (self.days[-1].timesteps[-1].date + datetime.timedelta(hours=6)) and num_timesteps == 2: - - err_str = 'There is no forecast available for the requested time. The requested time is more than 6 hours after the first available forecast' - - raise APIException(err_str) - - # Loop over all timesteps - # Calculate the first time difference - prev_td = target - self.days[0].timesteps[0].date - prev_ts = self.days[0].timesteps[0] - - for day in self.days: - for timestep in day.timesteps: - # Calculate the difference between the target time and the - # timestep. - td = target - timestep.date - - # Find the timestep which is further from the target than the - # previous one. Return the previous timestep - if abs(td.total_seconds()) > abs(prev_td.total_seconds()): - # We are further from the target - return prev_ts - if abs(td.total_seconds()) < 5400 and num_timesteps == 8: - # if we are past the final timestep, and it is a 3 hourly - # forecast, check that we are within 90 minutes of it - return timestep - if abs(td.total_seconds()) < 21600 and num_timesteps == 2: - # if we are past the final timestep, and it is a daily - # forecast, check that we are within 6 hours of it - return timestep - - prev_ts = timestep - prev_td = td - - def now(self): - """Function to return the closest timestep to the current time - """ - - d = datetime.datetime.now(tz=self.days[0].date.tzinfo) - return self.at_datetime(d) - - def future(self, in_days=0, in_hours=0, in_minutes=0, in_seconds=0): - """Return the closest timestep to a date in a given amount of time""" - - d = datetime.datetime.now(tz=self.days[0].date.tzinfo) - target = d + datetime.timedelta(days=in_days, hours=in_hours, - minutes=in_minutes, seconds=in_seconds) - - return self.at_datetime(target) diff --git a/datapoint/Manager.py b/datapoint/Manager.py deleted file mode 100644 index 18bcc64..0000000 --- a/datapoint/Manager.py +++ /dev/null @@ -1,685 +0,0 @@ -""" -Datapoint python module -""" - -from datetime import datetime -from datetime import timedelta -from time import time -from math import radians, cos, sin, asin, sqrt -import pytz -from warnings import warn - -import requests -from requests.adapters import HTTPAdapter -from requests.packages.urllib3.util.retry import Retry - -from datapoint.exceptions import APIException -from datapoint.Site import Site -from datapoint.Forecast import Forecast -from datapoint.Observation import Observation -from datapoint.Day import Day -from datapoint.Timestep import Timestep -from datapoint.Element import Element -from datapoint.regions.RegionManager import RegionManager - - -FORECAST_URL = "http://datapoint.metoffice.gov.uk/public/data/val/wxfcs/all/json" -OBSERVATION_URL = "http://datapoint.metoffice.gov.uk/public/data/val/wxobs/all/json" -DATE_FORMAT = "%Y-%m-%dZ" -DATA_DATE_FORMAT = "%Y-%m-%dT%XZ" -FORECAST_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" - -# See: -# https://www.metoffice.gov.uk/binaries/content/assets/mohippo/pdf/3/0/datapoint_api_reference.pdf -# pages 8 onwards for a description of the response anatomy, and the elements - -ELEMENTS = { - "Day": - {"U":"U", "V":"V", "W":"W", "T":"Dm", "S":"S", "Pp":"PPd", - "H":"Hn", "G":"Gn", "F":"FDm", "D":"D"}, - "Night": - {"V":"V", "W":"W", "T":"Nm", "S":"S", "Pp":"PPn", - "H":"Hm", "G":"Gm", "F":"FNm", "D":"D"}, - "Default": - {"V":"V", "W":"W", "T":"T", "S":"S", "Pp":"Pp", - "H":"H", "G":"G", "F":"F", "D":"D", "U":"U"}, - "Observation": - {"T":"T", "V":"V", "D":"D", "S":"S", - "W":"W", "P":"P", "Pt":"Pt", "Dp":"Dp", "H":"H"} -} - -WEATHER_CODES = { - "0":"Clear night", - "1":"Sunny day", - "2":"Partly cloudy", - "3":"Partly cloudy", - "4":"Not used", - "5":"Mist", - "6":"Fog", - "7":"Cloudy", - "8":"Overcast", - "9":"Light rain shower", - "10":"Light rain shower", - "11":"Drizzle", - "12":"Light rain", - "13":"Heavy rain shower", - "14":"Heavy rain shower", - "15":"Heavy rain", - "16":"Sleet shower", - "17":"Sleet shower", - "18":"Sleet", - "19":"Hail shower", - "20":"Hail shower", - "21":"Hail", - "22":"Light snow shower", - "23":"Light snow shower", - "24":"Light snow", - "25":"Heavy snow shower", - "26":"Heavy snow shower", - "27":"Heavy snow", - "28":"Thunder shower", - "29":"Thunder shower", - "30":"Thunder" -} - - -class Manager(): - """ - Datapoint Manager object - """ - - def __init__(self, api_key=""): - self.api_key = api_key - self.call_response = None - - # The list of sites changes infrequently so limit to requesting it - # every hour. - self.forecast_sites_last_update = 0 - self.forecast_sites_last_request = None - self.forecast_sites_update_time = 3600 - - self.observation_sites_last_update = 0 - self.observation_sites_last_request = None - self.observation_sites_update_time = 3600 - - self.regions = RegionManager(self.api_key) - - def __retry_session(self, retries=10, backoff_factor=0.3, - status_forcelist=(500, 502, 504), - session=None): - """ - Retry the connection using requests if it fails. Use this as a wrapper - to request from datapoint - """ - - # requests.Session allows finer control, which is needed to use the - # retrying code - the_session = session or requests.Session() - - # The Retry object manages the actual retrying - retry = Retry(total=retries, read=retries, connect=retries, - backoff_factor=backoff_factor, - status_forcelist=status_forcelist) - - adapter = HTTPAdapter(max_retries=retry) - - the_session.mount('http://', adapter) - the_session.mount('https://', adapter) - - return the_session - - def __call_api(self, path, params=None, api_url=FORECAST_URL): - """ - Call the datapoint api using the requests module - - """ - if not params: - params = dict() - payload = {'key': self.api_key} - payload.update(params) - url = "%s/%s" % (api_url, path) - - # Add a timeout to the request. - # The value of 1 second is based on attempting 100 connections to - # datapoint and taking ten times the mean connection time (rounded up). - # Could expose to users in the functions which need to call the api. - #req = requests.get(url, params=payload, timeout=1) - # The wrapper function __retry_session returns a requests.Session - # object. This has a .get() function like requests.get(), so the use - # doesn't change here. - - sess = self.__retry_session() - req = sess.get(url, params=payload, timeout=1) - - try: - data = req.json() - except ValueError: - raise APIException("DataPoint has not returned any data, this could be due to an incorrect API key") - self.call_response = data - if req.status_code != 200: - msg = [data[m] for m in ("message", "error_message", "status") \ - if m in data][0] - raise Exception(msg) - return data - - def _distance_between_coords(self, lon1, lat1, lon2, lat2): - """ - Calculate the great circle distance between two points - on the earth (specified in decimal degrees). - Haversine formula states that: - - d = 2 * r * arcsin(sqrt(sin^2((lat1 - lat2) / 2 + - cos(lat1)cos(lat2)sin^2((lon1 - lon2) / 2)))) - - where r is the radius of the sphere. This assumes the earth is spherical. - """ - - # Convert the coordinates of the points to radians. - lon1, lat1, lon2, lat2 = map(radians, [lon1, lat1, lon2, lat2]) - r = 6371 - - d_hav = 2 * r * asin(sqrt((sin((lat1 - lat2) / 2))**2 + \ - cos(lat1) * cos(lat2) * (sin((lon1 - lon2) / 2)**2 ))) - - return d_hav - - def _get_wx_units(self, params, name): - """ - Give the Wx array returned from datapoint and an element - name and return the units for that element. - """ - units = "" - for param in params: - if str(name) == str(param['name']): - units = param['units'] - return units - - def _weather_to_text(self, code): - if not isinstance(code, int): - raise ValueError("Weather code must be an integer not", type(code)) - if code < 0 or code > 30: - raise ValueError("Weather code outof bounds, should be 0-30") - text = WEATHER_CODES[str(code)] - return text - - def _visibility_to_text(self, distance): - """ - Convert observed visibility in metres to text used in forecast - """ - - if not isinstance(distance, int): - raise ValueError("Distance must be an integer not", type(distance)) - if distance < 0: - raise ValueError("Distance out of bounds, should be 0 or greater") - - if 0 <= distance < 1000: - return 'VP' - elif 1000 <= distance < 4000: - return 'PO' - elif 4000 <= distance < 10000: - return 'MO' - elif 10000 <= distance < 20000: - return 'GO' - elif 20000 <= distance < 40000: - return 'VG' - else: - return 'EX' - - def get_all_sites(self): - """ - Deprecated. This function returns a list of Site object. - """ - warning_message = 'This function is deprecated. Use get_forecast_sites() instead' - warn(warning_message, DeprecationWarning, stacklevel=2) - - return self.get_forecast_sites() - - def get_forecast_sites(self): - """ - This function returns a list of Site object. - """ - - time_now = time() - if (time_now - self.forecast_sites_last_update) > self.forecast_sites_update_time or self.forecast_sites_last_request is None: - - data = self.__call_api("sitelist/") - sites = list() - for jsoned in data['Locations']['Location']: - site = Site() - site.name = jsoned['name'] - site.location_id = jsoned['id'] - site.latitude = jsoned['latitude'] - site.longitude = jsoned['longitude'] - - if 'region' in jsoned: - site.region = jsoned['region'] - - if 'elevation' in jsoned: - site.elevation = jsoned['elevation'] - - if 'unitaryAuthArea' in jsoned: - site.unitaryAuthArea = jsoned['unitaryAuthArea'] - - if 'nationalPark' in jsoned: - site.nationalPark = jsoned['nationalPark'] - - site.api_key = self.api_key - - sites.append(site) - self.forecast_sites_last_request = sites - # Only set self.sites_last_update once self.sites_last_request has - # been set - self.forecast_sites_last_update = time_now - else: - sites = self.forecast_sites_last_request - - return sites - - def get_nearest_site(self, latitude=None, longitude=None): - """ - Deprecated. This function returns nearest Site object to the specified - coordinates. - """ - warning_message = 'This function is deprecated. Use get_nearest_forecast_site() instead' - warn(warning_message, DeprecationWarning, stacklevel=2) - - return self.get_nearest_forecast_site(latitude, longitude) - - def get_nearest_forecast_site(self, latitude, longitude): - """ - This function returns the nearest Site object to the specified - coordinates. - """ - - nearest = False - distance = None - sites = self.get_forecast_sites() - # Sometimes there is a TypeError exception here: sites is None - # So, sometimes self.get_all_sites() has returned None. - for site in sites: - new_distance = \ - self._distance_between_coords( - float(site.longitude), - float(site.latitude), - float(longitude), - float(latitude)) - - if ((distance is None) or (new_distance < distance)): - distance = new_distance - nearest = site - - # If the nearest site is more than 30km away, raise an error - - if distance > 30: - raise APIException("There is no site within 30km.") - - return nearest - - def get_forecast_for_site(self, site_id, frequency="daily"): - """ - Get a forecast for the provided site - - A frequency of "daily" will return two timesteps: - "Day" and "Night". - - A frequency of "3hourly" will return 8 timesteps: - 0, 180, 360 ... 1260 (minutes since midnight UTC) - """ - data = self.__call_api(site_id, {"res": frequency}) - params = data['SiteRep']['Wx']['Param'] - forecast = Forecast(frequency=frequency) - - # If the 'Location' key is missing, there is no data for the site, - # raise an error. - if 'Location' not in data['SiteRep']['DV']: - err_string = ('DataPoint has not returned any data for the' - 'requested site.') - raise APIException(err_string) - - # Check if the other keys we need are in the data returned from the - # datapoint API. If they are not, the data elements in the python class - # are left as None. It is currently the responsibility of the program - # using them to cope with this. - if 'dataDate' in data['SiteRep']['DV']: - forecast.data_date = datetime.strptime(data['SiteRep']['DV']['dataDate'], DATA_DATE_FORMAT).replace(tzinfo=pytz.UTC) - - if 'continent' in data['SiteRep']['DV']['Location']: - forecast.continent = data['SiteRep']['DV']['Location']['continent'] - - if 'country' in data['SiteRep']['DV']['Location']: - forecast.country = data['SiteRep']['DV']['Location']['country'] - - if 'name' in data['SiteRep']['DV']['Location']: - forecast.name = data['SiteRep']['DV']['Location']['name'] - - if 'lon' in data['SiteRep']['DV']['Location']: - forecast.longitude = data['SiteRep']['DV']['Location']['lon'] - - if 'lat' in data['SiteRep']['DV']['Location']: - forecast.latitude = data['SiteRep']['DV']['Location']['lat'] - - if 'i' in data['SiteRep']['DV']['Location']: - forecast.location_id = data['SiteRep']['DV']['Location']['i'] - - if 'elevation' in data['SiteRep']['DV']['Location']: - forecast.elevation = data['SiteRep']['DV']['Location']['elevation'] - - for day in data['SiteRep']['DV']['Location']['Period']: - new_day = Day() - new_day.date = datetime.strptime(day['value'], DATE_FORMAT).replace(tzinfo=pytz.UTC) - - for timestep in day['Rep']: - new_timestep = Timestep() - - # According to the datapoint documentation - # (https://www.metoffice.gov.uk/datapoint/product/uk-daily-site-specific-forecast), - # the data provided are for noon (local time) and midnight - # (local time). This implies that midnight is 00:00 and noon is - # 12:00. - - if timestep['$'] == "Day": - cur_elements = ELEMENTS['Day'] - new_timestep.date = datetime.strptime(day['value'], DATE_FORMAT).replace(tzinfo=pytz.UTC) \ - + timedelta(hours=12) - elif timestep['$'] == "Night": - cur_elements = ELEMENTS['Night'] - new_timestep.date = datetime.strptime(day['value'], DATE_FORMAT).replace(tzinfo=pytz.UTC) - else: - cur_elements = ELEMENTS['Default'] - new_timestep.date = datetime.strptime(day['value'], DATE_FORMAT).replace(tzinfo=pytz.UTC) \ - + timedelta(minutes=int(timestep['$'])) - - if frequency == 'daily': - new_timestep.name = timestep['$'] - elif frequency == '3hourly': - new_timestep.name = int(timestep['$']) - - new_timestep.weather = \ - Element(cur_elements['W'], - timestep[cur_elements['W']], - self._get_wx_units(params, cur_elements['W'])) - new_timestep.weather.text = self._weather_to_text(int(timestep[cur_elements['W']])) - - new_timestep.temperature = \ - Element(cur_elements['T'], - int(timestep[cur_elements['T']]), - self._get_wx_units(params, cur_elements['T'])) - - new_timestep.feels_like_temperature = \ - Element(cur_elements['F'], - int(timestep[cur_elements['F']]), - self._get_wx_units(params, cur_elements['F'])) - - new_timestep.wind_speed = \ - Element(cur_elements['S'], - int(timestep[cur_elements['S']]), - self._get_wx_units(params, cur_elements['S'])) - - new_timestep.wind_direction = \ - Element(cur_elements['D'], - timestep[cur_elements['D']], - self._get_wx_units(params, cur_elements['D'])) - - - new_timestep.wind_gust = \ - Element(cur_elements['G'], - int(timestep[cur_elements['G']]), - self._get_wx_units(params, cur_elements['G'])) - - new_timestep.visibility = \ - Element(cur_elements['V'], - timestep[cur_elements['V']], - self._get_wx_units(params, cur_elements['V'])) - - new_timestep.precipitation = \ - Element(cur_elements['Pp'], - int(timestep[cur_elements['Pp']]), - self._get_wx_units(params, cur_elements['Pp'])) - - new_timestep.humidity = \ - Element(cur_elements['H'], - int(timestep[cur_elements['H']]), - self._get_wx_units(params, cur_elements['H'])) - - if 'U' in cur_elements and cur_elements['U'] in timestep: - new_timestep.uv = \ - Element(cur_elements['U'], - timestep[cur_elements['U']], - self._get_wx_units(params, cur_elements['U'])) - - new_day.timesteps.append(new_timestep) - - # The daily timesteps are not sorted by time. Sort them - if frequency == 'daily': - new_day.timesteps.sort(key=lambda r: r.date) - forecast.days.append(new_day) - - return forecast - - def get_observation_sites(self): - """ - This function returns a list of Site objects for which observations are available. - """ - if (time() - self.observation_sites_last_update) > self.observation_sites_update_time: - self.observation_sites_last_update = time() - data = self.__call_api("sitelist/", None, OBSERVATION_URL) - sites = list() - for jsoned in data['Locations']['Location']: - site = Site() - site.name = jsoned['name'] - site.location_id = jsoned['id'] - site.latitude = jsoned['latitude'] - site.longitude = jsoned['longitude'] - - if 'region' in jsoned: - site.region = jsoned['region'] - - if 'elevation' in jsoned: - site.elevation = jsoned['elevation'] - - if 'unitaryAuthArea' in jsoned: - site.unitaryAuthArea = jsoned['unitaryAuthArea'] - - if 'nationalPark' in jsoned: - site.nationalPark = jsoned['nationalPark'] - - site.api_key = self.api_key - - sites.append(site) - self.observation_sites_last_request = sites - else: - sites = self.observation_sites_last_request - - return sites - - def get_nearest_observation_site(self, latitude, longitude): - """ - This function returns the nearest Site to the specified - coordinates that supports observations - """ - - nearest = False - distance = None - sites = self.get_observation_sites() - for site in sites: - new_distance = \ - self._distance_between_coords( - float(site.longitude), - float(site.latitude), - float(longitude), - float(latitude)) - - if ((distance == None) or (new_distance < distance)): - distance = new_distance - nearest = site - - # If the nearest site is more than 20km away, raise an error - if distance > 20: - raise APIException("There is no site within 30km.") - - return nearest - - - def get_observations_for_site(self, site_id, frequency='hourly'): - """ - Get observations for the provided site - - Returns hourly observations for the previous 24 hours - """ - - data = self.__call_api(site_id,{"res": frequency}, OBSERVATION_URL) - - params = data['SiteRep']['Wx']['Param'] - observation = Observation() - - # Check if keys are in data returned before using them. - if 'dataDate' in data['SiteRep']['DV']: - observation.data_date = datetime.strptime(data['SiteRep']['DV']['dataDate'], DATA_DATE_FORMAT).replace(tzinfo=pytz.UTC) - - if 'Location' in data['SiteRep']['DV']: - if 'continent' in data['SiteRep']['DV']['Location']: - observation.continent = data['SiteRep']['DV']['Location']['continent'] - - if 'country' in data['SiteRep']['DV']['Location']: - observation.country = data['SiteRep']['DV']['Location']['country'] - - if 'name' in data['SiteRep']['DV']['Location']: - observation.name = data['SiteRep']['DV']['Location']['name'] - - if 'lon' in data['SiteRep']['DV']['Location']: - observation.longitude = data['SiteRep']['DV']['Location']['lon'] - - if 'lat' in data['SiteRep']['DV']['Location']: - observation.latitude = data['SiteRep']['DV']['Location']['lat'] - - if 'i' in data['SiteRep']['DV']['Location']: - observation.location_id = data['SiteRep']['DV']['Location']['i'] - - if 'elevation' in data['SiteRep']['DV']['Location']: - observation.elevation = data['SiteRep']['DV']['Location']['elevation'] - - for day in data['SiteRep']['DV']['Location']['Period']: - new_day = Day() - new_day.date = datetime.strptime(day['value'], DATE_FORMAT).replace(tzinfo=pytz.UTC) - - # If the day only has 1 timestep, put it into a list by itself - # so it can be treated the same as a day with multiple timesteps - if type(day['Rep']) is not list: - day['Rep'] = [day['Rep']] - - for timestep in day['Rep']: - # As stated in - # https://www.metoffice.gov.uk/datapoint/product/uk-hourly-site-specific-observations, - # some sites do not have all parameters available for - # observations. The documentation does not state which - # fields may be absent. If the parameter is not available, - # nothing is returned from the API. If this happens the - # value of the element is set to 'Not reported'. This may - # change to the element not being assigned to the timestep. - - new_timestep = Timestep() - # Assume the '$' field is always present. - new_timestep.name = int(timestep['$']) - - cur_elements = ELEMENTS['Observation'] - - new_timestep.date = datetime.strptime(day['value'], DATE_FORMAT).replace(tzinfo=pytz.UTC) + timedelta(minutes=int(timestep['$'])) - - if cur_elements['W'] in timestep: - new_timestep.weather = \ - Element(cur_elements['W'], - timestep[cur_elements['W']], - self._get_wx_units(params, cur_elements['W'])) - new_timestep.weather.text = \ - self._weather_to_text(int(timestep[cur_elements['W']])) - else: - new_timestep.weather = \ - Element(cur_elements['W'], - 'Not reported') - - if cur_elements['T'] in timestep: - new_timestep.temperature = \ - Element(cur_elements['T'], - float(timestep[cur_elements['T']]), - self._get_wx_units(params, cur_elements['T'])) - else: - new_timestep.temperature = \ - Element(cur_elements['T'], - 'Not reported') - - if 'S' in timestep: - new_timestep.wind_speed = \ - Element(cur_elements['S'], - int(timestep[cur_elements['S']]), - self._get_wx_units(params, cur_elements['S'])) - else: - new_timestep.wind_speed = \ - Element(cur_elements['S'], - 'Not reported') - - if 'D' in timestep: - new_timestep.wind_direction = \ - Element(cur_elements['D'], - timestep[cur_elements['D']], - self._get_wx_units(params, cur_elements['D'])) - else: - new_timestep.wind_direction = \ - Element(cur_elements['D'], - 'Not reported') - - if cur_elements['V'] in timestep: - new_timestep.visibility = \ - Element(cur_elements['V'], - int(timestep[cur_elements['V']]), - self._get_wx_units(params, cur_elements['V'])) - new_timestep.visibility.text = self._visibility_to_text(int(timestep[cur_elements['V']])) - else: - new_timestep.visibility = \ - Element(cur_elements['V'], - 'Not reported') - - if cur_elements['H'] in timestep: - new_timestep.humidity = \ - Element(cur_elements['H'], - float(timestep[cur_elements['H']]), - self._get_wx_units(params, cur_elements['H'])) - else: - new_timestep.humidity = \ - Element(cur_elements['H'], - 'Not reported') - - if cur_elements['Dp'] in timestep: - new_timestep.dew_point = \ - Element(cur_elements['Dp'], - float(timestep[cur_elements['Dp']]), - self._get_wx_units(params, - cur_elements['Dp'])) - else: - new_timestep.dew_point = \ - Element(cur_elements['Dp'], - 'Not reported') - - if cur_elements['P'] in timestep: - new_timestep.pressure = \ - Element(cur_elements['P'], - float(timestep[cur_elements['P']]), - self._get_wx_units(params, cur_elements['P'])) - else: - new_timestep.pressure = \ - Element(cur_elements['P'], - 'Not reported') - - if cur_elements['Pt'] in timestep: - new_timestep.pressure_tendency = \ - Element(cur_elements['Pt'], - timestep[cur_elements['Pt']], - self._get_wx_units(params, cur_elements['Pt'])) - else: - new_timestep.pressure_tendency = \ - Element(cur_elements['Pt'], - 'Not reported') - - new_day.timesteps.append(new_timestep) - observation.days.append(new_day) - - return observation diff --git a/datapoint/Observation.py b/datapoint/Observation.py deleted file mode 100644 index 93e12a1..0000000 --- a/datapoint/Observation.py +++ /dev/null @@ -1,20 +0,0 @@ -class Observation(): - def __init__(self): - self.data_date = None - self.continent = None - self.country = None - self.name = None - self.longitude = None - self.latitude = None - self.location_id = None - self.elevation = None - # Stores a list of observations in days - self.days = [] - - def now(self): - """ - Return the final timestep available. This is the most recent - observation. - """ - - return self.days[-1].timesteps[-1] diff --git a/datapoint/Site.py b/datapoint/Site.py deleted file mode 100644 index 331c402..0000000 --- a/datapoint/Site.py +++ /dev/null @@ -1,18 +0,0 @@ -class Site(): - def __init__(self): - self.name = None - self.location_id = None - self.elevation = None - self.latitude = None - self.longitude = None - self.nationalPark = None - self.region = None - self.unitaryAuthArea = None - - def __str__(self): - site_string = '' - for attr, value in self.__dict__.items(): - to_append = attr + ': ' + str(value) + '\n' - site_string += to_append - - return site_string diff --git a/datapoint/Timestep.py b/datapoint/Timestep.py deleted file mode 100644 index 2cb415b..0000000 --- a/datapoint/Timestep.py +++ /dev/null @@ -1,36 +0,0 @@ -from .Element import Element - -class Timestep(): - def __init__(self): - self.name = None - self.date = None - self.weather = None - self.temperature = None - self.feels_like_temperature = None - self.wind_speed = None - self.wind_direction = None - self.wind_gust = None - self.visibility = None - self.uv = None - self.precipitation = None - self.humidity = None - self.pressure = None - self.pressure_tendency = None - self.dew_point = None - - def __iter__(self): - for attr, value in self.__dict__.items(): - yield attr, value - - def elements(self): - """Return a list of the Elements which are not None""" - elements = [el[1] for el in self.__dict__.items() if isinstance(el[1], Element)] - - return elements - - def __str__(self): - timestep_string = '' - for attr, value in self.__dict__.items(): - to_append = attr + ': ' + str(value) + '\n' - timestep_string += to_append - return timestep_string diff --git a/datapoint/_version.py b/datapoint/_version.py deleted file mode 100644 index dfcebde..0000000 --- a/datapoint/_version.py +++ /dev/null @@ -1,683 +0,0 @@ - -# This file helps to compute a version number in source trees obtained from -# git-archive tarball (such as those provided by githubs download-from-tag -# feature). Distribution tarballs (built by setup.py sdist) and build -# directories (produced by setup.py build) will contain a much shorter file -# that just contains the computed version number. - -# This file is released into the public domain. -# Generated by versioneer-0.29 -# https://github.com/python-versioneer/python-versioneer - -"""Git implementation of _version.py.""" - -import errno -import os -import re -import subprocess -import sys -from typing import Any, Callable, Dict, List, Optional, Tuple -import functools - - -def get_keywords() -> Dict[str, str]: - """Get the keywords needed to look up the version information.""" - # these strings will be replaced by git during git-archive. - # setup.py/versioneer.py will grep for the variable names, so they must - # each be defined on a line of their own. _version.py will just call - # get_keywords(). - git_refnames = "$Format:%d$" - git_full = "$Format:%H$" - git_date = "$Format:%ci$" - keywords = {"refnames": git_refnames, "full": git_full, "date": git_date} - return keywords - - -class VersioneerConfig: - """Container for Versioneer configuration parameters.""" - - VCS: str - style: str - tag_prefix: str - parentdir_prefix: str - versionfile_source: str - verbose: bool - - -def get_config() -> VersioneerConfig: - """Create, populate and return the VersioneerConfig() object.""" - # these strings are filled in when 'setup.py versioneer' creates - # _version.py - cfg = VersioneerConfig() - cfg.VCS = "git" - cfg.style = "" - cfg.tag_prefix = "" - cfg.parentdir_prefix = "None" - cfg.versionfile_source = "datapoint/_version.py" - cfg.verbose = False - return cfg - - -class NotThisMethod(Exception): - """Exception raised if a method is not valid for the current scenario.""" - - -LONG_VERSION_PY: Dict[str, str] = {} -HANDLERS: Dict[str, Dict[str, Callable]] = {} - - -def register_vcs_handler(vcs: str, method: str) -> Callable: # decorator - """Create decorator to mark a method as the handler of a VCS.""" - def decorate(f: Callable) -> Callable: - """Store f in HANDLERS[vcs][method].""" - if vcs not in HANDLERS: - HANDLERS[vcs] = {} - HANDLERS[vcs][method] = f - return f - return decorate - - -def run_command( - commands: List[str], - args: List[str], - cwd: Optional[str] = None, - verbose: bool = False, - hide_stderr: bool = False, - env: Optional[Dict[str, str]] = None, -) -> Tuple[Optional[str], Optional[int]]: - """Call the given command(s).""" - assert isinstance(commands, list) - process = None - - popen_kwargs: Dict[str, Any] = {} - if sys.platform == "win32": - # This hides the console window if pythonw.exe is used - startupinfo = subprocess.STARTUPINFO() - startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - popen_kwargs["startupinfo"] = startupinfo - - for command in commands: - try: - dispcmd = str([command] + args) - # remember shell=False, so use git.cmd on windows, not just git - process = subprocess.Popen([command] + args, cwd=cwd, env=env, - stdout=subprocess.PIPE, - stderr=(subprocess.PIPE if hide_stderr - else None), **popen_kwargs) - break - except OSError as e: - if e.errno == errno.ENOENT: - continue - if verbose: - print("unable to run %s" % dispcmd) - print(e) - return None, None - else: - if verbose: - print("unable to find command, tried %s" % (commands,)) - return None, None - stdout = process.communicate()[0].strip().decode() - if process.returncode != 0: - if verbose: - print("unable to run %s (error)" % dispcmd) - print("stdout was %s" % stdout) - return None, process.returncode - return stdout, process.returncode - - -def versions_from_parentdir( - parentdir_prefix: str, - root: str, - verbose: bool, -) -> Dict[str, Any]: - """Try to determine the version from the parent directory name. - - Source tarballs conventionally unpack into a directory that includes both - the project name and a version string. We will also support searching up - two directory levels for an appropriately named parent directory - """ - rootdirs = [] - - for _ in range(3): - dirname = os.path.basename(root) - if dirname.startswith(parentdir_prefix): - return {"version": dirname[len(parentdir_prefix):], - "full-revisionid": None, - "dirty": False, "error": None, "date": None} - rootdirs.append(root) - root = os.path.dirname(root) # up a level - - if verbose: - print("Tried directories %s but none started with prefix %s" % - (str(rootdirs), parentdir_prefix)) - raise NotThisMethod("rootdir doesn't start with parentdir_prefix") - - -@register_vcs_handler("git", "get_keywords") -def git_get_keywords(versionfile_abs: str) -> Dict[str, str]: - """Extract version information from the given file.""" - # the code embedded in _version.py can just fetch the value of these - # keywords. When used from setup.py, we don't want to import _version.py, - # so we do it with a regexp instead. This function is not used from - # _version.py. - keywords: Dict[str, str] = {} - try: - with open(versionfile_abs, "r") as fobj: - for line in fobj: - if line.strip().startswith("git_refnames ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["refnames"] = mo.group(1) - if line.strip().startswith("git_full ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["full"] = mo.group(1) - if line.strip().startswith("git_date ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["date"] = mo.group(1) - except OSError: - pass - return keywords - - -@register_vcs_handler("git", "keywords") -def git_versions_from_keywords( - keywords: Dict[str, str], - tag_prefix: str, - verbose: bool, -) -> Dict[str, Any]: - """Get version information from git keywords.""" - if "refnames" not in keywords: - raise NotThisMethod("Short version file found") - date = keywords.get("date") - if date is not None: - # Use only the last line. Previous lines may contain GPG signature - # information. - date = date.splitlines()[-1] - - # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant - # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 - # -like" string, which we must then edit to make compliant), because - # it's been around since git-1.5.3, and it's too difficult to - # discover which version we're using, or to work around using an - # older one. - date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) - refnames = keywords["refnames"].strip() - if refnames.startswith("$Format"): - if verbose: - print("keywords are unexpanded, not using") - raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = {r.strip() for r in refnames.strip("()").split(",")} - # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of - # just "foo-1.0". If we see a "tag: " prefix, prefer those. - TAG = "tag: " - tags = {r[len(TAG):] for r in refs if r.startswith(TAG)} - if not tags: - # Either we're using git < 1.8.3, or there really are no tags. We use - # a heuristic: assume all version tags have a digit. The old git %d - # expansion behaves like git log --decorate=short and strips out the - # refs/heads/ and refs/tags/ prefixes that would let us distinguish - # between branches and tags. By ignoring refnames without digits, we - # filter out many common branch names like "release" and - # "stabilization", as well as "HEAD" and "master". - tags = {r for r in refs if re.search(r'\d', r)} - if verbose: - print("discarding '%s', no digits" % ",".join(refs - tags)) - if verbose: - print("likely tags: %s" % ",".join(sorted(tags))) - for ref in sorted(tags): - # sorting will prefer e.g. "2.0" over "2.0rc1" - if ref.startswith(tag_prefix): - r = ref[len(tag_prefix):] - # Filter out refs that exactly match prefix or that don't start - # with a number once the prefix is stripped (mostly a concern - # when prefix is '') - if not re.match(r'\d', r): - continue - if verbose: - print("picking %s" % r) - return {"version": r, - "full-revisionid": keywords["full"].strip(), - "dirty": False, "error": None, - "date": date} - # no suitable tags, so version is "0+unknown", but full hex is still there - if verbose: - print("no suitable tags, using unknown + full revision id") - return {"version": "0+unknown", - "full-revisionid": keywords["full"].strip(), - "dirty": False, "error": "no suitable tags", "date": None} - - -@register_vcs_handler("git", "pieces_from_vcs") -def git_pieces_from_vcs( - tag_prefix: str, - root: str, - verbose: bool, - runner: Callable = run_command -) -> Dict[str, Any]: - """Get version from 'git describe' in the root of the source tree. - - This only gets called if the git-archive 'subst' keywords were *not* - expanded, and _version.py hasn't already been rewritten with a short - version string, meaning we're inside a checked out source tree. - """ - GITS = ["git"] - if sys.platform == "win32": - GITS = ["git.cmd", "git.exe"] - - # GIT_DIR can interfere with correct operation of Versioneer. - # It may be intended to be passed to the Versioneer-versioned project, - # but that should not change where we get our version from. - env = os.environ.copy() - env.pop("GIT_DIR", None) - runner = functools.partial(runner, env=env) - - _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root, - hide_stderr=not verbose) - if rc != 0: - if verbose: - print("Directory %s not under git control" % root) - raise NotThisMethod("'git rev-parse --git-dir' returned error") - - # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] - # if there isn't one, this yields HEX[-dirty] (no NUM) - describe_out, rc = runner(GITS, [ - "describe", "--tags", "--dirty", "--always", "--long", - "--match", f"{tag_prefix}[[:digit:]]*" - ], cwd=root) - # --long was added in git-1.5.5 - if describe_out is None: - raise NotThisMethod("'git describe' failed") - describe_out = describe_out.strip() - full_out, rc = runner(GITS, ["rev-parse", "HEAD"], cwd=root) - if full_out is None: - raise NotThisMethod("'git rev-parse' failed") - full_out = full_out.strip() - - pieces: Dict[str, Any] = {} - pieces["long"] = full_out - pieces["short"] = full_out[:7] # maybe improved later - pieces["error"] = None - - branch_name, rc = runner(GITS, ["rev-parse", "--abbrev-ref", "HEAD"], - cwd=root) - # --abbrev-ref was added in git-1.6.3 - if rc != 0 or branch_name is None: - raise NotThisMethod("'git rev-parse --abbrev-ref' returned error") - branch_name = branch_name.strip() - - if branch_name == "HEAD": - # If we aren't exactly on a branch, pick a branch which represents - # the current commit. If all else fails, we are on a branchless - # commit. - branches, rc = runner(GITS, ["branch", "--contains"], cwd=root) - # --contains was added in git-1.5.4 - if rc != 0 or branches is None: - raise NotThisMethod("'git branch --contains' returned error") - branches = branches.split("\n") - - # Remove the first line if we're running detached - if "(" in branches[0]: - branches.pop(0) - - # Strip off the leading "* " from the list of branches. - branches = [branch[2:] for branch in branches] - if "master" in branches: - branch_name = "master" - elif not branches: - branch_name = None - else: - # Pick the first branch that is returned. Good or bad. - branch_name = branches[0] - - pieces["branch"] = branch_name - - # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] - # TAG might have hyphens. - git_describe = describe_out - - # look for -dirty suffix - dirty = git_describe.endswith("-dirty") - pieces["dirty"] = dirty - if dirty: - git_describe = git_describe[:git_describe.rindex("-dirty")] - - # now we have TAG-NUM-gHEX or HEX - - if "-" in git_describe: - # TAG-NUM-gHEX - mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) - if not mo: - # unparsable. Maybe git-describe is misbehaving? - pieces["error"] = ("unable to parse git-describe output: '%s'" - % describe_out) - return pieces - - # tag - full_tag = mo.group(1) - if not full_tag.startswith(tag_prefix): - if verbose: - fmt = "tag '%s' doesn't start with prefix '%s'" - print(fmt % (full_tag, tag_prefix)) - pieces["error"] = ("tag '%s' doesn't start with prefix '%s'" - % (full_tag, tag_prefix)) - return pieces - pieces["closest-tag"] = full_tag[len(tag_prefix):] - - # distance: number of commits since tag - pieces["distance"] = int(mo.group(2)) - - # commit: short hex revision ID - pieces["short"] = mo.group(3) - - else: - # HEX: no tags - pieces["closest-tag"] = None - out, rc = runner(GITS, ["rev-list", "HEAD", "--left-right"], cwd=root) - pieces["distance"] = len(out.split()) # total number of commits - - # commit date: see ISO-8601 comment in git_versions_from_keywords() - date = runner(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() - # Use only the last line. Previous lines may contain GPG signature - # information. - date = date.splitlines()[-1] - pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) - - return pieces - - -def plus_or_dot(pieces: Dict[str, Any]) -> str: - """Return a + if we don't already have one, else return a .""" - if "+" in pieces.get("closest-tag", ""): - return "." - return "+" - - -def render_pep440(pieces: Dict[str, Any]) -> str: - """Build up version string, with post-release "local version identifier". - - Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you - get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty - - Exceptions: - 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += plus_or_dot(pieces) - rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - else: - # exception #1 - rendered = "0+untagged.%d.g%s" % (pieces["distance"], - pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - return rendered - - -def render_pep440_branch(pieces: Dict[str, Any]) -> str: - """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] . - - The ".dev0" means not master branch. Note that .dev0 sorts backwards - (a feature branch will appear "older" than the master branch). - - Exceptions: - 1: no tags. 0[.dev0]+untagged.DISTANCE.gHEX[.dirty] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - if pieces["branch"] != "master": - rendered += ".dev0" - rendered += plus_or_dot(pieces) - rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - else: - # exception #1 - rendered = "0" - if pieces["branch"] != "master": - rendered += ".dev0" - rendered += "+untagged.%d.g%s" % (pieces["distance"], - pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - return rendered - - -def pep440_split_post(ver: str) -> Tuple[str, Optional[int]]: - """Split pep440 version string at the post-release segment. - - Returns the release segments before the post-release and the - post-release version number (or -1 if no post-release segment is present). - """ - vc = str.split(ver, ".post") - return vc[0], int(vc[1] or 0) if len(vc) == 2 else None - - -def render_pep440_pre(pieces: Dict[str, Any]) -> str: - """TAG[.postN.devDISTANCE] -- No -dirty. - - Exceptions: - 1: no tags. 0.post0.devDISTANCE - """ - if pieces["closest-tag"]: - if pieces["distance"]: - # update the post release segment - tag_version, post_version = pep440_split_post(pieces["closest-tag"]) - rendered = tag_version - if post_version is not None: - rendered += ".post%d.dev%d" % (post_version + 1, pieces["distance"]) - else: - rendered += ".post0.dev%d" % (pieces["distance"]) - else: - # no commits, use the tag as the version - rendered = pieces["closest-tag"] - else: - # exception #1 - rendered = "0.post0.dev%d" % pieces["distance"] - return rendered - - -def render_pep440_post(pieces: Dict[str, Any]) -> str: - """TAG[.postDISTANCE[.dev0]+gHEX] . - - The ".dev0" means dirty. Note that .dev0 sorts backwards - (a dirty tree will appear "older" than the corresponding clean one), - but you shouldn't be releasing software with -dirty anyways. - - Exceptions: - 1: no tags. 0.postDISTANCE[.dev0] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += ".post%d" % pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - rendered += plus_or_dot(pieces) - rendered += "g%s" % pieces["short"] - else: - # exception #1 - rendered = "0.post%d" % pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - rendered += "+g%s" % pieces["short"] - return rendered - - -def render_pep440_post_branch(pieces: Dict[str, Any]) -> str: - """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] . - - The ".dev0" means not master branch. - - Exceptions: - 1: no tags. 0.postDISTANCE[.dev0]+gHEX[.dirty] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += ".post%d" % pieces["distance"] - if pieces["branch"] != "master": - rendered += ".dev0" - rendered += plus_or_dot(pieces) - rendered += "g%s" % pieces["short"] - if pieces["dirty"]: - rendered += ".dirty" - else: - # exception #1 - rendered = "0.post%d" % pieces["distance"] - if pieces["branch"] != "master": - rendered += ".dev0" - rendered += "+g%s" % pieces["short"] - if pieces["dirty"]: - rendered += ".dirty" - return rendered - - -def render_pep440_old(pieces: Dict[str, Any]) -> str: - """TAG[.postDISTANCE[.dev0]] . - - The ".dev0" means dirty. - - Exceptions: - 1: no tags. 0.postDISTANCE[.dev0] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += ".post%d" % pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - else: - # exception #1 - rendered = "0.post%d" % pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - return rendered - - -def render_git_describe(pieces: Dict[str, Any]) -> str: - """TAG[-DISTANCE-gHEX][-dirty]. - - Like 'git describe --tags --dirty --always'. - - Exceptions: - 1: no tags. HEX[-dirty] (note: no 'g' prefix) - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"]: - rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) - else: - # exception #1 - rendered = pieces["short"] - if pieces["dirty"]: - rendered += "-dirty" - return rendered - - -def render_git_describe_long(pieces: Dict[str, Any]) -> str: - """TAG-DISTANCE-gHEX[-dirty]. - - Like 'git describe --tags --dirty --always -long'. - The distance/hash is unconditional. - - Exceptions: - 1: no tags. HEX[-dirty] (note: no 'g' prefix) - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) - else: - # exception #1 - rendered = pieces["short"] - if pieces["dirty"]: - rendered += "-dirty" - return rendered - - -def render(pieces: Dict[str, Any], style: str) -> Dict[str, Any]: - """Render the given version pieces into the requested style.""" - if pieces["error"]: - return {"version": "unknown", - "full-revisionid": pieces.get("long"), - "dirty": None, - "error": pieces["error"], - "date": None} - - if not style or style == "default": - style = "pep440" # the default - - if style == "pep440": - rendered = render_pep440(pieces) - elif style == "pep440-branch": - rendered = render_pep440_branch(pieces) - elif style == "pep440-pre": - rendered = render_pep440_pre(pieces) - elif style == "pep440-post": - rendered = render_pep440_post(pieces) - elif style == "pep440-post-branch": - rendered = render_pep440_post_branch(pieces) - elif style == "pep440-old": - rendered = render_pep440_old(pieces) - elif style == "git-describe": - rendered = render_git_describe(pieces) - elif style == "git-describe-long": - rendered = render_git_describe_long(pieces) - else: - raise ValueError("unknown style '%s'" % style) - - return {"version": rendered, "full-revisionid": pieces["long"], - "dirty": pieces["dirty"], "error": None, - "date": pieces.get("date")} - - -def get_versions() -> Dict[str, Any]: - """Get version information or return default if unable to do so.""" - # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have - # __file__, we can work backwards from there to the root. Some - # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which - # case we can only use expanded keywords. - - cfg = get_config() - verbose = cfg.verbose - - try: - return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, - verbose) - except NotThisMethod: - pass - - try: - root = os.path.realpath(__file__) - # versionfile_source is the relative path from the top of the source - # tree (where the .git directory might live) to this file. Invert - # this to find the root from __file__. - for _ in cfg.versionfile_source.split('/'): - root = os.path.dirname(root) - except NameError: - return {"version": "0+unknown", "full-revisionid": None, - "dirty": None, - "error": "unable to find root of source tree", - "date": None} - - try: - pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) - return render(pieces, cfg.style) - except NotThisMethod: - pass - - try: - if cfg.parentdir_prefix: - return versions_from_parentdir(cfg.parentdir_prefix, root, verbose) - except NotThisMethod: - pass - - return {"version": "0+unknown", "full-revisionid": None, - "dirty": None, - "error": "unable to compute version", "date": None} diff --git a/datapoint/regions/RegionManager.py b/datapoint/regions/RegionManager.py deleted file mode 100644 index 54cffc4..0000000 --- a/datapoint/regions/RegionManager.py +++ /dev/null @@ -1,66 +0,0 @@ -import json -from time import time - -import requests - -from datapoint.Site import Site -from datapoint.regions.region_names import REGION_NAMES -REGIONS_BASE_URL = 'http://datapoint.metoffice.gov.uk/public/data/txt/wxfcs/regionalforecast/json' - - -class RegionManager(): - ''' - Datapoint Manager for national and regional text forecasts - ''' - def __init__(self, api_key, base_url=None): - self.api_key = api_key - self.all_regions_path = '/sitelist' - if not base_url: - self.base_url = REGIONS_BASE_URL - - # The list of regions changes infrequently so limit to requesting it - # every hour. - self.regions_last_update = 0 - self.regions_last_request = None - self.regions_update_time = 3600 - - def call_api(self, path, **kwargs): - ''' - Call datapoint api - ''' - if 'key' not in kwargs: - kwargs['key'] = self.api_key - req = requests.get('{0}{1}'.format(self.base_url, path), params=kwargs) - - if req.status_code != requests.codes.ok: - req.raise_for_status() - - return req.json() - - def get_all_regions(self): - ''' - Request a list of regions from Datapoint. Returns each Region - as a Site object. Regions rarely change, so we cache the response - for one hour to minimise requests to API. - ''' - if (time() - self.regions_last_update) < self.regions_update_time: - return self.regions_last_request - - response = self.call_api(self.all_regions_path) - regions = [] - for location in response['Locations']['Location']: - region = Site() - region.location_id = location['@id'] - region.region = location['@name'] - region.name = REGION_NAMES[location['@name']] - regions.append(region) - - self.regions_last_update = time() - self.regions_last_request = regions - return regions - - def get_raw_forecast(self, region_id): - ''' - Request unformatted forecast for a specific region_id. - ''' - return self.call_api('/{0}'.format(region_id)) diff --git a/datapoint/regions/__init__.py b/datapoint/regions/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/datapoint/regions/region_names.py b/datapoint/regions/region_names.py deleted file mode 100644 index 8dd5d45..0000000 --- a/datapoint/regions/region_names.py +++ /dev/null @@ -1,19 +0,0 @@ -REGION_NAMES = { - 'os': 'Orkney & Shetland', - 'he': 'Highland & Eilean Siar', - 'gr': 'Grampian', - 'ta': 'Tayside', - 'st': 'Strathclyde', - 'dg': 'Dumfries, Galloway, Lothian', - 'ni': 'Northern Ireland', - 'yh': 'Yorkshire & the Humber', - 'ne': 'Northeast England', - 'em': 'East Midlands', - 'ee': 'East of England', - 'se': 'London & Southeast England', - 'nw': 'Northwest England', - 'wm': 'West Midlands', - 'sw': 'Southwest England', - 'wl': 'Wales', - 'uk': 'UK', -} \ No newline at end of file diff --git a/examples/simple_forecast/simple_forecast.py b/examples/simple_forecast/simple_forecast.py index 83daf2f..c8b87c6 100755 --- a/examples/simple_forecast/simple_forecast.py +++ b/examples/simple_forecast/simple_forecast.py @@ -5,26 +5,27 @@ """ import datapoint +import datetime # Create datapoint connection -conn = datapoint.Manager(api_key="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") +manager = datapoint.Manager( + api_key="api key goes here" +) -# Get nearest site and print out its name -site = conn.get_nearest_forecast_site(51.500728, -0.124626) -print(site.name) +forecast = manager.get_forecast(51.500728, -0.124626, frequency="hourly") -# Get a forecast for the nearest site -forecast = conn.get_forecast_for_site(site.location_id, "3hourly") +# Loop through timesteps and print information +for timestep in forecast.timesteps: + print(timestep["time"]) + print(timestep["significantWeatherCode"]["value"]) + print( + "{temp} {temp_units}".format( + temp=timestep["screenTemperature"]["value"], + temp_units=timestep["screenTemperature"]["unit_symbol"], + ) + ) -# Loop through days and print date -for day in forecast.days: - print("\n%s" % day.date) +print(forecast.now()) - # Loop through time steps and print out info - for timestep in day.timesteps: - print(timestep.date) - print(timestep.weather.text) - print("%s%s%s" % (timestep.temperature.value, - '\xb0', #Unicode character for degree symbol - timestep.temperature.units)) +print(forecast.at_datetime(datetime.datetime(2024, 2, 11, 14, 0))) diff --git a/pyproject.toml b/pyproject.toml index e1692a4..84955c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,4 +1,43 @@ [build-system] -requires = ["setuptools", -"versioneer"] -build-backend = "setuptools.build_meta" \ No newline at end of file +requires = ["hatchling", "versioningit"] +build-backend = "hatchling.build" + +[project] +name = "datapoint" +dynamic = ["version"] +authors = [ + {name="Emlyn Price", email="emlyn.je.price@gmail.com"}, + { name="Jacob Tomlinson"}, +] +description = "Python interface to the Met Office's Datapoint API" +readme = "README.md" +requires-python = ">=3.8" +classifiers=[ + "Development Status :: 3 - Alpha", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", +] +dependencies = [ + "requests >= 2.20.0,<3", + "appdirs >=1,<2", +] +license = {file = "LICENSE"} +keywords = ["weather", "weather forecast", "Met Office", "DataHub"] + +[project.urls] +Homepage = "https://github.com/ejep/datapoint-python" +Documentation = "http://datapoint-python.readthedocs.org/en/latest" + +[tool.hatch.build.targets.sdist] +exclude = [ + "tests/", + "examples/", +] + +[tool.hatch.version] +source = "versioningit" \ No newline at end of file diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 3a35110..0000000 --- a/setup.cfg +++ /dev/null @@ -1,5 +0,0 @@ -[versioneer] -VCS = git -versionfile_source = datapoint/_version.py -versionfile_build = datapoint/_version.py -tag_prefix = '' \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 29ce7ac..0000000 --- a/setup.py +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env python - -from setuptools import setup -import versioneer - -setup(name='datapoint', - version='0.9.9', - #cmdclass=versioneer.get_cmdclass(), - install_requires=[ - "requests >= 2.20.0,<3", - "appdirs >=1,<2", - "pytz", - ], - description='Python interface to the Met Office\'s Datapoint API', - long_description_content_type='text/x-rst', - long_description=''' -Datapoint for Python --------------------- - -*A Python module for accessing weather data via the Met -Office's open data API known as -`Datapoint`.* - -**Disclaimer: This module is in no way part of the datapoint -project/service. This module is intended to simplify the use of -Datapoint for small Python projects (e.g school projects). No support -for this module is provided by the Met Office and may break as the -Datapoint service grows/evolves. The author will make reasonable efforts -to keep it up to date and fully featured.** - -Changelog ---------- - -+ Explicitly state the use of semantic versioning in `README.md`. -+ Add `elements()` function to `Timestep`. -+ Remove night/day indication from weather codes which have them. -+ Change the logic used to calculate the closest timestep to a datetime. The closest timestep to the datetime is now used. Add a new function, `Forecast.at_datetime(target)` to do this. `Forecast.now()` has been changed to use this new function. The old behaviour is deprecated and available using `Forecast.now_old()`. `Forecast.future()` has been changed to use this new function. The old behaviour is deprecated and available using `Forecast.future_old()`. -+ Check if keys are returned from datapoint api in `Manager.py`. Do not attempt to read the values from the dict if they are not there. - - -Installation ------------- - -.. code:: bash - - $ pip install datapoint - -You will also require a `Datapoint API` -key from http://www.metoffice.gov.uk/datapoint/API. - -Features --------- - -- List forecast sites -- Get nearest forecast site from latitiude and longitude -- Get the following 5 day forecast types for any site -- Daily (Two timesteps, midday and midnight UTC) -- 3 hourly (Eight timesteps, every 3 hours starting at midnight UTC) -- Get observation sites -- Get observations for any site - -Contributing changes --------------------- - -Please feel free to submit issues and pull requests. - -License -------- - -GPLv3. -''', - author='Jacob Tomlinson, Emlyn Price', - author_email='emlyn.je.price@gmail.com', - maintainer='Emlyn Price', - maintainer_email='emlyn.je.price@gmail.com', - url='https://github.com/ejep/datapoint-python', - license='GPLv3', - packages=['datapoint', 'datapoint.regions'], - classifiers=[ - 'Development Status :: 3 - Alpha', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - ] - ) diff --git a/src/datapoint/Forecast.py b/src/datapoint/Forecast.py new file mode 100644 index 0000000..e707352 --- /dev/null +++ b/src/datapoint/Forecast.py @@ -0,0 +1,212 @@ +import datetime +from datapoint.exceptions import APIException +from datapoint.Timestep import Timestep +from datapoint.weather_codes import WEATHER_CODES + + +class Forecast: + def __init__(self, frequency, api_data): + self.frequency = frequency + self.data_date = datetime.datetime.fromisoformat( + api_data["features"][0]["properties"]["modelRunDate"] + ) + self.name = api_data["features"][0]["properties"]["location"] + self.forecast_longitude = api_data["features"][0]["geometry"]["coordinates"][0] + self.forecast_latitude = api_data["features"][0]["geometry"]["coordinates"][1] + self.distance_from_requested_location = api_data["features"][0]["properties"][ + "requestPointDistance" + ] + # N.B. Elevation is in metres above or below the WGS 84 reference + # ellipsoid as per GeoJSON spec. + self.elevation = api_data["features"][0]["geometry"]["coordinates"][2] + + # Need different parsing to cope with daily vs. hourly/three-hourly + # forecasts. Do hourly first + + forecasts = api_data["features"][0]["properties"]["timeSeries"] + parameters = api_data["parameters"][0] + if frequency == "daily": + self.timesteps = self.__build_timesteps_from_daily(forecasts, parameters) + else: + self.timesteps = [] + for forecast in forecasts: + self.timesteps.append(self.__build_timestep(forecast, parameters)) + + def __build_timesteps_from_daily(self, forecasts, parameters): + timesteps = [] + for forecast in forecasts: + night_step = {"time": datetime.datetime.fromisoformat(forecast["time"])} + day_step = {"time": datetime.datetime.fromisoformat( forecast["time"] ) + datetime.timedelta(hours=12)} + + for element, value in forecast.items(): + if element.startswith("midday"): + day_step[element.replace("midday", "")] = { + "value": value, + "description": parameters[element]["description"], + "unit_name": parameters[element]["unit"]["label"], + "unit_symbol": parameters[element]["unit"]["symbol"]["type"], + } + elif element.startswith("midnight"): + night_step[element.replace("midnight", "")] = { + "value": value, + "description": parameters[element]["description"], + "unit_name": parameters[element]["unit"]["label"], + "unit_symbol": parameters[element]["unit"]["symbol"]["type"], + } + elif element.startswith("day"): + day_step[element.replace("day", "")] = { + "value": value, + "description": parameters[element]["description"], + "unit_name": parameters[element]["unit"]["label"], + "unit_symbol": parameters[element]["unit"]["symbol"]["type"], + } + elif element.startswith("night"): + night_step[element.replace("night", "")] = { + "value": value, + "description": parameters[element]["description"], + "unit_name": parameters[element]["unit"]["label"], + "unit_symbol": parameters[element]["unit"]["symbol"]["type"], + } + elif element == "maxUvIndex": + day_step[element] = { + "value": value, + "description": parameters[element]["description"], + "unit_name": parameters[element]["unit"]["label"], + "unit_symbol": parameters[element]["unit"]["symbol"]["type"], + } + + timesteps.append(night_step) + timesteps.append(day_step) + + timesteps = sorted(timesteps, key=lambda t: t["time"]) + return timesteps + + def __build_timestep(self, forecast, parameters): + timestep = {} + for element, value in forecast.items(): + if element == "time": + timestep["time"] = datetime.datetime.fromisoformat(forecast["time"]) + elif element == "significantWeatherCode": + timestep[element] = { + "value": WEATHER_CODES[str(value)], + "description": parameters[element]["description"], + "unit_name": parameters[element]["unit"]["label"], + "unit_symbol": parameters[element]["unit"]["symbol"]["type"], + } + else: + timestep[element] = { + "value": value, + "description": parameters[element]["description"], + "unit_name": parameters[element]["unit"]["label"], + "unit_symbol": parameters[element]["unit"]["symbol"]["type"], + } + + return timestep + + def at_datetime(self, target): + """Return the timestep closest to the target datetime""" + + # Convert target to offset aware datetime + if target.tzinfo is None: + target = datetime.datetime.combine( + target.date(), target.time(), self.timesteps[0]["time"].tzinfo + ) + + # Check that there is a forecast for the requested time. + # If we have an hourly forecast, check that the requested time is at + # most 30 minutes before the first datetime we have a forecast for. + if self.frequency == "hourly" and target < self.timesteps[0][ + "time" + ] - datetime.timedelta(hours=0, minutes=30): + err_str = ( + "There is no forecast available for the requested time. " + + "The requested time is more than 30 minutes before the first available forecast" + ) + raise APIException(err_str) + + # If we have a three-hourly forecast, check that the requested time is at + # most 1.5 hours before the first datetime we have a forecast for. + if self.frequency == "three-hourly" and target < self.timesteps[0][ + "time" + ] - datetime.timedelta(hours=1, minutes=30): + err_str = ( + "There is no forecast available for the requested time. " + + "The requested time is more than 1 hour and 30 minutes before the first available forecast" + ) + raise APIException(err_str) + + # If we have a daily forecast, check that the requested time is at + # most 6 hours before the first datetime we have a forecast for. + if self.frequency == "daily" and target < self.timesteps[0][ + "time" + ] - datetime.timedelta(hours=6): + err_str = ( + "There is no forecast available for the requested time. " + + "The requested time is more than 6 hours before the first available forecast" + ) + + raise APIException(err_str) + + # If we have an hourly forecast, check that the requested time is at + # most 30 minutes after the final datetime we have a forecast for + if self.frequency == "hourly" and target > ( + self.timesteps[-1]["time"] + datetime.timedelta(hours=0, minutes=30) + ): + err_str = "There is no forecast available for the requested time. The requested time is more than 30 minutes after the first available forecast" + + raise APIException(err_str) + + # If we have a three-hourly forecast, then the target must be within 1.5 + # hours of the last timestep + if self.frequency == "three-hourly" and target > ( + self.timesteps[-1]["time"] + datetime.timedelta(hours=1, minutes=30) + ): + err_str = "There is no forecast available for the requested time. The requested time is more than 1.5 hours after the first available forecast" + + raise APIException(err_str) + + # If we have a daily forecast, then the target must be within 6 hours + # of the last timestep + if self.frequency == "daily" and target > ( + self.timesteps[-1]["time"] + datetime.timedelta(hours=6) + ): + err_str = "There is no forecast available for the requested time. The requested time is more than 6 hours after the first available forecast" + + raise APIException(err_str) + + # Loop over all timesteps + # Calculate the first time difference + prev_td = target - self.timesteps[0]["time"] + prev_ts = self.timesteps[0] + + for i, timestep in enumerate(self.timesteps, start=1): + # Calculate the difference between the target time and the + # timestep. + td = target - timestep["time"] + + # Find the timestep which is further from the target than the + # previous one. Return the previous timestep + if abs(td.total_seconds()) > abs(prev_td.total_seconds()): + # We are further from the target + return prev_ts + if i == len(self.timesteps): + return timestep + + prev_ts = timestep + prev_td = td + + def now(self): + """Function to return the closest timestep to the current time""" + + d = datetime.datetime.now(tz=self.timesteps[0]["time"].tzinfo) + return self.at_datetime(d) + + def future(self, in_days=0, in_hours=0, in_minutes=0, in_seconds=0): + """Return the closest timestep to a date in a given amount of time""" + + d = datetime.datetime.now(tz=self.timesteps[0]["time"].tzinfo) + target = d + datetime.timedelta( + days=in_days, hours=in_hours, minutes=in_minutes, seconds=in_seconds + ) + + return self.at_datetime(target) diff --git a/src/datapoint/Manager.py b/src/datapoint/Manager.py new file mode 100644 index 0000000..144f614 --- /dev/null +++ b/src/datapoint/Manager.py @@ -0,0 +1,148 @@ +""" +Datapoint python module +""" + +import requests +from requests.adapters import HTTPAdapter +from requests.packages.urllib3.util.retry import Retry +import geojson + +from datapoint.exceptions import APIException +from datapoint.Forecast import Forecast + + +API_URL = "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/" +DATE_FORMAT = "%Y-%m-%dZ" +DATA_DATE_FORMAT = "%Y-%m-%dT%XZ" +FORECAST_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" + + +class Manager: + """ + Datapoint Manager object + """ + + def __init__(self, api_key=""): + self.api_key = api_key + self.call_response = None + + def __retry_session( + self, + retries=10, + backoff_factor=0.3, + status_forcelist=(500, 502, 504), + session=None, + ): + """ + Retry the connection using requests if it fails. Use this as a wrapper + to request from datapoint + """ + + # requests.Session allows finer control, which is needed to use the + # retrying code + the_session = session or requests.Session() + + # The Retry object manages the actual retrying + retry = Retry( + total=retries, + read=retries, + connect=retries, + backoff_factor=backoff_factor, + status_forcelist=status_forcelist, + ) + + adapter = HTTPAdapter(max_retries=retry) + + the_session.mount("http://", adapter) + the_session.mount("https://", adapter) + + return the_session + + def __call_api(self, latitude, longitude, frequency): + """ + Call the datapoint api using the requests module + + """ + params = { + "latitude": latitude, + "longitude": longitude, + "includeLocationName": True, + "excludeParameterMetadata": False, + } + headers = { + "accept": "application/json", + "apikey": self.api_key, + } + request_url = API_URL + frequency + + # Add a timeout to the request. + # The value of 1 second is based on attempting 100 connections to + # datapoint and taking ten times the mean connection time (rounded up). + # Could expose to users in the functions which need to call the api. + # req = requests.get(url, params=payload, timeout=1) + # The wrapper function __retry_session returns a requests.Session + # object. This has a .get() function like requests.get(), so the use + # doesn't change here. + + sess = self.__retry_session() + req = sess.get( + request_url, + params=params, + headers=headers, + timeout=1, + ) + + try: + data = geojson.loads(req.text) + except ValueError as exc: + raise APIException( + "DataPoint has not returned any data, this could be due to an incorrect API key" + ) from exc + self.call_response = data + if req.status_code != 200: + msg = [ + data[m] for m in ("message", "error_message", "status") if m in data + ][0] + raise Exception(msg) + return data + + def _visibility_to_text(self, distance): + """ + Convert observed visibility in metres to text used in forecast + """ + + if not isinstance(distance, int): + raise ValueError("Distance must be an integer not", type(distance)) + if distance < 0: + raise ValueError("Distance out of bounds, should be 0 or greater") + + if 0 <= distance < 1000: + return "VP" + elif 1000 <= distance < 4000: + return "PO" + elif 4000 <= distance < 10000: + return "MO" + elif 10000 <= distance < 20000: + return "GO" + elif 20000 <= distance < 40000: + return "VG" + else: + return "EX" + + def get_forecast(self, latitude, longitude, frequency="daily"): + """ + Get a forecast for the provided site + + A frequency of "daily" will return two timesteps: + "Day" and "Night". + + A frequency of "3hourly" will return 8 timesteps: + 0, 180, 360 ... 1260 (minutes since midnight UTC) + """ + if frequency not in ["hourly", "three-hourly", "daily"]: + raise ValueError("frequency must be set to one of 'hourly', 'three-hourly', 'daily'") + data = self.__call_api(latitude, longitude, frequency) + #print(data) + forecast = Forecast(frequency=frequency, api_data=data) + + return forecast diff --git a/datapoint/__init__.py b/src/datapoint/__init__.py similarity index 100% rename from datapoint/__init__.py rename to src/datapoint/__init__.py diff --git a/datapoint/exceptions.py b/src/datapoint/exceptions.py similarity index 100% rename from datapoint/exceptions.py rename to src/datapoint/exceptions.py diff --git a/datapoint/profile.py b/src/datapoint/profile.py similarity index 100% rename from datapoint/profile.py rename to src/datapoint/profile.py diff --git a/src/datapoint/weather_codes.py b/src/datapoint/weather_codes.py new file mode 100644 index 0000000..8359afb --- /dev/null +++ b/src/datapoint/weather_codes.py @@ -0,0 +1,36 @@ +# See https://www.metoffice.gov.uk/services/data/datapoint/code-definitions for definitions +WEATHER_CODES = { + "-1": "Trace rain", + "0": "Clear night", + "1": "Sunny day", + "2": "Partly cloudy", + "3": "Partly cloudy", + "4": "Not used", + "5": "Mist", + "6": "Fog", + "7": "Cloudy", + "8": "Overcast", + "9": "Light rain shower", + "10": "Light rain shower", + "11": "Drizzle", + "12": "Light rain", + "13": "Heavy rain shower", + "14": "Heavy rain shower", + "15": "Heavy rain", + "16": "Sleet shower", + "17": "Sleet shower", + "18": "Sleet", + "19": "Hail shower", + "20": "Hail shower", + "21": "Hail", + "22": "Light snow shower", + "23": "Light snow shower", + "24": "Light snow", + "25": "Heavy snow shower", + "26": "Heavy snow shower", + "27": "Heavy snow", + "28": "Thunder shower", + "29": "Thunder shower", + "30": "Thunder", +} + diff --git a/versioneer.py b/versioneer.py deleted file mode 100644 index 1e3753e..0000000 --- a/versioneer.py +++ /dev/null @@ -1,2277 +0,0 @@ - -# Version: 0.29 - -"""The Versioneer - like a rocketeer, but for versions. - -The Versioneer -============== - -* like a rocketeer, but for versions! -* https://github.com/python-versioneer/python-versioneer -* Brian Warner -* License: Public Domain (Unlicense) -* Compatible with: Python 3.7, 3.8, 3.9, 3.10, 3.11 and pypy3 -* [![Latest Version][pypi-image]][pypi-url] -* [![Build Status][travis-image]][travis-url] - -This is a tool for managing a recorded version number in setuptools-based -python projects. The goal is to remove the tedious and error-prone "update -the embedded version string" step from your release process. Making a new -release should be as easy as recording a new tag in your version-control -system, and maybe making new tarballs. - - -## Quick Install - -Versioneer provides two installation modes. The "classic" vendored mode installs -a copy of versioneer into your repository. The experimental build-time dependency mode -is intended to allow you to skip this step and simplify the process of upgrading. - -### Vendored mode - -* `pip install versioneer` to somewhere in your $PATH - * A [conda-forge recipe](https://github.com/conda-forge/versioneer-feedstock) is - available, so you can also use `conda install -c conda-forge versioneer` -* add a `[tool.versioneer]` section to your `pyproject.toml` or a - `[versioneer]` section to your `setup.cfg` (see [Install](INSTALL.md)) - * Note that you will need to add `tomli; python_version < "3.11"` to your - build-time dependencies if you use `pyproject.toml` -* run `versioneer install --vendor` in your source tree, commit the results -* verify version information with `python setup.py version` - -### Build-time dependency mode - -* `pip install versioneer` to somewhere in your $PATH - * A [conda-forge recipe](https://github.com/conda-forge/versioneer-feedstock) is - available, so you can also use `conda install -c conda-forge versioneer` -* add a `[tool.versioneer]` section to your `pyproject.toml` or a - `[versioneer]` section to your `setup.cfg` (see [Install](INSTALL.md)) -* add `versioneer` (with `[toml]` extra, if configuring in `pyproject.toml`) - to the `requires` key of the `build-system` table in `pyproject.toml`: - ```toml - [build-system] - requires = ["setuptools", "versioneer[toml]"] - build-backend = "setuptools.build_meta" - ``` -* run `versioneer install --no-vendor` in your source tree, commit the results -* verify version information with `python setup.py version` - -## Version Identifiers - -Source trees come from a variety of places: - -* a version-control system checkout (mostly used by developers) -* a nightly tarball, produced by build automation -* a snapshot tarball, produced by a web-based VCS browser, like github's - "tarball from tag" feature -* a release tarball, produced by "setup.py sdist", distributed through PyPI - -Within each source tree, the version identifier (either a string or a number, -this tool is format-agnostic) can come from a variety of places: - -* ask the VCS tool itself, e.g. "git describe" (for checkouts), which knows - about recent "tags" and an absolute revision-id -* the name of the directory into which the tarball was unpacked -* an expanded VCS keyword ($Id$, etc) -* a `_version.py` created by some earlier build step - -For released software, the version identifier is closely related to a VCS -tag. Some projects use tag names that include more than just the version -string (e.g. "myproject-1.2" instead of just "1.2"), in which case the tool -needs to strip the tag prefix to extract the version identifier. For -unreleased software (between tags), the version identifier should provide -enough information to help developers recreate the same tree, while also -giving them an idea of roughly how old the tree is (after version 1.2, before -version 1.3). Many VCS systems can report a description that captures this, -for example `git describe --tags --dirty --always` reports things like -"0.7-1-g574ab98-dirty" to indicate that the checkout is one revision past the -0.7 tag, has a unique revision id of "574ab98", and is "dirty" (it has -uncommitted changes). - -The version identifier is used for multiple purposes: - -* to allow the module to self-identify its version: `myproject.__version__` -* to choose a name and prefix for a 'setup.py sdist' tarball - -## Theory of Operation - -Versioneer works by adding a special `_version.py` file into your source -tree, where your `__init__.py` can import it. This `_version.py` knows how to -dynamically ask the VCS tool for version information at import time. - -`_version.py` also contains `$Revision$` markers, and the installation -process marks `_version.py` to have this marker rewritten with a tag name -during the `git archive` command. As a result, generated tarballs will -contain enough information to get the proper version. - -To allow `setup.py` to compute a version too, a `versioneer.py` is added to -the top level of your source tree, next to `setup.py` and the `setup.cfg` -that configures it. This overrides several distutils/setuptools commands to -compute the version when invoked, and changes `setup.py build` and `setup.py -sdist` to replace `_version.py` with a small static file that contains just -the generated version data. - -## Installation - -See [INSTALL.md](./INSTALL.md) for detailed installation instructions. - -## Version-String Flavors - -Code which uses Versioneer can learn about its version string at runtime by -importing `_version` from your main `__init__.py` file and running the -`get_versions()` function. From the "outside" (e.g. in `setup.py`), you can -import the top-level `versioneer.py` and run `get_versions()`. - -Both functions return a dictionary with different flavors of version -information: - -* `['version']`: A condensed version string, rendered using the selected - style. This is the most commonly used value for the project's version - string. The default "pep440" style yields strings like `0.11`, - `0.11+2.g1076c97`, or `0.11+2.g1076c97.dirty`. See the "Styles" section - below for alternative styles. - -* `['full-revisionid']`: detailed revision identifier. For Git, this is the - full SHA1 commit id, e.g. "1076c978a8d3cfc70f408fe5974aa6c092c949ac". - -* `['date']`: Date and time of the latest `HEAD` commit. For Git, it is the - commit date in ISO 8601 format. This will be None if the date is not - available. - -* `['dirty']`: a boolean, True if the tree has uncommitted changes. Note that - this is only accurate if run in a VCS checkout, otherwise it is likely to - be False or None - -* `['error']`: if the version string could not be computed, this will be set - to a string describing the problem, otherwise it will be None. It may be - useful to throw an exception in setup.py if this is set, to avoid e.g. - creating tarballs with a version string of "unknown". - -Some variants are more useful than others. Including `full-revisionid` in a -bug report should allow developers to reconstruct the exact code being tested -(or indicate the presence of local changes that should be shared with the -developers). `version` is suitable for display in an "about" box or a CLI -`--version` output: it can be easily compared against release notes and lists -of bugs fixed in various releases. - -The installer adds the following text to your `__init__.py` to place a basic -version in `YOURPROJECT.__version__`: - - from ._version import get_versions - __version__ = get_versions()['version'] - del get_versions - -## Styles - -The setup.cfg `style=` configuration controls how the VCS information is -rendered into a version string. - -The default style, "pep440", produces a PEP440-compliant string, equal to the -un-prefixed tag name for actual releases, and containing an additional "local -version" section with more detail for in-between builds. For Git, this is -TAG[+DISTANCE.gHEX[.dirty]] , using information from `git describe --tags ---dirty --always`. For example "0.11+2.g1076c97.dirty" indicates that the -tree is like the "1076c97" commit but has uncommitted changes (".dirty"), and -that this commit is two revisions ("+2") beyond the "0.11" tag. For released -software (exactly equal to a known tag), the identifier will only contain the -stripped tag, e.g. "0.11". - -Other styles are available. See [details.md](details.md) in the Versioneer -source tree for descriptions. - -## Debugging - -Versioneer tries to avoid fatal errors: if something goes wrong, it will tend -to return a version of "0+unknown". To investigate the problem, run `setup.py -version`, which will run the version-lookup code in a verbose mode, and will -display the full contents of `get_versions()` (including the `error` string, -which may help identify what went wrong). - -## Known Limitations - -Some situations are known to cause problems for Versioneer. This details the -most significant ones. More can be found on Github -[issues page](https://github.com/python-versioneer/python-versioneer/issues). - -### Subprojects - -Versioneer has limited support for source trees in which `setup.py` is not in -the root directory (e.g. `setup.py` and `.git/` are *not* siblings). The are -two common reasons why `setup.py` might not be in the root: - -* Source trees which contain multiple subprojects, such as - [Buildbot](https://github.com/buildbot/buildbot), which contains both - "master" and "slave" subprojects, each with their own `setup.py`, - `setup.cfg`, and `tox.ini`. Projects like these produce multiple PyPI - distributions (and upload multiple independently-installable tarballs). -* Source trees whose main purpose is to contain a C library, but which also - provide bindings to Python (and perhaps other languages) in subdirectories. - -Versioneer will look for `.git` in parent directories, and most operations -should get the right version string. However `pip` and `setuptools` have bugs -and implementation details which frequently cause `pip install .` from a -subproject directory to fail to find a correct version string (so it usually -defaults to `0+unknown`). - -`pip install --editable .` should work correctly. `setup.py install` might -work too. - -Pip-8.1.1 is known to have this problem, but hopefully it will get fixed in -some later version. - -[Bug #38](https://github.com/python-versioneer/python-versioneer/issues/38) is tracking -this issue. The discussion in -[PR #61](https://github.com/python-versioneer/python-versioneer/pull/61) describes the -issue from the Versioneer side in more detail. -[pip PR#3176](https://github.com/pypa/pip/pull/3176) and -[pip PR#3615](https://github.com/pypa/pip/pull/3615) contain work to improve -pip to let Versioneer work correctly. - -Versioneer-0.16 and earlier only looked for a `.git` directory next to the -`setup.cfg`, so subprojects were completely unsupported with those releases. - -### Editable installs with setuptools <= 18.5 - -`setup.py develop` and `pip install --editable .` allow you to install a -project into a virtualenv once, then continue editing the source code (and -test) without re-installing after every change. - -"Entry-point scripts" (`setup(entry_points={"console_scripts": ..})`) are a -convenient way to specify executable scripts that should be installed along -with the python package. - -These both work as expected when using modern setuptools. When using -setuptools-18.5 or earlier, however, certain operations will cause -`pkg_resources.DistributionNotFound` errors when running the entrypoint -script, which must be resolved by re-installing the package. This happens -when the install happens with one version, then the egg_info data is -regenerated while a different version is checked out. Many setup.py commands -cause egg_info to be rebuilt (including `sdist`, `wheel`, and installing into -a different virtualenv), so this can be surprising. - -[Bug #83](https://github.com/python-versioneer/python-versioneer/issues/83) describes -this one, but upgrading to a newer version of setuptools should probably -resolve it. - - -## Updating Versioneer - -To upgrade your project to a new release of Versioneer, do the following: - -* install the new Versioneer (`pip install -U versioneer` or equivalent) -* edit `setup.cfg` and `pyproject.toml`, if necessary, - to include any new configuration settings indicated by the release notes. - See [UPGRADING](./UPGRADING.md) for details. -* re-run `versioneer install --[no-]vendor` in your source tree, to replace - `SRC/_version.py` -* commit any changed files - -## Future Directions - -This tool is designed to make it easily extended to other version-control -systems: all VCS-specific components are in separate directories like -src/git/ . The top-level `versioneer.py` script is assembled from these -components by running make-versioneer.py . In the future, make-versioneer.py -will take a VCS name as an argument, and will construct a version of -`versioneer.py` that is specific to the given VCS. It might also take the -configuration arguments that are currently provided manually during -installation by editing setup.py . Alternatively, it might go the other -direction and include code from all supported VCS systems, reducing the -number of intermediate scripts. - -## Similar projects - -* [setuptools_scm](https://github.com/pypa/setuptools_scm/) - a non-vendored build-time - dependency -* [minver](https://github.com/jbweston/miniver) - a lightweight reimplementation of - versioneer -* [versioningit](https://github.com/jwodder/versioningit) - a PEP 518-based setuptools - plugin - -## License - -To make Versioneer easier to embed, all its code is dedicated to the public -domain. The `_version.py` that it creates is also in the public domain. -Specifically, both are released under the "Unlicense", as described in -https://unlicense.org/. - -[pypi-image]: https://img.shields.io/pypi/v/versioneer.svg -[pypi-url]: https://pypi.python.org/pypi/versioneer/ -[travis-image]: -https://img.shields.io/travis/com/python-versioneer/python-versioneer.svg -[travis-url]: https://travis-ci.com/github/python-versioneer/python-versioneer - -""" -# pylint:disable=invalid-name,import-outside-toplevel,missing-function-docstring -# pylint:disable=missing-class-docstring,too-many-branches,too-many-statements -# pylint:disable=raise-missing-from,too-many-lines,too-many-locals,import-error -# pylint:disable=too-few-public-methods,redefined-outer-name,consider-using-with -# pylint:disable=attribute-defined-outside-init,too-many-arguments - -import configparser -import errno -import json -import os -import re -import subprocess -import sys -from pathlib import Path -from typing import Any, Callable, cast, Dict, List, Optional, Tuple, Union -from typing import NoReturn -import functools - -have_tomllib = True -if sys.version_info >= (3, 11): - import tomllib -else: - try: - import tomli as tomllib - except ImportError: - have_tomllib = False - - -class VersioneerConfig: - """Container for Versioneer configuration parameters.""" - - VCS: str - style: str - tag_prefix: str - versionfile_source: str - versionfile_build: Optional[str] - parentdir_prefix: Optional[str] - verbose: Optional[bool] - - -def get_root() -> str: - """Get the project root directory. - - We require that all commands are run from the project root, i.e. the - directory that contains setup.py, setup.cfg, and versioneer.py . - """ - root = os.path.realpath(os.path.abspath(os.getcwd())) - setup_py = os.path.join(root, "setup.py") - pyproject_toml = os.path.join(root, "pyproject.toml") - versioneer_py = os.path.join(root, "versioneer.py") - if not ( - os.path.exists(setup_py) - or os.path.exists(pyproject_toml) - or os.path.exists(versioneer_py) - ): - # allow 'python path/to/setup.py COMMAND' - root = os.path.dirname(os.path.realpath(os.path.abspath(sys.argv[0]))) - setup_py = os.path.join(root, "setup.py") - pyproject_toml = os.path.join(root, "pyproject.toml") - versioneer_py = os.path.join(root, "versioneer.py") - if not ( - os.path.exists(setup_py) - or os.path.exists(pyproject_toml) - or os.path.exists(versioneer_py) - ): - err = ("Versioneer was unable to run the project root directory. " - "Versioneer requires setup.py to be executed from " - "its immediate directory (like 'python setup.py COMMAND'), " - "or in a way that lets it use sys.argv[0] to find the root " - "(like 'python path/to/setup.py COMMAND').") - raise VersioneerBadRootError(err) - try: - # Certain runtime workflows (setup.py install/develop in a setuptools - # tree) execute all dependencies in a single python process, so - # "versioneer" may be imported multiple times, and python's shared - # module-import table will cache the first one. So we can't use - # os.path.dirname(__file__), as that will find whichever - # versioneer.py was first imported, even in later projects. - my_path = os.path.realpath(os.path.abspath(__file__)) - me_dir = os.path.normcase(os.path.splitext(my_path)[0]) - vsr_dir = os.path.normcase(os.path.splitext(versioneer_py)[0]) - if me_dir != vsr_dir and "VERSIONEER_PEP518" not in globals(): - print("Warning: build in %s is using versioneer.py from %s" - % (os.path.dirname(my_path), versioneer_py)) - except NameError: - pass - return root - - -def get_config_from_root(root: str) -> VersioneerConfig: - """Read the project setup.cfg file to determine Versioneer config.""" - # This might raise OSError (if setup.cfg is missing), or - # configparser.NoSectionError (if it lacks a [versioneer] section), or - # configparser.NoOptionError (if it lacks "VCS="). See the docstring at - # the top of versioneer.py for instructions on writing your setup.cfg . - root_pth = Path(root) - pyproject_toml = root_pth / "pyproject.toml" - setup_cfg = root_pth / "setup.cfg" - section: Union[Dict[str, Any], configparser.SectionProxy, None] = None - if pyproject_toml.exists() and have_tomllib: - try: - with open(pyproject_toml, 'rb') as fobj: - pp = tomllib.load(fobj) - section = pp['tool']['versioneer'] - except (tomllib.TOMLDecodeError, KeyError) as e: - print(f"Failed to load config from {pyproject_toml}: {e}") - print("Try to load it from setup.cfg") - if not section: - parser = configparser.ConfigParser() - with open(setup_cfg) as cfg_file: - parser.read_file(cfg_file) - parser.get("versioneer", "VCS") # raise error if missing - - section = parser["versioneer"] - - # `cast`` really shouldn't be used, but its simplest for the - # common VersioneerConfig users at the moment. We verify against - # `None` values elsewhere where it matters - - cfg = VersioneerConfig() - cfg.VCS = section['VCS'] - cfg.style = section.get("style", "") - cfg.versionfile_source = cast(str, section.get("versionfile_source")) - cfg.versionfile_build = section.get("versionfile_build") - cfg.tag_prefix = cast(str, section.get("tag_prefix")) - if cfg.tag_prefix in ("''", '""', None): - cfg.tag_prefix = "" - cfg.parentdir_prefix = section.get("parentdir_prefix") - if isinstance(section, configparser.SectionProxy): - # Make sure configparser translates to bool - cfg.verbose = section.getboolean("verbose") - else: - cfg.verbose = section.get("verbose") - - return cfg - - -class NotThisMethod(Exception): - """Exception raised if a method is not valid for the current scenario.""" - - -# these dictionaries contain VCS-specific tools -LONG_VERSION_PY: Dict[str, str] = {} -HANDLERS: Dict[str, Dict[str, Callable]] = {} - - -def register_vcs_handler(vcs: str, method: str) -> Callable: # decorator - """Create decorator to mark a method as the handler of a VCS.""" - def decorate(f: Callable) -> Callable: - """Store f in HANDLERS[vcs][method].""" - HANDLERS.setdefault(vcs, {})[method] = f - return f - return decorate - - -def run_command( - commands: List[str], - args: List[str], - cwd: Optional[str] = None, - verbose: bool = False, - hide_stderr: bool = False, - env: Optional[Dict[str, str]] = None, -) -> Tuple[Optional[str], Optional[int]]: - """Call the given command(s).""" - assert isinstance(commands, list) - process = None - - popen_kwargs: Dict[str, Any] = {} - if sys.platform == "win32": - # This hides the console window if pythonw.exe is used - startupinfo = subprocess.STARTUPINFO() - startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - popen_kwargs["startupinfo"] = startupinfo - - for command in commands: - try: - dispcmd = str([command] + args) - # remember shell=False, so use git.cmd on windows, not just git - process = subprocess.Popen([command] + args, cwd=cwd, env=env, - stdout=subprocess.PIPE, - stderr=(subprocess.PIPE if hide_stderr - else None), **popen_kwargs) - break - except OSError as e: - if e.errno == errno.ENOENT: - continue - if verbose: - print("unable to run %s" % dispcmd) - print(e) - return None, None - else: - if verbose: - print("unable to find command, tried %s" % (commands,)) - return None, None - stdout = process.communicate()[0].strip().decode() - if process.returncode != 0: - if verbose: - print("unable to run %s (error)" % dispcmd) - print("stdout was %s" % stdout) - return None, process.returncode - return stdout, process.returncode - - -LONG_VERSION_PY['git'] = r''' -# This file helps to compute a version number in source trees obtained from -# git-archive tarball (such as those provided by githubs download-from-tag -# feature). Distribution tarballs (built by setup.py sdist) and build -# directories (produced by setup.py build) will contain a much shorter file -# that just contains the computed version number. - -# This file is released into the public domain. -# Generated by versioneer-0.29 -# https://github.com/python-versioneer/python-versioneer - -"""Git implementation of _version.py.""" - -import errno -import os -import re -import subprocess -import sys -from typing import Any, Callable, Dict, List, Optional, Tuple -import functools - - -def get_keywords() -> Dict[str, str]: - """Get the keywords needed to look up the version information.""" - # these strings will be replaced by git during git-archive. - # setup.py/versioneer.py will grep for the variable names, so they must - # each be defined on a line of their own. _version.py will just call - # get_keywords(). - git_refnames = "%(DOLLAR)sFormat:%%d%(DOLLAR)s" - git_full = "%(DOLLAR)sFormat:%%H%(DOLLAR)s" - git_date = "%(DOLLAR)sFormat:%%ci%(DOLLAR)s" - keywords = {"refnames": git_refnames, "full": git_full, "date": git_date} - return keywords - - -class VersioneerConfig: - """Container for Versioneer configuration parameters.""" - - VCS: str - style: str - tag_prefix: str - parentdir_prefix: str - versionfile_source: str - verbose: bool - - -def get_config() -> VersioneerConfig: - """Create, populate and return the VersioneerConfig() object.""" - # these strings are filled in when 'setup.py versioneer' creates - # _version.py - cfg = VersioneerConfig() - cfg.VCS = "git" - cfg.style = "%(STYLE)s" - cfg.tag_prefix = "%(TAG_PREFIX)s" - cfg.parentdir_prefix = "%(PARENTDIR_PREFIX)s" - cfg.versionfile_source = "%(VERSIONFILE_SOURCE)s" - cfg.verbose = False - return cfg - - -class NotThisMethod(Exception): - """Exception raised if a method is not valid for the current scenario.""" - - -LONG_VERSION_PY: Dict[str, str] = {} -HANDLERS: Dict[str, Dict[str, Callable]] = {} - - -def register_vcs_handler(vcs: str, method: str) -> Callable: # decorator - """Create decorator to mark a method as the handler of a VCS.""" - def decorate(f: Callable) -> Callable: - """Store f in HANDLERS[vcs][method].""" - if vcs not in HANDLERS: - HANDLERS[vcs] = {} - HANDLERS[vcs][method] = f - return f - return decorate - - -def run_command( - commands: List[str], - args: List[str], - cwd: Optional[str] = None, - verbose: bool = False, - hide_stderr: bool = False, - env: Optional[Dict[str, str]] = None, -) -> Tuple[Optional[str], Optional[int]]: - """Call the given command(s).""" - assert isinstance(commands, list) - process = None - - popen_kwargs: Dict[str, Any] = {} - if sys.platform == "win32": - # This hides the console window if pythonw.exe is used - startupinfo = subprocess.STARTUPINFO() - startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - popen_kwargs["startupinfo"] = startupinfo - - for command in commands: - try: - dispcmd = str([command] + args) - # remember shell=False, so use git.cmd on windows, not just git - process = subprocess.Popen([command] + args, cwd=cwd, env=env, - stdout=subprocess.PIPE, - stderr=(subprocess.PIPE if hide_stderr - else None), **popen_kwargs) - break - except OSError as e: - if e.errno == errno.ENOENT: - continue - if verbose: - print("unable to run %%s" %% dispcmd) - print(e) - return None, None - else: - if verbose: - print("unable to find command, tried %%s" %% (commands,)) - return None, None - stdout = process.communicate()[0].strip().decode() - if process.returncode != 0: - if verbose: - print("unable to run %%s (error)" %% dispcmd) - print("stdout was %%s" %% stdout) - return None, process.returncode - return stdout, process.returncode - - -def versions_from_parentdir( - parentdir_prefix: str, - root: str, - verbose: bool, -) -> Dict[str, Any]: - """Try to determine the version from the parent directory name. - - Source tarballs conventionally unpack into a directory that includes both - the project name and a version string. We will also support searching up - two directory levels for an appropriately named parent directory - """ - rootdirs = [] - - for _ in range(3): - dirname = os.path.basename(root) - if dirname.startswith(parentdir_prefix): - return {"version": dirname[len(parentdir_prefix):], - "full-revisionid": None, - "dirty": False, "error": None, "date": None} - rootdirs.append(root) - root = os.path.dirname(root) # up a level - - if verbose: - print("Tried directories %%s but none started with prefix %%s" %% - (str(rootdirs), parentdir_prefix)) - raise NotThisMethod("rootdir doesn't start with parentdir_prefix") - - -@register_vcs_handler("git", "get_keywords") -def git_get_keywords(versionfile_abs: str) -> Dict[str, str]: - """Extract version information from the given file.""" - # the code embedded in _version.py can just fetch the value of these - # keywords. When used from setup.py, we don't want to import _version.py, - # so we do it with a regexp instead. This function is not used from - # _version.py. - keywords: Dict[str, str] = {} - try: - with open(versionfile_abs, "r") as fobj: - for line in fobj: - if line.strip().startswith("git_refnames ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["refnames"] = mo.group(1) - if line.strip().startswith("git_full ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["full"] = mo.group(1) - if line.strip().startswith("git_date ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["date"] = mo.group(1) - except OSError: - pass - return keywords - - -@register_vcs_handler("git", "keywords") -def git_versions_from_keywords( - keywords: Dict[str, str], - tag_prefix: str, - verbose: bool, -) -> Dict[str, Any]: - """Get version information from git keywords.""" - if "refnames" not in keywords: - raise NotThisMethod("Short version file found") - date = keywords.get("date") - if date is not None: - # Use only the last line. Previous lines may contain GPG signature - # information. - date = date.splitlines()[-1] - - # git-2.2.0 added "%%cI", which expands to an ISO-8601 -compliant - # datestamp. However we prefer "%%ci" (which expands to an "ISO-8601 - # -like" string, which we must then edit to make compliant), because - # it's been around since git-1.5.3, and it's too difficult to - # discover which version we're using, or to work around using an - # older one. - date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) - refnames = keywords["refnames"].strip() - if refnames.startswith("$Format"): - if verbose: - print("keywords are unexpanded, not using") - raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = {r.strip() for r in refnames.strip("()").split(",")} - # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of - # just "foo-1.0". If we see a "tag: " prefix, prefer those. - TAG = "tag: " - tags = {r[len(TAG):] for r in refs if r.startswith(TAG)} - if not tags: - # Either we're using git < 1.8.3, or there really are no tags. We use - # a heuristic: assume all version tags have a digit. The old git %%d - # expansion behaves like git log --decorate=short and strips out the - # refs/heads/ and refs/tags/ prefixes that would let us distinguish - # between branches and tags. By ignoring refnames without digits, we - # filter out many common branch names like "release" and - # "stabilization", as well as "HEAD" and "master". - tags = {r for r in refs if re.search(r'\d', r)} - if verbose: - print("discarding '%%s', no digits" %% ",".join(refs - tags)) - if verbose: - print("likely tags: %%s" %% ",".join(sorted(tags))) - for ref in sorted(tags): - # sorting will prefer e.g. "2.0" over "2.0rc1" - if ref.startswith(tag_prefix): - r = ref[len(tag_prefix):] - # Filter out refs that exactly match prefix or that don't start - # with a number once the prefix is stripped (mostly a concern - # when prefix is '') - if not re.match(r'\d', r): - continue - if verbose: - print("picking %%s" %% r) - return {"version": r, - "full-revisionid": keywords["full"].strip(), - "dirty": False, "error": None, - "date": date} - # no suitable tags, so version is "0+unknown", but full hex is still there - if verbose: - print("no suitable tags, using unknown + full revision id") - return {"version": "0+unknown", - "full-revisionid": keywords["full"].strip(), - "dirty": False, "error": "no suitable tags", "date": None} - - -@register_vcs_handler("git", "pieces_from_vcs") -def git_pieces_from_vcs( - tag_prefix: str, - root: str, - verbose: bool, - runner: Callable = run_command -) -> Dict[str, Any]: - """Get version from 'git describe' in the root of the source tree. - - This only gets called if the git-archive 'subst' keywords were *not* - expanded, and _version.py hasn't already been rewritten with a short - version string, meaning we're inside a checked out source tree. - """ - GITS = ["git"] - if sys.platform == "win32": - GITS = ["git.cmd", "git.exe"] - - # GIT_DIR can interfere with correct operation of Versioneer. - # It may be intended to be passed to the Versioneer-versioned project, - # but that should not change where we get our version from. - env = os.environ.copy() - env.pop("GIT_DIR", None) - runner = functools.partial(runner, env=env) - - _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root, - hide_stderr=not verbose) - if rc != 0: - if verbose: - print("Directory %%s not under git control" %% root) - raise NotThisMethod("'git rev-parse --git-dir' returned error") - - # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] - # if there isn't one, this yields HEX[-dirty] (no NUM) - describe_out, rc = runner(GITS, [ - "describe", "--tags", "--dirty", "--always", "--long", - "--match", f"{tag_prefix}[[:digit:]]*" - ], cwd=root) - # --long was added in git-1.5.5 - if describe_out is None: - raise NotThisMethod("'git describe' failed") - describe_out = describe_out.strip() - full_out, rc = runner(GITS, ["rev-parse", "HEAD"], cwd=root) - if full_out is None: - raise NotThisMethod("'git rev-parse' failed") - full_out = full_out.strip() - - pieces: Dict[str, Any] = {} - pieces["long"] = full_out - pieces["short"] = full_out[:7] # maybe improved later - pieces["error"] = None - - branch_name, rc = runner(GITS, ["rev-parse", "--abbrev-ref", "HEAD"], - cwd=root) - # --abbrev-ref was added in git-1.6.3 - if rc != 0 or branch_name is None: - raise NotThisMethod("'git rev-parse --abbrev-ref' returned error") - branch_name = branch_name.strip() - - if branch_name == "HEAD": - # If we aren't exactly on a branch, pick a branch which represents - # the current commit. If all else fails, we are on a branchless - # commit. - branches, rc = runner(GITS, ["branch", "--contains"], cwd=root) - # --contains was added in git-1.5.4 - if rc != 0 or branches is None: - raise NotThisMethod("'git branch --contains' returned error") - branches = branches.split("\n") - - # Remove the first line if we're running detached - if "(" in branches[0]: - branches.pop(0) - - # Strip off the leading "* " from the list of branches. - branches = [branch[2:] for branch in branches] - if "master" in branches: - branch_name = "master" - elif not branches: - branch_name = None - else: - # Pick the first branch that is returned. Good or bad. - branch_name = branches[0] - - pieces["branch"] = branch_name - - # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] - # TAG might have hyphens. - git_describe = describe_out - - # look for -dirty suffix - dirty = git_describe.endswith("-dirty") - pieces["dirty"] = dirty - if dirty: - git_describe = git_describe[:git_describe.rindex("-dirty")] - - # now we have TAG-NUM-gHEX or HEX - - if "-" in git_describe: - # TAG-NUM-gHEX - mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) - if not mo: - # unparsable. Maybe git-describe is misbehaving? - pieces["error"] = ("unable to parse git-describe output: '%%s'" - %% describe_out) - return pieces - - # tag - full_tag = mo.group(1) - if not full_tag.startswith(tag_prefix): - if verbose: - fmt = "tag '%%s' doesn't start with prefix '%%s'" - print(fmt %% (full_tag, tag_prefix)) - pieces["error"] = ("tag '%%s' doesn't start with prefix '%%s'" - %% (full_tag, tag_prefix)) - return pieces - pieces["closest-tag"] = full_tag[len(tag_prefix):] - - # distance: number of commits since tag - pieces["distance"] = int(mo.group(2)) - - # commit: short hex revision ID - pieces["short"] = mo.group(3) - - else: - # HEX: no tags - pieces["closest-tag"] = None - out, rc = runner(GITS, ["rev-list", "HEAD", "--left-right"], cwd=root) - pieces["distance"] = len(out.split()) # total number of commits - - # commit date: see ISO-8601 comment in git_versions_from_keywords() - date = runner(GITS, ["show", "-s", "--format=%%ci", "HEAD"], cwd=root)[0].strip() - # Use only the last line. Previous lines may contain GPG signature - # information. - date = date.splitlines()[-1] - pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) - - return pieces - - -def plus_or_dot(pieces: Dict[str, Any]) -> str: - """Return a + if we don't already have one, else return a .""" - if "+" in pieces.get("closest-tag", ""): - return "." - return "+" - - -def render_pep440(pieces: Dict[str, Any]) -> str: - """Build up version string, with post-release "local version identifier". - - Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you - get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty - - Exceptions: - 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += plus_or_dot(pieces) - rendered += "%%d.g%%s" %% (pieces["distance"], pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - else: - # exception #1 - rendered = "0+untagged.%%d.g%%s" %% (pieces["distance"], - pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - return rendered - - -def render_pep440_branch(pieces: Dict[str, Any]) -> str: - """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] . - - The ".dev0" means not master branch. Note that .dev0 sorts backwards - (a feature branch will appear "older" than the master branch). - - Exceptions: - 1: no tags. 0[.dev0]+untagged.DISTANCE.gHEX[.dirty] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - if pieces["branch"] != "master": - rendered += ".dev0" - rendered += plus_or_dot(pieces) - rendered += "%%d.g%%s" %% (pieces["distance"], pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - else: - # exception #1 - rendered = "0" - if pieces["branch"] != "master": - rendered += ".dev0" - rendered += "+untagged.%%d.g%%s" %% (pieces["distance"], - pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - return rendered - - -def pep440_split_post(ver: str) -> Tuple[str, Optional[int]]: - """Split pep440 version string at the post-release segment. - - Returns the release segments before the post-release and the - post-release version number (or -1 if no post-release segment is present). - """ - vc = str.split(ver, ".post") - return vc[0], int(vc[1] or 0) if len(vc) == 2 else None - - -def render_pep440_pre(pieces: Dict[str, Any]) -> str: - """TAG[.postN.devDISTANCE] -- No -dirty. - - Exceptions: - 1: no tags. 0.post0.devDISTANCE - """ - if pieces["closest-tag"]: - if pieces["distance"]: - # update the post release segment - tag_version, post_version = pep440_split_post(pieces["closest-tag"]) - rendered = tag_version - if post_version is not None: - rendered += ".post%%d.dev%%d" %% (post_version + 1, pieces["distance"]) - else: - rendered += ".post0.dev%%d" %% (pieces["distance"]) - else: - # no commits, use the tag as the version - rendered = pieces["closest-tag"] - else: - # exception #1 - rendered = "0.post0.dev%%d" %% pieces["distance"] - return rendered - - -def render_pep440_post(pieces: Dict[str, Any]) -> str: - """TAG[.postDISTANCE[.dev0]+gHEX] . - - The ".dev0" means dirty. Note that .dev0 sorts backwards - (a dirty tree will appear "older" than the corresponding clean one), - but you shouldn't be releasing software with -dirty anyways. - - Exceptions: - 1: no tags. 0.postDISTANCE[.dev0] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += ".post%%d" %% pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - rendered += plus_or_dot(pieces) - rendered += "g%%s" %% pieces["short"] - else: - # exception #1 - rendered = "0.post%%d" %% pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - rendered += "+g%%s" %% pieces["short"] - return rendered - - -def render_pep440_post_branch(pieces: Dict[str, Any]) -> str: - """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] . - - The ".dev0" means not master branch. - - Exceptions: - 1: no tags. 0.postDISTANCE[.dev0]+gHEX[.dirty] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += ".post%%d" %% pieces["distance"] - if pieces["branch"] != "master": - rendered += ".dev0" - rendered += plus_or_dot(pieces) - rendered += "g%%s" %% pieces["short"] - if pieces["dirty"]: - rendered += ".dirty" - else: - # exception #1 - rendered = "0.post%%d" %% pieces["distance"] - if pieces["branch"] != "master": - rendered += ".dev0" - rendered += "+g%%s" %% pieces["short"] - if pieces["dirty"]: - rendered += ".dirty" - return rendered - - -def render_pep440_old(pieces: Dict[str, Any]) -> str: - """TAG[.postDISTANCE[.dev0]] . - - The ".dev0" means dirty. - - Exceptions: - 1: no tags. 0.postDISTANCE[.dev0] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += ".post%%d" %% pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - else: - # exception #1 - rendered = "0.post%%d" %% pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - return rendered - - -def render_git_describe(pieces: Dict[str, Any]) -> str: - """TAG[-DISTANCE-gHEX][-dirty]. - - Like 'git describe --tags --dirty --always'. - - Exceptions: - 1: no tags. HEX[-dirty] (note: no 'g' prefix) - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"]: - rendered += "-%%d-g%%s" %% (pieces["distance"], pieces["short"]) - else: - # exception #1 - rendered = pieces["short"] - if pieces["dirty"]: - rendered += "-dirty" - return rendered - - -def render_git_describe_long(pieces: Dict[str, Any]) -> str: - """TAG-DISTANCE-gHEX[-dirty]. - - Like 'git describe --tags --dirty --always -long'. - The distance/hash is unconditional. - - Exceptions: - 1: no tags. HEX[-dirty] (note: no 'g' prefix) - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - rendered += "-%%d-g%%s" %% (pieces["distance"], pieces["short"]) - else: - # exception #1 - rendered = pieces["short"] - if pieces["dirty"]: - rendered += "-dirty" - return rendered - - -def render(pieces: Dict[str, Any], style: str) -> Dict[str, Any]: - """Render the given version pieces into the requested style.""" - if pieces["error"]: - return {"version": "unknown", - "full-revisionid": pieces.get("long"), - "dirty": None, - "error": pieces["error"], - "date": None} - - if not style or style == "default": - style = "pep440" # the default - - if style == "pep440": - rendered = render_pep440(pieces) - elif style == "pep440-branch": - rendered = render_pep440_branch(pieces) - elif style == "pep440-pre": - rendered = render_pep440_pre(pieces) - elif style == "pep440-post": - rendered = render_pep440_post(pieces) - elif style == "pep440-post-branch": - rendered = render_pep440_post_branch(pieces) - elif style == "pep440-old": - rendered = render_pep440_old(pieces) - elif style == "git-describe": - rendered = render_git_describe(pieces) - elif style == "git-describe-long": - rendered = render_git_describe_long(pieces) - else: - raise ValueError("unknown style '%%s'" %% style) - - return {"version": rendered, "full-revisionid": pieces["long"], - "dirty": pieces["dirty"], "error": None, - "date": pieces.get("date")} - - -def get_versions() -> Dict[str, Any]: - """Get version information or return default if unable to do so.""" - # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have - # __file__, we can work backwards from there to the root. Some - # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which - # case we can only use expanded keywords. - - cfg = get_config() - verbose = cfg.verbose - - try: - return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, - verbose) - except NotThisMethod: - pass - - try: - root = os.path.realpath(__file__) - # versionfile_source is the relative path from the top of the source - # tree (where the .git directory might live) to this file. Invert - # this to find the root from __file__. - for _ in cfg.versionfile_source.split('/'): - root = os.path.dirname(root) - except NameError: - return {"version": "0+unknown", "full-revisionid": None, - "dirty": None, - "error": "unable to find root of source tree", - "date": None} - - try: - pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) - return render(pieces, cfg.style) - except NotThisMethod: - pass - - try: - if cfg.parentdir_prefix: - return versions_from_parentdir(cfg.parentdir_prefix, root, verbose) - except NotThisMethod: - pass - - return {"version": "0+unknown", "full-revisionid": None, - "dirty": None, - "error": "unable to compute version", "date": None} -''' - - -@register_vcs_handler("git", "get_keywords") -def git_get_keywords(versionfile_abs: str) -> Dict[str, str]: - """Extract version information from the given file.""" - # the code embedded in _version.py can just fetch the value of these - # keywords. When used from setup.py, we don't want to import _version.py, - # so we do it with a regexp instead. This function is not used from - # _version.py. - keywords: Dict[str, str] = {} - try: - with open(versionfile_abs, "r") as fobj: - for line in fobj: - if line.strip().startswith("git_refnames ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["refnames"] = mo.group(1) - if line.strip().startswith("git_full ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["full"] = mo.group(1) - if line.strip().startswith("git_date ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["date"] = mo.group(1) - except OSError: - pass - return keywords - - -@register_vcs_handler("git", "keywords") -def git_versions_from_keywords( - keywords: Dict[str, str], - tag_prefix: str, - verbose: bool, -) -> Dict[str, Any]: - """Get version information from git keywords.""" - if "refnames" not in keywords: - raise NotThisMethod("Short version file found") - date = keywords.get("date") - if date is not None: - # Use only the last line. Previous lines may contain GPG signature - # information. - date = date.splitlines()[-1] - - # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant - # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 - # -like" string, which we must then edit to make compliant), because - # it's been around since git-1.5.3, and it's too difficult to - # discover which version we're using, or to work around using an - # older one. - date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) - refnames = keywords["refnames"].strip() - if refnames.startswith("$Format"): - if verbose: - print("keywords are unexpanded, not using") - raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = {r.strip() for r in refnames.strip("()").split(",")} - # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of - # just "foo-1.0". If we see a "tag: " prefix, prefer those. - TAG = "tag: " - tags = {r[len(TAG):] for r in refs if r.startswith(TAG)} - if not tags: - # Either we're using git < 1.8.3, or there really are no tags. We use - # a heuristic: assume all version tags have a digit. The old git %d - # expansion behaves like git log --decorate=short and strips out the - # refs/heads/ and refs/tags/ prefixes that would let us distinguish - # between branches and tags. By ignoring refnames without digits, we - # filter out many common branch names like "release" and - # "stabilization", as well as "HEAD" and "master". - tags = {r for r in refs if re.search(r'\d', r)} - if verbose: - print("discarding '%s', no digits" % ",".join(refs - tags)) - if verbose: - print("likely tags: %s" % ",".join(sorted(tags))) - for ref in sorted(tags): - # sorting will prefer e.g. "2.0" over "2.0rc1" - if ref.startswith(tag_prefix): - r = ref[len(tag_prefix):] - # Filter out refs that exactly match prefix or that don't start - # with a number once the prefix is stripped (mostly a concern - # when prefix is '') - if not re.match(r'\d', r): - continue - if verbose: - print("picking %s" % r) - return {"version": r, - "full-revisionid": keywords["full"].strip(), - "dirty": False, "error": None, - "date": date} - # no suitable tags, so version is "0+unknown", but full hex is still there - if verbose: - print("no suitable tags, using unknown + full revision id") - return {"version": "0+unknown", - "full-revisionid": keywords["full"].strip(), - "dirty": False, "error": "no suitable tags", "date": None} - - -@register_vcs_handler("git", "pieces_from_vcs") -def git_pieces_from_vcs( - tag_prefix: str, - root: str, - verbose: bool, - runner: Callable = run_command -) -> Dict[str, Any]: - """Get version from 'git describe' in the root of the source tree. - - This only gets called if the git-archive 'subst' keywords were *not* - expanded, and _version.py hasn't already been rewritten with a short - version string, meaning we're inside a checked out source tree. - """ - GITS = ["git"] - if sys.platform == "win32": - GITS = ["git.cmd", "git.exe"] - - # GIT_DIR can interfere with correct operation of Versioneer. - # It may be intended to be passed to the Versioneer-versioned project, - # but that should not change where we get our version from. - env = os.environ.copy() - env.pop("GIT_DIR", None) - runner = functools.partial(runner, env=env) - - _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root, - hide_stderr=not verbose) - if rc != 0: - if verbose: - print("Directory %s not under git control" % root) - raise NotThisMethod("'git rev-parse --git-dir' returned error") - - # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] - # if there isn't one, this yields HEX[-dirty] (no NUM) - describe_out, rc = runner(GITS, [ - "describe", "--tags", "--dirty", "--always", "--long", - "--match", f"{tag_prefix}[[:digit:]]*" - ], cwd=root) - # --long was added in git-1.5.5 - if describe_out is None: - raise NotThisMethod("'git describe' failed") - describe_out = describe_out.strip() - full_out, rc = runner(GITS, ["rev-parse", "HEAD"], cwd=root) - if full_out is None: - raise NotThisMethod("'git rev-parse' failed") - full_out = full_out.strip() - - pieces: Dict[str, Any] = {} - pieces["long"] = full_out - pieces["short"] = full_out[:7] # maybe improved later - pieces["error"] = None - - branch_name, rc = runner(GITS, ["rev-parse", "--abbrev-ref", "HEAD"], - cwd=root) - # --abbrev-ref was added in git-1.6.3 - if rc != 0 or branch_name is None: - raise NotThisMethod("'git rev-parse --abbrev-ref' returned error") - branch_name = branch_name.strip() - - if branch_name == "HEAD": - # If we aren't exactly on a branch, pick a branch which represents - # the current commit. If all else fails, we are on a branchless - # commit. - branches, rc = runner(GITS, ["branch", "--contains"], cwd=root) - # --contains was added in git-1.5.4 - if rc != 0 or branches is None: - raise NotThisMethod("'git branch --contains' returned error") - branches = branches.split("\n") - - # Remove the first line if we're running detached - if "(" in branches[0]: - branches.pop(0) - - # Strip off the leading "* " from the list of branches. - branches = [branch[2:] for branch in branches] - if "master" in branches: - branch_name = "master" - elif not branches: - branch_name = None - else: - # Pick the first branch that is returned. Good or bad. - branch_name = branches[0] - - pieces["branch"] = branch_name - - # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] - # TAG might have hyphens. - git_describe = describe_out - - # look for -dirty suffix - dirty = git_describe.endswith("-dirty") - pieces["dirty"] = dirty - if dirty: - git_describe = git_describe[:git_describe.rindex("-dirty")] - - # now we have TAG-NUM-gHEX or HEX - - if "-" in git_describe: - # TAG-NUM-gHEX - mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) - if not mo: - # unparsable. Maybe git-describe is misbehaving? - pieces["error"] = ("unable to parse git-describe output: '%s'" - % describe_out) - return pieces - - # tag - full_tag = mo.group(1) - if not full_tag.startswith(tag_prefix): - if verbose: - fmt = "tag '%s' doesn't start with prefix '%s'" - print(fmt % (full_tag, tag_prefix)) - pieces["error"] = ("tag '%s' doesn't start with prefix '%s'" - % (full_tag, tag_prefix)) - return pieces - pieces["closest-tag"] = full_tag[len(tag_prefix):] - - # distance: number of commits since tag - pieces["distance"] = int(mo.group(2)) - - # commit: short hex revision ID - pieces["short"] = mo.group(3) - - else: - # HEX: no tags - pieces["closest-tag"] = None - out, rc = runner(GITS, ["rev-list", "HEAD", "--left-right"], cwd=root) - pieces["distance"] = len(out.split()) # total number of commits - - # commit date: see ISO-8601 comment in git_versions_from_keywords() - date = runner(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() - # Use only the last line. Previous lines may contain GPG signature - # information. - date = date.splitlines()[-1] - pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) - - return pieces - - -def do_vcs_install(versionfile_source: str, ipy: Optional[str]) -> None: - """Git-specific installation logic for Versioneer. - - For Git, this means creating/changing .gitattributes to mark _version.py - for export-subst keyword substitution. - """ - GITS = ["git"] - if sys.platform == "win32": - GITS = ["git.cmd", "git.exe"] - files = [versionfile_source] - if ipy: - files.append(ipy) - if "VERSIONEER_PEP518" not in globals(): - try: - my_path = __file__ - if my_path.endswith((".pyc", ".pyo")): - my_path = os.path.splitext(my_path)[0] + ".py" - versioneer_file = os.path.relpath(my_path) - except NameError: - versioneer_file = "versioneer.py" - files.append(versioneer_file) - present = False - try: - with open(".gitattributes", "r") as fobj: - for line in fobj: - if line.strip().startswith(versionfile_source): - if "export-subst" in line.strip().split()[1:]: - present = True - break - except OSError: - pass - if not present: - with open(".gitattributes", "a+") as fobj: - fobj.write(f"{versionfile_source} export-subst\n") - files.append(".gitattributes") - run_command(GITS, ["add", "--"] + files) - - -def versions_from_parentdir( - parentdir_prefix: str, - root: str, - verbose: bool, -) -> Dict[str, Any]: - """Try to determine the version from the parent directory name. - - Source tarballs conventionally unpack into a directory that includes both - the project name and a version string. We will also support searching up - two directory levels for an appropriately named parent directory - """ - rootdirs = [] - - for _ in range(3): - dirname = os.path.basename(root) - if dirname.startswith(parentdir_prefix): - return {"version": dirname[len(parentdir_prefix):], - "full-revisionid": None, - "dirty": False, "error": None, "date": None} - rootdirs.append(root) - root = os.path.dirname(root) # up a level - - if verbose: - print("Tried directories %s but none started with prefix %s" % - (str(rootdirs), parentdir_prefix)) - raise NotThisMethod("rootdir doesn't start with parentdir_prefix") - - -SHORT_VERSION_PY = """ -# This file was generated by 'versioneer.py' (0.29) from -# revision-control system data, or from the parent directory name of an -# unpacked source archive. Distribution tarballs contain a pre-generated copy -# of this file. - -import json - -version_json = ''' -%s -''' # END VERSION_JSON - - -def get_versions(): - return json.loads(version_json) -""" - - -def versions_from_file(filename: str) -> Dict[str, Any]: - """Try to determine the version from _version.py if present.""" - try: - with open(filename) as f: - contents = f.read() - except OSError: - raise NotThisMethod("unable to read _version.py") - mo = re.search(r"version_json = '''\n(.*)''' # END VERSION_JSON", - contents, re.M | re.S) - if not mo: - mo = re.search(r"version_json = '''\r\n(.*)''' # END VERSION_JSON", - contents, re.M | re.S) - if not mo: - raise NotThisMethod("no version_json in _version.py") - return json.loads(mo.group(1)) - - -def write_to_version_file(filename: str, versions: Dict[str, Any]) -> None: - """Write the given version number to the given _version.py file.""" - contents = json.dumps(versions, sort_keys=True, - indent=1, separators=(",", ": ")) - with open(filename, "w") as f: - f.write(SHORT_VERSION_PY % contents) - - print("set %s to '%s'" % (filename, versions["version"])) - - -def plus_or_dot(pieces: Dict[str, Any]) -> str: - """Return a + if we don't already have one, else return a .""" - if "+" in pieces.get("closest-tag", ""): - return "." - return "+" - - -def render_pep440(pieces: Dict[str, Any]) -> str: - """Build up version string, with post-release "local version identifier". - - Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you - get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty - - Exceptions: - 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += plus_or_dot(pieces) - rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - else: - # exception #1 - rendered = "0+untagged.%d.g%s" % (pieces["distance"], - pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - return rendered - - -def render_pep440_branch(pieces: Dict[str, Any]) -> str: - """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] . - - The ".dev0" means not master branch. Note that .dev0 sorts backwards - (a feature branch will appear "older" than the master branch). - - Exceptions: - 1: no tags. 0[.dev0]+untagged.DISTANCE.gHEX[.dirty] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - if pieces["branch"] != "master": - rendered += ".dev0" - rendered += plus_or_dot(pieces) - rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - else: - # exception #1 - rendered = "0" - if pieces["branch"] != "master": - rendered += ".dev0" - rendered += "+untagged.%d.g%s" % (pieces["distance"], - pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - return rendered - - -def pep440_split_post(ver: str) -> Tuple[str, Optional[int]]: - """Split pep440 version string at the post-release segment. - - Returns the release segments before the post-release and the - post-release version number (or -1 if no post-release segment is present). - """ - vc = str.split(ver, ".post") - return vc[0], int(vc[1] or 0) if len(vc) == 2 else None - - -def render_pep440_pre(pieces: Dict[str, Any]) -> str: - """TAG[.postN.devDISTANCE] -- No -dirty. - - Exceptions: - 1: no tags. 0.post0.devDISTANCE - """ - if pieces["closest-tag"]: - if pieces["distance"]: - # update the post release segment - tag_version, post_version = pep440_split_post(pieces["closest-tag"]) - rendered = tag_version - if post_version is not None: - rendered += ".post%d.dev%d" % (post_version + 1, pieces["distance"]) - else: - rendered += ".post0.dev%d" % (pieces["distance"]) - else: - # no commits, use the tag as the version - rendered = pieces["closest-tag"] - else: - # exception #1 - rendered = "0.post0.dev%d" % pieces["distance"] - return rendered - - -def render_pep440_post(pieces: Dict[str, Any]) -> str: - """TAG[.postDISTANCE[.dev0]+gHEX] . - - The ".dev0" means dirty. Note that .dev0 sorts backwards - (a dirty tree will appear "older" than the corresponding clean one), - but you shouldn't be releasing software with -dirty anyways. - - Exceptions: - 1: no tags. 0.postDISTANCE[.dev0] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += ".post%d" % pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - rendered += plus_or_dot(pieces) - rendered += "g%s" % pieces["short"] - else: - # exception #1 - rendered = "0.post%d" % pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - rendered += "+g%s" % pieces["short"] - return rendered - - -def render_pep440_post_branch(pieces: Dict[str, Any]) -> str: - """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] . - - The ".dev0" means not master branch. - - Exceptions: - 1: no tags. 0.postDISTANCE[.dev0]+gHEX[.dirty] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += ".post%d" % pieces["distance"] - if pieces["branch"] != "master": - rendered += ".dev0" - rendered += plus_or_dot(pieces) - rendered += "g%s" % pieces["short"] - if pieces["dirty"]: - rendered += ".dirty" - else: - # exception #1 - rendered = "0.post%d" % pieces["distance"] - if pieces["branch"] != "master": - rendered += ".dev0" - rendered += "+g%s" % pieces["short"] - if pieces["dirty"]: - rendered += ".dirty" - return rendered - - -def render_pep440_old(pieces: Dict[str, Any]) -> str: - """TAG[.postDISTANCE[.dev0]] . - - The ".dev0" means dirty. - - Exceptions: - 1: no tags. 0.postDISTANCE[.dev0] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += ".post%d" % pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - else: - # exception #1 - rendered = "0.post%d" % pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - return rendered - - -def render_git_describe(pieces: Dict[str, Any]) -> str: - """TAG[-DISTANCE-gHEX][-dirty]. - - Like 'git describe --tags --dirty --always'. - - Exceptions: - 1: no tags. HEX[-dirty] (note: no 'g' prefix) - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"]: - rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) - else: - # exception #1 - rendered = pieces["short"] - if pieces["dirty"]: - rendered += "-dirty" - return rendered - - -def render_git_describe_long(pieces: Dict[str, Any]) -> str: - """TAG-DISTANCE-gHEX[-dirty]. - - Like 'git describe --tags --dirty --always -long'. - The distance/hash is unconditional. - - Exceptions: - 1: no tags. HEX[-dirty] (note: no 'g' prefix) - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) - else: - # exception #1 - rendered = pieces["short"] - if pieces["dirty"]: - rendered += "-dirty" - return rendered - - -def render(pieces: Dict[str, Any], style: str) -> Dict[str, Any]: - """Render the given version pieces into the requested style.""" - if pieces["error"]: - return {"version": "unknown", - "full-revisionid": pieces.get("long"), - "dirty": None, - "error": pieces["error"], - "date": None} - - if not style or style == "default": - style = "pep440" # the default - - if style == "pep440": - rendered = render_pep440(pieces) - elif style == "pep440-branch": - rendered = render_pep440_branch(pieces) - elif style == "pep440-pre": - rendered = render_pep440_pre(pieces) - elif style == "pep440-post": - rendered = render_pep440_post(pieces) - elif style == "pep440-post-branch": - rendered = render_pep440_post_branch(pieces) - elif style == "pep440-old": - rendered = render_pep440_old(pieces) - elif style == "git-describe": - rendered = render_git_describe(pieces) - elif style == "git-describe-long": - rendered = render_git_describe_long(pieces) - else: - raise ValueError("unknown style '%s'" % style) - - return {"version": rendered, "full-revisionid": pieces["long"], - "dirty": pieces["dirty"], "error": None, - "date": pieces.get("date")} - - -class VersioneerBadRootError(Exception): - """The project root directory is unknown or missing key files.""" - - -def get_versions(verbose: bool = False) -> Dict[str, Any]: - """Get the project version from whatever source is available. - - Returns dict with two keys: 'version' and 'full'. - """ - if "versioneer" in sys.modules: - # see the discussion in cmdclass.py:get_cmdclass() - del sys.modules["versioneer"] - - root = get_root() - cfg = get_config_from_root(root) - - assert cfg.VCS is not None, "please set [versioneer]VCS= in setup.cfg" - handlers = HANDLERS.get(cfg.VCS) - assert handlers, "unrecognized VCS '%s'" % cfg.VCS - verbose = verbose or bool(cfg.verbose) # `bool()` used to avoid `None` - assert cfg.versionfile_source is not None, \ - "please set versioneer.versionfile_source" - assert cfg.tag_prefix is not None, "please set versioneer.tag_prefix" - - versionfile_abs = os.path.join(root, cfg.versionfile_source) - - # extract version from first of: _version.py, VCS command (e.g. 'git - # describe'), parentdir. This is meant to work for developers using a - # source checkout, for users of a tarball created by 'setup.py sdist', - # and for users of a tarball/zipball created by 'git archive' or github's - # download-from-tag feature or the equivalent in other VCSes. - - get_keywords_f = handlers.get("get_keywords") - from_keywords_f = handlers.get("keywords") - if get_keywords_f and from_keywords_f: - try: - keywords = get_keywords_f(versionfile_abs) - ver = from_keywords_f(keywords, cfg.tag_prefix, verbose) - if verbose: - print("got version from expanded keyword %s" % ver) - return ver - except NotThisMethod: - pass - - try: - ver = versions_from_file(versionfile_abs) - if verbose: - print("got version from file %s %s" % (versionfile_abs, ver)) - return ver - except NotThisMethod: - pass - - from_vcs_f = handlers.get("pieces_from_vcs") - if from_vcs_f: - try: - pieces = from_vcs_f(cfg.tag_prefix, root, verbose) - ver = render(pieces, cfg.style) - if verbose: - print("got version from VCS %s" % ver) - return ver - except NotThisMethod: - pass - - try: - if cfg.parentdir_prefix: - ver = versions_from_parentdir(cfg.parentdir_prefix, root, verbose) - if verbose: - print("got version from parentdir %s" % ver) - return ver - except NotThisMethod: - pass - - if verbose: - print("unable to compute version") - - return {"version": "0+unknown", "full-revisionid": None, - "dirty": None, "error": "unable to compute version", - "date": None} - - -def get_version() -> str: - """Get the short version string for this project.""" - return get_versions()["version"] - - -def get_cmdclass(cmdclass: Optional[Dict[str, Any]] = None): - """Get the custom setuptools subclasses used by Versioneer. - - If the package uses a different cmdclass (e.g. one from numpy), it - should be provide as an argument. - """ - if "versioneer" in sys.modules: - del sys.modules["versioneer"] - # this fixes the "python setup.py develop" case (also 'install' and - # 'easy_install .'), in which subdependencies of the main project are - # built (using setup.py bdist_egg) in the same python process. Assume - # a main project A and a dependency B, which use different versions - # of Versioneer. A's setup.py imports A's Versioneer, leaving it in - # sys.modules by the time B's setup.py is executed, causing B to run - # with the wrong versioneer. Setuptools wraps the sub-dep builds in a - # sandbox that restores sys.modules to it's pre-build state, so the - # parent is protected against the child's "import versioneer". By - # removing ourselves from sys.modules here, before the child build - # happens, we protect the child from the parent's versioneer too. - # Also see https://github.com/python-versioneer/python-versioneer/issues/52 - - cmds = {} if cmdclass is None else cmdclass.copy() - - # we add "version" to setuptools - from setuptools import Command - - class cmd_version(Command): - description = "report generated version string" - user_options: List[Tuple[str, str, str]] = [] - boolean_options: List[str] = [] - - def initialize_options(self) -> None: - pass - - def finalize_options(self) -> None: - pass - - def run(self) -> None: - vers = get_versions(verbose=True) - print("Version: %s" % vers["version"]) - print(" full-revisionid: %s" % vers.get("full-revisionid")) - print(" dirty: %s" % vers.get("dirty")) - print(" date: %s" % vers.get("date")) - if vers["error"]: - print(" error: %s" % vers["error"]) - cmds["version"] = cmd_version - - # we override "build_py" in setuptools - # - # most invocation pathways end up running build_py: - # distutils/build -> build_py - # distutils/install -> distutils/build ->.. - # setuptools/bdist_wheel -> distutils/install ->.. - # setuptools/bdist_egg -> distutils/install_lib -> build_py - # setuptools/install -> bdist_egg ->.. - # setuptools/develop -> ? - # pip install: - # copies source tree to a tempdir before running egg_info/etc - # if .git isn't copied too, 'git describe' will fail - # then does setup.py bdist_wheel, or sometimes setup.py install - # setup.py egg_info -> ? - - # pip install -e . and setuptool/editable_wheel will invoke build_py - # but the build_py command is not expected to copy any files. - - # we override different "build_py" commands for both environments - if 'build_py' in cmds: - _build_py: Any = cmds['build_py'] - else: - from setuptools.command.build_py import build_py as _build_py - - class cmd_build_py(_build_py): - def run(self) -> None: - root = get_root() - cfg = get_config_from_root(root) - versions = get_versions() - _build_py.run(self) - if getattr(self, "editable_mode", False): - # During editable installs `.py` and data files are - # not copied to build_lib - return - # now locate _version.py in the new build/ directory and replace - # it with an updated value - if cfg.versionfile_build: - target_versionfile = os.path.join(self.build_lib, - cfg.versionfile_build) - print("UPDATING %s" % target_versionfile) - write_to_version_file(target_versionfile, versions) - cmds["build_py"] = cmd_build_py - - if 'build_ext' in cmds: - _build_ext: Any = cmds['build_ext'] - else: - from setuptools.command.build_ext import build_ext as _build_ext - - class cmd_build_ext(_build_ext): - def run(self) -> None: - root = get_root() - cfg = get_config_from_root(root) - versions = get_versions() - _build_ext.run(self) - if self.inplace: - # build_ext --inplace will only build extensions in - # build/lib<..> dir with no _version.py to write to. - # As in place builds will already have a _version.py - # in the module dir, we do not need to write one. - return - # now locate _version.py in the new build/ directory and replace - # it with an updated value - if not cfg.versionfile_build: - return - target_versionfile = os.path.join(self.build_lib, - cfg.versionfile_build) - if not os.path.exists(target_versionfile): - print(f"Warning: {target_versionfile} does not exist, skipping " - "version update. This can happen if you are running build_ext " - "without first running build_py.") - return - print("UPDATING %s" % target_versionfile) - write_to_version_file(target_versionfile, versions) - cmds["build_ext"] = cmd_build_ext - - if "cx_Freeze" in sys.modules: # cx_freeze enabled? - from cx_Freeze.dist import build_exe as _build_exe # type: ignore - # nczeczulin reports that py2exe won't like the pep440-style string - # as FILEVERSION, but it can be used for PRODUCTVERSION, e.g. - # setup(console=[{ - # "version": versioneer.get_version().split("+", 1)[0], # FILEVERSION - # "product_version": versioneer.get_version(), - # ... - - class cmd_build_exe(_build_exe): - def run(self) -> None: - root = get_root() - cfg = get_config_from_root(root) - versions = get_versions() - target_versionfile = cfg.versionfile_source - print("UPDATING %s" % target_versionfile) - write_to_version_file(target_versionfile, versions) - - _build_exe.run(self) - os.unlink(target_versionfile) - with open(cfg.versionfile_source, "w") as f: - LONG = LONG_VERSION_PY[cfg.VCS] - f.write(LONG % - {"DOLLAR": "$", - "STYLE": cfg.style, - "TAG_PREFIX": cfg.tag_prefix, - "PARENTDIR_PREFIX": cfg.parentdir_prefix, - "VERSIONFILE_SOURCE": cfg.versionfile_source, - }) - cmds["build_exe"] = cmd_build_exe - del cmds["build_py"] - - if 'py2exe' in sys.modules: # py2exe enabled? - try: - from py2exe.setuptools_buildexe import py2exe as _py2exe # type: ignore - except ImportError: - from py2exe.distutils_buildexe import py2exe as _py2exe # type: ignore - - class cmd_py2exe(_py2exe): - def run(self) -> None: - root = get_root() - cfg = get_config_from_root(root) - versions = get_versions() - target_versionfile = cfg.versionfile_source - print("UPDATING %s" % target_versionfile) - write_to_version_file(target_versionfile, versions) - - _py2exe.run(self) - os.unlink(target_versionfile) - with open(cfg.versionfile_source, "w") as f: - LONG = LONG_VERSION_PY[cfg.VCS] - f.write(LONG % - {"DOLLAR": "$", - "STYLE": cfg.style, - "TAG_PREFIX": cfg.tag_prefix, - "PARENTDIR_PREFIX": cfg.parentdir_prefix, - "VERSIONFILE_SOURCE": cfg.versionfile_source, - }) - cmds["py2exe"] = cmd_py2exe - - # sdist farms its file list building out to egg_info - if 'egg_info' in cmds: - _egg_info: Any = cmds['egg_info'] - else: - from setuptools.command.egg_info import egg_info as _egg_info - - class cmd_egg_info(_egg_info): - def find_sources(self) -> None: - # egg_info.find_sources builds the manifest list and writes it - # in one shot - super().find_sources() - - # Modify the filelist and normalize it - root = get_root() - cfg = get_config_from_root(root) - self.filelist.append('versioneer.py') - if cfg.versionfile_source: - # There are rare cases where versionfile_source might not be - # included by default, so we must be explicit - self.filelist.append(cfg.versionfile_source) - self.filelist.sort() - self.filelist.remove_duplicates() - - # The write method is hidden in the manifest_maker instance that - # generated the filelist and was thrown away - # We will instead replicate their final normalization (to unicode, - # and POSIX-style paths) - from setuptools import unicode_utils - normalized = [unicode_utils.filesys_decode(f).replace(os.sep, '/') - for f in self.filelist.files] - - manifest_filename = os.path.join(self.egg_info, 'SOURCES.txt') - with open(manifest_filename, 'w') as fobj: - fobj.write('\n'.join(normalized)) - - cmds['egg_info'] = cmd_egg_info - - # we override different "sdist" commands for both environments - if 'sdist' in cmds: - _sdist: Any = cmds['sdist'] - else: - from setuptools.command.sdist import sdist as _sdist - - class cmd_sdist(_sdist): - def run(self) -> None: - versions = get_versions() - self._versioneer_generated_versions = versions - # unless we update this, the command will keep using the old - # version - self.distribution.metadata.version = versions["version"] - return _sdist.run(self) - - def make_release_tree(self, base_dir: str, files: List[str]) -> None: - root = get_root() - cfg = get_config_from_root(root) - _sdist.make_release_tree(self, base_dir, files) - # now locate _version.py in the new base_dir directory - # (remembering that it may be a hardlink) and replace it with an - # updated value - target_versionfile = os.path.join(base_dir, cfg.versionfile_source) - print("UPDATING %s" % target_versionfile) - write_to_version_file(target_versionfile, - self._versioneer_generated_versions) - cmds["sdist"] = cmd_sdist - - return cmds - - -CONFIG_ERROR = """ -setup.cfg is missing the necessary Versioneer configuration. You need -a section like: - - [versioneer] - VCS = git - style = pep440 - versionfile_source = src/myproject/_version.py - versionfile_build = myproject/_version.py - tag_prefix = - parentdir_prefix = myproject- - -You will also need to edit your setup.py to use the results: - - import versioneer - setup(version=versioneer.get_version(), - cmdclass=versioneer.get_cmdclass(), ...) - -Please read the docstring in ./versioneer.py for configuration instructions, -edit setup.cfg, and re-run the installer or 'python versioneer.py setup'. -""" - -SAMPLE_CONFIG = """ -# See the docstring in versioneer.py for instructions. Note that you must -# re-run 'versioneer.py setup' after changing this section, and commit the -# resulting files. - -[versioneer] -#VCS = git -#style = pep440 -#versionfile_source = -#versionfile_build = -#tag_prefix = -#parentdir_prefix = - -""" - -OLD_SNIPPET = """ -from ._version import get_versions -__version__ = get_versions()['version'] -del get_versions -""" - -INIT_PY_SNIPPET = """ -from . import {0} -__version__ = {0}.get_versions()['version'] -""" - - -def do_setup() -> int: - """Do main VCS-independent setup function for installing Versioneer.""" - root = get_root() - try: - cfg = get_config_from_root(root) - except (OSError, configparser.NoSectionError, - configparser.NoOptionError) as e: - if isinstance(e, (OSError, configparser.NoSectionError)): - print("Adding sample versioneer config to setup.cfg", - file=sys.stderr) - with open(os.path.join(root, "setup.cfg"), "a") as f: - f.write(SAMPLE_CONFIG) - print(CONFIG_ERROR, file=sys.stderr) - return 1 - - print(" creating %s" % cfg.versionfile_source) - with open(cfg.versionfile_source, "w") as f: - LONG = LONG_VERSION_PY[cfg.VCS] - f.write(LONG % {"DOLLAR": "$", - "STYLE": cfg.style, - "TAG_PREFIX": cfg.tag_prefix, - "PARENTDIR_PREFIX": cfg.parentdir_prefix, - "VERSIONFILE_SOURCE": cfg.versionfile_source, - }) - - ipy = os.path.join(os.path.dirname(cfg.versionfile_source), - "__init__.py") - maybe_ipy: Optional[str] = ipy - if os.path.exists(ipy): - try: - with open(ipy, "r") as f: - old = f.read() - except OSError: - old = "" - module = os.path.splitext(os.path.basename(cfg.versionfile_source))[0] - snippet = INIT_PY_SNIPPET.format(module) - if OLD_SNIPPET in old: - print(" replacing boilerplate in %s" % ipy) - with open(ipy, "w") as f: - f.write(old.replace(OLD_SNIPPET, snippet)) - elif snippet not in old: - print(" appending to %s" % ipy) - with open(ipy, "a") as f: - f.write(snippet) - else: - print(" %s unmodified" % ipy) - else: - print(" %s doesn't exist, ok" % ipy) - maybe_ipy = None - - # Make VCS-specific changes. For git, this means creating/changing - # .gitattributes to mark _version.py for export-subst keyword - # substitution. - do_vcs_install(cfg.versionfile_source, maybe_ipy) - return 0 - - -def scan_setup_py() -> int: - """Validate the contents of setup.py against Versioneer's expectations.""" - found = set() - setters = False - errors = 0 - with open("setup.py", "r") as f: - for line in f.readlines(): - if "import versioneer" in line: - found.add("import") - if "versioneer.get_cmdclass()" in line: - found.add("cmdclass") - if "versioneer.get_version()" in line: - found.add("get_version") - if "versioneer.VCS" in line: - setters = True - if "versioneer.versionfile_source" in line: - setters = True - if len(found) != 3: - print("") - print("Your setup.py appears to be missing some important items") - print("(but I might be wrong). Please make sure it has something") - print("roughly like the following:") - print("") - print(" import versioneer") - print(" setup( version=versioneer.get_version(),") - print(" cmdclass=versioneer.get_cmdclass(), ...)") - print("") - errors += 1 - if setters: - print("You should remove lines like 'versioneer.VCS = ' and") - print("'versioneer.versionfile_source = ' . This configuration") - print("now lives in setup.cfg, and should be removed from setup.py") - print("") - errors += 1 - return errors - - -def setup_command() -> NoReturn: - """Set up Versioneer and exit with appropriate error code.""" - errors = do_setup() - errors += scan_setup_py() - sys.exit(1 if errors else 0) - - -if __name__ == "__main__": - cmd = sys.argv[1] - if cmd == "setup": - setup_command() From 281e42c805d9df9cedc06e28a4626390b952d605 Mon Sep 17 00:00:00 2001 From: Emlyn Price Date: Mon, 12 Feb 2024 21:28:49 +0000 Subject: [PATCH 02/51] Format code --- src/datapoint/Forecast.py | 5 ++++- src/datapoint/Manager.py | 6 ++++-- src/datapoint/__init__.py | 14 +++++++++----- src/datapoint/profile.py | 9 ++++----- src/datapoint/weather_codes.py | 1 - 5 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/datapoint/Forecast.py b/src/datapoint/Forecast.py index e707352..30636f9 100644 --- a/src/datapoint/Forecast.py +++ b/src/datapoint/Forecast.py @@ -36,7 +36,10 @@ def __build_timesteps_from_daily(self, forecasts, parameters): timesteps = [] for forecast in forecasts: night_step = {"time": datetime.datetime.fromisoformat(forecast["time"])} - day_step = {"time": datetime.datetime.fromisoformat( forecast["time"] ) + datetime.timedelta(hours=12)} + day_step = { + "time": datetime.datetime.fromisoformat(forecast["time"]) + + datetime.timedelta(hours=12) + } for element, value in forecast.items(): if element.startswith("midday"): diff --git a/src/datapoint/Manager.py b/src/datapoint/Manager.py index 144f614..9e10f4a 100644 --- a/src/datapoint/Manager.py +++ b/src/datapoint/Manager.py @@ -140,9 +140,11 @@ def get_forecast(self, latitude, longitude, frequency="daily"): 0, 180, 360 ... 1260 (minutes since midnight UTC) """ if frequency not in ["hourly", "three-hourly", "daily"]: - raise ValueError("frequency must be set to one of 'hourly', 'three-hourly', 'daily'") + raise ValueError( + "frequency must be set to one of 'hourly', 'three-hourly', 'daily'" + ) data = self.__call_api(latitude, longitude, frequency) - #print(data) + # print(data) forecast = Forecast(frequency=frequency, api_data=data) return forecast diff --git a/src/datapoint/__init__.py b/src/datapoint/__init__.py index a6181c8..e81a3b8 100644 --- a/src/datapoint/__init__.py +++ b/src/datapoint/__init__.py @@ -6,17 +6,21 @@ import datapoint.profile -def connection(profile_name='default', api_key=None): +def connection(profile_name="default", api_key=None): """Connect to DataPoint with the given API key profile name.""" if api_key is None: profile_fname = datapoint.profile.API_profile_fname(profile_name) if not os.path.exists(profile_fname): - raise ValueError('Profile not found in {}. Please install your API \n' - 'key with datapoint.profile.install_API_key(' - '"")'.format(profile_fname)) + raise ValueError( + "Profile not found in {}. Please install your API \n" + "key with datapoint.profile.install_API_key(" + '"")'.format(profile_fname) + ) with open(profile_fname) as fh: api_key = fh.readlines() return Manager(api_key=api_key) + from . import _version -__version__ = _version.get_versions()['version'] + +__version__ = _version.get_versions()["version"] diff --git a/src/datapoint/profile.py b/src/datapoint/profile.py index 54803b4..34e823c 100644 --- a/src/datapoint/profile.py +++ b/src/datapoint/profile.py @@ -3,16 +3,15 @@ import appdirs -def API_profile_fname(profile_name='default'): +def API_profile_fname(profile_name="default"): """Get the API key profile filename.""" - return os.path.join(appdirs.user_data_dir('DataPoint'), - profile_name + '.key') + return os.path.join(appdirs.user_data_dir("DataPoint"), profile_name + ".key") -def install_API_key(api_key, profile_name='default'): +def install_API_key(api_key, profile_name="default"): """Put the given API key into the given profile name.""" fname = API_profile_fname(profile_name) if not os.path.isdir(os.path.dirname(fname)): os.makedirs(os.path.dirname(fname)) - with open(fname, 'w') as fh: + with open(fname, "w") as fh: fh.write(api_key) diff --git a/src/datapoint/weather_codes.py b/src/datapoint/weather_codes.py index 8359afb..d54cce4 100644 --- a/src/datapoint/weather_codes.py +++ b/src/datapoint/weather_codes.py @@ -33,4 +33,3 @@ "29": "Thunder shower", "30": "Thunder", } - From 733bcc8913b6e3ad57c2e3f7ac25cabffc1f1f2c Mon Sep 17 00:00:00 2001 From: Emlyn Price Date: Mon, 12 Feb 2024 21:28:56 +0000 Subject: [PATCH 03/51] Remove versioneer bit left over --- src/datapoint/__init__.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/datapoint/__init__.py b/src/datapoint/__init__.py index e81a3b8..8408295 100644 --- a/src/datapoint/__init__.py +++ b/src/datapoint/__init__.py @@ -19,8 +19,3 @@ def connection(profile_name="default", api_key=None): with open(profile_fname) as fh: api_key = fh.readlines() return Manager(api_key=api_key) - - -from . import _version - -__version__ = _version.get_versions()["version"] From 64eb6db084f85feb997a51cf1b9b45657b8feb63 Mon Sep 17 00:00:00 2001 From: Emlyn Price Date: Mon, 12 Feb 2024 21:35:19 +0000 Subject: [PATCH 04/51] Code formatting and setup --- .flake8 | 5 +++++ docs/conf.py | 1 + examples/postcodes_to_lat_lng/postcodes_to_lat_lng.py | 3 ++- examples/simple_forecast/simple_forecast.py | 3 ++- examples/tube_bike/tube_bike.py | 3 ++- examples/washing/washing.py | 3 ++- pyproject.toml | 6 +++++- src/datapoint/Forecast.py | 2 +- src/datapoint/Manager.py | 3 +-- src/datapoint/__init__.py | 2 +- tests/integration/test_datapoint.py | 7 ++++--- tests/integration/test_manager.py | 2 ++ tests/integration/test_regions.py | 4 +++- tests/unit/test_forecast.py | 3 ++- tests/unit/test_manager.py | 2 ++ tests/unit/test_observation.py | 4 +++- 16 files changed, 38 insertions(+), 15 deletions(-) create mode 100644 .flake8 diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..cf2eabb --- /dev/null +++ b/.flake8 @@ -0,0 +1,5 @@ +[flake8] +max-complexity = 10 +max-line-length = 80 +extend-select = B950 +extend-ignore = E203,E501,E701 \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 60dccb2..f7a2b96 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -14,6 +14,7 @@ # import os import sys + sys.path.insert(0, os.path.abspath('.')) # Need to change the place we put in path to work with readthedocs sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) diff --git a/examples/postcodes_to_lat_lng/postcodes_to_lat_lng.py b/examples/postcodes_to_lat_lng/postcodes_to_lat_lng.py index 6decab7..f97e890 100644 --- a/examples/postcodes_to_lat_lng/postcodes_to_lat_lng.py +++ b/examples/postcodes_to_lat_lng/postcodes_to_lat_lng.py @@ -3,9 +3,10 @@ A variation on current_weather.py which uses postcodes rather than lon lat. """ -import datapoint import postcodes_io_api +import datapoint + # Create datapoint connection conn = datapoint.Manager(api_key="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") diff --git a/examples/simple_forecast/simple_forecast.py b/examples/simple_forecast/simple_forecast.py index c8b87c6..f8a47d0 100755 --- a/examples/simple_forecast/simple_forecast.py +++ b/examples/simple_forecast/simple_forecast.py @@ -4,9 +4,10 @@ It will allow us to explore the day, timestep and element objects. """ -import datapoint import datetime +import datapoint + # Create datapoint connection manager = datapoint.Manager( api_key="api key goes here" diff --git a/examples/tube_bike/tube_bike.py b/examples/tube_bike/tube_bike.py index 15d5d54..fbaea24 100644 --- a/examples/tube_bike/tube_bike.py +++ b/examples/tube_bike/tube_bike.py @@ -5,9 +5,10 @@ cycling or catching the tube. """ -import datapoint import tubestatus +import datapoint + # Create datapoint connection conn = datapoint.Manager(api_key="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") diff --git a/examples/washing/washing.py b/examples/washing/washing.py index 88e1cef..a9985dc 100644 --- a/examples/washing/washing.py +++ b/examples/washing/washing.py @@ -8,9 +8,10 @@ them and print out the best. """ -import datapoint from datetime import datetime +import datapoint + # Set thresholds MAX_WIND = 31 # in mph. We don't want the washing to blow away MAX_PRECIPITATION = 20 # Max chance of rain we will accept diff --git a/pyproject.toml b/pyproject.toml index 84955c3..9744b6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,4 +40,8 @@ exclude = [ ] [tool.hatch.version] -source = "versioningit" \ No newline at end of file +source = "versioningit" + +[tool.isort] +profile = "black" +src_paths = ["src", "tests"] \ No newline at end of file diff --git a/src/datapoint/Forecast.py b/src/datapoint/Forecast.py index 30636f9..c5f8523 100644 --- a/src/datapoint/Forecast.py +++ b/src/datapoint/Forecast.py @@ -1,6 +1,6 @@ import datetime + from datapoint.exceptions import APIException -from datapoint.Timestep import Timestep from datapoint.weather_codes import WEATHER_CODES diff --git a/src/datapoint/Manager.py b/src/datapoint/Manager.py index 9e10f4a..c99350c 100644 --- a/src/datapoint/Manager.py +++ b/src/datapoint/Manager.py @@ -2,15 +2,14 @@ Datapoint python module """ +import geojson import requests from requests.adapters import HTTPAdapter from requests.packages.urllib3.util.retry import Retry -import geojson from datapoint.exceptions import APIException from datapoint.Forecast import Forecast - API_URL = "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/" DATE_FORMAT = "%Y-%m-%dZ" DATA_DATE_FORMAT = "%Y-%m-%dT%XZ" diff --git a/src/datapoint/__init__.py b/src/datapoint/__init__.py index 8408295..145ce89 100644 --- a/src/datapoint/__init__.py +++ b/src/datapoint/__init__.py @@ -2,8 +2,8 @@ import os.path -from datapoint.Manager import Manager import datapoint.profile +from datapoint.Manager import Manager def connection(profile_name="default", api_key=None): diff --git a/tests/integration/test_datapoint.py b/tests/integration/test_datapoint.py index d305621..f20f563 100644 --- a/tests/integration/test_datapoint.py +++ b/tests/integration/test_datapoint.py @@ -1,11 +1,12 @@ -from datetime import datetime, date import json import pathlib -import requests -from requests_mock import Mocker import unittest +from datetime import date, datetime from unittest.mock import patch +import requests +from requests_mock import Mocker + import datapoint DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S%z" diff --git a/tests/integration/test_manager.py b/tests/integration/test_manager.py index d258ea9..c8ffac5 100644 --- a/tests/integration/test_manager.py +++ b/tests/integration/test_manager.py @@ -1,8 +1,10 @@ import datetime import os import unittest + import datapoint + class ManagerIntegrationTestCase(unittest.TestCase): def setUp(self): diff --git a/tests/integration/test_regions.py b/tests/integration/test_regions.py index ee52a4e..e84ab41 100644 --- a/tests/integration/test_regions.py +++ b/tests/integration/test_regions.py @@ -1,6 +1,8 @@ import os -from requests import HTTPError import unittest + +from requests import HTTPError + import datapoint diff --git a/tests/unit/test_forecast.py b/tests/unit/test_forecast.py index d84a09c..fe091b3 100644 --- a/tests/unit/test_forecast.py +++ b/tests/unit/test_forecast.py @@ -1,7 +1,8 @@ import datetime -import datapoint import unittest +import datapoint + class TestForecast(unittest.TestCase): diff --git a/tests/unit/test_manager.py b/tests/unit/test_manager.py index bad677a..2d7ae98 100644 --- a/tests/unit/test_manager.py +++ b/tests/unit/test_manager.py @@ -1,6 +1,8 @@ import unittest + import datapoint + class ManagerTestCase(unittest.TestCase): def setUp(self): diff --git a/tests/unit/test_observation.py b/tests/unit/test_observation.py index a5d0251..a9694ca 100644 --- a/tests/unit/test_observation.py +++ b/tests/unit/test_observation.py @@ -1,7 +1,9 @@ -import unittest import datetime +import unittest + import datapoint + class ObservationTestCase(unittest.TestCase): def setUp(self): From deb148b9176a16bce4edfa3919062b18e45b46bf Mon Sep 17 00:00:00 2001 From: Emlyn Price Date: Mon, 12 Feb 2024 21:39:13 +0000 Subject: [PATCH 05/51] Add requirements dev --- requirements-dev.txt | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 requirements-dev.txt diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..947cce3 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +black==24.* +isort==5.* +flake8==7.* +flake8-bugbear==24.* From e8f98754b2cb4b21a84e4f5c6b675ec0bf8ef1bd Mon Sep 17 00:00:00 2001 From: Emlyn Price Date: Mon, 12 Feb 2024 21:39:53 +0000 Subject: [PATCH 06/51] Remove unneeded pytz --- requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index af8a0b0..4f67aa9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ requests==2.24.0 appdirs==1.4.4 -pytz==2020.1 requests-mock==1.8.0 From 4aa76d4264bcdea6612ab6d3da9932c4d4be421e Mon Sep 17 00:00:00 2001 From: Emlyn Price Date: Mon, 12 Feb 2024 22:10:57 +0000 Subject: [PATCH 07/51] Remove obsolete test --- tests/unit/test_observation.py | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 tests/unit/test_observation.py diff --git a/tests/unit/test_observation.py b/tests/unit/test_observation.py deleted file mode 100644 index a9694ca..0000000 --- a/tests/unit/test_observation.py +++ /dev/null @@ -1,25 +0,0 @@ -import datetime -import unittest - -import datapoint - - -class ObservationTestCase(unittest.TestCase): - - def setUp(self): - self.observation = datapoint.Observation.Observation() - - def test_observation(self): - # Just copy the forecast test - test_day = datapoint.Day.Day() - test_day.date = datetime.datetime.utcnow() - - test_timestep = datapoint.Timestep.Timestep() - test_timestep.name = 1 - - test_day.timesteps.append(test_timestep) - - self.observation.days.append(test_day) - - # What is this asserting? - assert self.observation.now() From 5aa5e17075b8d0211f127b600a37048c44b20343 Mon Sep 17 00:00:00 2001 From: Emlyn Price Date: Mon, 12 Feb 2024 22:11:17 +0000 Subject: [PATCH 08/51] remove obsolete test --- tests/integration/test_regions.py | 43 ------------------------------- 1 file changed, 43 deletions(-) delete mode 100644 tests/integration/test_regions.py diff --git a/tests/integration/test_regions.py b/tests/integration/test_regions.py deleted file mode 100644 index e84ab41..0000000 --- a/tests/integration/test_regions.py +++ /dev/null @@ -1,43 +0,0 @@ -import os -import unittest - -from requests import HTTPError - -import datapoint - - -class RegionsIntegrationTestCase(unittest.TestCase): - def setUp(self): - self.manager = datapoint.Manager(api_key=os.environ['API_KEY']) - self.regions = self.manager.regions - - def test_key(self): - self.assertEqual(self.regions.api_key, os.environ['API_KEY']) - - def test_call_api(self): - self.assertIn ( - u'RegionalFcst', self.regions.call_api('/500')) - self.assertRaises( - HTTPError, self.regions.call_api, '/fake_path') - self.assertRaises( - HTTPError, self.regions.call_api, '/500', key='fake_key') - - def test_get_all_regions(self): - all_regions = self.regions.get_all_regions() - sample_region = next( - region for region in all_regions - if region.location_id == '515') - self.assertEqual(sample_region.name, 'UK') - self.assertEqual(sample_region.region, 'uk') - - def test_get_raw_forecast(self): - sample_region = self.regions.get_all_regions()[0] - response = self.regions.get_raw_forecast( - sample_region.location_id)['RegionalFcst'] - self.assertEqual(response['regionId'], sample_region.region) - - # Based on what Datapoint serves at time of writing... - forecast_periods = response['FcstPeriods']['Period'] - forecast_ids = [period['id'] for period in forecast_periods] - expected_ids = ['day1to2', 'day3to5', 'day6to15', 'day16to30'] - self.assertEqual(forecast_ids, expected_ids) From cb40051feb5aee0fa20e6f542027ca82d8447e5c Mon Sep 17 00:00:00 2001 From: Emlyn Price Date: Mon, 12 Feb 2024 22:13:10 +0000 Subject: [PATCH 09/51] Update requirements --- pyproject.toml | 8 +++++++- requirements.txt | 5 +++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9744b6b..64d3aee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ classifiers=[ dependencies = [ "requests >= 2.20.0,<3", "appdirs >=1,<2", + "geojson >= 3.0.0,<4", ] license = {file = "LICENSE"} keywords = ["weather", "weather forecast", "Met Office", "DataHub"] @@ -44,4 +45,9 @@ source = "versioningit" [tool.isort] profile = "black" -src_paths = ["src", "tests"] \ No newline at end of file +src_paths = ["src", "tests"] + +[tool.versioningit.format] +distance = "{base_version}" +dirty = "{base_version}" +distance-dirty = "{base_version}" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 4f67aa9..27964c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ -requests==2.24.0 +requests==2.31.0 appdirs==1.4.4 -requests-mock==1.8.0 +requests-mock==1.110 +geojson==3.10.0 From de61aa272a39b75c0301d49d665cd4f827062cc4 Mon Sep 17 00:00:00 2001 From: Emlyn Price Date: Thu, 15 Feb 2024 19:04:04 +0000 Subject: [PATCH 10/51] Begin setting up pytest --- .flake8 | 3 ++- pyproject.toml | 11 ++++++++--- requirements-dev.txt | 2 ++ src/datapoint/Forecast.py | 27 ++++++++++++++++----------- 4 files changed, 28 insertions(+), 15 deletions(-) diff --git a/.flake8 b/.flake8 index cf2eabb..1884c6b 100644 --- a/.flake8 +++ b/.flake8 @@ -2,4 +2,5 @@ max-complexity = 10 max-line-length = 80 extend-select = B950 -extend-ignore = E203,E501,E701 \ No newline at end of file +extend-ignore = E203,E501,E701 +exclude = .git,__pycache__,build,dist \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 64d3aee..df67700 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,11 @@ profile = "black" src_paths = ["src", "tests"] [tool.versioningit.format] -distance = "{base_version}" -dirty = "{base_version}" -distance-dirty = "{base_version}" \ No newline at end of file +distance = "{base_version}.post{distance}+{vcs}{rev}" +dirty = "{base_version}+d{build_date:%Y%m%d}" +distance-dirty = "{base_version}.post{distance}+{vcs}{rev}.d{build_date:%Y%m%d}" + +[tool.pytest.ini_options] +addopts = [ + "--import-mode=importlib", +] \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index 947cce3..1e85ea9 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,3 +2,5 @@ black==24.* isort==5.* flake8==7.* flake8-bugbear==24.* +flake8-pytest-style==1.* +pytest==8.* diff --git a/src/datapoint/Forecast.py b/src/datapoint/Forecast.py index c5f8523..44fb182 100644 --- a/src/datapoint/Forecast.py +++ b/src/datapoint/Forecast.py @@ -106,15 +106,7 @@ def __build_timestep(self, forecast, parameters): return timestep - def at_datetime(self, target): - """Return the timestep closest to the target datetime""" - - # Convert target to offset aware datetime - if target.tzinfo is None: - target = datetime.datetime.combine( - target.date(), target.time(), self.timesteps[0]["time"].tzinfo - ) - + def __check_requested_time(self, target): # Check that there is a forecast for the requested time. # If we have an hourly forecast, check that the requested time is at # most 30 minutes before the first datetime we have a forecast for. @@ -177,10 +169,22 @@ def at_datetime(self, target): raise APIException(err_str) + def at_datetime(self, target): + """Return the timestep closest to the target datetime""" + + # Convert target to offset aware datetime + if target.tzinfo is None: + target = datetime.datetime.combine( + target.date(), target.time(), self.timesteps[0]["time"].tzinfo + ) + + self.__check_requested_time(target) + # Loop over all timesteps # Calculate the first time difference prev_td = target - self.timesteps[0]["time"] prev_ts = self.timesteps[0] + to_return = None for i, timestep in enumerate(self.timesteps, start=1): # Calculate the difference between the target time and the @@ -191,12 +195,13 @@ def at_datetime(self, target): # previous one. Return the previous timestep if abs(td.total_seconds()) > abs(prev_td.total_seconds()): # We are further from the target - return prev_ts + to_return = prev_ts if i == len(self.timesteps): - return timestep + to_return = timestep prev_ts = timestep prev_td = td + return to_return def now(self): """Function to return the closest timestep to the current time""" From 794f394d895a7e448949ad0286aaa5b72bc97648 Mon Sep 17 00:00:00 2001 From: Emlyn Price Date: Thu, 15 Feb 2024 20:33:55 +0000 Subject: [PATCH 11/51] Begin adding new tests --- src/datapoint/Forecast.py | 108 +++++++++++++++++++++++++++++--- src/datapoint/Manager.py | 92 ++++++++++++++------------- tests/unit/hourly_api_data.json | 1 + tests/unit/test_forecast_new.py | 27 ++++++++ 4 files changed, 176 insertions(+), 52 deletions(-) create mode 100644 tests/unit/hourly_api_data.json create mode 100644 tests/unit/test_forecast_new.py diff --git a/src/datapoint/Forecast.py b/src/datapoint/Forecast.py index 44fb182..108cf81 100644 --- a/src/datapoint/Forecast.py +++ b/src/datapoint/Forecast.py @@ -5,12 +5,34 @@ class Forecast: + """Forecast data returned from DataPoint + + Provides access to forecasts as far ahead as provided by DataPoint: + + x for hourly forecasts + + y for three-hourly forecasts + + z for daily forecasts + + Basic Usage:: + + >>> import datapoint + >>> m = datapoint.Manager(api_key = "blah") + >>> f = m.get_forecast(latitude=50, longitude=0, frequency="hourly") + >>> f.now() + + """ + def __init__(self, frequency, api_data): + """ + :param frequency: Frequency of forecast: 'hourly', 'three-hourly' or 'daily' + :param api_data: Data returned from API call + :type frequency: string + :type api_data: dict + """ self.frequency = frequency self.data_date = datetime.datetime.fromisoformat( api_data["features"][0]["properties"]["modelRunDate"] ) - self.name = api_data["features"][0]["properties"]["location"] + self.name = api_data["features"][0]["properties"]["location"]["name"] self.forecast_longitude = api_data["features"][0]["geometry"]["coordinates"][0] self.forecast_latitude = api_data["features"][0]["geometry"]["coordinates"][1] self.distance_from_requested_location = api_data["features"][0]["properties"][ @@ -33,6 +55,19 @@ def __init__(self, frequency, api_data): self.timesteps.append(self.__build_timestep(forecast, parameters)) def __build_timesteps_from_daily(self, forecasts, parameters): + """Build individual timesteps from forecasts and metadata + + Take the forecast data from DataHub and combine with unit information + in each timestep. Break each day into day and night steps. ASSUME that + each step has data for the night referred to in the timestamp and the + following dawn-dusk period. + + :parameter forecasts: Forecast data from DataHub + :parameter parameters: Unit information from DataHub + + :return: List of timesteps + :rtype: list + """ timesteps = [] for forecast in forecasts: night_step = {"time": datetime.datetime.fromisoformat(forecast["time"])} @@ -85,6 +120,19 @@ def __build_timesteps_from_daily(self, forecasts, parameters): return timesteps def __build_timestep(self, forecast, parameters): + """Build individual timestep from forecast and metadata + + Take the forecast data from DataHub for a single time and combine with + unit information in each timestep. + + :parameter forecasts: Forecast data from DataHub + :parameter parameters: Unit information from DataHub + + :return: Individual forecast timestep + :rtype: dict + + """ + timestep = {} for element, value in forecast.items(): if element == "time": @@ -107,6 +155,11 @@ def __build_timestep(self, forecast, parameters): return timestep def __check_requested_time(self, target): + """Check that a forecast for the requested time can be provided + + :parameter target: The requested time for the forecast + :type target: datetime + """ # Check that there is a forecast for the requested time. # If we have an hourly forecast, check that the requested time is at # most 30 minutes before the first datetime we have a forecast for. @@ -170,7 +223,15 @@ def __check_requested_time(self, target): raise APIException(err_str) def at_datetime(self, target): - """Return the timestep closest to the target datetime""" + """Return the timestep closest to the target datetime + + :parameter target: Time to get the forecast for + :type target: datetime + + :return: Individual forecast timestep + :rtype: dict + + """ # Convert target to offset aware datetime if target.tzinfo is None: @@ -204,17 +265,48 @@ def at_datetime(self, target): return to_return def now(self): - """Function to return the closest timestep to the current time""" + """Function to return the closest timestep to the current time + + :return: Individual forecast timestep + :rtype: dict + """ d = datetime.datetime.now(tz=self.timesteps[0]["time"].tzinfo) return self.at_datetime(d) - def future(self, in_days=0, in_hours=0, in_minutes=0, in_seconds=0): - """Return the closest timestep to a date in a given amount of time""" + def future(self, days=0, hours=0, minutes=0): + """Return the closest timestep to a date in a given amount of time + + Either provide components of the time to the forecast or the total + hours or minutes + + Providing components:: + + >>> import datapoint + >>> m = datapoint.Manager(api_key = "blah") + >>> f = m.get_forecast(latitude=50, longitude=0, frequency="hourly") + >>> f.future(days=1, hours=2) + + Providing total hours:: + + >>> import datapoint + >>> m = datapoint.Manager(api_key = "blah") + >>> f = m.get_forecast(latitude=50, longitude=0, frequency="hourly") + >>> f.future(hours=26) + + + :parameter days: How many days ahead + :parameter hours: How many hours ahead + :parameter minutes: How many minutes ahead + :type days: int + :type hours: int + :type minutes: int + + :return: Individual forecast timestep + :rtype: dict + """ d = datetime.datetime.now(tz=self.timesteps[0]["time"].tzinfo) - target = d + datetime.timedelta( - days=in_days, hours=in_hours, minutes=in_minutes, seconds=in_seconds - ) + target = d + datetime.timedelta(days=days, hours=hours, minutes=minutes) return self.at_datetime(target) diff --git a/src/datapoint/Manager.py b/src/datapoint/Manager.py index c99350c..e81068a 100644 --- a/src/datapoint/Manager.py +++ b/src/datapoint/Manager.py @@ -11,21 +11,26 @@ from datapoint.Forecast import Forecast API_URL = "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/" -DATE_FORMAT = "%Y-%m-%dZ" -DATA_DATE_FORMAT = "%Y-%m-%dT%XZ" -FORECAST_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" class Manager: """ Datapoint Manager object + + Wraps calls to DataHub API, and provides Forecast objects + Basic Usage:: + + >>> import datapoint + >>> m = datapoint.Manager(api_key = "blah") + >>> f = m.get_forecast(latitude=50, longitude=0, frequency="hourly") + >>> f.now() + """ def __init__(self, api_key=""): self.api_key = api_key - self.call_response = None - def __retry_session( + def __get_retry_session( self, retries=10, backoff_factor=0.3, @@ -34,7 +39,17 @@ def __retry_session( ): """ Retry the connection using requests if it fails. Use this as a wrapper - to request from datapoint + to request from datapoint. See + https://requests.readthedocs.io/en/latest/user/advanced/?highlight=retry#example-automatic-retries + for more details. + + :parameter retries: How many times to retry + :parameter backoff_factor: Backoff between attempts after second try + :parameter status_forcelist: Codes to force a retry on + :parameter session: Existing session to use + + :return: Session object + :rtype: TBD """ # requests.Session allows finer control, which is needed to use the @@ -61,6 +76,15 @@ def __call_api(self, latitude, longitude, frequency): """ Call the datapoint api using the requests module + :parameter latitude: Latitude of forecast location + :parameter longitude: Longitude of forecast location + :parameter frequency: Forecast frequency. One of 'hourly', 'three-hourly, 'daily' + :type latitude: float + :type longitude: float + :type frequency: string + + :return: Data from DataPoint + :rtype: dict """ params = { "latitude": latitude, @@ -83,7 +107,10 @@ def __call_api(self, latitude, longitude, frequency): # object. This has a .get() function like requests.get(), so the use # doesn't change here. - sess = self.__retry_session() + print(request_url) + print(params) + print(headers) + sess = self.__get_retry_session() req = sess.get( request_url, params=params, @@ -91,59 +118,36 @@ def __call_api(self, latitude, longitude, frequency): timeout=1, ) + req.raise_for_status() + try: data = geojson.loads(req.text) except ValueError as exc: - raise APIException( - "DataPoint has not returned any data, this could be due to an incorrect API key" - ) from exc - self.call_response = data - if req.status_code != 200: - msg = [ - data[m] for m in ("message", "error_message", "status") if m in data - ][0] - raise Exception(msg) - return data + raise APIException("DataPoint has not returned valid JSON") from exc - def _visibility_to_text(self, distance): - """ - Convert observed visibility in metres to text used in forecast - """ - - if not isinstance(distance, int): - raise ValueError("Distance must be an integer not", type(distance)) - if distance < 0: - raise ValueError("Distance out of bounds, should be 0 or greater") - - if 0 <= distance < 1000: - return "VP" - elif 1000 <= distance < 4000: - return "PO" - elif 4000 <= distance < 10000: - return "MO" - elif 10000 <= distance < 20000: - return "GO" - elif 20000 <= distance < 40000: - return "VG" - else: - return "EX" + return data def get_forecast(self, latitude, longitude, frequency="daily"): """ Get a forecast for the provided site - A frequency of "daily" will return two timesteps: - "Day" and "Night". + :parameter latitude: Latitude of forecast location + :parameter longitude: Longitude of forecast location + :parameter frequency: Forecast frequency. One of 'hourly', 'three-hourly, 'daily' + :type latitude: float + :type longitude: float + :type frequency: string - A frequency of "3hourly" will return 8 timesteps: - 0, 180, 360 ... 1260 (minutes since midnight UTC) + :return: :class: `Forecast ` object + :rtype: datapoint.Forecast """ if frequency not in ["hourly", "three-hourly", "daily"]: raise ValueError( "frequency must be set to one of 'hourly', 'three-hourly', 'daily'" ) data = self.__call_api(latitude, longitude, frequency) - # print(data) + #with open('./hourly_api_data.json', 'w') as f: + # geojson.dump(data, f) forecast = Forecast(frequency=frequency, api_data=data) return forecast diff --git a/tests/unit/hourly_api_data.json b/tests/unit/hourly_api_data.json new file mode 100644 index 0000000..11302dd --- /dev/null +++ b/tests/unit/hourly_api_data.json @@ -0,0 +1 @@ +{"type": "FeatureCollection", "parameters": [{"totalSnowAmount": {"type": "Parameter", "description": "Total Snow Amount Over Previous Hour", "unit": {"label": "millimetres", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "mm"}}}, "screenTemperature": {"type": "Parameter", "description": "Screen Air Temperature", "unit": {"label": "degrees Celsius", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel"}}}, "visibility": {"type": "Parameter", "description": "Visibility", "unit": {"label": "metres", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "m"}}}, "windDirectionFrom10m": {"type": "Parameter", "description": "10m Wind From Direction", "unit": {"label": "degrees", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "deg"}}}, "precipitationRate": {"type": "Parameter", "description": "Precipitation Rate", "unit": {"label": "millimetres per hour", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "mm/h"}}}, "maxScreenAirTemp": {"type": "Parameter", "description": "Maximum Screen Air Temperature Over Previous Hour", "unit": {"label": "degrees Celsius", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel"}}}, "feelsLikeTemperature": {"type": "Parameter", "description": "Feels Like Temperature", "unit": {"label": "degrees Celsius", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel"}}}, "screenDewPointTemperature": {"type": "Parameter", "description": "Screen Dew Point Temperature", "unit": {"label": "degrees Celsius", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel"}}}, "screenRelativeHumidity": {"type": "Parameter", "description": "Screen Relative Humidity", "unit": {"label": "percentage", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "%"}}}, "windSpeed10m": {"type": "Parameter", "description": "10m Wind Speed", "unit": {"label": "metres per second", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "m/s"}}}, "probOfPrecipitation": {"type": "Parameter", "description": "Probability of Precipitation", "unit": {"label": "percentage", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "%"}}}, "max10mWindGust": {"type": "Parameter", "description": "Maximum 10m Wind Gust Speed Over Previous Hour", "unit": {"label": "metres per second", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "m/s"}}}, "significantWeatherCode": {"type": "Parameter", "description": "Significant Weather Code", "unit": {"label": "dimensionless", "symbol": {"value": "https://metoffice.apiconnect.ibmcloud.com/metoffice/production/", "type": "1"}}}, "minScreenAirTemp": {"type": "Parameter", "description": "Minimum Screen Air Temperature Over Previous Hour", "unit": {"label": "degrees Celsius", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel"}}}, "totalPrecipAmount": {"type": "Parameter", "description": "Total Precipitation Amount Over Previous Hour", "unit": {"label": "millimetres", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "mm"}}}, "mslp": {"type": "Parameter", "description": "Mean Sea Level Pressure", "unit": {"label": "pascals", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "Pa"}}}, "windGustSpeed10m": {"type": "Parameter", "description": "10m Wind Gust Speed", "unit": {"label": "metres per second", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "m/s"}}}, "uvIndex": {"type": "Parameter", "description": "UV Index", "unit": {"label": "dimensionless", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "1"}}}}], "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [0.0154, 50.9992, 37.0]}, "properties": {"location": {"name": "Sheffield Park"}, "requestPointDistance": 1081.5349, "modelRunDate": "2024-02-15T19:00Z", "timeSeries": [{"time": "2024-02-15T19:00Z", "screenTemperature": 11.0, "maxScreenAirTemp": 11.55, "minScreenAirTemp": 10.98, "screenDewPointTemperature": 8.94, "feelsLikeTemperature": 10.87, "windSpeed10m": 1.18, "windDirectionFrom10m": 180, "windGustSpeed10m": 6.69, "max10mWindGust": 8.92, "visibility": 19174, "screenRelativeHumidity": 86.99, "mslp": 100660, "uvIndex": 0, "significantWeatherCode": 2, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 4}, {"time": "2024-02-15T20:00Z", "screenTemperature": 11.38, "maxScreenAirTemp": 11.38, "minScreenAirTemp": 11.0, "screenDewPointTemperature": 9.55, "feelsLikeTemperature": 10.96, "windSpeed10m": 1.68, "windDirectionFrom10m": 96, "windGustSpeed10m": 3.55, "max10mWindGust": 5.64, "visibility": 17279, "screenRelativeHumidity": 88.39, "mslp": 100653, "uvIndex": 0, "significantWeatherCode": 2, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 1}, {"time": "2024-02-15T21:00Z", "screenTemperature": 11.49, "maxScreenAirTemp": 11.5, "minScreenAirTemp": 11.38, "screenDewPointTemperature": 9.97, "feelsLikeTemperature": 11.21, "windSpeed10m": 1.52, "windDirectionFrom10m": 79, "windGustSpeed10m": 3.34, "max10mWindGust": 5.42, "visibility": 14995, "screenRelativeHumidity": 90.31, "mslp": 100714, "uvIndex": 0, "significantWeatherCode": 7, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 4}, {"time": "2024-02-15T22:00Z", "screenTemperature": 11.18, "maxScreenAirTemp": 11.49, "minScreenAirTemp": 11.13, "screenDewPointTemperature": 9.93, "feelsLikeTemperature": 10.47, "windSpeed10m": 2.05, "windDirectionFrom10m": 112, "windGustSpeed10m": 4.34, "max10mWindGust": 5.18, "visibility": 13525, "screenRelativeHumidity": 91.97, "mslp": 100668, "uvIndex": 0, "significantWeatherCode": 2, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 0}, {"time": "2024-02-15T23:00Z", "screenTemperature": 10.84, "maxScreenAirTemp": 11.18, "minScreenAirTemp": 10.78, "screenDewPointTemperature": 9.7, "feelsLikeTemperature": 10.15, "windSpeed10m": 1.96, "windDirectionFrom10m": 108, "windGustSpeed10m": 4.74, "max10mWindGust": 5.33, "visibility": 12925, "screenRelativeHumidity": 92.64, "mslp": 100680, "uvIndex": 0, "significantWeatherCode": 2, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 1}, {"time": "2024-02-16T00:00Z", "screenTemperature": 10.53, "maxScreenAirTemp": 10.84, "minScreenAirTemp": 10.52, "screenDewPointTemperature": 9.55, "feelsLikeTemperature": 10.1, "windSpeed10m": 1.47, "windDirectionFrom10m": 108, "windGustSpeed10m": 5.09, "max10mWindGust": 5.43, "visibility": 12220, "screenRelativeHumidity": 93.66, "mslp": 100650, "uvIndex": 0, "significantWeatherCode": 2, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 1}, {"time": "2024-02-16T01:00Z", "screenTemperature": 10.72, "maxScreenAirTemp": 10.78, "minScreenAirTemp": 10.53, "screenDewPointTemperature": 9.62, "feelsLikeTemperature": 10.31, "windSpeed10m": 1.48, "windDirectionFrom10m": 135, "windGustSpeed10m": 5.05, "max10mWindGust": 5.65, "visibility": 14094, "screenRelativeHumidity": 92.91, "mslp": 100660, "uvIndex": 0, "significantWeatherCode": 7, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 5}, {"time": "2024-02-16T02:00Z", "screenTemperature": 10.73, "maxScreenAirTemp": 10.82, "minScreenAirTemp": 10.65, "screenDewPointTemperature": 9.75, "feelsLikeTemperature": 10.51, "windSpeed10m": 1.17, "windDirectionFrom10m": 185, "windGustSpeed10m": 5.33, "max10mWindGust": 5.73, "visibility": 13709, "screenRelativeHumidity": 93.64, "mslp": 100688, "uvIndex": 0, "significantWeatherCode": 2, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 2}, {"time": "2024-02-16T03:00Z", "screenTemperature": 10.93, "maxScreenAirTemp": 10.94, "minScreenAirTemp": 10.73, "screenDewPointTemperature": 9.92, "feelsLikeTemperature": 10.38, "windSpeed10m": 1.61, "windDirectionFrom10m": 240, "windGustSpeed10m": 5.97, "max10mWindGust": 6.16, "visibility": 13864, "screenRelativeHumidity": 93.55, "mslp": 100744, "uvIndex": 0, "significantWeatherCode": 7, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 6}, {"time": "2024-02-16T04:00Z", "screenTemperature": 11.03, "maxScreenAirTemp": 11.05, "minScreenAirTemp": 10.93, "screenDewPointTemperature": 10.21, "feelsLikeTemperature": 9.95, "windSpeed10m": 2.59, "windDirectionFrom10m": 279, "windGustSpeed10m": 7.23, "max10mWindGust": 7.35, "visibility": 11033, "screenRelativeHumidity": 94.71, "mslp": 100806, "uvIndex": 0, "significantWeatherCode": 8, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 13}, {"time": "2024-02-16T05:00Z", "screenTemperature": 11.27, "maxScreenAirTemp": 11.28, "minScreenAirTemp": 11.03, "screenDewPointTemperature": 10.54, "feelsLikeTemperature": 9.74, "windSpeed10m": 3.47, "windDirectionFrom10m": 288, "windGustSpeed10m": 8.23, "max10mWindGust": 8.45, "visibility": 14201, "screenRelativeHumidity": 95.25, "mslp": 100909, "uvIndex": 0, "significantWeatherCode": 12, "precipitationRate": 0.23, "totalPrecipAmount": 0.08, "totalSnowAmount": 0, "probOfPrecipitation": 43}, {"time": "2024-02-16T06:00Z", "screenTemperature": 10.92, "maxScreenAirTemp": 11.13, "minScreenAirTemp": 10.9, "screenDewPointTemperature": 10.04, "feelsLikeTemperature": 9.49, "windSpeed10m": 3.18, "windDirectionFrom10m": 283, "windGustSpeed10m": 7.71, "max10mWindGust": 8.27, "visibility": 25090, "screenRelativeHumidity": 94.41, "mslp": 101042, "uvIndex": 0, "significantWeatherCode": 7, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 7}, {"time": "2024-02-16T07:00Z", "screenTemperature": 10.62, "maxScreenAirTemp": 10.92, "minScreenAirTemp": 10.61, "screenDewPointTemperature": 9.43, "feelsLikeTemperature": 8.87, "windSpeed10m": 3.68, "windDirectionFrom10m": 279, "windGustSpeed10m": 8.1, "max10mWindGust": 8.55, "visibility": 21863, "screenRelativeHumidity": 92.4, "mslp": 101186, "uvIndex": 0, "significantWeatherCode": 7, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 7}, {"time": "2024-02-16T08:00Z", "screenTemperature": 10.3, "maxScreenAirTemp": 10.62, "minScreenAirTemp": 10.27, "screenDewPointTemperature": 8.77, "feelsLikeTemperature": 8.27, "windSpeed10m": 4.15, "windDirectionFrom10m": 278, "windGustSpeed10m": 8.77, "max10mWindGust": 8.86, "visibility": 17499, "screenRelativeHumidity": 90.34, "mslp": 101326, "uvIndex": 1, "significantWeatherCode": 3, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 1}, {"time": "2024-02-16T09:00Z", "screenTemperature": 10.46, "maxScreenAirTemp": 10.47, "minScreenAirTemp": 10.3, "screenDewPointTemperature": 8.47, "feelsLikeTemperature": 8.26, "windSpeed10m": 4.59, "windDirectionFrom10m": 279, "windGustSpeed10m": 8.75, "max10mWindGust": 8.75, "visibility": 16833, "screenRelativeHumidity": 87.56, "mslp": 101456, "uvIndex": 1, "significantWeatherCode": 3, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 0}, {"time": "2024-02-16T10:00Z", "screenTemperature": 11.07, "maxScreenAirTemp": 11.09, "minScreenAirTemp": 10.46, "screenDewPointTemperature": 8.27, "feelsLikeTemperature": 8.92, "windSpeed10m": 4.68, "windDirectionFrom10m": 276, "windGustSpeed10m": 8.7, "max10mWindGust": 8.7, "visibility": 20678, "screenRelativeHumidity": 82.98, "mslp": 101557, "uvIndex": 1, "significantWeatherCode": 3, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 0}, {"time": "2024-02-16T11:00Z", "screenTemperature": 11.71, "maxScreenAirTemp": 11.74, "minScreenAirTemp": 11.07, "screenDewPointTemperature": 7.9, "feelsLikeTemperature": 9.42, "windSpeed10m": 5.16, "windDirectionFrom10m": 273, "windGustSpeed10m": 9.42, "max10mWindGust": 9.42, "visibility": 30259, "screenRelativeHumidity": 77.53, "mslp": 101647, "uvIndex": 1, "significantWeatherCode": 1, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 0}, {"time": "2024-02-16T12:00Z", "screenTemperature": 12.37, "maxScreenAirTemp": 12.39, "minScreenAirTemp": 11.71, "screenDewPointTemperature": 7.67, "feelsLikeTemperature": 10.03, "windSpeed10m": 5.39, "windDirectionFrom10m": 275, "windGustSpeed10m": 9.94, "max10mWindGust": 9.94, "visibility": 34462, "screenRelativeHumidity": 73.05, "mslp": 101700, "uvIndex": 1, "significantWeatherCode": 3, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 0}, {"time": "2024-02-16T13:00Z", "screenTemperature": 12.91, "maxScreenAirTemp": 12.94, "minScreenAirTemp": 12.37, "screenDewPointTemperature": 7.75, "feelsLikeTemperature": 10.76, "windSpeed10m": 4.97, "windDirectionFrom10m": 271, "windGustSpeed10m": 9.39, "max10mWindGust": 9.65, "visibility": 37584, "screenRelativeHumidity": 70.9, "mslp": 101750, "uvIndex": 1, "significantWeatherCode": 3, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 0}, {"time": "2024-02-16T14:00Z", "screenTemperature": 12.9, "maxScreenAirTemp": 13.04, "minScreenAirTemp": 12.69, "screenDewPointTemperature": 7.06, "feelsLikeTemperature": 10.54, "windSpeed10m": 5.39, "windDirectionFrom10m": 270, "windGustSpeed10m": 10.16, "max10mWindGust": 10.16, "visibility": 38254, "screenRelativeHumidity": 67.76, "mslp": 101813, "uvIndex": 1, "significantWeatherCode": 3, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 1}, {"time": "2024-02-16T15:00Z", "screenTemperature": 12.79, "maxScreenAirTemp": 12.9, "minScreenAirTemp": 12.72, "screenDewPointTemperature": 6.92, "feelsLikeTemperature": 10.69, "windSpeed10m": 4.78, "windDirectionFrom10m": 267, "windGustSpeed10m": 9.36, "max10mWindGust": 9.96, "visibility": 38521, "screenRelativeHumidity": 67.6, "mslp": 101887, "uvIndex": 1, "significantWeatherCode": 3, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 1}, {"time": "2024-02-16T16:00Z", "screenTemperature": 12.06, "maxScreenAirTemp": 12.79, "minScreenAirTemp": 12.04, "screenDewPointTemperature": 6.8, "feelsLikeTemperature": 10.23, "windSpeed10m": 4.07, "windDirectionFrom10m": 266, "windGustSpeed10m": 8.66, "max10mWindGust": 9.27, "visibility": 37284, "screenRelativeHumidity": 70.37, "mslp": 101980, "uvIndex": 1, "significantWeatherCode": 1, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 0}, {"time": "2024-02-16T17:00Z", "screenTemperature": 10.8, "maxScreenAirTemp": 12.06, "minScreenAirTemp": 10.77, "screenDewPointTemperature": 6.94, "feelsLikeTemperature": 9.36, "windSpeed10m": 3.16, "windDirectionFrom10m": 261, "windGustSpeed10m": 8.0, "max10mWindGust": 8.43, "visibility": 33668, "screenRelativeHumidity": 77.2, "mslp": 102066, "uvIndex": 1, "significantWeatherCode": 1, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 0}, {"time": "2024-02-16T18:00Z", "screenTemperature": 9.6, "maxScreenAirTemp": 10.8, "minScreenAirTemp": 9.58, "screenDewPointTemperature": 6.7, "feelsLikeTemperature": 8.37, "windSpeed10m": 2.58, "windDirectionFrom10m": 257, "windGustSpeed10m": 7.4, "max10mWindGust": 8.52, "visibility": 29126, "screenRelativeHumidity": 82.34, "mslp": 102166, "uvIndex": 0, "significantWeatherCode": 0, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 0}, {"time": "2024-02-16T19:00Z", "screenTemperature": 8.94, "maxScreenAirTemp": 9.6, "minScreenAirTemp": 8.9, "screenDewPointTemperature": 6.8, "feelsLikeTemperature": 7.68, "windSpeed10m": 2.46, "windDirectionFrom10m": 255, "windGustSpeed10m": 7.35, "max10mWindGust": 8.02, "visibility": 22767, "screenRelativeHumidity": 86.81, "mslp": 102246, "uvIndex": 0, "significantWeatherCode": 0, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 0}, {"time": "2024-02-16T20:00Z", "screenTemperature": 8.42, "maxScreenAirTemp": 8.94, "minScreenAirTemp": 8.41, "screenDewPointTemperature": 6.3, "feelsLikeTemperature": 7.06, "windSpeed10m": 2.45, "windDirectionFrom10m": 263, "windGustSpeed10m": 7.47, "max10mWindGust": 7.99, "visibility": 21802, "screenRelativeHumidity": 86.73, "mslp": 102329, "uvIndex": 0, "significantWeatherCode": 0, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 0}, {"time": "2024-02-16T21:00Z", "screenTemperature": 7.87, "maxScreenAirTemp": 8.42, "minScreenAirTemp": 7.86, "screenDewPointTemperature": 6.11, "feelsLikeTemperature": 6.51, "windSpeed10m": 2.33, "windDirectionFrom10m": 267, "windGustSpeed10m": 7.0, "max10mWindGust": 7.86, "visibility": 21303, "screenRelativeHumidity": 88.8, "mslp": 102406, "uvIndex": 0, "significantWeatherCode": 0, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 0}, {"time": "2024-02-16T22:00Z", "screenTemperature": 7.48, "maxScreenAirTemp": 7.87, "minScreenAirTemp": 7.46, "screenDewPointTemperature": 5.89, "feelsLikeTemperature": 6.06, "windSpeed10m": 2.32, "windDirectionFrom10m": 274, "windGustSpeed10m": 6.96, "max10mWindGust": 7.77, "visibility": 20523, "screenRelativeHumidity": 89.86, "mslp": 102479, "uvIndex": 0, "significantWeatherCode": 0, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 1}, {"time": "2024-02-16T23:00Z", "screenTemperature": 7.04, "maxScreenAirTemp": 7.48, "minScreenAirTemp": 7.01, "screenDewPointTemperature": 5.66, "feelsLikeTemperature": 5.68, "windSpeed10m": 2.18, "windDirectionFrom10m": 280, "windGustSpeed10m": 6.69, "max10mWindGust": 7.53, "visibility": 20867, "screenRelativeHumidity": 91.09, "mslp": 102545, "uvIndex": 0, "significantWeatherCode": 0, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 1}, {"time": "2024-02-17T00:00Z", "screenTemperature": 6.7, "maxScreenAirTemp": 7.04, "minScreenAirTemp": 6.67, "screenDewPointTemperature": 5.59, "feelsLikeTemperature": 5.32, "windSpeed10m": 2.11, "windDirectionFrom10m": 281, "windGustSpeed10m": 6.34, "max10mWindGust": 7.01, "visibility": 20045, "screenRelativeHumidity": 92.83, "mslp": 102614, "uvIndex": 0, "significantWeatherCode": 0, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 1}, {"time": "2024-02-17T01:00Z", "screenTemperature": 6.26, "maxScreenAirTemp": 6.7, "minScreenAirTemp": 6.21, "screenDewPointTemperature": 5.34, "feelsLikeTemperature": 4.85, "windSpeed10m": 2.05, "windDirectionFrom10m": 283, "windGustSpeed10m": 6.13, "max10mWindGust": 6.88, "visibility": 18378, "screenRelativeHumidity": 94.11, "mslp": 102657, "uvIndex": 0, "significantWeatherCode": 0, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 1}, {"time": "2024-02-17T02:00Z", "screenTemperature": 5.72, "maxScreenAirTemp": 6.26, "minScreenAirTemp": 5.66, "screenDewPointTemperature": 5.02, "feelsLikeTemperature": 4.45, "windSpeed10m": 1.73, "windDirectionFrom10m": 282, "windGustSpeed10m": 5.73, "max10mWindGust": 6.69, "visibility": 14463, "screenRelativeHumidity": 95.57, "mslp": 102697, "uvIndex": 0, "significantWeatherCode": 0, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 2}, {"time": "2024-02-17T03:00Z", "screenTemperature": 5.22, "maxScreenAirTemp": 5.72, "minScreenAirTemp": 5.17, "screenDewPointTemperature": 4.68, "feelsLikeTemperature": 4.03, "windSpeed10m": 1.37, "windDirectionFrom10m": 267, "windGustSpeed10m": 5.08, "max10mWindGust": 6.2, "visibility": 12881, "screenRelativeHumidity": 96.48, "mslp": 102729, "uvIndex": 0, "significantWeatherCode": 0, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 3}, {"time": "2024-02-17T04:00Z", "screenTemperature": 5.44, "maxScreenAirTemp": 5.46, "minScreenAirTemp": 5.22, "screenDewPointTemperature": 4.9, "feelsLikeTemperature": 4.28, "windSpeed10m": 1.22, "windDirectionFrom10m": 234, "windGustSpeed10m": 4.72, "max10mWindGust": 5.38, "visibility": 13816, "screenRelativeHumidity": 96.53, "mslp": 102767, "uvIndex": 0, "significantWeatherCode": 2, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 2}, {"time": "2024-02-17T05:00Z", "screenTemperature": 5.67, "maxScreenAirTemp": 5.73, "minScreenAirTemp": 5.44, "screenDewPointTemperature": 5.3, "feelsLikeTemperature": 4.55, "windSpeed10m": 1.28, "windDirectionFrom10m": 182, "windGustSpeed10m": 3.99, "max10mWindGust": 4.95, "visibility": 4000, "screenRelativeHumidity": 97.73, "mslp": 102819, "uvIndex": 0, "significantWeatherCode": 7, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 5}, {"time": "2024-02-17T06:00Z", "screenTemperature": 6.01, "maxScreenAirTemp": 6.24, "minScreenAirTemp": 5.67, "screenDewPointTemperature": 5.66, "feelsLikeTemperature": 4.95, "windSpeed10m": 1.51, "windDirectionFrom10m": 156, "windGustSpeed10m": 3.65, "max10mWindGust": 3.87, "visibility": 999, "screenRelativeHumidity": 97.85, "mslp": 102866, "uvIndex": 0, "significantWeatherCode": 6, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 17}, {"time": "2024-02-17T07:00Z", "screenTemperature": 6.74, "maxScreenAirTemp": 6.75, "minScreenAirTemp": 6.01, "screenDewPointTemperature": 6.32, "feelsLikeTemperature": 5.73, "windSpeed10m": 1.63, "windDirectionFrom10m": 161, "windGustSpeed10m": 4.12, "max10mWindGust": 4.12, "visibility": 4213, "screenRelativeHumidity": 97.43, "mslp": 102918, "uvIndex": 0, "significantWeatherCode": 7, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 8}, {"time": "2024-02-17T08:00Z", "screenTemperature": 7.5, "maxScreenAirTemp": 7.54, "minScreenAirTemp": 6.74, "screenDewPointTemperature": 7.09, "feelsLikeTemperature": 6.54, "windSpeed10m": 1.8, "windDirectionFrom10m": 171, "windGustSpeed10m": 4.56, "max10mWindGust": 4.56, "visibility": 5736, "screenRelativeHumidity": 97.44, "mslp": 102968, "uvIndex": 1, "significantWeatherCode": 7, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 14}, {"time": "2024-02-17T09:00Z", "screenTemperature": 8.46, "maxScreenAirTemp": 8.48, "minScreenAirTemp": 7.5, "screenDewPointTemperature": 7.89, "feelsLikeTemperature": 7.54, "windSpeed10m": 1.9, "windDirectionFrom10m": 176, "windGustSpeed10m": 5.23, "max10mWindGust": 5.23, "visibility": 5476, "screenRelativeHumidity": 96.48, "mslp": 103015, "uvIndex": 1, "significantWeatherCode": 7, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 15}, {"time": "2024-02-17T10:00Z", "screenTemperature": 9.31, "maxScreenAirTemp": 9.32, "minScreenAirTemp": 8.46, "screenDewPointTemperature": 8.71, "feelsLikeTemperature": 8.16, "windSpeed10m": 2.49, "windDirectionFrom10m": 194, "windGustSpeed10m": 5.87, "max10mWindGust": 6.19, "visibility": 5671, "screenRelativeHumidity": 96.3, "mslp": 103039, "uvIndex": 1, "significantWeatherCode": 8, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 13}, {"time": "2024-02-17T11:00Z", "screenTemperature": 10.23, "maxScreenAirTemp": 10.23, "minScreenAirTemp": 9.31, "screenDewPointTemperature": 9.34, "feelsLikeTemperature": 8.78, "windSpeed10m": 3.08, "windDirectionFrom10m": 202, "windGustSpeed10m": 6.31, "max10mWindGust": 6.31, "visibility": 11241, "screenRelativeHumidity": 94.6, "mslp": 103069, "uvIndex": 1, "significantWeatherCode": 8, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 13}, {"time": "2024-02-17T12:00Z", "screenTemperature": 10.79, "maxScreenAirTemp": 10.82, "minScreenAirTemp": 10.23, "screenDewPointTemperature": 9.48, "feelsLikeTemperature": 9.18, "windSpeed10m": 3.5, "windDirectionFrom10m": 203, "windGustSpeed10m": 6.77, "max10mWindGust": 6.77, "visibility": 13088, "screenRelativeHumidity": 92.01, "mslp": 103068, "uvIndex": 1, "significantWeatherCode": 8, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 13}, {"time": "2024-02-17T13:00Z", "screenTemperature": 10.84, "maxScreenAirTemp": 10.84, "minScreenAirTemp": 10.79, "screenDewPointTemperature": 9.5, "feelsLikeTemperature": 9.17, "windSpeed10m": 3.63, "windDirectionFrom10m": 202, "windGustSpeed10m": 7.09, "max10mWindGust": 7.09, "visibility": 13756, "screenRelativeHumidity": 91.77, "mslp": 103050, "uvIndex": 1, "significantWeatherCode": 8, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 14}, {"time": "2024-02-17T14:00Z", "screenTemperature": 10.63, "maxScreenAirTemp": 10.84, "minScreenAirTemp": 10.63, "screenDewPointTemperature": 9.58, "feelsLikeTemperature": 8.92, "windSpeed10m": 3.62, "windDirectionFrom10m": 201, "windGustSpeed10m": 7.07, "max10mWindGust": 7.07, "visibility": 12109, "screenRelativeHumidity": 93.68, "mslp": 103021, "uvIndex": 1, "significantWeatherCode": 8, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 14}, {"time": "2024-02-17T15:00Z", "screenTemperature": 10.62, "maxScreenAirTemp": 10.73, "minScreenAirTemp": 10.6, "screenDewPointTemperature": 9.53, "feelsLikeTemperature": 8.92, "windSpeed10m": 3.61, "windDirectionFrom10m": 200, "windGustSpeed10m": 7.22, "max10mWindGust": 7.22, "visibility": 12463, "screenRelativeHumidity": 93.39, "mslp": 103003, "uvIndex": 1, "significantWeatherCode": 8, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 12}, {"time": "2024-02-17T16:00Z", "screenTemperature": 10.57, "maxScreenAirTemp": 10.62, "minScreenAirTemp": 10.56, "screenDewPointTemperature": 9.47, "feelsLikeTemperature": 8.88, "windSpeed10m": 3.65, "windDirectionFrom10m": 197, "windGustSpeed10m": 7.38, "max10mWindGust": 7.38, "visibility": 12932, "screenRelativeHumidity": 93.29, "mslp": 102986, "uvIndex": 1, "significantWeatherCode": 8, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 12}, {"time": "2024-02-17T17:00Z", "screenTemperature": 10.45, "maxScreenAirTemp": 10.57, "minScreenAirTemp": 10.44, "screenDewPointTemperature": 9.39, "feelsLikeTemperature": 8.75, "windSpeed10m": 3.67, "windDirectionFrom10m": 191, "windGustSpeed10m": 7.54, "max10mWindGust": 7.54, "visibility": 11295, "screenRelativeHumidity": 93.5, "mslp": 102968, "uvIndex": 1, "significantWeatherCode": 8, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 14}, {"time": "2024-02-17T18:00Z", "screenTemperature": 10.25, "screenDewPointTemperature": 9.3, "feelsLikeTemperature": 8.46, "windSpeed10m": 3.77, "windDirectionFrom10m": 182, "windGustSpeed10m": 7.94, "visibility": 10383, "screenRelativeHumidity": 94.28, "mslp": 102949, "uvIndex": 0, "significantWeatherCode": 7, "precipitationRate": 0.0, "probOfPrecipitation": 11}, {"time": "2024-02-17T19:00Z", "screenTemperature": 10.34, "screenDewPointTemperature": 9.37, "feelsLikeTemperature": 8.34, "windSpeed10m": 4.29, "windDirectionFrom10m": 187, "windGustSpeed10m": 8.68, "visibility": 10128, "screenRelativeHumidity": 94.17, "mslp": 102910, "uvIndex": 0, "significantWeatherCode": 8, "precipitationRate": 0.0, "probOfPrecipitation": 16}]}}]} \ No newline at end of file diff --git a/tests/unit/test_forecast_new.py b/tests/unit/test_forecast_new.py new file mode 100644 index 0000000..50236ca --- /dev/null +++ b/tests/unit/test_forecast_new.py @@ -0,0 +1,27 @@ +import pytest +import geojson +from datapoint import Forecast + + +@pytest.fixture +def hourly_forecast(): + with open('./tests/unit/hourly_api_data.json') as f: + my_json = geojson.load(f) + return Forecast.Forecast('hourly', my_json) + + +class TestForecast: + def test_hourly_forecast_frequency(self, hourly_forecast): + assert hourly_forecast.frequency == 'hourly' + + def test_hourly_forecast_location_name(self, hourly_forecast): + assert hourly_forecast.name == 'Sheffield Park' + + def test_hourly_forecast_location_latitude(self, hourly_forecast): + assert hourly_forecast.forecast_latitude == 50.9992 + + def test_hourly_forecast_location_longitude(self, hourly_forecast): + assert hourly_forecast.forecast_longitude == 0.0154 + + def test_hourly_forecast_distance_from_request(self, hourly_forecast): + assert hourly_forecast.distance_from_requested_location == 1081.5349 From 91cd5836060b14416ed2e5fa281d2d24850909b0 Mon Sep 17 00:00:00 2001 From: Emlyn Price Date: Thu, 15 Feb 2024 20:34:08 +0000 Subject: [PATCH 12/51] Formatting --- docs/conf.py | 56 ++--- examples/current_weather/current_weather.py | 11 +- .../postcodes_to_lat_lng.py | 17 +- examples/simple_forecast/simple_forecast.py | 4 +- examples/text_forecast/text_forecast.py | 12 +- examples/tube_bike/tube_bike.py | 31 +-- examples/washing/washing.py | 22 +- tests/integration/test_datapoint.py | 151 ++++++++----- tests/integration/test_manager.py | 202 ++++++++++-------- tests/unit/test_forecast.py | 98 +++++---- tests/unit/test_manager.py | 5 +- 11 files changed, 346 insertions(+), 263 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index f7a2b96..e138db5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,21 +15,21 @@ import os import sys -sys.path.insert(0, os.path.abspath('.')) +sys.path.insert(0, os.path.abspath(".")) # Need to change the place we put in path to work with readthedocs sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) import datapoint # -- Project information ----------------------------------------------------- -project = 'datapoint-python' -copyright = '2014, Jacon Tomlinson' -author = 'Jacob Tomlinson, Emlyn Price' +project = "datapoint-python" +copyright = "2014, Jacon Tomlinson" +author = "Jacob Tomlinson, Emlyn Price" # The full version, including alpha/beta/rc tags release = datapoint.__version__ # The short X.Y version -version = '.'.join(release.split('.')[:2]) +version = ".".join(release.split(".")[:2]) # -- General configuration --------------------------------------------------- @@ -41,20 +41,19 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = [ -] +extensions = [] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] -source_suffix = '.rst' +source_suffix = ".rst" # The master toctree document. -master_doc = 'index' +master_doc = "index" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -66,7 +65,7 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # The name of the Pygments (syntax highlighting) style to use. pygments_style = None @@ -77,7 +76,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'alabaster' +html_theme = "alabaster" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -88,7 +87,7 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # Custom sidebar templates, must be a dictionary that maps document names # to template names. @@ -104,7 +103,7 @@ # -- Options for HTMLHelp output --------------------------------------------- # Output file base name for HTML help builder. -htmlhelp_basename = 'datapoint-python-doc' +htmlhelp_basename = "datapoint-python-doc" # -- Options for LaTeX output ------------------------------------------------ @@ -113,15 +112,12 @@ # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. # # 'preamble': '', - # Latex figure (float) alignment # # 'figure_align': 'htbp', @@ -131,8 +127,13 @@ # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'datapoint-python.tex', 'datapoint-python Documentation', - 'Jacob Tomlinson, Emlyn Price', 'manual'), + ( + master_doc, + "datapoint-python.tex", + "datapoint-python Documentation", + "Jacob Tomlinson, Emlyn Price", + "manual", + ), ] @@ -141,8 +142,7 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - (master_doc, 'datapoint-python', 'datapoint-python Documentation', - [author], 1) + (master_doc, "datapoint-python", "datapoint-python Documentation", [author], 1) ] @@ -152,9 +152,15 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'datapoint-python', 'datapoint-python Documentation', - author, 'datapoint-python', 'One line description of project.', - 'Miscellaneous'), + ( + master_doc, + "datapoint-python", + "datapoint-python Documentation", + author, + "datapoint-python", + "One line description of project.", + "Miscellaneous", + ), ] @@ -173,4 +179,4 @@ # epub_uid = '' # A list of files that should not be packed into the epub file. -epub_exclude_files = ['search.html'] +epub_exclude_files = ["search.html"] diff --git a/examples/current_weather/current_weather.py b/examples/current_weather/current_weather.py index 6de1117..4dab522 100644 --- a/examples/current_weather/current_weather.py +++ b/examples/current_weather/current_weather.py @@ -20,6 +20,11 @@ # Get the current timestep using now() and print out some info now = forecast.now() print(now.weather.text) -print("%s%s%s" % (now.temperature.value, - '\xb0', #Unicode character for degree symbol - now.temperature.units)) +print( + "%s%s%s" + % ( + now.temperature.value, + "\xb0", # Unicode character for degree symbol + now.temperature.units, + ) +) diff --git a/examples/postcodes_to_lat_lng/postcodes_to_lat_lng.py b/examples/postcodes_to_lat_lng/postcodes_to_lat_lng.py index f97e890..8f9af82 100644 --- a/examples/postcodes_to_lat_lng/postcodes_to_lat_lng.py +++ b/examples/postcodes_to_lat_lng/postcodes_to_lat_lng.py @@ -13,9 +13,9 @@ # Get longitude and latitude from postcode postcodes_conn = postcodes_io_api.Api() -postcode = postcodes_conn.get_postcode('SW1A 2AA') -latitude = postcode['result']['latitude'] -longitude = postcode['result']['longitude'] +postcode = postcodes_conn.get_postcode("SW1A 2AA") +latitude = postcode["result"]["latitude"] +longitude = postcode["result"]["longitude"] # Get nearest site and print out its name site = conn.get_nearest_forecast_site(latitude, longitude) @@ -27,6 +27,11 @@ # Get the current timestep using now() and print out some info now = forecast.now() print(now.weather.text) -print("%s%s%s" % (now.temperature.value, - '\xb0', #Unicode character for degree symbol - now.temperature.units)) +print( + "%s%s%s" + % ( + now.temperature.value, + "\xb0", # Unicode character for degree symbol + now.temperature.units, + ) +) diff --git a/examples/simple_forecast/simple_forecast.py b/examples/simple_forecast/simple_forecast.py index f8a47d0..4982b0b 100755 --- a/examples/simple_forecast/simple_forecast.py +++ b/examples/simple_forecast/simple_forecast.py @@ -9,9 +9,7 @@ import datapoint # Create datapoint connection -manager = datapoint.Manager( - api_key="api key goes here" -) +manager = datapoint.Manager(api_key="api key goes here") forecast = manager.get_forecast(51.500728, -0.124626, frequency="hourly") diff --git a/examples/text_forecast/text_forecast.py b/examples/text_forecast/text_forecast.py index c7becb3..aaf68f8 100644 --- a/examples/text_forecast/text_forecast.py +++ b/examples/text_forecast/text_forecast.py @@ -15,15 +15,15 @@ # Get all forecasts for a specific region my_region = regions[0] -forecast = conn.regions.get_raw_forecast(my_region.location_id)['RegionalFcst'] +forecast = conn.regions.get_raw_forecast(my_region.location_id)["RegionalFcst"] # Print the forecast details -print('Forecast for {} (issued at {}):'.format(my_region.name, forecast['issuedAt'])) +print("Forecast for {} (issued at {}):".format(my_region.name, forecast["issuedAt"])) -sections = forecast['FcstPeriods']['Period'] -for section in forecast['FcstPeriods']['Period']: +sections = forecast["FcstPeriods"]["Period"] +for section in forecast["FcstPeriods"]["Period"]: paragraph = [] - content = section['Paragraph'] + content = section["Paragraph"] # Some paragraphs have multiple sections if isinstance(content, dict): @@ -32,4 +32,4 @@ paragraph = content for line in paragraph: - print('{}\n{}\n'.format(line['title'], line['$'])) + print("{}\n{}\n".format(line["title"], line["$"])) diff --git a/examples/tube_bike/tube_bike.py b/examples/tube_bike/tube_bike.py index fbaea24..97896fe 100644 --- a/examples/tube_bike/tube_bike.py +++ b/examples/tube_bike/tube_bike.py @@ -28,29 +28,32 @@ current_status = tubestatus.Status() # Get the status of the Waterloo and City line -waterloo_status = current_status.get_status('Waterloo and City') +waterloo_status = current_status.get_status("Waterloo and City") # Check whether there are any problems with rain or the tube -if (my_house_now.precipitation.value < 40 and \ - work_now.precipitation.value < 40 and \ - waterloo_status.description == "Good Service"): - +if ( + my_house_now.precipitation.value < 40 + and work_now.precipitation.value < 40 + and waterloo_status.description == "Good Service" +): print("Rain is unlikely and tube service is good, the decision is yours.") # If it is going to rain then suggest the tube -elif ((my_house_now.precipitation.value >= 40 or \ - work_now.precipitation.value >= 40) and \ - waterloo_status.description == "Good Service"): - +elif ( + my_house_now.precipitation.value >= 40 or work_now.precipitation.value >= 40 +) and waterloo_status.description == "Good Service": print("Looks like rain, better get the tube") # If the tube isn't running then suggest cycling -elif (my_house_now.precipitation.value < 40 and \ - work_now.precipitation.value < 40 and \ - waterloo_status.description != "Good Service"): - +elif ( + my_house_now.precipitation.value < 40 + and work_now.precipitation.value < 40 + and waterloo_status.description != "Good Service" +): print("Bad service on the tube, cycling it is!") # Else if both are bad then suggest cycling in the rain else: - print("The tube has poor service so you'll have to cycle, but it's raining so take your waterproofs.") + print( + "The tube has poor service so you'll have to cycle, but it's raining so take your waterproofs." + ) diff --git a/examples/washing/washing.py b/examples/washing/washing.py index a9985dc..cfc9eb8 100644 --- a/examples/washing/washing.py +++ b/examples/washing/washing.py @@ -13,12 +13,12 @@ import datapoint # Set thresholds -MAX_WIND = 31 # in mph. We don't want the washing to blow away -MAX_PRECIPITATION = 20 # Max chance of rain we will accept +MAX_WIND = 31 # in mph. We don't want the washing to blow away +MAX_PRECIPITATION = 20 # Max chance of rain we will accept # Variables for later best_day = None -best_day_score = 0 # For simplicity the score will be temperature + wind speed +best_day_score = 0 # For simplicity the score will be temperature + wind speed # Create datapoint connection conn = datapoint.Manager(api_key="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") @@ -33,16 +33,16 @@ # Loop through days for day in forecast.days: - # Get the 'Day' timestep - if day.timesteps[0].name == 'Day': + if day.timesteps[0].name == "Day": timestep = day.timesteps[0] # If precipitation, wind speed and gust are less than their threshold - if timestep.precipitation.value < MAX_PRECIPITATION and \ - timestep.wind_speed.value < MAX_WIND and \ - timestep.wind_gust.value < MAX_WIND: - + if ( + timestep.precipitation.value < MAX_PRECIPITATION + and timestep.wind_speed.value < MAX_WIND + and timestep.wind_gust.value < MAX_WIND + ): # Calculate the score for this timestep timestep_score = timestep.wind_speed.value + timestep.temperature.value @@ -59,4 +59,6 @@ else: # Get the day of the week from the datetime object best_day_formatted = datetime.strftime(best_day, "%A") - print("%s is the best day with a score of %s" % (best_day_formatted, best_day_score)) + print( + "%s is the best day with a score of %s" % (best_day_formatted, best_day_score) + ) diff --git a/tests/integration/test_datapoint.py b/tests/integration/test_datapoint.py index f20f563..514881d 100644 --- a/tests/integration/test_datapoint.py +++ b/tests/integration/test_datapoint.py @@ -13,51 +13,63 @@ class MockDateTime(datetime): - """Replacement for datetime that can be mocked for testing.""" - def __new__(cls, *args, **kwargs): - return datetime.__new__(datetime, *args, **kwargs) + """Replacement for datetime that can be mocked for testing.""" + + def __new__(cls, *args, **kwargs): + return datetime.__new__(datetime, *args, **kwargs) class TestDataPoint(unittest.TestCase): @Mocker() def setUp(self, mock_request): - with open("{}/datapoint.json".format(pathlib.Path(__file__).parent.absolute())) as f: + with open( + "{}/datapoint.json".format(pathlib.Path(__file__).parent.absolute()) + ) as f: mock_json = json.load(f) - self.all_sites = json.dumps(mock_json['all_sites']) - self.wavertree_hourly = json.dumps(mock_json['wavertree_hourly']) - self.wavertree_daily = json.dumps(mock_json['wavertree_daily']) - self.kingslynn_hourly = json.dumps(mock_json['kingslynn_hourly']) + self.all_sites = json.dumps(mock_json["all_sites"]) + self.wavertree_hourly = json.dumps(mock_json["wavertree_hourly"]) + self.wavertree_daily = json.dumps(mock_json["wavertree_daily"]) + self.kingslynn_hourly = json.dumps(mock_json["kingslynn_hourly"]) self.conn = datapoint.connection(api_key="abcdefgh-acbd-abcd-abcd-abcdefghijkl") - mock_request.get('/public/data/val/wxfcs/all/json/sitelist/', text=self.all_sites) + mock_request.get( + "/public/data/val/wxfcs/all/json/sitelist/", text=self.all_sites + ) self.wavertree = self.conn.get_nearest_forecast_site(53.38374, -2.90929) self.kingslynn = self.conn.get_nearest_forecast_site(52.75556, 0.44231) - @Mocker() - @patch('datetime.datetime', MockDateTime) + @patch("datetime.datetime", MockDateTime) def test_wavertree_hourly(self, mock_request): from datetime import datetime, timezone - MockDateTime.now = classmethod(lambda cls, **kwargs: datetime(2020, 4, 25, 12, tzinfo=timezone.utc)) - mock_request.get('/public/data/val/wxfcs/all/json/354107?res=3hourly', text=self.wavertree_hourly) - forecast = self.conn.get_forecast_for_site(self.wavertree.location_id, "3hourly") + MockDateTime.now = classmethod( + lambda cls, **kwargs: datetime(2020, 4, 25, 12, tzinfo=timezone.utc) + ) + mock_request.get( + "/public/data/val/wxfcs/all/json/354107?res=3hourly", + text=self.wavertree_hourly, + ) + + forecast = self.conn.get_forecast_for_site( + self.wavertree.location_id, "3hourly" + ) now = forecast.now() - self.assertEqual(self.wavertree.location_id, '354107') - self.assertEqual(self.wavertree.name, 'Wavertree') + self.assertEqual(self.wavertree.location_id, "354107") + self.assertEqual(self.wavertree.name, "Wavertree") - self.assertEqual(now.date.strftime(DATETIME_FORMAT), '2020-04-25 12:00:00+0000') - self.assertEqual(now.weather.value, '1') + self.assertEqual(now.date.strftime(DATETIME_FORMAT), "2020-04-25 12:00:00+0000") + self.assertEqual(now.weather.value, "1") self.assertEqual(now.temperature.value, 17) self.assertEqual(now.feels_like_temperature.value, 14) self.assertEqual(now.wind_speed.value, 9) - self.assertEqual(now.wind_direction.value, 'SSE') + self.assertEqual(now.wind_direction.value, "SSE") self.assertEqual(now.wind_gust.value, 16) - self.assertEqual(now.visibility.value, 'GO') - self.assertEqual(now.uv.value, '5') + self.assertEqual(now.visibility.value, "GO") + self.assertEqual(now.uv.value, "5") self.assertEqual(now.precipitation.value, 0) self.assertEqual(now.humidity.value, 50) @@ -65,41 +77,49 @@ def test_wavertree_hourly(self, mock_request): self.assertEqual(len(forecast.days[0].timesteps), 7) self.assertEqual(len(forecast.days[3].timesteps), 8) - self.assertEqual(forecast.days[3].timesteps[7].date.strftime(DATETIME_FORMAT), '2020-04-28 21:00:00+0000') - self.assertEqual(forecast.days[3].timesteps[7].weather.value, '7') + self.assertEqual( + forecast.days[3].timesteps[7].date.strftime(DATETIME_FORMAT), + "2020-04-28 21:00:00+0000", + ) + self.assertEqual(forecast.days[3].timesteps[7].weather.value, "7") self.assertEqual(forecast.days[3].timesteps[7].temperature.value, 10) self.assertEqual(forecast.days[3].timesteps[7].feels_like_temperature.value, 9) self.assertEqual(forecast.days[3].timesteps[7].wind_speed.value, 4) - self.assertEqual(forecast.days[3].timesteps[7].wind_direction.value, 'NNE') + self.assertEqual(forecast.days[3].timesteps[7].wind_direction.value, "NNE") self.assertEqual(forecast.days[3].timesteps[7].wind_gust.value, 11) - self.assertEqual(forecast.days[3].timesteps[7].visibility.value, 'VG') - self.assertEqual(forecast.days[3].timesteps[7].uv.value, '0') + self.assertEqual(forecast.days[3].timesteps[7].visibility.value, "VG") + self.assertEqual(forecast.days[3].timesteps[7].uv.value, "0") self.assertEqual(forecast.days[3].timesteps[7].precipitation.value, 9) self.assertEqual(forecast.days[3].timesteps[7].humidity.value, 72) - @Mocker() - @patch('datetime.datetime', MockDateTime) + @patch("datetime.datetime", MockDateTime) def test_wavertree_daily(self, mock_request): from datetime import datetime, timezone - MockDateTime.now = classmethod(lambda cls, **kwargs: datetime(2020, 4, 25, 12, tzinfo=timezone.utc)) - mock_request.get('/public/data/val/wxfcs/all/json/354107?res=daily', text=self.wavertree_daily) + + MockDateTime.now = classmethod( + lambda cls, **kwargs: datetime(2020, 4, 25, 12, tzinfo=timezone.utc) + ) + mock_request.get( + "/public/data/val/wxfcs/all/json/354107?res=daily", + text=self.wavertree_daily, + ) forecast = self.conn.get_forecast_for_site(self.wavertree.location_id, "daily") now = forecast.now() - self.assertEqual(self.wavertree.location_id, '354107') - self.assertEqual(self.wavertree.name, 'Wavertree') + self.assertEqual(self.wavertree.location_id, "354107") + self.assertEqual(self.wavertree.name, "Wavertree") - self.assertEqual(now.date.strftime(DATETIME_FORMAT), '2020-04-25 12:00:00+0000') - self.assertEqual(now.weather.value, '1') + self.assertEqual(now.date.strftime(DATETIME_FORMAT), "2020-04-25 12:00:00+0000") + self.assertEqual(now.weather.value, "1") self.assertEqual(now.temperature.value, 19) self.assertEqual(now.feels_like_temperature.value, 18) self.assertEqual(now.wind_speed.value, 9) - self.assertEqual(now.wind_direction.value, 'SSE') + self.assertEqual(now.wind_direction.value, "SSE") self.assertEqual(now.wind_gust.value, 16) - self.assertEqual(now.visibility.value, 'GO') - self.assertEqual(now.uv.value, '5') + self.assertEqual(now.visibility.value, "GO") + self.assertEqual(now.uv.value, "5") self.assertEqual(now.precipitation.value, 2) self.assertEqual(now.humidity.value, 50) @@ -107,40 +127,51 @@ def test_wavertree_daily(self, mock_request): self.assertEqual(len(forecast.days[0].timesteps), 2) self.assertEqual(len(forecast.days[4].timesteps), 2) - self.assertEqual(forecast.days[4].timesteps[1].date.strftime(DATETIME_FORMAT), '2020-04-29 12:00:00+0000') - self.assertEqual(forecast.days[4].timesteps[1].weather.value, '12') + self.assertEqual( + forecast.days[4].timesteps[1].date.strftime(DATETIME_FORMAT), + "2020-04-29 12:00:00+0000", + ) + self.assertEqual(forecast.days[4].timesteps[1].weather.value, "12") self.assertEqual(forecast.days[4].timesteps[1].temperature.value, 13) self.assertEqual(forecast.days[4].timesteps[1].feels_like_temperature.value, 10) self.assertEqual(forecast.days[4].timesteps[1].wind_speed.value, 13) - self.assertEqual(forecast.days[4].timesteps[1].wind_direction.value, 'SE') + self.assertEqual(forecast.days[4].timesteps[1].wind_direction.value, "SE") self.assertEqual(forecast.days[4].timesteps[1].wind_gust.value, 27) - self.assertEqual(forecast.days[4].timesteps[1].visibility.value, 'GO') - self.assertEqual(forecast.days[4].timesteps[1].uv.value, '3') + self.assertEqual(forecast.days[4].timesteps[1].visibility.value, "GO") + self.assertEqual(forecast.days[4].timesteps[1].uv.value, "3") self.assertEqual(forecast.days[4].timesteps[1].precipitation.value, 59) self.assertEqual(forecast.days[4].timesteps[1].humidity.value, 72) @Mocker() - @patch('datetime.datetime', MockDateTime) + @patch("datetime.datetime", MockDateTime) def test_kingslynn_hourly(self, mock_request): from datetime import datetime, timezone - MockDateTime.now = classmethod(lambda cls, **kwargs: datetime(2020, 4, 25, 12, tzinfo=timezone.utc)) - mock_request.get('/public/data/val/wxfcs/all/json/322380?res=3hourly', text=self.kingslynn_hourly) - forecast = self.conn.get_forecast_for_site(self.kingslynn.location_id, "3hourly") + MockDateTime.now = classmethod( + lambda cls, **kwargs: datetime(2020, 4, 25, 12, tzinfo=timezone.utc) + ) + mock_request.get( + "/public/data/val/wxfcs/all/json/322380?res=3hourly", + text=self.kingslynn_hourly, + ) + + forecast = self.conn.get_forecast_for_site( + self.kingslynn.location_id, "3hourly" + ) now = forecast.now() - self.assertEqual(self.kingslynn.location_id, '322380') + self.assertEqual(self.kingslynn.location_id, "322380") self.assertEqual(self.kingslynn.name, "King's Lynn") - self.assertEqual(now.date.strftime(DATETIME_FORMAT), '2020-04-25 12:00:00+0000') - self.assertEqual(now.weather.value, '1') + self.assertEqual(now.date.strftime(DATETIME_FORMAT), "2020-04-25 12:00:00+0000") + self.assertEqual(now.weather.value, "1") self.assertEqual(now.temperature.value, 14) self.assertEqual(now.feels_like_temperature.value, 13) self.assertEqual(now.wind_speed.value, 2) - self.assertEqual(now.wind_direction.value, 'E') + self.assertEqual(now.wind_direction.value, "E") self.assertEqual(now.wind_gust.value, 7) - self.assertEqual(now.visibility.value, 'VG') - self.assertEqual(now.uv.value, '6') + self.assertEqual(now.visibility.value, "VG") + self.assertEqual(now.uv.value, "6") self.assertEqual(now.precipitation.value, 0) self.assertEqual(now.humidity.value, 60) @@ -148,17 +179,21 @@ def test_kingslynn_hourly(self, mock_request): self.assertEqual(len(forecast.days[0].timesteps), 7) self.assertEqual(len(forecast.days[4].timesteps), 8) - self.assertEqual(forecast.days[4].timesteps[5].date.strftime(DATETIME_FORMAT), '2020-04-29 15:00:00+0000') - self.assertEqual(forecast.days[4].timesteps[5].weather.value, '12') + self.assertEqual( + forecast.days[4].timesteps[5].date.strftime(DATETIME_FORMAT), + "2020-04-29 15:00:00+0000", + ) + self.assertEqual(forecast.days[4].timesteps[5].weather.value, "12") self.assertEqual(forecast.days[4].timesteps[5].temperature.value, 14) self.assertEqual(forecast.days[4].timesteps[5].feels_like_temperature.value, 12) self.assertEqual(forecast.days[4].timesteps[5].wind_speed.value, 13) - self.assertEqual(forecast.days[4].timesteps[5].wind_direction.value, 'S') + self.assertEqual(forecast.days[4].timesteps[5].wind_direction.value, "S") self.assertEqual(forecast.days[4].timesteps[5].wind_gust.value, 27) - self.assertEqual(forecast.days[4].timesteps[5].visibility.value, 'GO') - self.assertEqual(forecast.days[4].timesteps[5].uv.value, '1') + self.assertEqual(forecast.days[4].timesteps[5].visibility.value, "GO") + self.assertEqual(forecast.days[4].timesteps[5].uv.value, "1") self.assertEqual(forecast.days[4].timesteps[5].precipitation.value, 55) self.assertEqual(forecast.days[4].timesteps[5].humidity.value, 68) -if __name__ == '__main__': + +if __name__ == "__main__": unittest.main() diff --git a/tests/integration/test_manager.py b/tests/integration/test_manager.py index c8ffac5..3d8d063 100644 --- a/tests/integration/test_manager.py +++ b/tests/integration/test_manager.py @@ -6,13 +6,14 @@ class ManagerIntegrationTestCase(unittest.TestCase): - def setUp(self): - self.manager = datapoint.Manager(api_key=os.environ['API_KEY']) + self.manager = datapoint.Manager(api_key=os.environ["API_KEY"]) def test_site(self): - site = self.manager.get_nearest_forecast_site(latitude=51.500728, longitude=-0.124626) - self.assertEqual(site.name.upper(), 'HORSEGUARDS PARADE') + site = self.manager.get_nearest_forecast_site( + latitude=51.500728, longitude=-0.124626 + ) + self.assertEqual(site.name.upper(), "HORSEGUARDS PARADE") def test_get_forecast_sites(self): sites = self.manager.get_forecast_sites() @@ -21,127 +22,146 @@ def test_get_forecast_sites(self): assert sites def test_get_daily_forecast(self): - site = self.manager.get_nearest_forecast_site(latitude=51.500728, longitude=-0.124626) - forecast = self.manager.get_forecast_for_site(site.location_id, 'daily') + site = self.manager.get_nearest_forecast_site( + latitude=51.500728, longitude=-0.124626 + ) + forecast = self.manager.get_forecast_for_site(site.location_id, "daily") self.assertIsInstance(forecast, datapoint.Forecast.Forecast) - self.assertEqual(forecast.continent.upper(), 'EUROPE') - self.assertEqual(forecast.country.upper(), 'ENGLAND') - self.assertEqual(forecast.name.upper(),'HORSEGUARDS PARADE') + self.assertEqual(forecast.continent.upper(), "EUROPE") + self.assertEqual(forecast.country.upper(), "ENGLAND") + self.assertEqual(forecast.name.upper(), "HORSEGUARDS PARADE") self.assertLess(abs(float(forecast.latitude) - 51.500728), 0.1) self.assertLess(abs(float(forecast.longitude) - (-0.124626)), 0.1) # Forecast should have been made within last 3 hours tz = forecast.data_date.tzinfo - self.assertLess(forecast.data_date - datetime.datetime.now(tz=tz), - datetime.timedelta(hours=3)) + self.assertLess( + forecast.data_date - datetime.datetime.now(tz=tz), + datetime.timedelta(hours=3), + ) # First forecast should be less than 12 hours away tz = forecast.days[0].timesteps[0].date.tzinfo - self.assertLess(forecast.days[0].timesteps[0].date - - datetime.datetime.now(tz=tz), - datetime.timedelta(hours=12)) + self.assertLess( + forecast.days[0].timesteps[0].date - datetime.datetime.now(tz=tz), + datetime.timedelta(hours=12), + ) for day in forecast.days: for timestep in day.timesteps: - self.assertIn(timestep.name, ['Day', 'Night']) - self.assertEqual(self.manager._weather_to_text(int(timestep.weather.value)), - timestep.weather.text) + self.assertIn(timestep.name, ["Day", "Night"]) + self.assertEqual( + self.manager._weather_to_text(int(timestep.weather.value)), + timestep.weather.text, + ) self.assertGreater(timestep.temperature.value, -100) self.assertLess(timestep.temperature.value, 100) - self.assertEqual(timestep.temperature.units, 'C') + self.assertEqual(timestep.temperature.units, "C") - self.assertGreater(timestep.feels_like_temperature.value , -100) - self.assertLess(timestep.feels_like_temperature.value , 100) - self.assertEqual(timestep.feels_like_temperature.units, 'C') + self.assertGreater(timestep.feels_like_temperature.value, -100) + self.assertLess(timestep.feels_like_temperature.value, 100) + self.assertEqual(timestep.feels_like_temperature.units, "C") self.assertGreaterEqual(timestep.wind_speed.value, 0) self.assertLess(timestep.wind_speed.value, 300) - self.assertEqual(timestep.wind_speed.units,'mph') + self.assertEqual(timestep.wind_speed.units, "mph") for char in timestep.wind_direction.value: - self.assertIn(char, ['N', 'E', 'S', 'W']) - self.assertEqual(timestep.wind_direction.units, 'compass') + self.assertIn(char, ["N", "E", "S", "W"]) + self.assertEqual(timestep.wind_direction.units, "compass") self.assertGreaterEqual(timestep.wind_gust.value, 0) self.assertLess(timestep.wind_gust.value, 300) - self.assertEqual(timestep.wind_gust.units, 'mph') + self.assertEqual(timestep.wind_gust.units, "mph") - self.assertIn(timestep.visibility.value, - ['UN', 'VP', 'PO', 'MO', 'GO', 'VG', 'EX']) + self.assertIn( + timestep.visibility.value, + ["UN", "VP", "PO", "MO", "GO", "VG", "EX"], + ) self.assertGreaterEqual(timestep.precipitation.value, 0) self.assertLessEqual(timestep.precipitation.value, 100) - self.assertEqual(timestep.precipitation.units, '%') + self.assertEqual(timestep.precipitation.units, "%") self.assertGreaterEqual(timestep.humidity.value, 0) self.assertLessEqual(timestep.humidity.value, 100) - self.assertEqual(timestep.humidity.units, '%') + self.assertEqual(timestep.humidity.units, "%") - if hasattr(timestep.uv, 'value'): + if hasattr(timestep.uv, "value"): self.assertGreaterEqual(int(timestep.uv.value), 0) self.assertLess(int(timestep.uv.value), 30) - def test_get_3hour_forecast(self): - site = self.manager.get_nearest_forecast_site(latitude=51.500728, longitude=-0.124626) - forecast = self.manager.get_forecast_for_site(site.location_id, '3hourly') + site = self.manager.get_nearest_forecast_site( + latitude=51.500728, longitude=-0.124626 + ) + forecast = self.manager.get_forecast_for_site(site.location_id, "3hourly") self.assertIsInstance(forecast, datapoint.Forecast.Forecast) - self.assertEqual(forecast.continent.upper(), 'EUROPE') - self.assertEqual(forecast.country.upper(), 'ENGLAND') - self.assertEqual(forecast.name.upper(),'HORSEGUARDS PARADE') + self.assertEqual(forecast.continent.upper(), "EUROPE") + self.assertEqual(forecast.country.upper(), "ENGLAND") + self.assertEqual(forecast.name.upper(), "HORSEGUARDS PARADE") self.assertLess(abs(float(forecast.latitude) - 51.500728), 0.1) self.assertLess(abs(float(forecast.longitude) - (-0.124626)), 0.1) # Forecast should have been made within last 3 hours tz = forecast.data_date.tzinfo - self.assertLess(forecast.data_date - datetime.datetime.now(tz=tz), - datetime.timedelta(hours=3)) + self.assertLess( + forecast.data_date - datetime.datetime.now(tz=tz), + datetime.timedelta(hours=3), + ) # First forecast should be less than 12 hours away tz = forecast.days[0].timesteps[0].date.tzinfo - self.assertLess(forecast.days[0].timesteps[0].date - - datetime.datetime.now(tz=tz), - datetime.timedelta(hours=12)) + self.assertLess( + forecast.days[0].timesteps[0].date - datetime.datetime.now(tz=tz), + datetime.timedelta(hours=12), + ) for day in forecast.days: for timestep in day.timesteps: self.assertIsInstance(timestep.name, int) - self.assertEqual(self.manager._weather_to_text( - int(timestep.weather.value)), timestep.weather.text) + self.assertEqual( + self.manager._weather_to_text(int(timestep.weather.value)), + timestep.weather.text, + ) self.assertGreater(timestep.temperature.value, -100) self.assertLess(timestep.temperature.value, 100) - self.assertEqual(timestep.temperature.units, 'C') + self.assertEqual(timestep.temperature.units, "C") - self.assertGreater(timestep.feels_like_temperature.value , -100) - self.assertLess(timestep.feels_like_temperature.value , 100) - self.assertEqual(timestep.feels_like_temperature.units, 'C') + self.assertGreater(timestep.feels_like_temperature.value, -100) + self.assertLess(timestep.feels_like_temperature.value, 100) + self.assertEqual(timestep.feels_like_temperature.units, "C") self.assertGreaterEqual(timestep.wind_speed.value, 0) self.assertLess(timestep.wind_speed.value, 300) - self.assertEqual(timestep.wind_speed.units,'mph') + self.assertEqual(timestep.wind_speed.units, "mph") for char in timestep.wind_direction.value: - self.assertIn(char, ['N', 'E', 'S', 'W']) - self.assertEqual(timestep.wind_direction.units, 'compass') + self.assertIn(char, ["N", "E", "S", "W"]) + self.assertEqual(timestep.wind_direction.units, "compass") self.assertGreaterEqual(timestep.wind_gust.value, 0) self.assertLess(timestep.wind_gust.value, 300) - self.assertEqual(timestep.wind_gust.units, 'mph') + self.assertEqual(timestep.wind_gust.units, "mph") - self.assertIn(timestep.visibility.value, - ['UN', 'VP', 'PO', 'MO', 'GO', 'VG', 'EX']) + self.assertIn( + timestep.visibility.value, + ["UN", "VP", "PO", "MO", "GO", "VG", "EX"], + ) self.assertGreaterEqual(timestep.precipitation.value, 0) self.assertLessEqual(timestep.precipitation.value, 100) - self.assertEqual(timestep.precipitation.units, '%') + self.assertEqual(timestep.precipitation.units, "%") self.assertGreaterEqual(timestep.humidity.value, 0) self.assertLessEqual(timestep.humidity.value, 100) - self.assertEqual(timestep.humidity.units, '%') + self.assertEqual(timestep.humidity.units, "%") - if hasattr(timestep.uv, 'value'): + if hasattr(timestep.uv, "value"): self.assertGreaterEqual(int(timestep.uv.value), 0) self.assertLess(int(timestep.uv.value), 30) def test_get_nearest_observation_site(self): - site = self.manager.get_nearest_observation_site(longitude=-0.1025, latitude=51.3263) - self.assertEqual(site.name.upper(), 'KENLEY') + site = self.manager.get_nearest_observation_site( + longitude=-0.1025, latitude=51.3263 + ) + self.assertEqual(site.name.upper(), "KENLEY") def test_get_observation_sites(self): sites = self.manager.get_observation_sites() @@ -152,23 +172,27 @@ def test_get_observation_sites(self): def test_get_observation_with_wind_data(self): observation = self.manager.get_observations_for_site(3840) self.assertIsInstance(observation, datapoint.Observation.Observation) - self.assertEqual(observation.continent.upper(), 'EUROPE') - self.assertEqual(observation.country.upper(), 'ENGLAND') - self.assertEqual(observation.name.upper(), 'DUNKESWELL AERODROME') + self.assertEqual(observation.continent.upper(), "EUROPE") + self.assertEqual(observation.country.upper(), "ENGLAND") + self.assertEqual(observation.name.upper(), "DUNKESWELL AERODROME") # Observation should be from within the last hour tz = observation.data_date.tzinfo - self.assertLess(observation.data_date - datetime.datetime.now(tz=tz), - datetime.timedelta(hours=1)) + self.assertLess( + observation.data_date - datetime.datetime.now(tz=tz), + datetime.timedelta(hours=1), + ) # First observation should be between 24 and 25 hours old tz = observation.days[0].timesteps[0].date.tzinfo - self.assertGreater(datetime.datetime.now(tz=tz) - - observation.days[0].timesteps[0].date, - datetime.timedelta(hours=24)) - self.assertLess(datetime.datetime.now(tz=tz) - - observation.days[0].timesteps[0].date, - datetime.timedelta(hours=25)) + self.assertGreater( + datetime.datetime.now(tz=tz) - observation.days[0].timesteps[0].date, + datetime.timedelta(hours=24), + ) + self.assertLess( + datetime.datetime.now(tz=tz) - observation.days[0].timesteps[0].date, + datetime.timedelta(hours=25), + ) # Should have total 25 observations across all days number_of_timesteps = 0 @@ -179,44 +203,46 @@ def test_get_observation_with_wind_data(self): for day in observation.days: for timestep in day.timesteps: self.assertIsInstance(timestep.name, int) - if timestep.weather.value != 'Not reported': - self.assertEqual(self.manager._weather_to_text(int(timestep.weather.value)), - timestep.weather.text) + if timestep.weather.value != "Not reported": + self.assertEqual( + self.manager._weather_to_text(int(timestep.weather.value)), + timestep.weather.text, + ) self.assertGreater(timestep.temperature.value, -100) self.assertLess(timestep.temperature.value, 100) - self.assertEqual(timestep.temperature.units, 'C') + self.assertEqual(timestep.temperature.units, "C") - if timestep.wind_speed.value != 'Not reported': + if timestep.wind_speed.value != "Not reported": self.assertGreaterEqual(timestep.wind_speed.value, 0) self.assertLess(timestep.wind_speed.value, 300) - self.assertEqual(timestep.wind_speed.units,'mph') + self.assertEqual(timestep.wind_speed.units, "mph") - if timestep.wind_direction.value != 'Not reported': + if timestep.wind_direction.value != "Not reported": for char in timestep.wind_direction.value: - self.assertIn(char, ['N', 'E', 'S', 'W']) - self.assertEqual(timestep.wind_direction.units, 'compass') + self.assertIn(char, ["N", "E", "S", "W"]) + self.assertEqual(timestep.wind_direction.units, "compass") self.assertGreaterEqual(timestep.visibility.value, 0) - self.assertIn(timestep.visibility.text, - ['UN', 'VP', 'PO', 'MO', 'GO', 'VG', 'EX']) + self.assertIn( + timestep.visibility.text, ["UN", "VP", "PO", "MO", "GO", "VG", "EX"] + ) self.assertGreaterEqual(timestep.humidity.value, 0) self.assertLessEqual(timestep.humidity.value, 100) - self.assertEqual(timestep.humidity.units, '%') + self.assertEqual(timestep.humidity.units, "%") self.assertGreaterEqual(timestep.dew_point.value, 0) self.assertLessEqual(timestep.dew_point.value, 100) - self.assertEqual(timestep.dew_point.units, 'C') + self.assertEqual(timestep.dew_point.units, "C") - if timestep.pressure.value != 'Not reported': + if timestep.pressure.value != "Not reported": self.assertGreaterEqual(timestep.pressure.value, 900) self.assertLessEqual(timestep.pressure.value, 1100) - self.assertEqual(timestep.pressure.units, 'hpa') - - if timestep.pressure_tendency.value != 'Not reported': - self.assertIn(timestep.pressure_tendency.value, ['R','F','S']) - self.assertEqual(timestep.pressure_tendency.units, 'Pa/s') + self.assertEqual(timestep.pressure.units, "hpa") + if timestep.pressure_tendency.value != "Not reported": + self.assertIn(timestep.pressure_tendency.value, ["R", "F", "S"]) + self.assertEqual(timestep.pressure_tendency.units, "Pa/s") # def test_get_observation_without_wind_data(self): # observation = self.manager.get_observations_for_site(3220) diff --git a/tests/unit/test_forecast.py b/tests/unit/test_forecast.py index fe091b3..e2910a9 100644 --- a/tests/unit/test_forecast.py +++ b/tests/unit/test_forecast.py @@ -5,17 +5,18 @@ class TestForecast(unittest.TestCase): - def setUp(self): - self.forecast_3hrly = datapoint.Forecast.Forecast(frequency='3hourly') + self.forecast_3hrly = datapoint.Forecast.Forecast(frequency="3hourly") test_day_0 = datapoint.Day.Day() test_day_0.date = datetime.datetime(2020, 3, 3, tzinfo=datetime.timezone.utc) for i in range(5): ts = datapoint.Timestep.Timestep() - ts.name = 9+(3*i) - ts.date = datetime.datetime(2020, 3, 3, 9+(3*i), tzinfo=datetime.timezone.utc) + ts.name = 9 + (3 * i) + ts.date = datetime.datetime( + 2020, 3, 3, 9 + (3 * i), tzinfo=datetime.timezone.utc + ) test_day_0.timesteps.append(ts) test_day_1 = datapoint.Day.Day() @@ -23,8 +24,8 @@ def setUp(self): for i in range(8): ts = datapoint.Timestep.Timestep() - ts.name = 3*i - ts.date = datetime.datetime(2020, 3, 4, 3*i, tzinfo=datetime.timezone.utc) + ts.name = 3 * i + ts.date = datetime.datetime(2020, 3, 4, 3 * i, tzinfo=datetime.timezone.utc) test_day_1.timesteps.append(ts) self.forecast_3hrly.days.append(test_day_0) @@ -35,8 +36,8 @@ def setUp(self): for i in range(2): ts = datapoint.Timestep.Timestep() - ts.name = 2*i - ts.date = datetime.datetime(2020, 3, 3, 2*i, tzinfo=datetime.timezone.utc) + ts.name = 2 * i + ts.date = datetime.datetime(2020, 3, 3, 2 * i, tzinfo=datetime.timezone.utc) test_day_0.timesteps.append(ts) test_day_1 = datapoint.Day.Day() @@ -44,70 +45,73 @@ def setUp(self): for i in range(2): ts = datapoint.Timestep.Timestep() - ts.name = 2*i - ts.date = datetime.datetime(2020, 3, 4, 2*i, tzinfo=datetime.timezone.utc) + ts.name = 2 * i + ts.date = datetime.datetime(2020, 3, 4, 2 * i, tzinfo=datetime.timezone.utc) test_day_1.timesteps.append(ts) - self.forecast_daily = datapoint.Forecast.Forecast(frequency='daily') + self.forecast_daily = datapoint.Forecast.Forecast(frequency="daily") self.forecast_daily.days.append(test_day_0) self.forecast_daily.days.append(test_day_1) def test_at_datetime_1_5_hours_before_after(self): - - target_before = datetime.datetime(2020, 3, 3, 7, 0, - tzinfo=datetime.timezone.utc) - - target_after = datetime.datetime(2020, 3, 4, 23, 0, - tzinfo=datetime.timezone.utc) - - self.assertRaises(datapoint.exceptions.APIException, - self.forecast_3hrly.at_datetime, target_before) - - self.assertRaises(datapoint.exceptions.APIException, - self.forecast_3hrly.at_datetime, target_after) + target_before = datetime.datetime( + 2020, 3, 3, 7, 0, tzinfo=datetime.timezone.utc + ) + + target_after = datetime.datetime( + 2020, 3, 4, 23, 0, tzinfo=datetime.timezone.utc + ) + + self.assertRaises( + datapoint.exceptions.APIException, + self.forecast_3hrly.at_datetime, + target_before, + ) + + self.assertRaises( + datapoint.exceptions.APIException, + self.forecast_3hrly.at_datetime, + target_after, + ) def test_at_datetime_6_hours_before_after(self): - # Generate 2 timesteps These are set at 00:00 and 12:00 - target_before = datetime.datetime(2020, 3, 2, 15, 0, - tzinfo=datetime.timezone.utc) + target_before = datetime.datetime( + 2020, 3, 2, 15, 0, tzinfo=datetime.timezone.utc + ) - target_after = datetime.datetime(2020, 3, 6, 7, 0, - tzinfo=datetime.timezone.utc) + target_after = datetime.datetime(2020, 3, 6, 7, 0, tzinfo=datetime.timezone.utc) - self.assertRaises(datapoint.exceptions.APIException, - self.forecast_daily.at_datetime, target_before) + self.assertRaises( + datapoint.exceptions.APIException, + self.forecast_daily.at_datetime, + target_before, + ) - self.assertRaises(datapoint.exceptions.APIException, - self.forecast_daily.at_datetime, target_after) + self.assertRaises( + datapoint.exceptions.APIException, + self.forecast_daily.at_datetime, + target_after, + ) def test_normal_time(self): - target = datetime.datetime(2020, 3, 3, 10, 0, - tzinfo=datetime.timezone.utc) + target = datetime.datetime(2020, 3, 3, 10, 0, tzinfo=datetime.timezone.utc) nearest = self.forecast_3hrly.at_datetime(target) - expected = datetime.datetime(2020, 3, 3, 9, - tzinfo=datetime.timezone.utc) + expected = datetime.datetime(2020, 3, 3, 9, tzinfo=datetime.timezone.utc) self.assertEqual(nearest.date, expected) - target = datetime.datetime(2020, 3, 3, 11, 0, - tzinfo=datetime.timezone.utc) + target = datetime.datetime(2020, 3, 3, 11, 0, tzinfo=datetime.timezone.utc) nearest = self.forecast_3hrly.at_datetime(target) - expected = datetime.datetime(2020, 3, 3, 12, - tzinfo=datetime.timezone.utc) + expected = datetime.datetime(2020, 3, 3, 12, tzinfo=datetime.timezone.utc) self.assertEqual(nearest.date, expected) - def test_forecase_midnight(self): - target = datetime.datetime(2020, 3, 4, 0, 15, - tzinfo=datetime.timezone.utc) + target = datetime.datetime(2020, 3, 4, 0, 15, tzinfo=datetime.timezone.utc) nearest = self.forecast_3hrly.at_datetime(target) - expected = datetime.datetime(2020, 3, 4, 0, - tzinfo=datetime.timezone.utc) + expected = datetime.datetime(2020, 3, 4, 0, tzinfo=datetime.timezone.utc) self.assertEqual(nearest.date, expected) - - diff --git a/tests/unit/test_manager.py b/tests/unit/test_manager.py index 2d7ae98..f830056 100644 --- a/tests/unit/test_manager.py +++ b/tests/unit/test_manager.py @@ -4,7 +4,6 @@ class ManagerTestCase(unittest.TestCase): - def setUp(self): self.manager = datapoint.Manager(api_key="") @@ -19,7 +18,7 @@ def test_weather_to_text_invalid_input_out_of_bounds(self): self.assertRaises(ValueError, self.manager._weather_to_text, 31) def test_weather_to_text_invalid_input_String(self): - self.assertRaises(ValueError, self.manager._weather_to_text, '1') + self.assertRaises(ValueError, self.manager._weather_to_text, "1") def test_visbility_to_text_invalid_input_None(self): self.assertRaises(ValueError, self.manager._weather_to_text, None) @@ -28,4 +27,4 @@ def test_visibility_to_text_invalid_input_out_of_bounds(self): self.assertRaises(ValueError, self.manager._weather_to_text, -1) def test_visibility_to_text_invalid_input_String(self): - self.assertRaises(ValueError, self.manager._weather_to_text, '1') + self.assertRaises(ValueError, self.manager._weather_to_text, "1") From eebb16e3b365b12432484fb75087a20ca9cb8066 Mon Sep 17 00:00:00 2001 From: Emlyn Price Date: Sat, 17 Feb 2024 13:10:28 +0000 Subject: [PATCH 13/51] Remove profile logic --- src/datapoint/__init__.py | 21 --------------------- src/datapoint/profile.py | 17 ----------------- 2 files changed, 38 deletions(-) delete mode 100644 src/datapoint/profile.py diff --git a/src/datapoint/__init__.py b/src/datapoint/__init__.py index 145ce89..e69de29 100644 --- a/src/datapoint/__init__.py +++ b/src/datapoint/__init__.py @@ -1,21 +0,0 @@ -"""Datapoint API to retrieve Met Office data""" - -import os.path - -import datapoint.profile -from datapoint.Manager import Manager - - -def connection(profile_name="default", api_key=None): - """Connect to DataPoint with the given API key profile name.""" - if api_key is None: - profile_fname = datapoint.profile.API_profile_fname(profile_name) - if not os.path.exists(profile_fname): - raise ValueError( - "Profile not found in {}. Please install your API \n" - "key with datapoint.profile.install_API_key(" - '"")'.format(profile_fname) - ) - with open(profile_fname) as fh: - api_key = fh.readlines() - return Manager(api_key=api_key) diff --git a/src/datapoint/profile.py b/src/datapoint/profile.py deleted file mode 100644 index 34e823c..0000000 --- a/src/datapoint/profile.py +++ /dev/null @@ -1,17 +0,0 @@ -import os - -import appdirs - - -def API_profile_fname(profile_name="default"): - """Get the API key profile filename.""" - return os.path.join(appdirs.user_data_dir("DataPoint"), profile_name + ".key") - - -def install_API_key(api_key, profile_name="default"): - """Put the given API key into the given profile name.""" - fname = API_profile_fname(profile_name) - if not os.path.isdir(os.path.dirname(fname)): - os.makedirs(os.path.dirname(fname)) - with open(fname, "w") as fh: - fh.write(api_key) From 04096c4627009ca696a82f1ee817953d1383dd9b Mon Sep 17 00:00:00 2001 From: Emlyn Price Date: Sat, 17 Feb 2024 17:17:15 +0000 Subject: [PATCH 14/51] Update forecast tests --- pyproject.toml | 6 +- requirements.txt | 4 +- src/datapoint/__init__.py | 1 + tests/__init__.py | 0 tests/unit/__init__.py | 0 tests/unit/daily_api_data.json | 805 ++++++++++ tests/unit/hourly_api_data.json | 1189 ++++++++++++++- tests/unit/reference_data_test_forecast.py | 1073 ++++++++++++++ tests/unit/test_forecast.py | 324 ++-- tests/unit/test_forecast_new.py | 27 - tests/unit/three_hourly_api_data.json | 1562 ++++++++++++++++++++ 11 files changed, 4873 insertions(+), 118 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/daily_api_data.json create mode 100644 tests/unit/reference_data_test_forecast.py delete mode 100644 tests/unit/test_forecast_new.py create mode 100644 tests/unit/three_hourly_api_data.json diff --git a/pyproject.toml b/pyproject.toml index df67700..17d3a5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,9 +48,9 @@ profile = "black" src_paths = ["src", "tests"] [tool.versioningit.format] -distance = "{base_version}.post{distance}+{vcs}{rev}" -dirty = "{base_version}+d{build_date:%Y%m%d}" -distance-dirty = "{base_version}.post{distance}+{vcs}{rev}.d{build_date:%Y%m%d}" +distance = "{base_version}" +dirty = "{base_version}" +distance-dirty = "{base_version}" [tool.pytest.ini_options] addopts = [ diff --git a/requirements.txt b/requirements.txt index 27964c5..b5cc6d6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ requests==2.31.0 appdirs==1.4.4 -requests-mock==1.110 -geojson==3.10.0 +requests-mock==1.11.0 +geojson==3.1.0 diff --git a/src/datapoint/__init__.py b/src/datapoint/__init__.py index e69de29..97b1ec8 100644 --- a/src/datapoint/__init__.py +++ b/src/datapoint/__init__.py @@ -0,0 +1 @@ +from datapoint.Manager import Manager diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/daily_api_data.json b/tests/unit/daily_api_data.json new file mode 100644 index 0000000..5cab156 --- /dev/null +++ b/tests/unit/daily_api_data.json @@ -0,0 +1,805 @@ +{ + "type": "FeatureCollection", + "parameters": [{ + "daySignificantWeatherCode": { + "type": "Parameter", + "description": "Day Significant Weather Code", + "unit": { + "label": "dimensionless", + "symbol": { + "value": "https://metoffice.apiconnect.ibmcloud.com/metoffice/production/", + "type": "1" + } + } + }, + "midnightRelativeHumidity": { + "type": "Parameter", + "description": "Relative Humidity at Local Midnight", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "nightProbabilityOfHeavyRain": { + "type": "Parameter", + "description": "Probability of Heavy Rain During The Night", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "midnight10MWindSpeed": { + "type": "Parameter", + "description": "10m Wind Speed at Local Midnight", + "unit": { + "label": "metres per second", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m/s" + } + } + }, + "nightUpperBoundMinFeelsLikeTemp": { + "type": "Parameter", + "description": "Upper Bound on Night Minimum Feels Like Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "nightUpperBoundMinTemp": { + "type": "Parameter", + "description": "Upper Bound on Night Minimum Screen Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "midnightVisibility": { + "type": "Parameter", + "description": "Visibility at Local Midnight", + "unit": { + "label": "metres", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m" + } + } + }, + "dayUpperBoundMaxFeelsLikeTemp": { + "type": "Parameter", + "description": "Upper Bound on Day Maximum Feels Like Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "nightProbabilityOfRain": { + "type": "Parameter", + "description": "Probability of Rain During The Night", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "midday10MWindDirection": { + "type": "Parameter", + "description": "10m Wind Direction at Local Midday", + "unit": { + "label": "degrees", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "deg" + } + } + }, + "nightLowerBoundMinFeelsLikeTemp": { + "type": "Parameter", + "description": "Lower Bound on Night Minimum Feels Like Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "nightProbabilityOfHail": { + "type": "Parameter", + "description": "Probability of Hail During The Night", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "middayMslp": { + "type": "Parameter", + "description": "Mean Sea Level Pressure at Local Midday", + "unit": { + "label": "pascals", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Pa" + } + } + }, + "dayProbabilityOfHeavySnow": { + "type": "Parameter", + "description": "Probability of Heavy Snow During The Day", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "nightProbabilityOfPrecipitation": { + "type": "Parameter", + "description": "Probability of Precipitation During The Night", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "dayProbabilityOfHail": { + "type": "Parameter", + "description": "Probability of Hail During The Day", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "dayProbabilityOfRain": { + "type": "Parameter", + "description": "Probability of Rain During The Day", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "midday10MWindSpeed": { + "type": "Parameter", + "description": "10m Wind Speed at Local Midday", + "unit": { + "label": "metres per second", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m/s" + } + } + }, + "midday10MWindGust": { + "type": "Parameter", + "description": "10m Wind Gust Speed at Local Midday", + "unit": { + "label": "metres per second", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m/s" + } + } + }, + "middayVisibility": { + "type": "Parameter", + "description": "Visibility at Local Midday", + "unit": { + "label": "metres", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m" + } + } + }, + "midnight10MWindGust": { + "type": "Parameter", + "description": "10m Wind Gust Speed at Local Midnight", + "unit": { + "label": "metres per second", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m/s" + } + } + }, + "midnightMslp": { + "type": "Parameter", + "description": "Mean Sea Level Pressure at Local Midnight", + "unit": { + "label": "pascals", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Pa" + } + } + }, + "dayProbabilityOfSferics": { + "type": "Parameter", + "description": "Probability of Sferics During The Day", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "nightSignificantWeatherCode": { + "type": "Parameter", + "description": "Night Significant Weather Code", + "unit": { + "label": "dimensionless", + "symbol": { + "value": "https://metoffice.apiconnect.ibmcloud.com/metoffice/production/", + "type": "1" + } + } + }, + "dayProbabilityOfPrecipitation": { + "type": "Parameter", + "description": "Probability of Precipitation During The Day", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "dayProbabilityOfHeavyRain": { + "type": "Parameter", + "description": "Probability of Heavy Rain During The Day", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "dayMaxScreenTemperature": { + "type": "Parameter", + "description": "Day Maximum Screen Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "nightMinScreenTemperature": { + "type": "Parameter", + "description": "Night Minimum Screen Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "midnight10MWindDirection": { + "type": "Parameter", + "description": "10m Wind Direction at Local Midnight", + "unit": { + "label": "degrees", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "deg" + } + } + }, + "maxUvIndex": { + "type": "Parameter", + "description": "Day Maximum UV Index", + "unit": { + "label": "dimensionless", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "1" + } + } + }, + "dayProbabilityOfSnow": { + "type": "Parameter", + "description": "Probability of Snow During The Day", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "nightProbabilityOfSnow": { + "type": "Parameter", + "description": "Probability of Snow During The Night", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "dayLowerBoundMaxTemp": { + "type": "Parameter", + "description": "Lower Bound on Day Maximum Screen Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "nightProbabilityOfHeavySnow": { + "type": "Parameter", + "description": "Probability of Heavy Snow During The Night", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "dayLowerBoundMaxFeelsLikeTemp": { + "type": "Parameter", + "description": "Lower Bound on Day Maximum Feels Like Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "dayUpperBoundMaxTemp": { + "type": "Parameter", + "description": "Upper Bound on Day Maximum Screen Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "dayMaxFeelsLikeTemp": { + "type": "Parameter", + "description": "Day Maximum Feels Like Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "middayRelativeHumidity": { + "type": "Parameter", + "description": "Relative Humidity at Local Midday", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "nightLowerBoundMinTemp": { + "type": "Parameter", + "description": "Lower Bound on Night Minimum Screen Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "nightMinFeelsLikeTemp": { + "type": "Parameter", + "description": "Night Minimum Feels Like Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "nightProbabilityOfSferics": { + "type": "Parameter", + "description": "Probability of Sferics During The Night", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + } + }], + "features": [{ + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [0.0154, 50.9992, 37.0] + }, + "properties": { + "location": { + "name": "Sheffield Park" + }, + "requestPointDistance": 1081.5349, + "modelRunDate": "2024-02-17T14:00Z", + "timeSeries": [{ + "time": "2024-02-16T00:00Z", + "midday10MWindSpeed": 5.04, + "midnight10MWindSpeed": 1.39, + "midday10MWindDirection": 273, + "midnight10MWindDirection": 243, + "midday10MWindGust": 8.75, + "midnight10MWindGust": 7.2, + "middayVisibility": 28772, + "midnightVisibility": 27712, + "middayRelativeHumidity": 75.21, + "midnightRelativeHumidity": 80.91, + "middayMslp": 101680, + "midnightMslp": 102640, + "nightSignificantWeatherCode": 7, + "dayMaxScreenTemperature": 12.82, + "nightMinScreenTemperature": 5.32, + "dayUpperBoundMaxTemp": 14.1, + "nightUpperBoundMinTemp": 9.17, + "dayLowerBoundMaxTemp": 11.97, + "nightLowerBoundMinTemp": 3.56, + "nightMinFeelsLikeTemp": 6.27, + "dayUpperBoundMaxFeelsLikeTemp": 12.47, + "nightUpperBoundMinFeelsLikeTemp": 8.74, + "dayLowerBoundMaxFeelsLikeTemp": 10.01, + "nightLowerBoundMinFeelsLikeTemp": 2.75, + "nightProbabilityOfPrecipitation": 11, + "nightProbabilityOfSnow": 0, + "nightProbabilityOfHeavySnow": 0, + "nightProbabilityOfRain": 10, + "nightProbabilityOfHeavyRain": 0, + "nightProbabilityOfHail": 0, + "nightProbabilityOfSferics": 0 + }, { + "time": "2024-02-17T00:00Z", + "midday10MWindSpeed": 4.32, + "midnight10MWindSpeed": 6.1, + "midday10MWindDirection": 230, + "midnight10MWindDirection": 218, + "midday10MWindGust": 8.75, + "midnight10MWindGust": 12.98, + "middayVisibility": 4158, + "midnightVisibility": 5915, + "middayRelativeHumidity": 97.38, + "midnightRelativeHumidity": 93.62, + "middayMslp": 103140, + "midnightMslp": 102800, + "maxUvIndex": 1, + "daySignificantWeatherCode": 8, + "nightSignificantWeatherCode": 15, + "dayMaxScreenTemperature": 12.0, + "nightMinScreenTemperature": 9.96, + "dayUpperBoundMaxTemp": 13.71, + "nightUpperBoundMinTemp": 10.71, + "dayLowerBoundMaxTemp": 10.23, + "nightLowerBoundMinTemp": 9.04, + "dayMaxFeelsLikeTemp": 10.6, + "nightMinFeelsLikeTemp": 7.76, + "dayUpperBoundMaxFeelsLikeTemp": 11.49, + "nightUpperBoundMinFeelsLikeTemp": 8.28, + "dayLowerBoundMaxFeelsLikeTemp": 8.38, + "nightLowerBoundMinFeelsLikeTemp": 7.04, + "dayProbabilityOfPrecipitation": 18, + "nightProbabilityOfPrecipitation": 97, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 18, + "nightProbabilityOfRain": 97, + "dayProbabilityOfHeavyRain": 5, + "nightProbabilityOfHeavyRain": 96, + "dayProbabilityOfHail": 0, + "nightProbabilityOfHail": 20, + "dayProbabilityOfSferics": 0, + "nightProbabilityOfSferics": 10 + }, { + "time": "2024-02-18T00:00Z", + "midday10MWindSpeed": 4.07, + "midnight10MWindSpeed": 2.84, + "midday10MWindDirection": 292, + "midnight10MWindDirection": 281, + "midday10MWindGust": 7.55, + "midnight10MWindGust": 6.36, + "middayVisibility": 25425, + "midnightVisibility": 17183, + "middayRelativeHumidity": 86.19, + "midnightRelativeHumidity": 94.68, + "middayMslp": 102625, + "midnightMslp": 103041, + "maxUvIndex": 1, + "daySignificantWeatherCode": 12, + "nightSignificantWeatherCode": 7, + "dayMaxScreenTemperature": 13.89, + "nightMinScreenTemperature": 7.15, + "dayUpperBoundMaxTemp": 14.73, + "nightUpperBoundMinTemp": 9.16, + "dayLowerBoundMaxTemp": 12.4, + "nightLowerBoundMinTemp": 5.31, + "dayMaxFeelsLikeTemp": 11.75, + "nightMinFeelsLikeTemp": 5.34, + "dayUpperBoundMaxFeelsLikeTemp": 13.47, + "nightUpperBoundMinFeelsLikeTemp": 7.12, + "dayLowerBoundMaxFeelsLikeTemp": 10.8, + "nightLowerBoundMinFeelsLikeTemp": 5.06, + "dayProbabilityOfPrecipitation": 55, + "nightProbabilityOfPrecipitation": 9, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 55, + "nightProbabilityOfRain": 9, + "dayProbabilityOfHeavyRain": 36, + "nightProbabilityOfHeavyRain": 2, + "dayProbabilityOfHail": 4, + "nightProbabilityOfHail": 0, + "dayProbabilityOfSferics": 3, + "nightProbabilityOfSferics": 0 + }, { + "time": "2024-02-19T00:00Z", + "midday10MWindSpeed": 5.47, + "midnight10MWindSpeed": 2.59, + "midday10MWindDirection": 276, + "midnight10MWindDirection": 296, + "midday10MWindGust": 10.47, + "midnight10MWindGust": 4.49, + "middayVisibility": 22511, + "midnightVisibility": 23913, + "middayRelativeHumidity": 82.45, + "midnightRelativeHumidity": 89.64, + "middayMslp": 102910, + "midnightMslp": 103121, + "maxUvIndex": 1, + "daySignificantWeatherCode": 3, + "nightSignificantWeatherCode": 7, + "dayMaxScreenTemperature": 12.12, + "nightMinScreenTemperature": 4.02, + "dayUpperBoundMaxTemp": 13.27, + "nightUpperBoundMinTemp": 8.61, + "dayLowerBoundMaxTemp": 10.18, + "nightLowerBoundMinTemp": 1.52, + "dayMaxFeelsLikeTemp": 9.55, + "nightMinFeelsLikeTemp": 2.36, + "dayUpperBoundMaxFeelsLikeTemp": 11.33, + "nightUpperBoundMinFeelsLikeTemp": 6.08, + "dayLowerBoundMaxFeelsLikeTemp": 7.8, + "nightLowerBoundMinFeelsLikeTemp": 1.06, + "dayProbabilityOfPrecipitation": 49, + "nightProbabilityOfPrecipitation": 5, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 1, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 49, + "nightProbabilityOfRain": 5, + "dayProbabilityOfHeavyRain": 30, + "nightProbabilityOfHeavyRain": 0, + "dayProbabilityOfHail": 3, + "nightProbabilityOfHail": 0, + "dayProbabilityOfSferics": 4, + "nightProbabilityOfSferics": 0 + }, { + "time": "2024-02-20T00:00Z", + "midday10MWindSpeed": 7.16, + "midnight10MWindSpeed": 6.4, + "midday10MWindDirection": 230, + "midnight10MWindDirection": 232, + "midday10MWindGust": 13.73, + "midnight10MWindGust": 11.87, + "middayVisibility": 24484, + "midnightVisibility": 15050, + "middayRelativeHumidity": 81.96, + "midnightRelativeHumidity": 92.53, + "middayMslp": 102756, + "midnightMslp": 102018, + "maxUvIndex": 1, + "daySignificantWeatherCode": 7, + "nightSignificantWeatherCode": 12, + "dayMaxScreenTemperature": 10.71, + "nightMinScreenTemperature": 8.75, + "dayUpperBoundMaxTemp": 12.07, + "nightUpperBoundMinTemp": 10.16, + "dayLowerBoundMaxTemp": 9.37, + "nightLowerBoundMinTemp": 5.87, + "dayMaxFeelsLikeTemp": 7.33, + "nightMinFeelsLikeTemp": 6.1, + "dayUpperBoundMaxFeelsLikeTemp": 8.37, + "nightUpperBoundMinFeelsLikeTemp": 7.54, + "dayLowerBoundMaxFeelsLikeTemp": 6.38, + "nightLowerBoundMinFeelsLikeTemp": 4.52, + "dayProbabilityOfPrecipitation": 13, + "nightProbabilityOfPrecipitation": 84, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 13, + "nightProbabilityOfRain": 84, + "dayProbabilityOfHeavyRain": 3, + "nightProbabilityOfHeavyRain": 79, + "dayProbabilityOfHail": 0, + "nightProbabilityOfHail": 15, + "dayProbabilityOfSferics": 0, + "nightProbabilityOfSferics": 8 + }, { + "time": "2024-02-21T00:00Z", + "midday10MWindSpeed": 8.38, + "midnight10MWindSpeed": 6.06, + "midday10MWindDirection": 206, + "midnight10MWindDirection": 228, + "midday10MWindGust": 15.83, + "midnight10MWindGust": 11.11, + "middayVisibility": 7351, + "midnightVisibility": 11329, + "middayRelativeHumidity": 90.56, + "midnightRelativeHumidity": 93.31, + "middayMslp": 100933, + "midnightMslp": 99967, + "maxUvIndex": 1, + "daySignificantWeatherCode": 15, + "nightSignificantWeatherCode": 12, + "dayMaxScreenTemperature": 10.79, + "nightMinScreenTemperature": 8.58, + "dayUpperBoundMaxTemp": 13.6, + "nightUpperBoundMinTemp": 11.01, + "dayLowerBoundMaxTemp": 9.05, + "nightLowerBoundMinTemp": 4.97, + "dayMaxFeelsLikeTemp": 6.95, + "nightMinFeelsLikeTemp": 6.72, + "dayUpperBoundMaxFeelsLikeTemp": 10.54, + "nightUpperBoundMinFeelsLikeTemp": 7.88, + "dayLowerBoundMaxFeelsLikeTemp": 5.69, + "nightLowerBoundMinFeelsLikeTemp": 1.79, + "dayProbabilityOfPrecipitation": 86, + "nightProbabilityOfPrecipitation": 78, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 86, + "nightProbabilityOfRain": 78, + "dayProbabilityOfHeavyRain": 82, + "nightProbabilityOfHeavyRain": 73, + "dayProbabilityOfHail": 16, + "nightProbabilityOfHail": 14, + "dayProbabilityOfSferics": 8, + "nightProbabilityOfSferics": 7 + }, { + "time": "2024-02-22T00:00Z", + "midday10MWindSpeed": 8.32, + "midnight10MWindSpeed": 6.61, + "midday10MWindDirection": 215, + "midnight10MWindDirection": 245, + "midday10MWindGust": 15.92, + "midnight10MWindGust": 11.58, + "middayVisibility": 13595, + "midnightVisibility": 25279, + "middayRelativeHumidity": 87.24, + "midnightRelativeHumidity": 77.46, + "middayMslp": 98762, + "midnightMslp": 98742, + "maxUvIndex": 1, + "daySignificantWeatherCode": 14, + "nightSignificantWeatherCode": 2, + "dayMaxScreenTemperature": 10.99, + "nightMinScreenTemperature": 4.47, + "dayUpperBoundMaxTemp": 12.73, + "nightUpperBoundMinTemp": 6.6, + "dayLowerBoundMaxTemp": 8.4, + "nightLowerBoundMinTemp": 2.22, + "dayMaxFeelsLikeTemp": 6.86, + "nightMinFeelsLikeTemp": 1.2, + "dayUpperBoundMaxFeelsLikeTemp": 9.46, + "nightUpperBoundMinFeelsLikeTemp": 2.54, + "dayLowerBoundMaxFeelsLikeTemp": 5.2, + "nightLowerBoundMinFeelsLikeTemp": -0.95, + "dayProbabilityOfPrecipitation": 71, + "nightProbabilityOfPrecipitation": 44, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 1, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 71, + "nightProbabilityOfRain": 44, + "dayProbabilityOfHeavyRain": 67, + "nightProbabilityOfHeavyRain": 32, + "dayProbabilityOfHail": 13, + "nightProbabilityOfHail": 4, + "dayProbabilityOfSferics": 12, + "nightProbabilityOfSferics": 6 + }, { + "time": "2024-02-23T00:00Z", + "midday10MWindSpeed": 7.11, + "midnight10MWindSpeed": 4.2, + "midday10MWindDirection": 231, + "midnight10MWindDirection": 232, + "midday10MWindGust": 13.38, + "midnight10MWindGust": 7.16, + "middayVisibility": 23049, + "midnightVisibility": 22325, + "middayRelativeHumidity": 73.25, + "midnightRelativeHumidity": 85.83, + "middayMslp": 98974, + "midnightMslp": 99364, + "maxUvIndex": 2, + "daySignificantWeatherCode": 10, + "nightSignificantWeatherCode": 2, + "dayMaxScreenTemperature": 8.57, + "nightMinScreenTemperature": 3.1, + "dayUpperBoundMaxTemp": 10.67, + "nightUpperBoundMinTemp": 6.84, + "dayLowerBoundMaxTemp": 6.67, + "nightLowerBoundMinTemp": -0.72, + "dayMaxFeelsLikeTemp": 4.42, + "nightMinFeelsLikeTemp": 0.74, + "dayUpperBoundMaxFeelsLikeTemp": 7.38, + "nightUpperBoundMinFeelsLikeTemp": 4.01, + "dayLowerBoundMaxFeelsLikeTemp": 3.89, + "nightLowerBoundMinFeelsLikeTemp": -2.25, + "dayProbabilityOfPrecipitation": 52, + "nightProbabilityOfPrecipitation": 9, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 1, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 1, + "dayProbabilityOfRain": 52, + "nightProbabilityOfRain": 9, + "dayProbabilityOfHeavyRain": 48, + "nightProbabilityOfHeavyRain": 4, + "dayProbabilityOfHail": 10, + "nightProbabilityOfHail": 1, + "dayProbabilityOfSferics": 11, + "nightProbabilityOfSferics": 1 + }] + } + }] +} diff --git a/tests/unit/hourly_api_data.json b/tests/unit/hourly_api_data.json index 11302dd..2aab58b 100644 --- a/tests/unit/hourly_api_data.json +++ b/tests/unit/hourly_api_data.json @@ -1 +1,1188 @@ -{"type": "FeatureCollection", "parameters": [{"totalSnowAmount": {"type": "Parameter", "description": "Total Snow Amount Over Previous Hour", "unit": {"label": "millimetres", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "mm"}}}, "screenTemperature": {"type": "Parameter", "description": "Screen Air Temperature", "unit": {"label": "degrees Celsius", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel"}}}, "visibility": {"type": "Parameter", "description": "Visibility", "unit": {"label": "metres", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "m"}}}, "windDirectionFrom10m": {"type": "Parameter", "description": "10m Wind From Direction", "unit": {"label": "degrees", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "deg"}}}, "precipitationRate": {"type": "Parameter", "description": "Precipitation Rate", "unit": {"label": "millimetres per hour", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "mm/h"}}}, "maxScreenAirTemp": {"type": "Parameter", "description": "Maximum Screen Air Temperature Over Previous Hour", "unit": {"label": "degrees Celsius", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel"}}}, "feelsLikeTemperature": {"type": "Parameter", "description": "Feels Like Temperature", "unit": {"label": "degrees Celsius", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel"}}}, "screenDewPointTemperature": {"type": "Parameter", "description": "Screen Dew Point Temperature", "unit": {"label": "degrees Celsius", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel"}}}, "screenRelativeHumidity": {"type": "Parameter", "description": "Screen Relative Humidity", "unit": {"label": "percentage", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "%"}}}, "windSpeed10m": {"type": "Parameter", "description": "10m Wind Speed", "unit": {"label": "metres per second", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "m/s"}}}, "probOfPrecipitation": {"type": "Parameter", "description": "Probability of Precipitation", "unit": {"label": "percentage", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "%"}}}, "max10mWindGust": {"type": "Parameter", "description": "Maximum 10m Wind Gust Speed Over Previous Hour", "unit": {"label": "metres per second", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "m/s"}}}, "significantWeatherCode": {"type": "Parameter", "description": "Significant Weather Code", "unit": {"label": "dimensionless", "symbol": {"value": "https://metoffice.apiconnect.ibmcloud.com/metoffice/production/", "type": "1"}}}, "minScreenAirTemp": {"type": "Parameter", "description": "Minimum Screen Air Temperature Over Previous Hour", "unit": {"label": "degrees Celsius", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel"}}}, "totalPrecipAmount": {"type": "Parameter", "description": "Total Precipitation Amount Over Previous Hour", "unit": {"label": "millimetres", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "mm"}}}, "mslp": {"type": "Parameter", "description": "Mean Sea Level Pressure", "unit": {"label": "pascals", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "Pa"}}}, "windGustSpeed10m": {"type": "Parameter", "description": "10m Wind Gust Speed", "unit": {"label": "metres per second", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "m/s"}}}, "uvIndex": {"type": "Parameter", "description": "UV Index", "unit": {"label": "dimensionless", "symbol": {"value": "http://www.opengis.net/def/uom/UCUM/", "type": "1"}}}}], "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [0.0154, 50.9992, 37.0]}, "properties": {"location": {"name": "Sheffield Park"}, "requestPointDistance": 1081.5349, "modelRunDate": "2024-02-15T19:00Z", "timeSeries": [{"time": "2024-02-15T19:00Z", "screenTemperature": 11.0, "maxScreenAirTemp": 11.55, "minScreenAirTemp": 10.98, "screenDewPointTemperature": 8.94, "feelsLikeTemperature": 10.87, "windSpeed10m": 1.18, "windDirectionFrom10m": 180, "windGustSpeed10m": 6.69, "max10mWindGust": 8.92, "visibility": 19174, "screenRelativeHumidity": 86.99, "mslp": 100660, "uvIndex": 0, "significantWeatherCode": 2, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 4}, {"time": "2024-02-15T20:00Z", "screenTemperature": 11.38, "maxScreenAirTemp": 11.38, "minScreenAirTemp": 11.0, "screenDewPointTemperature": 9.55, "feelsLikeTemperature": 10.96, "windSpeed10m": 1.68, "windDirectionFrom10m": 96, "windGustSpeed10m": 3.55, "max10mWindGust": 5.64, "visibility": 17279, "screenRelativeHumidity": 88.39, "mslp": 100653, "uvIndex": 0, "significantWeatherCode": 2, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 1}, {"time": "2024-02-15T21:00Z", "screenTemperature": 11.49, "maxScreenAirTemp": 11.5, "minScreenAirTemp": 11.38, "screenDewPointTemperature": 9.97, "feelsLikeTemperature": 11.21, "windSpeed10m": 1.52, "windDirectionFrom10m": 79, "windGustSpeed10m": 3.34, "max10mWindGust": 5.42, "visibility": 14995, "screenRelativeHumidity": 90.31, "mslp": 100714, "uvIndex": 0, "significantWeatherCode": 7, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 4}, {"time": "2024-02-15T22:00Z", "screenTemperature": 11.18, "maxScreenAirTemp": 11.49, "minScreenAirTemp": 11.13, "screenDewPointTemperature": 9.93, "feelsLikeTemperature": 10.47, "windSpeed10m": 2.05, "windDirectionFrom10m": 112, "windGustSpeed10m": 4.34, "max10mWindGust": 5.18, "visibility": 13525, "screenRelativeHumidity": 91.97, "mslp": 100668, "uvIndex": 0, "significantWeatherCode": 2, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 0}, {"time": "2024-02-15T23:00Z", "screenTemperature": 10.84, "maxScreenAirTemp": 11.18, "minScreenAirTemp": 10.78, "screenDewPointTemperature": 9.7, "feelsLikeTemperature": 10.15, "windSpeed10m": 1.96, "windDirectionFrom10m": 108, "windGustSpeed10m": 4.74, "max10mWindGust": 5.33, "visibility": 12925, "screenRelativeHumidity": 92.64, "mslp": 100680, "uvIndex": 0, "significantWeatherCode": 2, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 1}, {"time": "2024-02-16T00:00Z", "screenTemperature": 10.53, "maxScreenAirTemp": 10.84, "minScreenAirTemp": 10.52, "screenDewPointTemperature": 9.55, "feelsLikeTemperature": 10.1, "windSpeed10m": 1.47, "windDirectionFrom10m": 108, "windGustSpeed10m": 5.09, "max10mWindGust": 5.43, "visibility": 12220, "screenRelativeHumidity": 93.66, "mslp": 100650, "uvIndex": 0, "significantWeatherCode": 2, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 1}, {"time": "2024-02-16T01:00Z", "screenTemperature": 10.72, "maxScreenAirTemp": 10.78, "minScreenAirTemp": 10.53, "screenDewPointTemperature": 9.62, "feelsLikeTemperature": 10.31, "windSpeed10m": 1.48, "windDirectionFrom10m": 135, "windGustSpeed10m": 5.05, "max10mWindGust": 5.65, "visibility": 14094, "screenRelativeHumidity": 92.91, "mslp": 100660, "uvIndex": 0, "significantWeatherCode": 7, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 5}, {"time": "2024-02-16T02:00Z", "screenTemperature": 10.73, "maxScreenAirTemp": 10.82, "minScreenAirTemp": 10.65, "screenDewPointTemperature": 9.75, "feelsLikeTemperature": 10.51, "windSpeed10m": 1.17, "windDirectionFrom10m": 185, "windGustSpeed10m": 5.33, "max10mWindGust": 5.73, "visibility": 13709, "screenRelativeHumidity": 93.64, "mslp": 100688, "uvIndex": 0, "significantWeatherCode": 2, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 2}, {"time": "2024-02-16T03:00Z", "screenTemperature": 10.93, "maxScreenAirTemp": 10.94, "minScreenAirTemp": 10.73, "screenDewPointTemperature": 9.92, "feelsLikeTemperature": 10.38, "windSpeed10m": 1.61, "windDirectionFrom10m": 240, "windGustSpeed10m": 5.97, "max10mWindGust": 6.16, "visibility": 13864, "screenRelativeHumidity": 93.55, "mslp": 100744, "uvIndex": 0, "significantWeatherCode": 7, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 6}, {"time": "2024-02-16T04:00Z", "screenTemperature": 11.03, "maxScreenAirTemp": 11.05, "minScreenAirTemp": 10.93, "screenDewPointTemperature": 10.21, "feelsLikeTemperature": 9.95, "windSpeed10m": 2.59, "windDirectionFrom10m": 279, "windGustSpeed10m": 7.23, "max10mWindGust": 7.35, "visibility": 11033, "screenRelativeHumidity": 94.71, "mslp": 100806, "uvIndex": 0, "significantWeatherCode": 8, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 13}, {"time": "2024-02-16T05:00Z", "screenTemperature": 11.27, "maxScreenAirTemp": 11.28, "minScreenAirTemp": 11.03, "screenDewPointTemperature": 10.54, "feelsLikeTemperature": 9.74, "windSpeed10m": 3.47, "windDirectionFrom10m": 288, "windGustSpeed10m": 8.23, "max10mWindGust": 8.45, "visibility": 14201, "screenRelativeHumidity": 95.25, "mslp": 100909, "uvIndex": 0, "significantWeatherCode": 12, "precipitationRate": 0.23, "totalPrecipAmount": 0.08, "totalSnowAmount": 0, "probOfPrecipitation": 43}, {"time": "2024-02-16T06:00Z", "screenTemperature": 10.92, "maxScreenAirTemp": 11.13, "minScreenAirTemp": 10.9, "screenDewPointTemperature": 10.04, "feelsLikeTemperature": 9.49, "windSpeed10m": 3.18, "windDirectionFrom10m": 283, "windGustSpeed10m": 7.71, "max10mWindGust": 8.27, "visibility": 25090, "screenRelativeHumidity": 94.41, "mslp": 101042, "uvIndex": 0, "significantWeatherCode": 7, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 7}, {"time": "2024-02-16T07:00Z", "screenTemperature": 10.62, "maxScreenAirTemp": 10.92, "minScreenAirTemp": 10.61, "screenDewPointTemperature": 9.43, "feelsLikeTemperature": 8.87, "windSpeed10m": 3.68, "windDirectionFrom10m": 279, "windGustSpeed10m": 8.1, "max10mWindGust": 8.55, "visibility": 21863, "screenRelativeHumidity": 92.4, "mslp": 101186, "uvIndex": 0, "significantWeatherCode": 7, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 7}, {"time": "2024-02-16T08:00Z", "screenTemperature": 10.3, "maxScreenAirTemp": 10.62, "minScreenAirTemp": 10.27, "screenDewPointTemperature": 8.77, "feelsLikeTemperature": 8.27, "windSpeed10m": 4.15, "windDirectionFrom10m": 278, "windGustSpeed10m": 8.77, "max10mWindGust": 8.86, "visibility": 17499, "screenRelativeHumidity": 90.34, "mslp": 101326, "uvIndex": 1, "significantWeatherCode": 3, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 1}, {"time": "2024-02-16T09:00Z", "screenTemperature": 10.46, "maxScreenAirTemp": 10.47, "minScreenAirTemp": 10.3, "screenDewPointTemperature": 8.47, "feelsLikeTemperature": 8.26, "windSpeed10m": 4.59, "windDirectionFrom10m": 279, "windGustSpeed10m": 8.75, "max10mWindGust": 8.75, "visibility": 16833, "screenRelativeHumidity": 87.56, "mslp": 101456, "uvIndex": 1, "significantWeatherCode": 3, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 0}, {"time": "2024-02-16T10:00Z", "screenTemperature": 11.07, "maxScreenAirTemp": 11.09, "minScreenAirTemp": 10.46, "screenDewPointTemperature": 8.27, "feelsLikeTemperature": 8.92, "windSpeed10m": 4.68, "windDirectionFrom10m": 276, "windGustSpeed10m": 8.7, "max10mWindGust": 8.7, "visibility": 20678, "screenRelativeHumidity": 82.98, "mslp": 101557, "uvIndex": 1, "significantWeatherCode": 3, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 0}, {"time": "2024-02-16T11:00Z", "screenTemperature": 11.71, "maxScreenAirTemp": 11.74, "minScreenAirTemp": 11.07, "screenDewPointTemperature": 7.9, "feelsLikeTemperature": 9.42, "windSpeed10m": 5.16, "windDirectionFrom10m": 273, "windGustSpeed10m": 9.42, "max10mWindGust": 9.42, "visibility": 30259, "screenRelativeHumidity": 77.53, "mslp": 101647, "uvIndex": 1, "significantWeatherCode": 1, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 0}, {"time": "2024-02-16T12:00Z", "screenTemperature": 12.37, "maxScreenAirTemp": 12.39, "minScreenAirTemp": 11.71, "screenDewPointTemperature": 7.67, "feelsLikeTemperature": 10.03, "windSpeed10m": 5.39, "windDirectionFrom10m": 275, "windGustSpeed10m": 9.94, "max10mWindGust": 9.94, "visibility": 34462, "screenRelativeHumidity": 73.05, "mslp": 101700, "uvIndex": 1, "significantWeatherCode": 3, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 0}, {"time": "2024-02-16T13:00Z", "screenTemperature": 12.91, "maxScreenAirTemp": 12.94, "minScreenAirTemp": 12.37, "screenDewPointTemperature": 7.75, "feelsLikeTemperature": 10.76, "windSpeed10m": 4.97, "windDirectionFrom10m": 271, "windGustSpeed10m": 9.39, "max10mWindGust": 9.65, "visibility": 37584, "screenRelativeHumidity": 70.9, "mslp": 101750, "uvIndex": 1, "significantWeatherCode": 3, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 0}, {"time": "2024-02-16T14:00Z", "screenTemperature": 12.9, "maxScreenAirTemp": 13.04, "minScreenAirTemp": 12.69, "screenDewPointTemperature": 7.06, "feelsLikeTemperature": 10.54, "windSpeed10m": 5.39, "windDirectionFrom10m": 270, "windGustSpeed10m": 10.16, "max10mWindGust": 10.16, "visibility": 38254, "screenRelativeHumidity": 67.76, "mslp": 101813, "uvIndex": 1, "significantWeatherCode": 3, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 1}, {"time": "2024-02-16T15:00Z", "screenTemperature": 12.79, "maxScreenAirTemp": 12.9, "minScreenAirTemp": 12.72, "screenDewPointTemperature": 6.92, "feelsLikeTemperature": 10.69, "windSpeed10m": 4.78, "windDirectionFrom10m": 267, "windGustSpeed10m": 9.36, "max10mWindGust": 9.96, "visibility": 38521, "screenRelativeHumidity": 67.6, "mslp": 101887, "uvIndex": 1, "significantWeatherCode": 3, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 1}, {"time": "2024-02-16T16:00Z", "screenTemperature": 12.06, "maxScreenAirTemp": 12.79, "minScreenAirTemp": 12.04, "screenDewPointTemperature": 6.8, "feelsLikeTemperature": 10.23, "windSpeed10m": 4.07, "windDirectionFrom10m": 266, "windGustSpeed10m": 8.66, "max10mWindGust": 9.27, "visibility": 37284, "screenRelativeHumidity": 70.37, "mslp": 101980, "uvIndex": 1, "significantWeatherCode": 1, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 0}, {"time": "2024-02-16T17:00Z", "screenTemperature": 10.8, "maxScreenAirTemp": 12.06, "minScreenAirTemp": 10.77, "screenDewPointTemperature": 6.94, "feelsLikeTemperature": 9.36, "windSpeed10m": 3.16, "windDirectionFrom10m": 261, "windGustSpeed10m": 8.0, "max10mWindGust": 8.43, "visibility": 33668, "screenRelativeHumidity": 77.2, "mslp": 102066, "uvIndex": 1, "significantWeatherCode": 1, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 0}, {"time": "2024-02-16T18:00Z", "screenTemperature": 9.6, "maxScreenAirTemp": 10.8, "minScreenAirTemp": 9.58, "screenDewPointTemperature": 6.7, "feelsLikeTemperature": 8.37, "windSpeed10m": 2.58, "windDirectionFrom10m": 257, "windGustSpeed10m": 7.4, "max10mWindGust": 8.52, "visibility": 29126, "screenRelativeHumidity": 82.34, "mslp": 102166, "uvIndex": 0, "significantWeatherCode": 0, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 0}, {"time": "2024-02-16T19:00Z", "screenTemperature": 8.94, "maxScreenAirTemp": 9.6, "minScreenAirTemp": 8.9, "screenDewPointTemperature": 6.8, "feelsLikeTemperature": 7.68, "windSpeed10m": 2.46, "windDirectionFrom10m": 255, "windGustSpeed10m": 7.35, "max10mWindGust": 8.02, "visibility": 22767, "screenRelativeHumidity": 86.81, "mslp": 102246, "uvIndex": 0, "significantWeatherCode": 0, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 0}, {"time": "2024-02-16T20:00Z", "screenTemperature": 8.42, "maxScreenAirTemp": 8.94, "minScreenAirTemp": 8.41, "screenDewPointTemperature": 6.3, "feelsLikeTemperature": 7.06, "windSpeed10m": 2.45, "windDirectionFrom10m": 263, "windGustSpeed10m": 7.47, "max10mWindGust": 7.99, "visibility": 21802, "screenRelativeHumidity": 86.73, "mslp": 102329, "uvIndex": 0, "significantWeatherCode": 0, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 0}, {"time": "2024-02-16T21:00Z", "screenTemperature": 7.87, "maxScreenAirTemp": 8.42, "minScreenAirTemp": 7.86, "screenDewPointTemperature": 6.11, "feelsLikeTemperature": 6.51, "windSpeed10m": 2.33, "windDirectionFrom10m": 267, "windGustSpeed10m": 7.0, "max10mWindGust": 7.86, "visibility": 21303, "screenRelativeHumidity": 88.8, "mslp": 102406, "uvIndex": 0, "significantWeatherCode": 0, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 0}, {"time": "2024-02-16T22:00Z", "screenTemperature": 7.48, "maxScreenAirTemp": 7.87, "minScreenAirTemp": 7.46, "screenDewPointTemperature": 5.89, "feelsLikeTemperature": 6.06, "windSpeed10m": 2.32, "windDirectionFrom10m": 274, "windGustSpeed10m": 6.96, "max10mWindGust": 7.77, "visibility": 20523, "screenRelativeHumidity": 89.86, "mslp": 102479, "uvIndex": 0, "significantWeatherCode": 0, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 1}, {"time": "2024-02-16T23:00Z", "screenTemperature": 7.04, "maxScreenAirTemp": 7.48, "minScreenAirTemp": 7.01, "screenDewPointTemperature": 5.66, "feelsLikeTemperature": 5.68, "windSpeed10m": 2.18, "windDirectionFrom10m": 280, "windGustSpeed10m": 6.69, "max10mWindGust": 7.53, "visibility": 20867, "screenRelativeHumidity": 91.09, "mslp": 102545, "uvIndex": 0, "significantWeatherCode": 0, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 1}, {"time": "2024-02-17T00:00Z", "screenTemperature": 6.7, "maxScreenAirTemp": 7.04, "minScreenAirTemp": 6.67, "screenDewPointTemperature": 5.59, "feelsLikeTemperature": 5.32, "windSpeed10m": 2.11, "windDirectionFrom10m": 281, "windGustSpeed10m": 6.34, "max10mWindGust": 7.01, "visibility": 20045, "screenRelativeHumidity": 92.83, "mslp": 102614, "uvIndex": 0, "significantWeatherCode": 0, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 1}, {"time": "2024-02-17T01:00Z", "screenTemperature": 6.26, "maxScreenAirTemp": 6.7, "minScreenAirTemp": 6.21, "screenDewPointTemperature": 5.34, "feelsLikeTemperature": 4.85, "windSpeed10m": 2.05, "windDirectionFrom10m": 283, "windGustSpeed10m": 6.13, "max10mWindGust": 6.88, "visibility": 18378, "screenRelativeHumidity": 94.11, "mslp": 102657, "uvIndex": 0, "significantWeatherCode": 0, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 1}, {"time": "2024-02-17T02:00Z", "screenTemperature": 5.72, "maxScreenAirTemp": 6.26, "minScreenAirTemp": 5.66, "screenDewPointTemperature": 5.02, "feelsLikeTemperature": 4.45, "windSpeed10m": 1.73, "windDirectionFrom10m": 282, "windGustSpeed10m": 5.73, "max10mWindGust": 6.69, "visibility": 14463, "screenRelativeHumidity": 95.57, "mslp": 102697, "uvIndex": 0, "significantWeatherCode": 0, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 2}, {"time": "2024-02-17T03:00Z", "screenTemperature": 5.22, "maxScreenAirTemp": 5.72, "minScreenAirTemp": 5.17, "screenDewPointTemperature": 4.68, "feelsLikeTemperature": 4.03, "windSpeed10m": 1.37, "windDirectionFrom10m": 267, "windGustSpeed10m": 5.08, "max10mWindGust": 6.2, "visibility": 12881, "screenRelativeHumidity": 96.48, "mslp": 102729, "uvIndex": 0, "significantWeatherCode": 0, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 3}, {"time": "2024-02-17T04:00Z", "screenTemperature": 5.44, "maxScreenAirTemp": 5.46, "minScreenAirTemp": 5.22, "screenDewPointTemperature": 4.9, "feelsLikeTemperature": 4.28, "windSpeed10m": 1.22, "windDirectionFrom10m": 234, "windGustSpeed10m": 4.72, "max10mWindGust": 5.38, "visibility": 13816, "screenRelativeHumidity": 96.53, "mslp": 102767, "uvIndex": 0, "significantWeatherCode": 2, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 2}, {"time": "2024-02-17T05:00Z", "screenTemperature": 5.67, "maxScreenAirTemp": 5.73, "minScreenAirTemp": 5.44, "screenDewPointTemperature": 5.3, "feelsLikeTemperature": 4.55, "windSpeed10m": 1.28, "windDirectionFrom10m": 182, "windGustSpeed10m": 3.99, "max10mWindGust": 4.95, "visibility": 4000, "screenRelativeHumidity": 97.73, "mslp": 102819, "uvIndex": 0, "significantWeatherCode": 7, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 5}, {"time": "2024-02-17T06:00Z", "screenTemperature": 6.01, "maxScreenAirTemp": 6.24, "minScreenAirTemp": 5.67, "screenDewPointTemperature": 5.66, "feelsLikeTemperature": 4.95, "windSpeed10m": 1.51, "windDirectionFrom10m": 156, "windGustSpeed10m": 3.65, "max10mWindGust": 3.87, "visibility": 999, "screenRelativeHumidity": 97.85, "mslp": 102866, "uvIndex": 0, "significantWeatherCode": 6, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 17}, {"time": "2024-02-17T07:00Z", "screenTemperature": 6.74, "maxScreenAirTemp": 6.75, "minScreenAirTemp": 6.01, "screenDewPointTemperature": 6.32, "feelsLikeTemperature": 5.73, "windSpeed10m": 1.63, "windDirectionFrom10m": 161, "windGustSpeed10m": 4.12, "max10mWindGust": 4.12, "visibility": 4213, "screenRelativeHumidity": 97.43, "mslp": 102918, "uvIndex": 0, "significantWeatherCode": 7, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 8}, {"time": "2024-02-17T08:00Z", "screenTemperature": 7.5, "maxScreenAirTemp": 7.54, "minScreenAirTemp": 6.74, "screenDewPointTemperature": 7.09, "feelsLikeTemperature": 6.54, "windSpeed10m": 1.8, "windDirectionFrom10m": 171, "windGustSpeed10m": 4.56, "max10mWindGust": 4.56, "visibility": 5736, "screenRelativeHumidity": 97.44, "mslp": 102968, "uvIndex": 1, "significantWeatherCode": 7, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 14}, {"time": "2024-02-17T09:00Z", "screenTemperature": 8.46, "maxScreenAirTemp": 8.48, "minScreenAirTemp": 7.5, "screenDewPointTemperature": 7.89, "feelsLikeTemperature": 7.54, "windSpeed10m": 1.9, "windDirectionFrom10m": 176, "windGustSpeed10m": 5.23, "max10mWindGust": 5.23, "visibility": 5476, "screenRelativeHumidity": 96.48, "mslp": 103015, "uvIndex": 1, "significantWeatherCode": 7, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 15}, {"time": "2024-02-17T10:00Z", "screenTemperature": 9.31, "maxScreenAirTemp": 9.32, "minScreenAirTemp": 8.46, "screenDewPointTemperature": 8.71, "feelsLikeTemperature": 8.16, "windSpeed10m": 2.49, "windDirectionFrom10m": 194, "windGustSpeed10m": 5.87, "max10mWindGust": 6.19, "visibility": 5671, "screenRelativeHumidity": 96.3, "mslp": 103039, "uvIndex": 1, "significantWeatherCode": 8, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 13}, {"time": "2024-02-17T11:00Z", "screenTemperature": 10.23, "maxScreenAirTemp": 10.23, "minScreenAirTemp": 9.31, "screenDewPointTemperature": 9.34, "feelsLikeTemperature": 8.78, "windSpeed10m": 3.08, "windDirectionFrom10m": 202, "windGustSpeed10m": 6.31, "max10mWindGust": 6.31, "visibility": 11241, "screenRelativeHumidity": 94.6, "mslp": 103069, "uvIndex": 1, "significantWeatherCode": 8, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 13}, {"time": "2024-02-17T12:00Z", "screenTemperature": 10.79, "maxScreenAirTemp": 10.82, "minScreenAirTemp": 10.23, "screenDewPointTemperature": 9.48, "feelsLikeTemperature": 9.18, "windSpeed10m": 3.5, "windDirectionFrom10m": 203, "windGustSpeed10m": 6.77, "max10mWindGust": 6.77, "visibility": 13088, "screenRelativeHumidity": 92.01, "mslp": 103068, "uvIndex": 1, "significantWeatherCode": 8, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 13}, {"time": "2024-02-17T13:00Z", "screenTemperature": 10.84, "maxScreenAirTemp": 10.84, "minScreenAirTemp": 10.79, "screenDewPointTemperature": 9.5, "feelsLikeTemperature": 9.17, "windSpeed10m": 3.63, "windDirectionFrom10m": 202, "windGustSpeed10m": 7.09, "max10mWindGust": 7.09, "visibility": 13756, "screenRelativeHumidity": 91.77, "mslp": 103050, "uvIndex": 1, "significantWeatherCode": 8, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 14}, {"time": "2024-02-17T14:00Z", "screenTemperature": 10.63, "maxScreenAirTemp": 10.84, "minScreenAirTemp": 10.63, "screenDewPointTemperature": 9.58, "feelsLikeTemperature": 8.92, "windSpeed10m": 3.62, "windDirectionFrom10m": 201, "windGustSpeed10m": 7.07, "max10mWindGust": 7.07, "visibility": 12109, "screenRelativeHumidity": 93.68, "mslp": 103021, "uvIndex": 1, "significantWeatherCode": 8, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 14}, {"time": "2024-02-17T15:00Z", "screenTemperature": 10.62, "maxScreenAirTemp": 10.73, "minScreenAirTemp": 10.6, "screenDewPointTemperature": 9.53, "feelsLikeTemperature": 8.92, "windSpeed10m": 3.61, "windDirectionFrom10m": 200, "windGustSpeed10m": 7.22, "max10mWindGust": 7.22, "visibility": 12463, "screenRelativeHumidity": 93.39, "mslp": 103003, "uvIndex": 1, "significantWeatherCode": 8, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 12}, {"time": "2024-02-17T16:00Z", "screenTemperature": 10.57, "maxScreenAirTemp": 10.62, "minScreenAirTemp": 10.56, "screenDewPointTemperature": 9.47, "feelsLikeTemperature": 8.88, "windSpeed10m": 3.65, "windDirectionFrom10m": 197, "windGustSpeed10m": 7.38, "max10mWindGust": 7.38, "visibility": 12932, "screenRelativeHumidity": 93.29, "mslp": 102986, "uvIndex": 1, "significantWeatherCode": 8, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 12}, {"time": "2024-02-17T17:00Z", "screenTemperature": 10.45, "maxScreenAirTemp": 10.57, "minScreenAirTemp": 10.44, "screenDewPointTemperature": 9.39, "feelsLikeTemperature": 8.75, "windSpeed10m": 3.67, "windDirectionFrom10m": 191, "windGustSpeed10m": 7.54, "max10mWindGust": 7.54, "visibility": 11295, "screenRelativeHumidity": 93.5, "mslp": 102968, "uvIndex": 1, "significantWeatherCode": 8, "precipitationRate": 0.0, "totalPrecipAmount": 0.0, "totalSnowAmount": 0, "probOfPrecipitation": 14}, {"time": "2024-02-17T18:00Z", "screenTemperature": 10.25, "screenDewPointTemperature": 9.3, "feelsLikeTemperature": 8.46, "windSpeed10m": 3.77, "windDirectionFrom10m": 182, "windGustSpeed10m": 7.94, "visibility": 10383, "screenRelativeHumidity": 94.28, "mslp": 102949, "uvIndex": 0, "significantWeatherCode": 7, "precipitationRate": 0.0, "probOfPrecipitation": 11}, {"time": "2024-02-17T19:00Z", "screenTemperature": 10.34, "screenDewPointTemperature": 9.37, "feelsLikeTemperature": 8.34, "windSpeed10m": 4.29, "windDirectionFrom10m": 187, "windGustSpeed10m": 8.68, "visibility": 10128, "screenRelativeHumidity": 94.17, "mslp": 102910, "uvIndex": 0, "significantWeatherCode": 8, "precipitationRate": 0.0, "probOfPrecipitation": 16}]}}]} \ No newline at end of file +{ + "type": "FeatureCollection", + "parameters": [{ + "totalSnowAmount": { + "type": "Parameter", + "description": "Total Snow Amount Over Previous Hour", + "unit": { + "label": "millimetres", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "mm" + } + } + }, + "screenTemperature": { + "type": "Parameter", + "description": "Screen Air Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "visibility": { + "type": "Parameter", + "description": "Visibility", + "unit": { + "label": "metres", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m" + } + } + }, + "windDirectionFrom10m": { + "type": "Parameter", + "description": "10m Wind From Direction", + "unit": { + "label": "degrees", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "deg" + } + } + }, + "precipitationRate": { + "type": "Parameter", + "description": "Precipitation Rate", + "unit": { + "label": "millimetres per hour", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "mm/h" + } + } + }, + "maxScreenAirTemp": { + "type": "Parameter", + "description": "Maximum Screen Air Temperature Over Previous Hour", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "feelsLikeTemperature": { + "type": "Parameter", + "description": "Feels Like Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "screenDewPointTemperature": { + "type": "Parameter", + "description": "Screen Dew Point Temperature", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "screenRelativeHumidity": { + "type": "Parameter", + "description": "Screen Relative Humidity", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "windSpeed10m": { + "type": "Parameter", + "description": "10m Wind Speed", + "unit": { + "label": "metres per second", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m/s" + } + } + }, + "probOfPrecipitation": { + "type": "Parameter", + "description": "Probability of Precipitation", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "max10mWindGust": { + "type": "Parameter", + "description": "Maximum 10m Wind Gust Speed Over Previous Hour", + "unit": { + "label": "metres per second", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m/s" + } + } + }, + "significantWeatherCode": { + "type": "Parameter", + "description": "Significant Weather Code", + "unit": { + "label": "dimensionless", + "symbol": { + "value": "https://metoffice.apiconnect.ibmcloud.com/metoffice/production/", + "type": "1" + } + } + }, + "minScreenAirTemp": { + "type": "Parameter", + "description": "Minimum Screen Air Temperature Over Previous Hour", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "totalPrecipAmount": { + "type": "Parameter", + "description": "Total Precipitation Amount Over Previous Hour", + "unit": { + "label": "millimetres", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "mm" + } + } + }, + "mslp": { + "type": "Parameter", + "description": "Mean Sea Level Pressure", + "unit": { + "label": "pascals", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Pa" + } + } + }, + "windGustSpeed10m": { + "type": "Parameter", + "description": "10m Wind Gust Speed", + "unit": { + "label": "metres per second", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m/s" + } + } + }, + "uvIndex": { + "type": "Parameter", + "description": "UV Index", + "unit": { + "label": "dimensionless", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "1" + } + } + } + }], + "features": [{ + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [0.0154, 50.9992, 37.0] + }, + "properties": { + "location": { + "name": "Sheffield Park" + }, + "requestPointDistance": 1081.5349, + "modelRunDate": "2024-02-15T19:00Z", + "timeSeries": [{ + "time": "2024-02-15T19:00Z", + "screenTemperature": 11.0, + "maxScreenAirTemp": 11.55, + "minScreenAirTemp": 10.98, + "screenDewPointTemperature": 8.94, + "feelsLikeTemperature": 10.87, + "windSpeed10m": 1.18, + "windDirectionFrom10m": 180, + "windGustSpeed10m": 6.69, + "max10mWindGust": 8.92, + "visibility": 19174, + "screenRelativeHumidity": 86.99, + "mslp": 100660, + "uvIndex": 0, + "significantWeatherCode": 2, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 4 + }, { + "time": "2024-02-15T20:00Z", + "screenTemperature": 11.38, + "maxScreenAirTemp": 11.38, + "minScreenAirTemp": 11.0, + "screenDewPointTemperature": 9.55, + "feelsLikeTemperature": 10.96, + "windSpeed10m": 1.68, + "windDirectionFrom10m": 96, + "windGustSpeed10m": 3.55, + "max10mWindGust": 5.64, + "visibility": 17279, + "screenRelativeHumidity": 88.39, + "mslp": 100653, + "uvIndex": 0, + "significantWeatherCode": 2, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 1 + }, { + "time": "2024-02-15T21:00Z", + "screenTemperature": 11.49, + "maxScreenAirTemp": 11.5, + "minScreenAirTemp": 11.38, + "screenDewPointTemperature": 9.97, + "feelsLikeTemperature": 11.21, + "windSpeed10m": 1.52, + "windDirectionFrom10m": 79, + "windGustSpeed10m": 3.34, + "max10mWindGust": 5.42, + "visibility": 14995, + "screenRelativeHumidity": 90.31, + "mslp": 100714, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 4 + }, { + "time": "2024-02-15T22:00Z", + "screenTemperature": 11.18, + "maxScreenAirTemp": 11.49, + "minScreenAirTemp": 11.13, + "screenDewPointTemperature": 9.93, + "feelsLikeTemperature": 10.47, + "windSpeed10m": 2.05, + "windDirectionFrom10m": 112, + "windGustSpeed10m": 4.34, + "max10mWindGust": 5.18, + "visibility": 13525, + "screenRelativeHumidity": 91.97, + "mslp": 100668, + "uvIndex": 0, + "significantWeatherCode": 2, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 0 + }, { + "time": "2024-02-15T23:00Z", + "screenTemperature": 10.84, + "maxScreenAirTemp": 11.18, + "minScreenAirTemp": 10.78, + "screenDewPointTemperature": 9.7, + "feelsLikeTemperature": 10.15, + "windSpeed10m": 1.96, + "windDirectionFrom10m": 108, + "windGustSpeed10m": 4.74, + "max10mWindGust": 5.33, + "visibility": 12925, + "screenRelativeHumidity": 92.64, + "mslp": 100680, + "uvIndex": 0, + "significantWeatherCode": 2, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 1 + }, { + "time": "2024-02-16T00:00Z", + "screenTemperature": 10.53, + "maxScreenAirTemp": 10.84, + "minScreenAirTemp": 10.52, + "screenDewPointTemperature": 9.55, + "feelsLikeTemperature": 10.1, + "windSpeed10m": 1.47, + "windDirectionFrom10m": 108, + "windGustSpeed10m": 5.09, + "max10mWindGust": 5.43, + "visibility": 12220, + "screenRelativeHumidity": 93.66, + "mslp": 100650, + "uvIndex": 0, + "significantWeatherCode": 2, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 1 + }, { + "time": "2024-02-16T01:00Z", + "screenTemperature": 10.72, + "maxScreenAirTemp": 10.78, + "minScreenAirTemp": 10.53, + "screenDewPointTemperature": 9.62, + "feelsLikeTemperature": 10.31, + "windSpeed10m": 1.48, + "windDirectionFrom10m": 135, + "windGustSpeed10m": 5.05, + "max10mWindGust": 5.65, + "visibility": 14094, + "screenRelativeHumidity": 92.91, + "mslp": 100660, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, { + "time": "2024-02-16T02:00Z", + "screenTemperature": 10.73, + "maxScreenAirTemp": 10.82, + "minScreenAirTemp": 10.65, + "screenDewPointTemperature": 9.75, + "feelsLikeTemperature": 10.51, + "windSpeed10m": 1.17, + "windDirectionFrom10m": 185, + "windGustSpeed10m": 5.33, + "max10mWindGust": 5.73, + "visibility": 13709, + "screenRelativeHumidity": 93.64, + "mslp": 100688, + "uvIndex": 0, + "significantWeatherCode": 2, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 2 + }, { + "time": "2024-02-16T03:00Z", + "screenTemperature": 10.93, + "maxScreenAirTemp": 10.94, + "minScreenAirTemp": 10.73, + "screenDewPointTemperature": 9.92, + "feelsLikeTemperature": 10.38, + "windSpeed10m": 1.61, + "windDirectionFrom10m": 240, + "windGustSpeed10m": 5.97, + "max10mWindGust": 6.16, + "visibility": 13864, + "screenRelativeHumidity": 93.55, + "mslp": 100744, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, { + "time": "2024-02-16T04:00Z", + "screenTemperature": 11.03, + "maxScreenAirTemp": 11.05, + "minScreenAirTemp": 10.93, + "screenDewPointTemperature": 10.21, + "feelsLikeTemperature": 9.95, + "windSpeed10m": 2.59, + "windDirectionFrom10m": 279, + "windGustSpeed10m": 7.23, + "max10mWindGust": 7.35, + "visibility": 11033, + "screenRelativeHumidity": 94.71, + "mslp": 100806, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 13 + }, { + "time": "2024-02-16T05:00Z", + "screenTemperature": 11.27, + "maxScreenAirTemp": 11.28, + "minScreenAirTemp": 11.03, + "screenDewPointTemperature": 10.54, + "feelsLikeTemperature": 9.74, + "windSpeed10m": 3.47, + "windDirectionFrom10m": 288, + "windGustSpeed10m": 8.23, + "max10mWindGust": 8.45, + "visibility": 14201, + "screenRelativeHumidity": 95.25, + "mslp": 100909, + "uvIndex": 0, + "significantWeatherCode": 12, + "precipitationRate": 0.23, + "totalPrecipAmount": 0.08, + "totalSnowAmount": 0, + "probOfPrecipitation": 43 + }, { + "time": "2024-02-16T06:00Z", + "screenTemperature": 10.92, + "maxScreenAirTemp": 11.13, + "minScreenAirTemp": 10.9, + "screenDewPointTemperature": 10.04, + "feelsLikeTemperature": 9.49, + "windSpeed10m": 3.18, + "windDirectionFrom10m": 283, + "windGustSpeed10m": 7.71, + "max10mWindGust": 8.27, + "visibility": 25090, + "screenRelativeHumidity": 94.41, + "mslp": 101042, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 7 + }, { + "time": "2024-02-16T07:00Z", + "screenTemperature": 10.62, + "maxScreenAirTemp": 10.92, + "minScreenAirTemp": 10.61, + "screenDewPointTemperature": 9.43, + "feelsLikeTemperature": 8.87, + "windSpeed10m": 3.68, + "windDirectionFrom10m": 279, + "windGustSpeed10m": 8.1, + "max10mWindGust": 8.55, + "visibility": 21863, + "screenRelativeHumidity": 92.4, + "mslp": 101186, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 7 + }, { + "time": "2024-02-16T08:00Z", + "screenTemperature": 10.3, + "maxScreenAirTemp": 10.62, + "minScreenAirTemp": 10.27, + "screenDewPointTemperature": 8.77, + "feelsLikeTemperature": 8.27, + "windSpeed10m": 4.15, + "windDirectionFrom10m": 278, + "windGustSpeed10m": 8.77, + "max10mWindGust": 8.86, + "visibility": 17499, + "screenRelativeHumidity": 90.34, + "mslp": 101326, + "uvIndex": 1, + "significantWeatherCode": 3, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 1 + }, { + "time": "2024-02-16T09:00Z", + "screenTemperature": 10.46, + "maxScreenAirTemp": 10.47, + "minScreenAirTemp": 10.3, + "screenDewPointTemperature": 8.47, + "feelsLikeTemperature": 8.26, + "windSpeed10m": 4.59, + "windDirectionFrom10m": 279, + "windGustSpeed10m": 8.75, + "max10mWindGust": 8.75, + "visibility": 16833, + "screenRelativeHumidity": 87.56, + "mslp": 101456, + "uvIndex": 1, + "significantWeatherCode": 3, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 0 + }, { + "time": "2024-02-16T10:00Z", + "screenTemperature": 11.07, + "maxScreenAirTemp": 11.09, + "minScreenAirTemp": 10.46, + "screenDewPointTemperature": 8.27, + "feelsLikeTemperature": 8.92, + "windSpeed10m": 4.68, + "windDirectionFrom10m": 276, + "windGustSpeed10m": 8.7, + "max10mWindGust": 8.7, + "visibility": 20678, + "screenRelativeHumidity": 82.98, + "mslp": 101557, + "uvIndex": 1, + "significantWeatherCode": 3, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 0 + }, { + "time": "2024-02-16T11:00Z", + "screenTemperature": 11.71, + "maxScreenAirTemp": 11.74, + "minScreenAirTemp": 11.07, + "screenDewPointTemperature": 7.9, + "feelsLikeTemperature": 9.42, + "windSpeed10m": 5.16, + "windDirectionFrom10m": 273, + "windGustSpeed10m": 9.42, + "max10mWindGust": 9.42, + "visibility": 30259, + "screenRelativeHumidity": 77.53, + "mslp": 101647, + "uvIndex": 1, + "significantWeatherCode": 1, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 0 + }, { + "time": "2024-02-16T12:00Z", + "screenTemperature": 12.37, + "maxScreenAirTemp": 12.39, + "minScreenAirTemp": 11.71, + "screenDewPointTemperature": 7.67, + "feelsLikeTemperature": 10.03, + "windSpeed10m": 5.39, + "windDirectionFrom10m": 275, + "windGustSpeed10m": 9.94, + "max10mWindGust": 9.94, + "visibility": 34462, + "screenRelativeHumidity": 73.05, + "mslp": 101700, + "uvIndex": 1, + "significantWeatherCode": 3, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 0 + }, { + "time": "2024-02-16T13:00Z", + "screenTemperature": 12.91, + "maxScreenAirTemp": 12.94, + "minScreenAirTemp": 12.37, + "screenDewPointTemperature": 7.75, + "feelsLikeTemperature": 10.76, + "windSpeed10m": 4.97, + "windDirectionFrom10m": 271, + "windGustSpeed10m": 9.39, + "max10mWindGust": 9.65, + "visibility": 37584, + "screenRelativeHumidity": 70.9, + "mslp": 101750, + "uvIndex": 1, + "significantWeatherCode": 3, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 0 + }, { + "time": "2024-02-16T14:00Z", + "screenTemperature": 12.9, + "maxScreenAirTemp": 13.04, + "minScreenAirTemp": 12.69, + "screenDewPointTemperature": 7.06, + "feelsLikeTemperature": 10.54, + "windSpeed10m": 5.39, + "windDirectionFrom10m": 270, + "windGustSpeed10m": 10.16, + "max10mWindGust": 10.16, + "visibility": 38254, + "screenRelativeHumidity": 67.76, + "mslp": 101813, + "uvIndex": 1, + "significantWeatherCode": 3, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 1 + }, { + "time": "2024-02-16T15:00Z", + "screenTemperature": 12.79, + "maxScreenAirTemp": 12.9, + "minScreenAirTemp": 12.72, + "screenDewPointTemperature": 6.92, + "feelsLikeTemperature": 10.69, + "windSpeed10m": 4.78, + "windDirectionFrom10m": 267, + "windGustSpeed10m": 9.36, + "max10mWindGust": 9.96, + "visibility": 38521, + "screenRelativeHumidity": 67.6, + "mslp": 101887, + "uvIndex": 1, + "significantWeatherCode": 3, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 1 + }, { + "time": "2024-02-16T16:00Z", + "screenTemperature": 12.06, + "maxScreenAirTemp": 12.79, + "minScreenAirTemp": 12.04, + "screenDewPointTemperature": 6.8, + "feelsLikeTemperature": 10.23, + "windSpeed10m": 4.07, + "windDirectionFrom10m": 266, + "windGustSpeed10m": 8.66, + "max10mWindGust": 9.27, + "visibility": 37284, + "screenRelativeHumidity": 70.37, + "mslp": 101980, + "uvIndex": 1, + "significantWeatherCode": 1, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 0 + }, { + "time": "2024-02-16T17:00Z", + "screenTemperature": 10.8, + "maxScreenAirTemp": 12.06, + "minScreenAirTemp": 10.77, + "screenDewPointTemperature": 6.94, + "feelsLikeTemperature": 9.36, + "windSpeed10m": 3.16, + "windDirectionFrom10m": 261, + "windGustSpeed10m": 8.0, + "max10mWindGust": 8.43, + "visibility": 33668, + "screenRelativeHumidity": 77.2, + "mslp": 102066, + "uvIndex": 1, + "significantWeatherCode": 1, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 0 + }, { + "time": "2024-02-16T18:00Z", + "screenTemperature": 9.6, + "maxScreenAirTemp": 10.8, + "minScreenAirTemp": 9.58, + "screenDewPointTemperature": 6.7, + "feelsLikeTemperature": 8.37, + "windSpeed10m": 2.58, + "windDirectionFrom10m": 257, + "windGustSpeed10m": 7.4, + "max10mWindGust": 8.52, + "visibility": 29126, + "screenRelativeHumidity": 82.34, + "mslp": 102166, + "uvIndex": 0, + "significantWeatherCode": 0, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 0 + }, { + "time": "2024-02-16T19:00Z", + "screenTemperature": 8.94, + "maxScreenAirTemp": 9.6, + "minScreenAirTemp": 8.9, + "screenDewPointTemperature": 6.8, + "feelsLikeTemperature": 7.68, + "windSpeed10m": 2.46, + "windDirectionFrom10m": 255, + "windGustSpeed10m": 7.35, + "max10mWindGust": 8.02, + "visibility": 22767, + "screenRelativeHumidity": 86.81, + "mslp": 102246, + "uvIndex": 0, + "significantWeatherCode": 0, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 0 + }, { + "time": "2024-02-16T20:00Z", + "screenTemperature": 8.42, + "maxScreenAirTemp": 8.94, + "minScreenAirTemp": 8.41, + "screenDewPointTemperature": 6.3, + "feelsLikeTemperature": 7.06, + "windSpeed10m": 2.45, + "windDirectionFrom10m": 263, + "windGustSpeed10m": 7.47, + "max10mWindGust": 7.99, + "visibility": 21802, + "screenRelativeHumidity": 86.73, + "mslp": 102329, + "uvIndex": 0, + "significantWeatherCode": 0, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 0 + }, { + "time": "2024-02-16T21:00Z", + "screenTemperature": 7.87, + "maxScreenAirTemp": 8.42, + "minScreenAirTemp": 7.86, + "screenDewPointTemperature": 6.11, + "feelsLikeTemperature": 6.51, + "windSpeed10m": 2.33, + "windDirectionFrom10m": 267, + "windGustSpeed10m": 7.0, + "max10mWindGust": 7.86, + "visibility": 21303, + "screenRelativeHumidity": 88.8, + "mslp": 102406, + "uvIndex": 0, + "significantWeatherCode": 0, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 0 + }, { + "time": "2024-02-16T22:00Z", + "screenTemperature": 7.48, + "maxScreenAirTemp": 7.87, + "minScreenAirTemp": 7.46, + "screenDewPointTemperature": 5.89, + "feelsLikeTemperature": 6.06, + "windSpeed10m": 2.32, + "windDirectionFrom10m": 274, + "windGustSpeed10m": 6.96, + "max10mWindGust": 7.77, + "visibility": 20523, + "screenRelativeHumidity": 89.86, + "mslp": 102479, + "uvIndex": 0, + "significantWeatherCode": 0, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 1 + }, { + "time": "2024-02-16T23:00Z", + "screenTemperature": 7.04, + "maxScreenAirTemp": 7.48, + "minScreenAirTemp": 7.01, + "screenDewPointTemperature": 5.66, + "feelsLikeTemperature": 5.68, + "windSpeed10m": 2.18, + "windDirectionFrom10m": 280, + "windGustSpeed10m": 6.69, + "max10mWindGust": 7.53, + "visibility": 20867, + "screenRelativeHumidity": 91.09, + "mslp": 102545, + "uvIndex": 0, + "significantWeatherCode": 0, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 1 + }, { + "time": "2024-02-17T00:00Z", + "screenTemperature": 6.7, + "maxScreenAirTemp": 7.04, + "minScreenAirTemp": 6.67, + "screenDewPointTemperature": 5.59, + "feelsLikeTemperature": 5.32, + "windSpeed10m": 2.11, + "windDirectionFrom10m": 281, + "windGustSpeed10m": 6.34, + "max10mWindGust": 7.01, + "visibility": 20045, + "screenRelativeHumidity": 92.83, + "mslp": 102614, + "uvIndex": 0, + "significantWeatherCode": 0, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 1 + }, { + "time": "2024-02-17T01:00Z", + "screenTemperature": 6.26, + "maxScreenAirTemp": 6.7, + "minScreenAirTemp": 6.21, + "screenDewPointTemperature": 5.34, + "feelsLikeTemperature": 4.85, + "windSpeed10m": 2.05, + "windDirectionFrom10m": 283, + "windGustSpeed10m": 6.13, + "max10mWindGust": 6.88, + "visibility": 18378, + "screenRelativeHumidity": 94.11, + "mslp": 102657, + "uvIndex": 0, + "significantWeatherCode": 0, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 1 + }, { + "time": "2024-02-17T02:00Z", + "screenTemperature": 5.72, + "maxScreenAirTemp": 6.26, + "minScreenAirTemp": 5.66, + "screenDewPointTemperature": 5.02, + "feelsLikeTemperature": 4.45, + "windSpeed10m": 1.73, + "windDirectionFrom10m": 282, + "windGustSpeed10m": 5.73, + "max10mWindGust": 6.69, + "visibility": 14463, + "screenRelativeHumidity": 95.57, + "mslp": 102697, + "uvIndex": 0, + "significantWeatherCode": 0, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 2 + }, { + "time": "2024-02-17T03:00Z", + "screenTemperature": 5.22, + "maxScreenAirTemp": 5.72, + "minScreenAirTemp": 5.17, + "screenDewPointTemperature": 4.68, + "feelsLikeTemperature": 4.03, + "windSpeed10m": 1.37, + "windDirectionFrom10m": 267, + "windGustSpeed10m": 5.08, + "max10mWindGust": 6.2, + "visibility": 12881, + "screenRelativeHumidity": 96.48, + "mslp": 102729, + "uvIndex": 0, + "significantWeatherCode": 0, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 3 + }, { + "time": "2024-02-17T04:00Z", + "screenTemperature": 5.44, + "maxScreenAirTemp": 5.46, + "minScreenAirTemp": 5.22, + "screenDewPointTemperature": 4.9, + "feelsLikeTemperature": 4.28, + "windSpeed10m": 1.22, + "windDirectionFrom10m": 234, + "windGustSpeed10m": 4.72, + "max10mWindGust": 5.38, + "visibility": 13816, + "screenRelativeHumidity": 96.53, + "mslp": 102767, + "uvIndex": 0, + "significantWeatherCode": 2, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 2 + }, { + "time": "2024-02-17T05:00Z", + "screenTemperature": 5.67, + "maxScreenAirTemp": 5.73, + "minScreenAirTemp": 5.44, + "screenDewPointTemperature": 5.3, + "feelsLikeTemperature": 4.55, + "windSpeed10m": 1.28, + "windDirectionFrom10m": 182, + "windGustSpeed10m": 3.99, + "max10mWindGust": 4.95, + "visibility": 4000, + "screenRelativeHumidity": 97.73, + "mslp": 102819, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, { + "time": "2024-02-17T06:00Z", + "screenTemperature": 6.01, + "maxScreenAirTemp": 6.24, + "minScreenAirTemp": 5.67, + "screenDewPointTemperature": 5.66, + "feelsLikeTemperature": 4.95, + "windSpeed10m": 1.51, + "windDirectionFrom10m": 156, + "windGustSpeed10m": 3.65, + "max10mWindGust": 3.87, + "visibility": 999, + "screenRelativeHumidity": 97.85, + "mslp": 102866, + "uvIndex": 0, + "significantWeatherCode": 6, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 17 + }, { + "time": "2024-02-17T07:00Z", + "screenTemperature": 6.74, + "maxScreenAirTemp": 6.75, + "minScreenAirTemp": 6.01, + "screenDewPointTemperature": 6.32, + "feelsLikeTemperature": 5.73, + "windSpeed10m": 1.63, + "windDirectionFrom10m": 161, + "windGustSpeed10m": 4.12, + "max10mWindGust": 4.12, + "visibility": 4213, + "screenRelativeHumidity": 97.43, + "mslp": 102918, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 8 + }, { + "time": "2024-02-17T08:00Z", + "screenTemperature": 7.5, + "maxScreenAirTemp": 7.54, + "minScreenAirTemp": 6.74, + "screenDewPointTemperature": 7.09, + "feelsLikeTemperature": 6.54, + "windSpeed10m": 1.8, + "windDirectionFrom10m": 171, + "windGustSpeed10m": 4.56, + "max10mWindGust": 4.56, + "visibility": 5736, + "screenRelativeHumidity": 97.44, + "mslp": 102968, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 14 + }, { + "time": "2024-02-17T09:00Z", + "screenTemperature": 8.46, + "maxScreenAirTemp": 8.48, + "minScreenAirTemp": 7.5, + "screenDewPointTemperature": 7.89, + "feelsLikeTemperature": 7.54, + "windSpeed10m": 1.9, + "windDirectionFrom10m": 176, + "windGustSpeed10m": 5.23, + "max10mWindGust": 5.23, + "visibility": 5476, + "screenRelativeHumidity": 96.48, + "mslp": 103015, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 15 + }, { + "time": "2024-02-17T10:00Z", + "screenTemperature": 9.31, + "maxScreenAirTemp": 9.32, + "minScreenAirTemp": 8.46, + "screenDewPointTemperature": 8.71, + "feelsLikeTemperature": 8.16, + "windSpeed10m": 2.49, + "windDirectionFrom10m": 194, + "windGustSpeed10m": 5.87, + "max10mWindGust": 6.19, + "visibility": 5671, + "screenRelativeHumidity": 96.3, + "mslp": 103039, + "uvIndex": 1, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 13 + }, { + "time": "2024-02-17T11:00Z", + "screenTemperature": 10.23, + "maxScreenAirTemp": 10.23, + "minScreenAirTemp": 9.31, + "screenDewPointTemperature": 9.34, + "feelsLikeTemperature": 8.78, + "windSpeed10m": 3.08, + "windDirectionFrom10m": 202, + "windGustSpeed10m": 6.31, + "max10mWindGust": 6.31, + "visibility": 11241, + "screenRelativeHumidity": 94.6, + "mslp": 103069, + "uvIndex": 1, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 13 + }, { + "time": "2024-02-17T12:00Z", + "screenTemperature": 10.79, + "maxScreenAirTemp": 10.82, + "minScreenAirTemp": 10.23, + "screenDewPointTemperature": 9.48, + "feelsLikeTemperature": 9.18, + "windSpeed10m": 3.5, + "windDirectionFrom10m": 203, + "windGustSpeed10m": 6.77, + "max10mWindGust": 6.77, + "visibility": 13088, + "screenRelativeHumidity": 92.01, + "mslp": 103068, + "uvIndex": 1, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 13 + }, { + "time": "2024-02-17T13:00Z", + "screenTemperature": 10.84, + "maxScreenAirTemp": 10.84, + "minScreenAirTemp": 10.79, + "screenDewPointTemperature": 9.5, + "feelsLikeTemperature": 9.17, + "windSpeed10m": 3.63, + "windDirectionFrom10m": 202, + "windGustSpeed10m": 7.09, + "max10mWindGust": 7.09, + "visibility": 13756, + "screenRelativeHumidity": 91.77, + "mslp": 103050, + "uvIndex": 1, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 14 + }, { + "time": "2024-02-17T14:00Z", + "screenTemperature": 10.63, + "maxScreenAirTemp": 10.84, + "minScreenAirTemp": 10.63, + "screenDewPointTemperature": 9.58, + "feelsLikeTemperature": 8.92, + "windSpeed10m": 3.62, + "windDirectionFrom10m": 201, + "windGustSpeed10m": 7.07, + "max10mWindGust": 7.07, + "visibility": 12109, + "screenRelativeHumidity": 93.68, + "mslp": 103021, + "uvIndex": 1, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 14 + }, { + "time": "2024-02-17T15:00Z", + "screenTemperature": 10.62, + "maxScreenAirTemp": 10.73, + "minScreenAirTemp": 10.6, + "screenDewPointTemperature": 9.53, + "feelsLikeTemperature": 8.92, + "windSpeed10m": 3.61, + "windDirectionFrom10m": 200, + "windGustSpeed10m": 7.22, + "max10mWindGust": 7.22, + "visibility": 12463, + "screenRelativeHumidity": 93.39, + "mslp": 103003, + "uvIndex": 1, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 12 + }, { + "time": "2024-02-17T16:00Z", + "screenTemperature": 10.57, + "maxScreenAirTemp": 10.62, + "minScreenAirTemp": 10.56, + "screenDewPointTemperature": 9.47, + "feelsLikeTemperature": 8.88, + "windSpeed10m": 3.65, + "windDirectionFrom10m": 197, + "windGustSpeed10m": 7.38, + "max10mWindGust": 7.38, + "visibility": 12932, + "screenRelativeHumidity": 93.29, + "mslp": 102986, + "uvIndex": 1, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 12 + }, { + "time": "2024-02-17T17:00Z", + "screenTemperature": 10.45, + "maxScreenAirTemp": 10.57, + "minScreenAirTemp": 10.44, + "screenDewPointTemperature": 9.39, + "feelsLikeTemperature": 8.75, + "windSpeed10m": 3.67, + "windDirectionFrom10m": 191, + "windGustSpeed10m": 7.54, + "max10mWindGust": 7.54, + "visibility": 11295, + "screenRelativeHumidity": 93.5, + "mslp": 102968, + "uvIndex": 1, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 14 + }, { + "time": "2024-02-17T18:00Z", + "screenTemperature": 10.25, + "screenDewPointTemperature": 9.3, + "feelsLikeTemperature": 8.46, + "windSpeed10m": 3.77, + "windDirectionFrom10m": 182, + "windGustSpeed10m": 7.94, + "visibility": 10383, + "screenRelativeHumidity": 94.28, + "mslp": 102949, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "probOfPrecipitation": 11 + }, { + "time": "2024-02-17T19:00Z", + "screenTemperature": 10.34, + "screenDewPointTemperature": 9.37, + "feelsLikeTemperature": 8.34, + "windSpeed10m": 4.29, + "windDirectionFrom10m": 187, + "windGustSpeed10m": 8.68, + "visibility": 10128, + "screenRelativeHumidity": 94.17, + "mslp": 102910, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "probOfPrecipitation": 16 + }] + } + }] +} diff --git a/tests/unit/reference_data_test_forecast.py b/tests/unit/reference_data_test_forecast.py new file mode 100644 index 0000000..0ed1c7f --- /dev/null +++ b/tests/unit/reference_data_test_forecast.py @@ -0,0 +1,1073 @@ +import datetime + +EXPECTED_FIRST_HOURLY_TIMESTEP = { + "time": datetime.datetime(2024, 2, 15, 19, 0, tzinfo=datetime.timezone.utc), + "screenTemperature": { + "value": 11.0, + "description": "Screen Air Temperature", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "maxScreenAirTemp": { + "value": 11.55, + "description": "Maximum Screen Air Temperature Over Previous Hour", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "minScreenAirTemp": { + "value": 10.98, + "description": "Minimum Screen Air Temperature Over Previous Hour", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "screenDewPointTemperature": { + "value": 8.94, + "description": "Screen Dew Point Temperature", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "feelsLikeTemperature": { + "value": 10.87, + "description": "Feels Like Temperature", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "windSpeed10m": { + "value": 1.18, + "description": "10m Wind Speed", + "unit_name": "metres per second", + "unit_symbol": "m/s", + }, + "windDirectionFrom10m": { + "value": 180, + "description": "10m Wind From Direction", + "unit_name": "degrees", + "unit_symbol": "deg", + }, + "windGustSpeed10m": { + "value": 6.69, + "description": "10m Wind Gust Speed", + "unit_name": "metres per second", + "unit_symbol": "m/s", + }, + "max10mWindGust": { + "value": 8.92, + "description": "Maximum 10m Wind Gust Speed Over Previous Hour", + "unit_name": "metres per second", + "unit_symbol": "m/s", + }, + "visibility": { + "value": 19174, + "description": "Visibility", + "unit_name": "metres", + "unit_symbol": "m", + }, + "screenRelativeHumidity": { + "value": 86.99, + "description": "Screen Relative Humidity", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "mslp": { + "value": 100660, + "description": "Mean Sea Level Pressure", + "unit_name": "pascals", + "unit_symbol": "Pa", + }, + "uvIndex": { + "value": 0, + "description": "UV Index", + "unit_name": "dimensionless", + "unit_symbol": "1", + }, + "significantWeatherCode": { + "value": "Partly cloudy", + "description": "Significant Weather Code", + "unit_name": "dimensionless", + "unit_symbol": "1", + }, + "precipitationRate": { + "value": 0.0, + "description": "Precipitation Rate", + "unit_name": "millimetres per hour", + "unit_symbol": "mm/h", + }, + "totalPrecipAmount": { + "value": 0.0, + "description": "Total Precipitation Amount Over Previous Hour", + "unit_name": "millimetres", + "unit_symbol": "mm", + }, + "totalSnowAmount": { + "value": 0, + "description": "Total Snow Amount Over Previous Hour", + "unit_name": "millimetres", + "unit_symbol": "mm", + }, + "probOfPrecipitation": { + "value": 4, + "description": "Probability of Precipitation", + "unit_name": "percentage", + "unit_symbol": "%", + }, +} + +EXPECTED_AT_DATETIME_HOURLY_TIMESTEP = { + "time": datetime.datetime(2024, 2, 16, 19, 0, tzinfo=datetime.timezone.utc), + "screenTemperature": { + "value": 8.94, + "description": "Screen Air Temperature", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "maxScreenAirTemp": { + "value": 9.6, + "description": "Maximum Screen Air Temperature Over Previous Hour", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "minScreenAirTemp": { + "value": 8.9, + "description": "Minimum Screen Air Temperature Over Previous Hour", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "screenDewPointTemperature": { + "value": 6.8, + "description": "Screen Dew Point Temperature", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "feelsLikeTemperature": { + "value": 7.68, + "description": "Feels Like Temperature", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "windSpeed10m": { + "value": 2.46, + "description": "10m Wind Speed", + "unit_name": "metres per second", + "unit_symbol": "m/s", + }, + "windDirectionFrom10m": { + "value": 255, + "description": "10m Wind From Direction", + "unit_name": "degrees", + "unit_symbol": "deg", + }, + "windGustSpeed10m": { + "value": 7.35, + "description": "10m Wind Gust Speed", + "unit_name": "metres per second", + "unit_symbol": "m/s", + }, + "max10mWindGust": { + "value": 8.02, + "description": "Maximum 10m Wind Gust Speed Over Previous Hour", + "unit_name": "metres per second", + "unit_symbol": "m/s", + }, + "visibility": { + "value": 22767, + "description": "Visibility", + "unit_name": "metres", + "unit_symbol": "m", + }, + "screenRelativeHumidity": { + "value": 86.81, + "description": "Screen Relative Humidity", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "mslp": { + "value": 102246, + "description": "Mean Sea Level Pressure", + "unit_name": "pascals", + "unit_symbol": "Pa", + }, + "uvIndex": { + "value": 0, + "description": "UV Index", + "unit_name": "dimensionless", + "unit_symbol": "1", + }, + "significantWeatherCode": { + "value": "Clear night", + "description": "Significant Weather Code", + "unit_name": "dimensionless", + "unit_symbol": "1", + }, + "precipitationRate": { + "value": 0.0, + "description": "Precipitation Rate", + "unit_name": "millimetres per hour", + "unit_symbol": "mm/h", + }, + "totalPrecipAmount": { + "value": 0.0, + "description": "Total Precipitation Amount Over Previous Hour", + "unit_name": "millimetres", + "unit_symbol": "mm", + }, + "totalSnowAmount": { + "value": 0, + "description": "Total Snow Amount Over Previous Hour", + "unit_name": "millimetres", + "unit_symbol": "mm", + }, + "probOfPrecipitation": { + "value": 0, + "description": "Probability of Precipitation", + "unit_name": "percentage", + "unit_symbol": "%", + }, +} + +EXPECTED_AT_DATETIME_HOURLY_FINAL_TIMESTEP = { + "time": datetime.datetime(2024, 2, 17, 19, 0, tzinfo=datetime.timezone.utc), + "screenTemperature": { + "value": 10.34, + "description": "Screen Air Temperature", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "screenDewPointTemperature": { + "value": 9.37, + "description": "Screen Dew Point Temperature", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "feelsLikeTemperature": { + "value": 8.34, + "description": "Feels Like Temperature", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "windSpeed10m": { + "value": 4.29, + "description": "10m Wind Speed", + "unit_name": "metres per second", + "unit_symbol": "m/s", + }, + "windDirectionFrom10m": { + "value": 187, + "description": "10m Wind From Direction", + "unit_name": "degrees", + "unit_symbol": "deg", + }, + "windGustSpeed10m": { + "value": 8.68, + "description": "10m Wind Gust Speed", + "unit_name": "metres per second", + "unit_symbol": "m/s", + }, + "visibility": { + "value": 10128, + "description": "Visibility", + "unit_name": "metres", + "unit_symbol": "m", + }, + "screenRelativeHumidity": { + "value": 94.17, + "description": "Screen Relative Humidity", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "mslp": { + "value": 102910, + "description": "Mean Sea Level Pressure", + "unit_name": "pascals", + "unit_symbol": "Pa", + }, + "uvIndex": { + "value": 0, + "description": "UV Index", + "unit_name": "dimensionless", + "unit_symbol": "1", + }, + "significantWeatherCode": { + "value": "Overcast", + "description": "Significant Weather Code", + "unit_name": "dimensionless", + "unit_symbol": "1", + }, + "precipitationRate": { + "value": 0.0, + "description": "Precipitation Rate", + "unit_name": "millimetres per hour", + "unit_symbol": "mm/h", + }, + "probOfPrecipitation": { + "value": 16, + "description": "Probability of Precipitation", + "unit_name": "percentage", + "unit_symbol": "%", + }, +} +EXPECTED_FIRST_DAILY_TIMESTEP = { + "time": datetime.datetime(2024, 2, 16, 0, 0, tzinfo=datetime.timezone.utc), + "10MWindSpeed": { + "value": 1.39, + "description": "10m Wind Speed at Local Midnight", + "unit_name": "metres per second", + "unit_symbol": "m/s", + }, + "10MWindDirection": { + "value": 243, + "description": "10m Wind Direction at Local Midnight", + "unit_name": "degrees", + "unit_symbol": "deg", + }, + "10MWindGust": { + "value": 7.2, + "description": "10m Wind Gust Speed at Local Midnight", + "unit_name": "metres per second", + "unit_symbol": "m/s", + }, + "Visibility": { + "value": 27712, + "description": "Visibility at Local Midnight", + "unit_name": "metres", + "unit_symbol": "m", + }, + "RelativeHumidity": { + "value": 80.91, + "description": "Relative Humidity at Local Midnight", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "Mslp": { + "value": 102640, + "description": "Mean Sea Level Pressure at Local Midnight", + "unit_name": "pascals", + "unit_symbol": "Pa", + }, + "SignificantWeatherCode": { + "value": 7, + "description": "Night Significant Weather Code", + "unit_name": "dimensionless", + "unit_symbol": "1", + }, + "MinScreenTemperature": { + "value": 5.32, + "description": "Night Minimum Screen Air Temperature", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "UpperBoundMinTemp": { + "value": 9.17, + "description": "Upper Bound on Night Minimum Screen Air Temperature", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "LowerBoundMinTemp": { + "value": 3.56, + "description": "Lower Bound on Night Minimum Screen Air Temperature", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "MinFeelsLikeTemp": { + "value": 6.27, + "description": "Night Minimum Feels Like Air Temperature", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "UpperBoundMinFeelsLikeTemp": { + "value": 8.74, + "description": "Upper Bound on Night Minimum Feels Like Air Temperature", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "LowerBoundMinFeelsLikeTemp": { + "value": 2.75, + "description": "Lower Bound on Night Minimum Feels Like Air Temperature", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "ProbabilityOfPrecipitation": { + "value": 11, + "description": "Probability of Precipitation During The Night", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "ProbabilityOfSnow": { + "value": 0, + "description": "Probability of Snow During The Night", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "ProbabilityOfHeavySnow": { + "value": 0, + "description": "Probability of Heavy Snow During The Night", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "ProbabilityOfRain": { + "value": 10, + "description": "Probability of Rain During The Night", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "ProbabilityOfHeavyRain": { + "value": 0, + "description": "Probability of Heavy Rain During The Night", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "ProbabilityOfHail": { + "value": 0, + "description": "Probability of Hail During The Night", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "ProbabilityOfSferics": { + "value": 0, + "description": "Probability of Sferics During The Night", + "unit_name": "percentage", + "unit_symbol": "%", + }, +} + +EXPECTED_AT_DATETIME_DAILY_TIMESTEP = { + "time": datetime.datetime(2024, 2, 17, 0, 0, tzinfo=datetime.timezone.utc), + "10MWindSpeed": { + "value": 6.1, + "description": "10m Wind Speed at Local Midnight", + "unit_name": "metres per second", + "unit_symbol": "m/s", + }, + "10MWindDirection": { + "value": 218, + "description": "10m Wind Direction at Local Midnight", + "unit_name": "degrees", + "unit_symbol": "deg", + }, + "10MWindGust": { + "value": 12.98, + "description": "10m Wind Gust Speed at Local Midnight", + "unit_name": "metres per second", + "unit_symbol": "m/s", + }, + "Visibility": { + "value": 5915, + "description": "Visibility at Local Midnight", + "unit_name": "metres", + "unit_symbol": "m", + }, + "RelativeHumidity": { + "value": 93.62, + "description": "Relative Humidity at Local Midnight", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "Mslp": { + "value": 102800, + "description": "Mean Sea Level Pressure at Local Midnight", + "unit_name": "pascals", + "unit_symbol": "Pa", + }, + "SignificantWeatherCode": { + "value": 15, + "description": "Night Significant Weather Code", + "unit_name": "dimensionless", + "unit_symbol": "1", + }, + "MinScreenTemperature": { + "value": 9.96, + "description": "Night Minimum Screen Air Temperature", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "UpperBoundMinTemp": { + "value": 10.71, + "description": "Upper Bound on Night Minimum Screen Air Temperature", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "LowerBoundMinTemp": { + "value": 9.04, + "description": "Lower Bound on Night Minimum Screen Air Temperature", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "MinFeelsLikeTemp": { + "value": 7.76, + "description": "Night Minimum Feels Like Air Temperature", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "UpperBoundMinFeelsLikeTemp": { + "value": 8.28, + "description": "Upper Bound on Night Minimum Feels Like Air Temperature", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "LowerBoundMinFeelsLikeTemp": { + "value": 7.04, + "description": "Lower Bound on Night Minimum Feels Like Air Temperature", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "ProbabilityOfPrecipitation": { + "value": 97, + "description": "Probability of Precipitation During The Night", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "ProbabilityOfSnow": { + "value": 0, + "description": "Probability of Snow During The Night", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "ProbabilityOfHeavySnow": { + "value": 0, + "description": "Probability of Heavy Snow During The Night", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "ProbabilityOfRain": { + "value": 97, + "description": "Probability of Rain During The Night", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "ProbabilityOfHeavyRain": { + "value": 96, + "description": "Probability of Heavy Rain During The Night", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "ProbabilityOfHail": { + "value": 20, + "description": "Probability of Hail During The Night", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "ProbabilityOfSferics": { + "value": 10, + "description": "Probability of Sferics During The Night", + "unit_name": "percentage", + "unit_symbol": "%", + }, +} +EXPECTED_AT_DATETIME_DAILY_FINAL_TIMESTEP = { + "time": datetime.datetime(2024, 2, 23, 12, 0, tzinfo=datetime.timezone.utc), + "10MWindSpeed": { + "value": 7.11, + "description": "10m Wind Speed at Local Midday", + "unit_name": "metres per second", + "unit_symbol": "m/s", + }, + "10MWindDirection": { + "value": 231, + "description": "10m Wind Direction at Local Midday", + "unit_name": "degrees", + "unit_symbol": "deg", + }, + "10MWindGust": { + "value": 13.38, + "description": "10m Wind Gust Speed at Local Midday", + "unit_name": "metres per second", + "unit_symbol": "m/s", + }, + "Visibility": { + "value": 23049, + "description": "Visibility at Local Midday", + "unit_name": "metres", + "unit_symbol": "m", + }, + "RelativeHumidity": { + "value": 73.25, + "description": "Relative Humidity at Local Midday", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "Mslp": { + "value": 98974, + "description": "Mean Sea Level Pressure at Local Midday", + "unit_name": "pascals", + "unit_symbol": "Pa", + }, + "maxUvIndex": { + "value": 2, + "description": "Day Maximum UV Index", + "unit_name": "dimensionless", + "unit_symbol": "1", + }, + "SignificantWeatherCode": { + "value": 10, + "description": "Day Significant Weather Code", + "unit_name": "dimensionless", + "unit_symbol": "1", + }, + "MaxScreenTemperature": { + "value": 8.57, + "description": "Day Maximum Screen Air Temperature", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "UpperBoundMaxTemp": { + "value": 10.67, + "description": "Upper Bound on Day Maximum Screen Air Temperature", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "LowerBoundMaxTemp": { + "value": 6.67, + "description": "Lower Bound on Day Maximum Screen Air Temperature", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "MaxFeelsLikeTemp": { + "value": 4.42, + "description": "Day Maximum Feels Like Air Temperature", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "UpperBoundMaxFeelsLikeTemp": { + "value": 7.38, + "description": "Upper Bound on Day Maximum Feels Like Air Temperature", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "LowerBoundMaxFeelsLikeTemp": { + "value": 3.89, + "description": "Lower Bound on Day Maximum Feels Like Air Temperature", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "ProbabilityOfPrecipitation": { + "value": 52, + "description": "Probability of Precipitation During The Day", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "ProbabilityOfSnow": { + "value": 0, + "description": "Probability of Snow During The Day", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "ProbabilityOfHeavySnow": { + "value": 0, + "description": "Probability of Heavy Snow During The Day", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "ProbabilityOfRain": { + "value": 52, + "description": "Probability of Rain During The Day", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "ProbabilityOfHeavyRain": { + "value": 48, + "description": "Probability of Heavy Rain During The Day", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "ProbabilityOfHail": { + "value": 10, + "description": "Probability of Hail During The Day", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "ProbabilityOfSferics": { + "value": 11, + "description": "Probability of Sferics During The Day", + "unit_name": "percentage", + "unit_symbol": "%", + }, +} + +EXPECTED_FIRST_THREE_HOURLY_TIMESTEP = { + "time": datetime.datetime(2024, 2, 17, 15, 0, tzinfo=datetime.timezone.utc), + "maxScreenAirTemp": { + "value": 12.0, + "description": "Maximum Screen Air Temperature Over Previous Three Hours", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "minScreenAirTemp": { + "value": 10.73, + "description": "Minimum Screen Air Temperature Over Previous Three Hours", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "max10mWindGust": { + "value": 8.87, + "description": "Maximum 10m Wind Gust Speed Over Previous Three Hours", + "unit_name": "metres per second", + "unit_symbol": "m/s", + }, + "significantWeatherCode": { + "value": "Overcast", + "description": "Three Hour Significant Weather Code", + "unit_name": "dimensionless", + "unit_symbol": "1", + }, + "totalPrecipAmount": { + "value": 0.0, + "description": "Total Precipitation Amount Over Previous Three Hours", + "unit_name": "millimetres", + "unit_symbol": "mm", + }, + "totalSnowAmount": { + "value": 0, + "description": "Total Snow Amount Over Previous Three Hours", + "unit_name": "millimetres", + "unit_symbol": "mm", + }, + "windSpeed10m": { + "value": 5.04, + "description": "10m Wind Speed Over Previous Three Hours", + "unit_name": "metres per second", + "unit_symbol": "m/s", + }, + "windDirectionFrom10m": { + "value": 225, + "description": "10m Wind From Direction Over Previous Three Hours", + "unit_name": "degrees", + "unit_symbol": "deg", + }, + "windGustSpeed10m": { + "value": 8.75, + "description": "10m Wind Gust Speed Over Previous Three Hours", + "unit_name": "metres per second", + "unit_symbol": "m/s", + }, + "visibility": { + "value": 9118, + "description": "Visibility Over Previous Three Hours", + "unit_name": "metres", + "unit_symbol": "m", + }, + "mslp": { + "value": 103090, + "description": "Mean Sea Level Pressure Over Previous Three Hours", + "unit_name": "pascals", + "unit_symbol": "Pa", + }, + "screenRelativeHumidity": { + "value": 96.84, + "description": "Screen Relative Humidity Over Previous Three Hours", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "feelsLikeTemp": { + "value": 8.53, + "description": "Feels Like Temperature Over Previous Three Hours", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "uvIndex": { + "value": 1, + "description": "Maximum UV Index Over Previous Three Hours", + "unit_name": "dimensionless", + "unit_symbol": "1", + }, + "probOfPrecipitation": { + "value": 10, + "description": "Three Hour Probability of Precipitation", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "probOfSnow": { + "value": 0, + "description": "Three Hour Probability of Snow", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "probOfHeavySnow": { + "value": 0, + "description": "Three Hour Probability of Heavy Snow", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "probOfRain": { + "value": 10, + "description": "Three Hour Probability of Rain", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "probOfHeavyRain": { + "value": 0, + "description": "Three Hour Probability of Heavy Rain", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "probOfHail": { + "value": 0, + "description": "Three Hour Probability of Hail", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "probOfSferics": { + "value": 0, + "description": "Three Hour Probability of Sferics", + "unit_name": "percentage", + "unit_symbol": "%", + }, +} + +EXPECTED_AT_DATETIME_THREE_HOURLY_TIMESTEP = { + "time": datetime.datetime(2024, 2, 22, 18, 0, tzinfo=datetime.timezone.utc), + "maxScreenAirTemp": { + "value": 9.82, + "description": "Maximum Screen Air Temperature Over Previous Three Hours", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "minScreenAirTemp": { + "value": 7.61, + "description": "Minimum Screen Air Temperature Over Previous Three Hours", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "max10mWindGust": { + "value": 19.28, + "description": "Maximum 10m Wind Gust Speed Over Previous Three Hours", + "unit_name": "metres per second", + "unit_symbol": "m/s", + }, + "significantWeatherCode": { + "value": "Light rain shower", + "description": "Three Hour Significant Weather Code", + "unit_name": "dimensionless", + "unit_symbol": "1", + }, + "totalPrecipAmount": { + "value": 1.11, + "description": "Total Precipitation Amount Over Previous Three Hours", + "unit_name": "millimetres", + "unit_symbol": "mm", + }, + "totalSnowAmount": { + "value": 0, + "description": "Total Snow Amount Over Previous Three Hours", + "unit_name": "millimetres", + "unit_symbol": "mm", + }, + "windSpeed10m": { + "value": 7.91, + "description": "10m Wind Speed Over Previous Three Hours", + "unit_name": "metres per second", + "unit_symbol": "m/s", + }, + "windDirectionFrom10m": { + "value": 239, + "description": "10m Wind From Direction Over Previous Three Hours", + "unit_name": "degrees", + "unit_symbol": "deg", + }, + "windGustSpeed10m": { + "value": 14.42, + "description": "10m Wind Gust Speed Over Previous Three Hours", + "unit_name": "metres per second", + "unit_symbol": "m/s", + }, + "visibility": { + "value": 20148, + "description": "Visibility Over Previous Three Hours", + "unit_name": "metres", + "unit_symbol": "m", + }, + "mslp": { + "value": 98434, + "description": "Mean Sea Level Pressure Over Previous Three Hours", + "unit_name": "pascals", + "unit_symbol": "Pa", + }, + "screenRelativeHumidity": { + "value": 79.36, + "description": "Screen Relative Humidity Over Previous Three Hours", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "feelsLikeTemp": { + "value": 3.74, + "description": "Feels Like Temperature Over Previous Three Hours", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "uvIndex": { + "value": 0, + "description": "Maximum UV Index Over Previous Three Hours", + "unit_name": "dimensionless", + "unit_symbol": "1", + }, + "probOfPrecipitation": { + "value": 44, + "description": "Three Hour Probability of Precipitation", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "probOfSnow": { + "value": 0, + "description": "Three Hour Probability of Snow", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "probOfHeavySnow": { + "value": 0, + "description": "Three Hour Probability of Heavy Snow", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "probOfRain": { + "value": 44, + "description": "Three Hour Probability of Rain", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "probOfHeavyRain": { + "value": 32, + "description": "Three Hour Probability of Heavy Rain", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "probOfHail": { + "value": 4, + "description": "Three Hour Probability of Hail", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "probOfSferics": { + "value": 6, + "description": "Three Hour Probability of Sferics", + "unit_name": "percentage", + "unit_symbol": "%", + }, +} + +EXPECTED_AT_DATETIME_THREE_HOURLY_FINAL_TIMESTEP = { + "time": datetime.datetime(2024, 2, 24, 15, 0, tzinfo=datetime.timezone.utc), + "maxScreenAirTemp": { + "value": 8.25, + "description": "Maximum Screen Air Temperature Over Previous Three Hours", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "minScreenAirTemp": { + "value": 7.96, + "description": "Minimum Screen Air Temperature Over Previous Three Hours", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "max10mWindGust": { + "value": 12.77, + "description": "Maximum 10m Wind Gust Speed Over Previous Three Hours", + "unit_name": "metres per second", + "unit_symbol": "m/s", + }, + "significantWeatherCode": { + "value": "Light rain shower", + "description": "Three Hour Significant Weather Code", + "unit_name": "dimensionless", + "unit_symbol": "1", + }, + "totalPrecipAmount": { + "value": 0.58, + "description": "Total Precipitation Amount Over Previous Three Hours", + "unit_name": "millimetres", + "unit_symbol": "mm", + }, + "totalSnowAmount": { + "value": 0, + "description": "Total Snow Amount Over Previous Three Hours", + "unit_name": "millimetres", + "unit_symbol": "mm", + }, + "windSpeed10m": { + "value": 5.98, + "description": "10m Wind Speed Over Previous Three Hours", + "unit_name": "metres per second", + "unit_symbol": "m/s", + }, + "windDirectionFrom10m": { + "value": 228, + "description": "10m Wind From Direction Over Previous Three Hours", + "unit_name": "degrees", + "unit_symbol": "deg", + }, + "windGustSpeed10m": { + "value": 11.48, + "description": "10m Wind Gust Speed Over Previous Three Hours", + "unit_name": "metres per second", + "unit_symbol": "m/s", + }, + "visibility": { + "value": 22483, + "description": "Visibility Over Previous Three Hours", + "unit_name": "metres", + "unit_symbol": "m", + }, + "mslp": { + "value": 99725, + "description": "Mean Sea Level Pressure Over Previous Three Hours", + "unit_name": "pascals", + "unit_symbol": "Pa", + }, + "screenRelativeHumidity": { + "value": 72.01, + "description": "Screen Relative Humidity Over Previous Three Hours", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "feelsLikeTemp": { + "value": 4.97, + "description": "Feels Like Temperature Over Previous Three Hours", + "unit_name": "degrees Celsius", + "unit_symbol": "Cel", + }, + "uvIndex": { + "value": 1, + "description": "Maximum UV Index Over Previous Three Hours", + "unit_name": "dimensionless", + "unit_symbol": "1", + }, + "probOfPrecipitation": { + "value": 32, + "description": "Three Hour Probability of Precipitation", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "probOfSnow": { + "value": 0, + "description": "Three Hour Probability of Snow", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "probOfHeavySnow": { + "value": 0, + "description": "Three Hour Probability of Heavy Snow", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "probOfRain": { + "value": 32, + "description": "Three Hour Probability of Rain", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "probOfHeavyRain": { + "value": 17, + "description": "Three Hour Probability of Heavy Rain", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "probOfHail": { + "value": 1, + "description": "Three Hour Probability of Hail", + "unit_name": "percentage", + "unit_symbol": "%", + }, + "probOfSferics": { + "value": 3, + "description": "Three Hour Probability of Sferics", + "unit_name": "percentage", + "unit_symbol": "%", + }, +} diff --git a/tests/unit/test_forecast.py b/tests/unit/test_forecast.py index e2910a9..551f88c 100644 --- a/tests/unit/test_forecast.py +++ b/tests/unit/test_forecast.py @@ -1,117 +1,271 @@ import datetime -import unittest -import datapoint +import geojson +import pytest +import tests.unit.reference_data_test_forecast as reference_data_test_forecast +from datapoint import Forecast +from datapoint.exceptions import APIException -class TestForecast(unittest.TestCase): - def setUp(self): - self.forecast_3hrly = datapoint.Forecast.Forecast(frequency="3hourly") +# TODO look into pytest-cases. Should reduce the amount of stored data structures - test_day_0 = datapoint.Day.Day() - test_day_0.date = datetime.datetime(2020, 3, 3, tzinfo=datetime.timezone.utc) - for i in range(5): - ts = datapoint.Timestep.Timestep() - ts.name = 9 + (3 * i) - ts.date = datetime.datetime( - 2020, 3, 3, 9 + (3 * i), tzinfo=datetime.timezone.utc - ) - test_day_0.timesteps.append(ts) +@pytest.fixture +def load_hourly_json(): + with open("./tests/unit/hourly_api_data.json") as f: + my_json = geojson.load(f) + return my_json - test_day_1 = datapoint.Day.Day() - test_day_1.date = datetime.datetime(2020, 3, 4, tzinfo=datetime.timezone.utc) - for i in range(8): - ts = datapoint.Timestep.Timestep() - ts.name = 3 * i - ts.date = datetime.datetime(2020, 3, 4, 3 * i, tzinfo=datetime.timezone.utc) - test_day_1.timesteps.append(ts) +@pytest.fixture +def load_daily_json(): + with open("./tests/unit/daily_api_data.json") as f: + my_json = geojson.load(f) + return my_json - self.forecast_3hrly.days.append(test_day_0) - self.forecast_3hrly.days.append(test_day_1) - test_day_0 = datapoint.Day.Day() - test_day_0.date = datetime.datetime(2020, 3, 3, tzinfo=datetime.timezone.utc) +@pytest.fixture +def load_three_hourly_json(): + with open("./tests/unit/three_hourly_api_data.json") as f: + my_json = geojson.load(f) + return my_json - for i in range(2): - ts = datapoint.Timestep.Timestep() - ts.name = 2 * i - ts.date = datetime.datetime(2020, 3, 3, 2 * i, tzinfo=datetime.timezone.utc) - test_day_0.timesteps.append(ts) - test_day_1 = datapoint.Day.Day() - test_day_1.date = datetime.datetime(2020, 3, 4, tzinfo=datetime.timezone.utc) +@pytest.fixture +def daily_forecast(load_daily_json): + return Forecast.Forecast("daily", load_daily_json) - for i in range(2): - ts = datapoint.Timestep.Timestep() - ts.name = 2 * i - ts.date = datetime.datetime(2020, 3, 4, 2 * i, tzinfo=datetime.timezone.utc) - test_day_1.timesteps.append(ts) - self.forecast_daily = datapoint.Forecast.Forecast(frequency="daily") +@pytest.fixture +def hourly_forecast(load_hourly_json): + return Forecast.Forecast("hourly", load_hourly_json) - self.forecast_daily.days.append(test_day_0) - self.forecast_daily.days.append(test_day_1) - def test_at_datetime_1_5_hours_before_after(self): - target_before = datetime.datetime( - 2020, 3, 3, 7, 0, tzinfo=datetime.timezone.utc - ) +@pytest.fixture +def three_hourly_forecast(load_three_hourly_json): + return Forecast.Forecast("three-hourly", load_three_hourly_json) - target_after = datetime.datetime( - 2020, 3, 4, 23, 0, tzinfo=datetime.timezone.utc - ) - self.assertRaises( - datapoint.exceptions.APIException, - self.forecast_3hrly.at_datetime, - target_before, - ) +@pytest.fixture +def hourly_first_forecast_and_parameters(load_hourly_json): + parameters = load_hourly_json["parameters"][0] + forecast = load_hourly_json["features"][0]["properties"]["timeSeries"][0] + return (forecast, parameters) - self.assertRaises( - datapoint.exceptions.APIException, - self.forecast_3hrly.at_datetime, - target_after, - ) +@pytest.fixture +def three_hourly_first_forecast_and_parameters(load_three_hourly_json): + parameters = load_three_hourly_json["parameters"][0] + forecast = load_three_hourly_json["features"][0]["properties"]["timeSeries"][0] + return (forecast, parameters) - def test_at_datetime_6_hours_before_after(self): - # Generate 2 timesteps These are set at 00:00 and 12:00 - target_before = datetime.datetime( - 2020, 3, 2, 15, 0, tzinfo=datetime.timezone.utc - ) - target_after = datetime.datetime(2020, 3, 6, 7, 0, tzinfo=datetime.timezone.utc) +@pytest.fixture +def expected_first_hourly_timestep(): + return reference_data_test_forecast.EXPECTED_FIRST_HOURLY_TIMESTEP + + +@pytest.fixture +def expected_at_datetime_hourly_timestep(): + return reference_data_test_forecast.EXPECTED_AT_DATETIME_HOURLY_TIMESTEP + + +@pytest.fixture +def expected_at_datetime_hourly_final_timestep(): + return reference_data_test_forecast.EXPECTED_AT_DATETIME_HOURLY_FINAL_TIMESTEP + + +@pytest.fixture +def expected_first_daily_timestep(): + return reference_data_test_forecast.EXPECTED_FIRST_DAILY_TIMESTEP + + +@pytest.fixture +def expected_at_datetime_daily_timestep(): + return reference_data_test_forecast.EXPECTED_AT_DATETIME_DAILY_TIMESTEP + + +@pytest.fixture +def expected_at_datetime_daily_final_timestep(): + return reference_data_test_forecast.EXPECTED_AT_DATETIME_DAILY_FINAL_TIMESTEP + +@pytest.fixture +def expected_first_three_hourly_timestep(): + return reference_data_test_forecast.EXPECTED_FIRST_THREE_HOURLY_TIMESTEP + +@pytest.fixture +def expected_first_three_hourly_timestep(): + return reference_data_test_forecast.EXPECTED_FIRST_THREE_HOURLY_TIMESTEP + +@pytest.fixture +def expected_at_datetime_three_hourly_timestep(): + return reference_data_test_forecast.EXPECTED_AT_DATETIME_THREE_HOURLY_TIMESTEP + +@pytest.fixture +def expected_at_datetime_three_hourly_final_timestep (): + return reference_data_test_forecast.EXPECTED_AT_DATETIME_THREE_HOURLY_FINAL_TIMESTEP + +class TestHourlyForecast: + def test_forecast_frequency(self, hourly_forecast): + assert hourly_forecast.frequency == "hourly" + + def test_forecast_location_name(self, hourly_forecast): + assert hourly_forecast.name == "Sheffield Park" + + def test_forecast_location_latitude(self, hourly_forecast): + assert hourly_forecast.forecast_latitude == 50.9992 + + def test_forecast_location_longitude(self, hourly_forecast): + assert hourly_forecast.forecast_longitude == 0.0154 + + def test_forecast_distance_from_request(self, hourly_forecast): + assert hourly_forecast.distance_from_requested_location == 1081.5349 + + def test_forecast_elevation(self, hourly_forecast): + assert hourly_forecast.elevation == 37.0 + + def test_forecast_first_timestep( + self, hourly_forecast, expected_first_hourly_timestep + ): + assert hourly_forecast.timesteps[0] == expected_first_hourly_timestep - self.assertRaises( - datapoint.exceptions.APIException, - self.forecast_daily.at_datetime, - target_before, + def test_build_timestep( + self, + hourly_forecast, + hourly_first_forecast_and_parameters, + expected_first_hourly_timestep, + ): + built_timestep = hourly_forecast._build_timestep( + hourly_first_forecast_and_parameters[0], + hourly_first_forecast_and_parameters[1], ) - self.assertRaises( - datapoint.exceptions.APIException, - self.forecast_daily.at_datetime, - target_after, + assert built_timestep == expected_first_hourly_timestep + + def test_at_datetime(self, hourly_forecast, expected_at_datetime_hourly_timestep): + ts = hourly_forecast.at_datetime(datetime.datetime(2024, 2, 16, 19, 15)) + assert ts == expected_at_datetime_hourly_timestep + + def test_at_datetime_final_timestamp( + self, hourly_forecast, expected_at_datetime_hourly_final_timestep + ): + ts = hourly_forecast.at_datetime(datetime.datetime(2024, 2, 17, 19, 20)) + assert ts == expected_at_datetime_hourly_final_timestep + + def test_requested_time_too_early(self, hourly_forecast): + with pytest.raises(APIException): + hourly_forecast.at_datetime(datetime.datetime(2024, 2, 15, 18, 25)) + + def test_requested_time_too_late(self, hourly_forecast): + with pytest.raises(APIException): + hourly_forecast.at_datetime(datetime.datetime(2024, 2, 17, 19, 35)) + + +class TestDailyForecast: + def test_forecast_frequency(self, daily_forecast): + assert daily_forecast.frequency == "daily" + + def test_forecast_location_name(self, daily_forecast): + assert daily_forecast.name == "Sheffield Park" + + def test_forecast_location_latitude(self, daily_forecast): + assert daily_forecast.forecast_latitude == 50.9992 + + def test_forecast_location_longitude(self, daily_forecast): + assert daily_forecast.forecast_longitude == 0.0154 + + def test_forecast_distance_from_request(self, daily_forecast): + assert daily_forecast.distance_from_requested_location == 1081.5349 + + def test_forecast_elevation(self, daily_forecast): + assert daily_forecast.elevation == 37.0 + + def test_forecast_first_timestep( + self, daily_forecast, expected_first_daily_timestep + ): + assert daily_forecast.timesteps[0] == expected_first_daily_timestep + + def test_build_timesteps_from_daily( + self, daily_forecast, load_daily_json, expected_first_daily_timestep + ): + timesteps = daily_forecast._build_timesteps_from_daily( + load_daily_json["features"][0]["properties"]["timeSeries"], + load_daily_json["parameters"][0], ) + assert timesteps[0] == expected_first_daily_timestep - def test_normal_time(self): - target = datetime.datetime(2020, 3, 3, 10, 0, tzinfo=datetime.timezone.utc) + def test_at_datetime(self, daily_forecast, expected_at_datetime_daily_timestep): + ts = daily_forecast.at_datetime(datetime.datetime(2024, 2, 16, 19, 15)) + assert ts == expected_at_datetime_daily_timestep + + def test_at_datetime_final_timestamp( + self, daily_forecast, expected_at_datetime_daily_final_timestep + ): + ts = daily_forecast.at_datetime(datetime.datetime(2024, 2, 23, 17)) + assert ts == expected_at_datetime_daily_final_timestep + + def test_requested_time_too_early(self, daily_forecast): + with pytest.raises(APIException): + daily_forecast.at_datetime(datetime.datetime(2024, 2, 15, 17)) + + def test_requested_time_too_late(self, daily_forecast): + with pytest.raises(APIException): + daily_forecast.at_datetime(datetime.datetime(2024, 2, 23, 19)) + + +class TestThreeHourlyForecast: + def test_forecast_frequency(self, three_hourly_forecast): + assert three_hourly_forecast.frequency == "three-hourly" + + def test_forecast_location_name(self, three_hourly_forecast): + assert three_hourly_forecast.name == "Sheffield Park" + + def test_forecast_location_latitude(self, three_hourly_forecast): + assert three_hourly_forecast.forecast_latitude == 50.9992 + + def test_forecast_location_longitude(self, three_hourly_forecast): + assert three_hourly_forecast.forecast_longitude == 0.0154 + + def test_forecast_distance_from_request(self, three_hourly_forecast): + assert three_hourly_forecast.distance_from_requested_location == 1081.5349 + + def test_forecast_elevation(self, three_hourly_forecast): + assert three_hourly_forecast.elevation == 37.0 + + def test_forecast_first_timestep( + self, three_hourly_forecast, expected_first_three_hourly_timestep + ): + assert three_hourly_forecast.timesteps[0] == expected_first_three_hourly_timestep + + def test_build_timestep( + self, + three_hourly_forecast, + three_hourly_first_forecast_and_parameters, + expected_first_three_hourly_timestep, + ): + built_timestep = three_hourly_forecast._build_timestep( + three_hourly_first_forecast_and_parameters[0], + three_hourly_first_forecast_and_parameters[1], + ) - nearest = self.forecast_3hrly.at_datetime(target) - expected = datetime.datetime(2020, 3, 3, 9, tzinfo=datetime.timezone.utc) - self.assertEqual(nearest.date, expected) + assert built_timestep == expected_first_three_hourly_timestep - target = datetime.datetime(2020, 3, 3, 11, 0, tzinfo=datetime.timezone.utc) + def test_at_datetime(self, three_hourly_forecast, + expected_at_datetime_three_hourly_timestep + ): + ts = three_hourly_forecast.at_datetime(datetime.datetime(2024, 2, 22, 19, 15)) + assert ts == expected_at_datetime_three_hourly_timestep - nearest = self.forecast_3hrly.at_datetime(target) - expected = datetime.datetime(2020, 3, 3, 12, tzinfo=datetime.timezone.utc) - self.assertEqual(nearest.date, expected) + def test_at_datetime_final_timestamp( + self, three_hourly_forecast, expected_at_datetime_three_hourly_final_timestep + ): + ts = three_hourly_forecast.at_datetime(datetime.datetime(2024, 2, 24, 16)) + assert ts == expected_at_datetime_three_hourly_final_timestep - def test_forecase_midnight(self): - target = datetime.datetime(2020, 3, 4, 0, 15, tzinfo=datetime.timezone.utc) + def test_requested_time_too_early(self, three_hourly_forecast): + with pytest.raises(APIException): + three_hourly_forecast.at_datetime(datetime.datetime(2024, 2, 17, 13, 20)) - nearest = self.forecast_3hrly.at_datetime(target) - expected = datetime.datetime(2020, 3, 4, 0, tzinfo=datetime.timezone.utc) - self.assertEqual(nearest.date, expected) + def test_requested_time_too_late(self, three_hourly_forecast): + with pytest.raises(APIException): + three_hourly_forecast.at_datetime(datetime.datetime(2024, 2, 24, 17)) diff --git a/tests/unit/test_forecast_new.py b/tests/unit/test_forecast_new.py deleted file mode 100644 index 50236ca..0000000 --- a/tests/unit/test_forecast_new.py +++ /dev/null @@ -1,27 +0,0 @@ -import pytest -import geojson -from datapoint import Forecast - - -@pytest.fixture -def hourly_forecast(): - with open('./tests/unit/hourly_api_data.json') as f: - my_json = geojson.load(f) - return Forecast.Forecast('hourly', my_json) - - -class TestForecast: - def test_hourly_forecast_frequency(self, hourly_forecast): - assert hourly_forecast.frequency == 'hourly' - - def test_hourly_forecast_location_name(self, hourly_forecast): - assert hourly_forecast.name == 'Sheffield Park' - - def test_hourly_forecast_location_latitude(self, hourly_forecast): - assert hourly_forecast.forecast_latitude == 50.9992 - - def test_hourly_forecast_location_longitude(self, hourly_forecast): - assert hourly_forecast.forecast_longitude == 0.0154 - - def test_hourly_forecast_distance_from_request(self, hourly_forecast): - assert hourly_forecast.distance_from_requested_location == 1081.5349 diff --git a/tests/unit/three_hourly_api_data.json b/tests/unit/three_hourly_api_data.json new file mode 100644 index 0000000..5329345 --- /dev/null +++ b/tests/unit/three_hourly_api_data.json @@ -0,0 +1,1562 @@ +{ + "type": "FeatureCollection", + "parameters": [{ + "totalSnowAmount": { + "type": "Parameter", + "description": "Total Snow Amount Over Previous Three Hours", + "unit": { + "label": "millimetres", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "mm" + } + } + }, + "visibility": { + "type": "Parameter", + "description": "Visibility Over Previous Three Hours", + "unit": { + "label": "metres", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m" + } + } + }, + "probOfHail": { + "type": "Parameter", + "description": "Three Hour Probability of Hail", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "windDirectionFrom10m": { + "type": "Parameter", + "description": "10m Wind From Direction Over Previous Three Hours", + "unit": { + "label": "degrees", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "deg" + } + } + }, + "probOfHeavyRain": { + "type": "Parameter", + "description": "Three Hour Probability of Heavy Rain", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "maxScreenAirTemp": { + "type": "Parameter", + "description": "Maximum Screen Air Temperature Over Previous Three Hours", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "feelsLikeTemp": { + "type": "Parameter", + "description": "Feels Like Temperature Over Previous Three Hours", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "probOfSferics": { + "type": "Parameter", + "description": "Three Hour Probability of Sferics", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "screenRelativeHumidity": { + "type": "Parameter", + "description": "Screen Relative Humidity Over Previous Three Hours", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "windSpeed10m": { + "type": "Parameter", + "description": "10m Wind Speed Over Previous Three Hours", + "unit": { + "label": "metres per second", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m/s" + } + } + }, + "probOfPrecipitation": { + "type": "Parameter", + "description": "Three Hour Probability of Precipitation", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "probOfRain": { + "type": "Parameter", + "description": "Three Hour Probability of Rain", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "max10mWindGust": { + "type": "Parameter", + "description": "Maximum 10m Wind Gust Speed Over Previous Three Hours", + "unit": { + "label": "metres per second", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m/s" + } + } + }, + "significantWeatherCode": { + "type": "Parameter", + "description": "Three Hour Significant Weather Code", + "unit": { + "label": "dimensionless", + "symbol": { + "value": "https://metoffice.apiconnect.ibmcloud.com/metoffice/production/", + "type": "1" + } + } + }, + "probOfHeavySnow": { + "type": "Parameter", + "description": "Three Hour Probability of Heavy Snow", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + }, + "minScreenAirTemp": { + "type": "Parameter", + "description": "Minimum Screen Air Temperature Over Previous Three Hours", + "unit": { + "label": "degrees Celsius", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Cel" + } + } + }, + "totalPrecipAmount": { + "type": "Parameter", + "description": "Total Precipitation Amount Over Previous Three Hours", + "unit": { + "label": "millimetres", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "mm" + } + } + }, + "mslp": { + "type": "Parameter", + "description": "Mean Sea Level Pressure Over Previous Three Hours", + "unit": { + "label": "pascals", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "Pa" + } + } + }, + "windGustSpeed10m": { + "type": "Parameter", + "description": "10m Wind Gust Speed Over Previous Three Hours", + "unit": { + "label": "metres per second", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "m/s" + } + } + }, + "uvIndex": { + "type": "Parameter", + "description": "Maximum UV Index Over Previous Three Hours", + "unit": { + "label": "dimensionless", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "1" + } + } + }, + "probOfSnow": { + "type": "Parameter", + "description": "Three Hour Probability of Snow", + "unit": { + "label": "percentage", + "symbol": { + "value": "http://www.opengis.net/def/uom/UCUM/", + "type": "%" + } + } + } + }], + "features": [{ + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [0.0154, 50.9992, 37.0] + }, + "properties": { + "location": { + "name": "Sheffield Park" + }, + "requestPointDistance": 1081.5349, + "modelRunDate": "2024-02-17T15:00Z", + "timeSeries": [{ + "time": "2024-02-17T15:00Z", + "maxScreenAirTemp": 12.0, + "minScreenAirTemp": 10.73, + "max10mWindGust": 8.87, + "significantWeatherCode": 8, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 5.04, + "windDirectionFrom10m": 225, + "windGustSpeed10m": 8.75, + "visibility": 9118, + "mslp": 103090, + "screenRelativeHumidity": 96.84, + "feelsLikeTemp": 8.53, + "uvIndex": 1, + "probOfPrecipitation": 10, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 10, + "probOfHeavyRain": 0, + "probOfHail": 0, + "probOfSferics": 0 + }, { + "time": "2024-02-17T18:00Z", + "maxScreenAirTemp": 10.92, + "minScreenAirTemp": 10.48, + "max10mWindGust": 10.01, + "significantWeatherCode": 8, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 4.22, + "windDirectionFrom10m": 191, + "windGustSpeed10m": 9.9, + "visibility": 9579, + "mslp": 103021, + "screenRelativeHumidity": 90.49, + "feelsLikeTemp": 8.57, + "uvIndex": 0, + "probOfPrecipitation": 13, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 13, + "probOfHeavyRain": 3, + "probOfHail": 0, + "probOfSferics": 0 + }, { + "time": "2024-02-17T21:00Z", + "maxScreenAirTemp": 11.21, + "minScreenAirTemp": 10.58, + "max10mWindGust": 12.91, + "significantWeatherCode": 15, + "totalPrecipAmount": 0.11, + "totalSnowAmount": 0, + "windSpeed10m": 5.81, + "windDirectionFrom10m": 198, + "windGustSpeed10m": 12.82, + "visibility": 11035, + "mslp": 102910, + "screenRelativeHumidity": 88.83, + "feelsLikeTemp": 8.55, + "uvIndex": 0, + "probOfPrecipitation": 94, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 94, + "probOfHeavyRain": 93, + "probOfHail": 19, + "probOfSferics": 9 + }, { + "time": "2024-02-18T00:00Z", + "maxScreenAirTemp": 11.1, + "minScreenAirTemp": 10.47, + "max10mWindGust": 12.89, + "significantWeatherCode": 15, + "totalPrecipAmount": 5.75, + "totalSnowAmount": 0, + "windSpeed10m": 5.83, + "windDirectionFrom10m": 217, + "windGustSpeed10m": 12.74, + "visibility": 4164, + "mslp": 102782, + "screenRelativeHumidity": 94.21, + "feelsLikeTemp": 7.83, + "uvIndex": 0, + "probOfPrecipitation": 97, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 97, + "probOfHeavyRain": 96, + "probOfHail": 19, + "probOfSferics": 10 + }, { + "time": "2024-02-18T03:00Z", + "maxScreenAirTemp": 10.53, + "minScreenAirTemp": 10.11, + "max10mWindGust": 12.75, + "significantWeatherCode": 15, + "totalPrecipAmount": 6.38, + "totalSnowAmount": 0, + "windSpeed10m": 4.95, + "windDirectionFrom10m": 229, + "windGustSpeed10m": 10.92, + "visibility": 5768, + "mslp": 102618, + "screenRelativeHumidity": 95.9, + "feelsLikeTemp": 7.79, + "uvIndex": 0, + "probOfPrecipitation": 97, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 97, + "probOfHeavyRain": 97, + "probOfHail": 20, + "probOfSferics": 10 + }, { + "time": "2024-02-18T06:00Z", + "maxScreenAirTemp": 10.16, + "minScreenAirTemp": 10.06, + "max10mWindGust": 10.65, + "significantWeatherCode": 15, + "totalPrecipAmount": 3.37, + "totalSnowAmount": 0, + "windSpeed10m": 3.51, + "windDirectionFrom10m": 243, + "windGustSpeed10m": 7.9, + "visibility": 19831, + "mslp": 102519, + "screenRelativeHumidity": 96.87, + "feelsLikeTemp": 8.48, + "uvIndex": 0, + "probOfPrecipitation": 90, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 90, + "probOfHeavyRain": 88, + "probOfHail": 18, + "probOfSferics": 12 + }, { + "time": "2024-02-18T09:00Z", + "maxScreenAirTemp": 10.69, + "minScreenAirTemp": 10.14, + "max10mWindGust": 7.51, + "significantWeatherCode": 12, + "totalPrecipAmount": 1.05, + "totalSnowAmount": 0, + "windSpeed10m": 2.34, + "windDirectionFrom10m": 250, + "windGustSpeed10m": 5.75, + "visibility": 9319, + "mslp": 102513, + "screenRelativeHumidity": 98.27, + "feelsLikeTemp": 9.86, + "uvIndex": 1, + "probOfPrecipitation": 52, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 52, + "probOfHeavyRain": 32, + "probOfHail": 3, + "probOfSferics": 1 + }, { + "time": "2024-02-18T12:00Z", + "maxScreenAirTemp": 13.18, + "minScreenAirTemp": 10.69, + "max10mWindGust": 7.44, + "significantWeatherCode": 7, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 3.99, + "windDirectionFrom10m": 297, + "windGustSpeed10m": 7.44, + "visibility": 25301, + "mslp": 102620, + "screenRelativeHumidity": 86.89, + "feelsLikeTemp": 11.57, + "uvIndex": 1, + "probOfPrecipitation": 11, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 11, + "probOfHeavyRain": 6, + "probOfHail": 1, + "probOfSferics": 1 + }, { + "time": "2024-02-18T15:00Z", + "maxScreenAirTemp": 13.65, + "minScreenAirTemp": 13.12, + "max10mWindGust": 7.92, + "significantWeatherCode": 3, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 3.93, + "windDirectionFrom10m": 297, + "windGustSpeed10m": 7.45, + "visibility": 29088, + "mslp": 102627, + "screenRelativeHumidity": 75.69, + "feelsLikeTemp": 11.95, + "uvIndex": 1, + "probOfPrecipitation": 3, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 3, + "probOfHeavyRain": 2, + "probOfHail": 0, + "probOfSferics": 0 + }, { + "time": "2024-02-18T18:00Z", + "maxScreenAirTemp": 13.52, + "minScreenAirTemp": 10.8, + "max10mWindGust": 7.29, + "significantWeatherCode": 2, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 2.14, + "windDirectionFrom10m": 287, + "windGustSpeed10m": 6.16, + "visibility": 21358, + "mslp": 102800, + "screenRelativeHumidity": 89.42, + "feelsLikeTemp": 10.28, + "uvIndex": 0, + "probOfPrecipitation": 2, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 2, + "probOfHeavyRain": 2, + "probOfHail": 0, + "probOfSferics": 0 + }, { + "time": "2024-02-18T21:00Z", + "maxScreenAirTemp": 11.01, + "minScreenAirTemp": 9.88, + "max10mWindGust": 7.24, + "significantWeatherCode": 2, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 2.59, + "windDirectionFrom10m": 282, + "windGustSpeed10m": 6.65, + "visibility": 19012, + "mslp": 102967, + "screenRelativeHumidity": 92.62, + "feelsLikeTemp": 8.71, + "uvIndex": 0, + "probOfPrecipitation": 1, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 1, + "probOfHeavyRain": 0, + "probOfHail": 0, + "probOfSferics": 0 + }, { + "time": "2024-02-19T00:00Z", + "maxScreenAirTemp": 9.91, + "minScreenAirTemp": 9.04, + "max10mWindGust": 7.07, + "significantWeatherCode": 7, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 2.81, + "windDirectionFrom10m": 282, + "windGustSpeed10m": 6.48, + "visibility": 17411, + "mslp": 103035, + "screenRelativeHumidity": 94.29, + "feelsLikeTemp": 7.51, + "uvIndex": 0, + "probOfPrecipitation": 4, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 4, + "probOfHeavyRain": 0, + "probOfHail": 0, + "probOfSferics": 0 + }, { + "time": "2024-02-19T03:00Z", + "maxScreenAirTemp": 9.05, + "minScreenAirTemp": 8.24, + "max10mWindGust": 7.07, + "significantWeatherCode": 7, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 2.33, + "windDirectionFrom10m": 273, + "windGustSpeed10m": 5.44, + "visibility": 16944, + "mslp": 103012, + "screenRelativeHumidity": 95.46, + "feelsLikeTemp": 6.97, + "uvIndex": 0, + "probOfPrecipitation": 4, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 4, + "probOfHeavyRain": 0, + "probOfHail": 0, + "probOfSferics": 0 + }, { + "time": "2024-02-19T06:00Z", + "maxScreenAirTemp": 8.27, + "minScreenAirTemp": 7.56, + "max10mWindGust": 7.47, + "significantWeatherCode": 8, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 3.33, + "windDirectionFrom10m": 258, + "windGustSpeed10m": 6.86, + "visibility": 20004, + "mslp": 102970, + "screenRelativeHumidity": 94.27, + "feelsLikeTemp": 5.54, + "uvIndex": 0, + "probOfPrecipitation": 10, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 10, + "probOfHeavyRain": 1, + "probOfHail": 0, + "probOfSferics": 0 + }, { + "time": "2024-02-19T09:00Z", + "maxScreenAirTemp": 8.94, + "minScreenAirTemp": 7.65, + "max10mWindGust": 9.55, + "significantWeatherCode": 7, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 4.71, + "windDirectionFrom10m": 264, + "windGustSpeed10m": 8.92, + "visibility": 12932, + "mslp": 102970, + "screenRelativeHumidity": 88.85, + "feelsLikeTemp": 6.39, + "uvIndex": 1, + "probOfPrecipitation": 52, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 52, + "probOfHeavyRain": 30, + "probOfHail": 2, + "probOfSferics": 1 + }, { + "time": "2024-02-19T12:00Z", + "maxScreenAirTemp": 10.71, + "minScreenAirTemp": 8.93, + "max10mWindGust": 10.18, + "significantWeatherCode": 7, + "totalPrecipAmount": 0.09, + "totalSnowAmount": 0, + "windSpeed10m": 5.29, + "windDirectionFrom10m": 278, + "windGustSpeed10m": 10.18, + "visibility": 24236, + "mslp": 102917, + "screenRelativeHumidity": 82.57, + "feelsLikeTemp": 8.2, + "uvIndex": 1, + "probOfPrecipitation": 13, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 13, + "probOfHeavyRain": 5, + "probOfHail": 0, + "probOfSferics": 1 + }, { + "time": "2024-02-19T15:00Z", + "maxScreenAirTemp": 12.13, + "minScreenAirTemp": 10.64, + "max10mWindGust": 11.16, + "significantWeatherCode": 3, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 5.06, + "windDirectionFrom10m": 309, + "windGustSpeed10m": 9.66, + "visibility": 34168, + "mslp": 102889, + "screenRelativeHumidity": 65.14, + "feelsLikeTemp": 9.69, + "uvIndex": 1, + "probOfPrecipitation": 4, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 4, + "probOfHeavyRain": 2, + "probOfHail": 0, + "probOfSferics": 0 + }, { + "time": "2024-02-19T18:00Z", + "maxScreenAirTemp": 11.98, + "minScreenAirTemp": 8.91, + "max10mWindGust": 10.5, + "significantWeatherCode": 0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 3.27, + "windDirectionFrom10m": 299, + "windGustSpeed10m": 6.36, + "visibility": 29205, + "mslp": 103024, + "screenRelativeHumidity": 77.5, + "feelsLikeTemp": 7.11, + "uvIndex": 0, + "probOfPrecipitation": 1, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 1, + "probOfHeavyRain": 0, + "probOfHail": 0, + "probOfSferics": 0 + }, { + "time": "2024-02-19T21:00Z", + "maxScreenAirTemp": 9.04, + "minScreenAirTemp": 6.96, + "max10mWindGust": 7.08, + "significantWeatherCode": 7, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 3.06, + "windDirectionFrom10m": 298, + "windGustSpeed10m": 5.3, + "visibility": 26260, + "mslp": 103122, + "screenRelativeHumidity": 85.18, + "feelsLikeTemp": 4.87, + "uvIndex": 0, + "probOfPrecipitation": 4, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 4, + "probOfHeavyRain": 0, + "probOfHail": 0, + "probOfSferics": 0 + }, { + "time": "2024-02-20T00:00Z", + "maxScreenAirTemp": 7.06, + "minScreenAirTemp": 5.8, + "max10mWindGust": 6.14, + "significantWeatherCode": 7, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 2.59, + "windDirectionFrom10m": 296, + "windGustSpeed10m": 4.49, + "visibility": 23913, + "mslp": 103122, + "screenRelativeHumidity": 89.64, + "feelsLikeTemp": 3.88, + "uvIndex": 0, + "probOfPrecipitation": 4, + "probOfSnow": 1, + "probOfHeavySnow": 0, + "probOfRain": 4, + "probOfHeavyRain": 0, + "probOfHail": 0, + "probOfSferics": 0 + }, { + "time": "2024-02-20T03:00Z", + "maxScreenAirTemp": 5.85, + "minScreenAirTemp": 4.32, + "max10mWindGust": 5.69, + "significantWeatherCode": 7, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 2.47, + "windDirectionFrom10m": 266, + "windGustSpeed10m": 3.93, + "visibility": 20523, + "mslp": 103057, + "screenRelativeHumidity": 94.0, + "feelsLikeTemp": 2.36, + "uvIndex": 0, + "probOfPrecipitation": 5, + "probOfSnow": 1, + "probOfHeavySnow": 0, + "probOfRain": 5, + "probOfHeavyRain": 0, + "probOfHail": 0, + "probOfSferics": 0 + }, { + "time": "2024-02-20T06:00Z", + "maxScreenAirTemp": 5.65, + "minScreenAirTemp": 4.45, + "max10mWindGust": 5.62, + "significantWeatherCode": 7, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 2.92, + "windDirectionFrom10m": 233, + "windGustSpeed10m": 5.24, + "visibility": 17975, + "mslp": 102979, + "screenRelativeHumidity": 94.91, + "feelsLikeTemp": 2.75, + "uvIndex": 0, + "probOfPrecipitation": 5, + "probOfSnow": 1, + "probOfHeavySnow": 0, + "probOfRain": 5, + "probOfHeavyRain": 0, + "probOfHail": 0, + "probOfSferics": 0 + }, { + "time": "2024-02-20T09:00Z", + "maxScreenAirTemp": 8.11, + "minScreenAirTemp": 5.04, + "max10mWindGust": 9.74, + "significantWeatherCode": 7, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 4.63, + "windDirectionFrom10m": 225, + "windGustSpeed10m": 8.8, + "visibility": 20465, + "mslp": 102896, + "screenRelativeHumidity": 91.17, + "feelsLikeTemp": 5.48, + "uvIndex": 1, + "probOfPrecipitation": 6, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 6, + "probOfHeavyRain": 0, + "probOfHail": 0, + "probOfSferics": 0 + }, { + "time": "2024-02-20T12:00Z", + "maxScreenAirTemp": 10.42, + "minScreenAirTemp": 8.11, + "max10mWindGust": 13.83, + "significantWeatherCode": 7, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 7.16, + "windDirectionFrom10m": 230, + "windGustSpeed10m": 13.73, + "visibility": 24484, + "mslp": 102757, + "screenRelativeHumidity": 81.96, + "feelsLikeTemp": 7.33, + "uvIndex": 1, + "probOfPrecipitation": 6, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 6, + "probOfHeavyRain": 0, + "probOfHail": 0, + "probOfSferics": 0 + }, { + "time": "2024-02-20T15:00Z", + "maxScreenAirTemp": 10.41, + "minScreenAirTemp": 10.17, + "max10mWindGust": 16.04, + "significantWeatherCode": 7, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 7.58, + "windDirectionFrom10m": 229, + "windGustSpeed10m": 14.32, + "visibility": 21127, + "mslp": 102515, + "screenRelativeHumidity": 85.35, + "feelsLikeTemp": 7.05, + "uvIndex": 1, + "probOfPrecipitation": 6, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 6, + "probOfHeavyRain": 1, + "probOfHail": 0, + "probOfSferics": 0 + }, { + "time": "2024-02-20T18:00Z", + "maxScreenAirTemp": 10.27, + "minScreenAirTemp": 9.66, + "max10mWindGust": 15.03, + "significantWeatherCode": 8, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 7.12, + "windDirectionFrom10m": 228, + "windGustSpeed10m": 13.37, + "visibility": 17884, + "mslp": 102364, + "screenRelativeHumidity": 89.82, + "feelsLikeTemp": 6.52, + "uvIndex": 0, + "probOfPrecipitation": 13, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 13, + "probOfHeavyRain": 3, + "probOfHail": 0, + "probOfSferics": 0 + }, { + "time": "2024-02-20T21:00Z", + "maxScreenAirTemp": 9.74, + "minScreenAirTemp": 9.68, + "max10mWindGust": 15.84, + "significantWeatherCode": 8, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 6.94, + "windDirectionFrom10m": 231, + "windGustSpeed10m": 12.99, + "visibility": 17806, + "mslp": 102220, + "screenRelativeHumidity": 90.71, + "feelsLikeTemp": 6.58, + "uvIndex": 0, + "probOfPrecipitation": 19, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 19, + "probOfHeavyRain": 8, + "probOfHail": 1, + "probOfSferics": 1 + }, { + "time": "2024-02-21T00:00Z", + "maxScreenAirTemp": 9.8, + "minScreenAirTemp": 9.54, + "max10mWindGust": 13.74, + "significantWeatherCode": 8, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 6.4, + "windDirectionFrom10m": 232, + "windGustSpeed10m": 11.87, + "visibility": 15050, + "mslp": 102019, + "screenRelativeHumidity": 92.53, + "feelsLikeTemp": 6.67, + "uvIndex": 0, + "probOfPrecipitation": 26, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 26, + "probOfHeavyRain": 15, + "probOfHail": 2, + "probOfSferics": 1 + }, { + "time": "2024-02-21T03:00Z", + "maxScreenAirTemp": 9.62, + "minScreenAirTemp": 9.31, + "max10mWindGust": 13.58, + "significantWeatherCode": 12, + "totalPrecipAmount": 0.47, + "totalSnowAmount": 0, + "windSpeed10m": 5.9, + "windDirectionFrom10m": 228, + "windGustSpeed10m": 10.84, + "visibility": 10455, + "mslp": 101768, + "screenRelativeHumidity": 93.57, + "feelsLikeTemp": 6.52, + "uvIndex": 0, + "probOfPrecipitation": 55, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 55, + "probOfHeavyRain": 32, + "probOfHail": 2, + "probOfSferics": 1 + }, { + "time": "2024-02-21T06:00Z", + "maxScreenAirTemp": 9.34, + "minScreenAirTemp": 8.97, + "max10mWindGust": 12.06, + "significantWeatherCode": 15, + "totalPrecipAmount": 1.04, + "totalSnowAmount": 0, + "windSpeed10m": 6.04, + "windDirectionFrom10m": 213, + "windGustSpeed10m": 11.11, + "visibility": 7726, + "mslp": 101529, + "screenRelativeHumidity": 93.56, + "feelsLikeTemp": 6.11, + "uvIndex": 0, + "probOfPrecipitation": 84, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 84, + "probOfHeavyRain": 79, + "probOfHail": 15, + "probOfSferics": 8 + }, { + "time": "2024-02-21T09:00Z", + "maxScreenAirTemp": 9.34, + "minScreenAirTemp": 9.02, + "max10mWindGust": 13.95, + "significantWeatherCode": 15, + "totalPrecipAmount": 2.02, + "totalSnowAmount": 0, + "windSpeed10m": 7.31, + "windDirectionFrom10m": 207, + "windGustSpeed10m": 13.69, + "visibility": 6769, + "mslp": 101264, + "screenRelativeHumidity": 92.54, + "feelsLikeTemp": 6.09, + "uvIndex": 1, + "probOfPrecipitation": 85, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 85, + "probOfHeavyRain": 82, + "probOfHail": 16, + "probOfSferics": 8 + }, { + "time": "2024-02-21T12:00Z", + "maxScreenAirTemp": 9.96, + "minScreenAirTemp": 9.32, + "max10mWindGust": 16.28, + "significantWeatherCode": 15, + "totalPrecipAmount": 2.06, + "totalSnowAmount": 0, + "windSpeed10m": 8.38, + "windDirectionFrom10m": 206, + "windGustSpeed10m": 15.83, + "visibility": 7351, + "mslp": 100933, + "screenRelativeHumidity": 90.56, + "feelsLikeTemp": 6.52, + "uvIndex": 1, + "probOfPrecipitation": 86, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 86, + "probOfHeavyRain": 82, + "probOfHail": 16, + "probOfSferics": 8 + }, { + "time": "2024-02-21T15:00Z", + "maxScreenAirTemp": 10.39, + "minScreenAirTemp": 9.93, + "max10mWindGust": 17.58, + "significantWeatherCode": 15, + "totalPrecipAmount": 1.85, + "totalSnowAmount": 0, + "windSpeed10m": 8.41, + "windDirectionFrom10m": 211, + "windGustSpeed10m": 15.81, + "visibility": 8521, + "mslp": 100550, + "screenRelativeHumidity": 90.45, + "feelsLikeTemp": 6.89, + "uvIndex": 1, + "probOfPrecipitation": 83, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 83, + "probOfHeavyRain": 78, + "probOfHail": 15, + "probOfSferics": 8 + }, { + "time": "2024-02-21T18:00Z", + "maxScreenAirTemp": 10.33, + "minScreenAirTemp": 10.08, + "max10mWindGust": 17.02, + "significantWeatherCode": 15, + "totalPrecipAmount": 1.55, + "totalSnowAmount": 0, + "windSpeed10m": 7.78, + "windDirectionFrom10m": 215, + "windGustSpeed10m": 14.46, + "visibility": 9682, + "mslp": 100328, + "screenRelativeHumidity": 92.32, + "feelsLikeTemp": 6.98, + "uvIndex": 0, + "probOfPrecipitation": 78, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 78, + "probOfHeavyRain": 73, + "probOfHail": 14, + "probOfSferics": 7 + }, { + "time": "2024-02-21T21:00Z", + "maxScreenAirTemp": 10.24, + "minScreenAirTemp": 10.14, + "max10mWindGust": 16.36, + "significantWeatherCode": 12, + "totalPrecipAmount": 0.96, + "totalSnowAmount": 0, + "windSpeed10m": 6.97, + "windDirectionFrom10m": 226, + "windGustSpeed10m": 12.85, + "visibility": 12699, + "mslp": 100164, + "screenRelativeHumidity": 93.13, + "feelsLikeTemp": 7.16, + "uvIndex": 0, + "probOfPrecipitation": 52, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 52, + "probOfHeavyRain": 33, + "probOfHail": 3, + "probOfSferics": 2 + }, { + "time": "2024-02-22T00:00Z", + "maxScreenAirTemp": 10.18, + "minScreenAirTemp": 9.7, + "max10mWindGust": 14.35, + "significantWeatherCode": 8, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 6.06, + "windDirectionFrom10m": 228, + "windGustSpeed10m": 11.11, + "visibility": 11329, + "mslp": 99968, + "screenRelativeHumidity": 93.31, + "feelsLikeTemp": 6.98, + "uvIndex": 0, + "probOfPrecipitation": 23, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 23, + "probOfHeavyRain": 12, + "probOfHail": 2, + "probOfSferics": 1 + }, { + "time": "2024-02-22T03:00Z", + "maxScreenAirTemp": 9.77, + "minScreenAirTemp": 9.47, + "max10mWindGust": 13.64, + "significantWeatherCode": 12, + "totalPrecipAmount": 0.59, + "totalSnowAmount": 0, + "windSpeed10m": 5.82, + "windDirectionFrom10m": 222, + "windGustSpeed10m": 10.68, + "visibility": 11002, + "mslp": 99689, + "screenRelativeHumidity": 93.29, + "feelsLikeTemp": 6.78, + "uvIndex": 0, + "probOfPrecipitation": 46, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 46, + "probOfHeavyRain": 25, + "probOfHail": 1, + "probOfSferics": 1 + }, { + "time": "2024-02-22T06:00Z", + "maxScreenAirTemp": 9.69, + "minScreenAirTemp": 9.45, + "max10mWindGust": 12.65, + "significantWeatherCode": 8, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 6.02, + "windDirectionFrom10m": 211, + "windGustSpeed10m": 11.2, + "visibility": 10456, + "mslp": 99392, + "screenRelativeHumidity": 93.57, + "feelsLikeTemp": 6.72, + "uvIndex": 0, + "probOfPrecipitation": 24, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 24, + "probOfHeavyRain": 14, + "probOfHail": 2, + "probOfSferics": 1 + }, { + "time": "2024-02-22T09:00Z", + "maxScreenAirTemp": 9.84, + "minScreenAirTemp": 9.53, + "max10mWindGust": 14.46, + "significantWeatherCode": 15, + "totalPrecipAmount": 1.11, + "totalSnowAmount": 0, + "windSpeed10m": 6.77, + "windDirectionFrom10m": 207, + "windGustSpeed10m": 12.65, + "visibility": 10933, + "mslp": 99076, + "screenRelativeHumidity": 92.04, + "feelsLikeTemp": 6.78, + "uvIndex": 1, + "probOfPrecipitation": 71, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 71, + "probOfHeavyRain": 67, + "probOfHail": 13, + "probOfSferics": 7 + }, { + "time": "2024-02-22T12:00Z", + "maxScreenAirTemp": 10.49, + "minScreenAirTemp": 9.8, + "max10mWindGust": 16.89, + "significantWeatherCode": 14, + "totalPrecipAmount": 1.44, + "totalSnowAmount": 0, + "windSpeed10m": 8.32, + "windDirectionFrom10m": 215, + "windGustSpeed10m": 15.92, + "visibility": 13595, + "mslp": 98762, + "screenRelativeHumidity": 87.24, + "feelsLikeTemp": 6.86, + "uvIndex": 1, + "probOfPrecipitation": 62, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 61, + "probOfHeavyRain": 57, + "probOfHail": 12, + "probOfSferics": 11 + }, { + "time": "2024-02-22T15:00Z", + "maxScreenAirTemp": 10.31, + "minScreenAirTemp": 9.72, + "max10mWindGust": 19.52, + "significantWeatherCode": 14, + "totalPrecipAmount": 1.48, + "totalSnowAmount": 0, + "windSpeed10m": 9.04, + "windDirectionFrom10m": 223, + "windGustSpeed10m": 17.18, + "visibility": 14585, + "mslp": 98434, + "screenRelativeHumidity": 82.96, + "feelsLikeTemp": 6.07, + "uvIndex": 1, + "probOfPrecipitation": 62, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 61, + "probOfHeavyRain": 57, + "probOfHail": 12, + "probOfSferics": 12 + }, { + "time": "2024-02-22T18:00Z", + "maxScreenAirTemp": 9.82, + "minScreenAirTemp": 7.61, + "max10mWindGust": 19.28, + "significantWeatherCode": 9, + "totalPrecipAmount": 1.11, + "totalSnowAmount": 0, + "windSpeed10m": 7.91, + "windDirectionFrom10m": 239, + "windGustSpeed10m": 14.42, + "visibility": 20148, + "mslp": 98434, + "screenRelativeHumidity": 79.36, + "feelsLikeTemp": 3.74, + "uvIndex": 0, + "probOfPrecipitation": 44, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 44, + "probOfHeavyRain": 32, + "probOfHail": 4, + "probOfSferics": 6 + }, { + "time": "2024-02-22T21:00Z", + "maxScreenAirTemp": 7.69, + "minScreenAirTemp": 6.05, + "max10mWindGust": 16.16, + "significantWeatherCode": 0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 7.38, + "windDirectionFrom10m": 249, + "windGustSpeed10m": 13.14, + "visibility": 23784, + "mslp": 98597, + "screenRelativeHumidity": 77.6, + "feelsLikeTemp": 1.88, + "uvIndex": 0, + "probOfPrecipitation": 12, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 12, + "probOfHeavyRain": 10, + "probOfHail": 2, + "probOfSferics": 1 + }, { + "time": "2024-02-23T00:00Z", + "maxScreenAirTemp": 6.1, + "minScreenAirTemp": 5.22, + "max10mWindGust": 14.6, + "significantWeatherCode": 0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 6.61, + "windDirectionFrom10m": 245, + "windGustSpeed10m": 11.58, + "visibility": 25279, + "mslp": 98742, + "screenRelativeHumidity": 77.46, + "feelsLikeTemp": 1.2, + "uvIndex": 0, + "probOfPrecipitation": 8, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 8, + "probOfHeavyRain": 5, + "probOfHail": 1, + "probOfSferics": 1 + }, { + "time": "2024-02-23T03:00Z", + "maxScreenAirTemp": 5.42, + "minScreenAirTemp": 5.18, + "max10mWindGust": 13.07, + "significantWeatherCode": 0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 6.22, + "windDirectionFrom10m": 237, + "windGustSpeed10m": 10.91, + "visibility": 24397, + "mslp": 98771, + "screenRelativeHumidity": 79.47, + "feelsLikeTemp": 1.25, + "uvIndex": 0, + "probOfPrecipitation": 5, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 5, + "probOfHeavyRain": 4, + "probOfHail": 1, + "probOfSferics": 1 + }, { + "time": "2024-02-23T06:00Z", + "maxScreenAirTemp": 5.39, + "minScreenAirTemp": 5.13, + "max10mWindGust": 12.51, + "significantWeatherCode": 7, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 5.96, + "windDirectionFrom10m": 232, + "windGustSpeed10m": 10.43, + "visibility": 23385, + "mslp": 98811, + "screenRelativeHumidity": 81.65, + "feelsLikeTemp": 1.36, + "uvIndex": 0, + "probOfPrecipitation": 10, + "probOfSnow": 1, + "probOfHeavySnow": 0, + "probOfRain": 10, + "probOfHeavyRain": 5, + "probOfHail": 1, + "probOfSferics": 1 + }, { + "time": "2024-02-23T09:00Z", + "maxScreenAirTemp": 6.69, + "minScreenAirTemp": 5.26, + "max10mWindGust": 12.62, + "significantWeatherCode": 1, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 6.33, + "windDirectionFrom10m": 228, + "windGustSpeed10m": 11.6, + "visibility": 23165, + "mslp": 98890, + "screenRelativeHumidity": 78.08, + "feelsLikeTemp": 2.96, + "uvIndex": 1, + "probOfPrecipitation": 10, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 10, + "probOfHeavyRain": 6, + "probOfHail": 1, + "probOfSferics": 1 + }, { + "time": "2024-02-23T12:00Z", + "maxScreenAirTemp": 7.99, + "minScreenAirTemp": 6.64, + "max10mWindGust": 14.79, + "significantWeatherCode": 14, + "totalPrecipAmount": 0.95, + "totalSnowAmount": 0, + "windSpeed10m": 7.11, + "windDirectionFrom10m": 231, + "windGustSpeed10m": 13.38, + "visibility": 23049, + "mslp": 98974, + "screenRelativeHumidity": 73.25, + "feelsLikeTemp": 3.88, + "uvIndex": 2, + "probOfPrecipitation": 52, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 52, + "probOfHeavyRain": 48, + "probOfHail": 10, + "probOfSferics": 11 + }, { + "time": "2024-02-23T15:00Z", + "maxScreenAirTemp": 8.11, + "minScreenAirTemp": 7.61, + "max10mWindGust": 14.99, + "significantWeatherCode": 10, + "totalPrecipAmount": 0.67, + "totalSnowAmount": 0, + "windSpeed10m": 6.59, + "windDirectionFrom10m": 230, + "windGustSpeed10m": 12.43, + "visibility": 23824, + "mslp": 99018, + "screenRelativeHumidity": 71.06, + "feelsLikeTemp": 4.42, + "uvIndex": 1, + "probOfPrecipitation": 34, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 34, + "probOfHeavyRain": 20, + "probOfHail": 1, + "probOfSferics": 4 + }, { + "time": "2024-02-23T18:00Z", + "maxScreenAirTemp": 7.88, + "minScreenAirTemp": 6.36, + "max10mWindGust": 13.18, + "significantWeatherCode": 0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 5.1, + "windDirectionFrom10m": 228, + "windGustSpeed10m": 8.88, + "visibility": 24021, + "mslp": 99142, + "screenRelativeHumidity": 78.49, + "feelsLikeTemp": 3.13, + "uvIndex": 0, + "probOfPrecipitation": 7, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 7, + "probOfHeavyRain": 4, + "probOfHail": 0, + "probOfSferics": 1 + }, { + "time": "2024-02-23T21:00Z", + "maxScreenAirTemp": 6.4, + "minScreenAirTemp": 5.49, + "max10mWindGust": 9.53, + "significantWeatherCode": 7, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 4.55, + "windDirectionFrom10m": 233, + "windGustSpeed10m": 7.87, + "visibility": 23349, + "mslp": 99270, + "screenRelativeHumidity": 82.54, + "feelsLikeTemp": 2.42, + "uvIndex": 0, + "probOfPrecipitation": 9, + "probOfSnow": 1, + "probOfHeavySnow": 0, + "probOfRain": 9, + "probOfHeavyRain": 4, + "probOfHail": 0, + "probOfSferics": 1 + }, { + "time": "2024-02-24T00:00Z", + "maxScreenAirTemp": 5.59, + "minScreenAirTemp": 4.77, + "max10mWindGust": 8.74, + "significantWeatherCode": 0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 4.2, + "windDirectionFrom10m": 232, + "windGustSpeed10m": 7.16, + "visibility": 22325, + "mslp": 99365, + "screenRelativeHumidity": 85.83, + "feelsLikeTemp": 1.8, + "uvIndex": 0, + "probOfPrecipitation": 7, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 6, + "probOfHeavyRain": 4, + "probOfHail": 0, + "probOfSferics": 1 + }, { + "time": "2024-02-24T03:00Z", + "maxScreenAirTemp": 4.92, + "minScreenAirTemp": 4.42, + "max10mWindGust": 8.5, + "significantWeatherCode": 7, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 4.23, + "windDirectionFrom10m": 238, + "windGustSpeed10m": 7.16, + "visibility": 20772, + "mslp": 99430, + "screenRelativeHumidity": 87.91, + "feelsLikeTemp": 1.28, + "uvIndex": 0, + "probOfPrecipitation": 8, + "probOfSnow": 1, + "probOfHeavySnow": 1, + "probOfRain": 8, + "probOfHeavyRain": 4, + "probOfHail": 1, + "probOfSferics": 1 + }, { + "time": "2024-02-24T06:00Z", + "maxScreenAirTemp": 4.49, + "minScreenAirTemp": 3.86, + "max10mWindGust": 8.07, + "significantWeatherCode": 2, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 4.09, + "windDirectionFrom10m": 235, + "windGustSpeed10m": 6.85, + "visibility": 20467, + "mslp": 99535, + "screenRelativeHumidity": 89.35, + "feelsLikeTemp": 0.74, + "uvIndex": 0, + "probOfPrecipitation": 7, + "probOfSnow": 1, + "probOfHeavySnow": 1, + "probOfRain": 6, + "probOfHeavyRain": 3, + "probOfHail": 1, + "probOfSferics": 1 + }, { + "time": "2024-02-24T09:00Z", + "maxScreenAirTemp": 6.32, + "minScreenAirTemp": 3.95, + "max10mWindGust": 8.97, + "significantWeatherCode": 7, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 4.86, + "windDirectionFrom10m": 236, + "windGustSpeed10m": 8.91, + "visibility": 23436, + "mslp": 99655, + "screenRelativeHumidity": 81.66, + "feelsLikeTemp": 3.25, + "uvIndex": 1, + "probOfPrecipitation": 9, + "probOfSnow": 1, + "probOfHeavySnow": 1, + "probOfRain": 9, + "probOfHeavyRain": 4, + "probOfHail": 0, + "probOfSferics": 1 + }, { + "time": "2024-02-24T12:00Z", + "maxScreenAirTemp": 8.36, + "minScreenAirTemp": 6.32, + "max10mWindGust": 11.98, + "significantWeatherCode": 7, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "windSpeed10m": 5.91, + "windDirectionFrom10m": 234, + "windGustSpeed10m": 11.62, + "visibility": 22370, + "mslp": 99735, + "screenRelativeHumidity": 70.47, + "feelsLikeTemp": 5.17, + "uvIndex": 2, + "probOfPrecipitation": 13, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 13, + "probOfHeavyRain": 6, + "probOfHail": 1, + "probOfSferics": 1 + }, { + "time": "2024-02-24T15:00Z", + "maxScreenAirTemp": 8.25, + "minScreenAirTemp": 7.96, + "max10mWindGust": 12.77, + "significantWeatherCode": 10, + "totalPrecipAmount": 0.58, + "totalSnowAmount": 0, + "windSpeed10m": 5.98, + "windDirectionFrom10m": 228, + "windGustSpeed10m": 11.48, + "visibility": 22483, + "mslp": 99725, + "screenRelativeHumidity": 72.01, + "feelsLikeTemp": 4.97, + "uvIndex": 1, + "probOfPrecipitation": 32, + "probOfSnow": 0, + "probOfHeavySnow": 0, + "probOfRain": 32, + "probOfHeavyRain": 17, + "probOfHail": 1, + "probOfSferics": 3 + }] + } + }] +} From 3ae8d8a594394f59954875e40d6703e6c3b62035 Mon Sep 17 00:00:00 2001 From: Emlyn Price Date: Sat, 17 Feb 2024 17:17:26 +0000 Subject: [PATCH 15/51] Update docstrings --- src/datapoint/Forecast.py | 89 +++++++++++++++++++++++++++++++-------- src/datapoint/Manager.py | 61 +++++++++++++++++++++++++-- 2 files changed, 129 insertions(+), 21 deletions(-) diff --git a/src/datapoint/Forecast.py b/src/datapoint/Forecast.py index 108cf81..97eb83d 100644 --- a/src/datapoint/Forecast.py +++ b/src/datapoint/Forecast.py @@ -7,18 +7,70 @@ class Forecast: """Forecast data returned from DataPoint - Provides access to forecasts as far ahead as provided by DataPoint: - + x for hourly forecasts - + y for three-hourly forecasts - + z for daily forecasts - - Basic Usage:: - - >>> import datapoint - >>> m = datapoint.Manager(api_key = "blah") - >>> f = m.get_forecast(latitude=50, longitude=0, frequency="hourly") - >>> f.now() - + Provides access to forecasts as far ahead as provided by DataPoint: + + x for hourly forecasts + + y for three-hourly forecasts + + z for daily forecasts + + Basic Usage:: + + >>> import datapoint + >>> m = datapoint.Manager(api_key = "blah") + >>> f = m.get_forecast(latitude=50, longitude=0, frequency="hourly") + >>> f.now() + {'time': datetime.datetime(2024, 2, 19, 13, 0, tzinfo=datetime.timezone.utc), + 'screenTemperature': {'value': 10.09, + 'description': 'Screen Air Temperature', + 'unit_name': 'degrees Celsius', + 'unit_symbol': 'Cel'}, + 'screenDewPointTemperature': {'value': 8.08, + 'description': 'Screen Dew Point Temperature', + 'unit_name': 'degrees Celsius', + 'unit_symbol': 'Cel'}, + 'feelsLikeTemperature': {'value': 6.85, + 'description': 'Feels Like Temperature', + 'unit_name': 'degrees Celsius', + 'unit_symbol': 'Cel'}, + 'windSpeed10m': {'value': 7.57, + 'description': '10m Wind Speed', + 'unit_name': 'metres per second', + 'unit_symbol': 'm/s'}, + 'windDirectionFrom10m': {'value': 263, + 'description': '10m Wind From Direction', + 'unit_name': 'degrees', + 'unit_symbol': 'deg'}, + 'windGustSpeed10m': {'value': 12.31, + 'description': '10m Wind Gust Speed', + 'unit_name': 'metres per second', + 'unit_symbol': 'm/s'}, + 'visibility': {'value': 21201, + 'description': 'Visibility', + 'unit_name': 'metres', + 'unit_symbol': 'm'}, + 'screenRelativeHumidity': {'value': 87.81, + 'description': 'Screen Relative Humidity', + 'unit_name': 'percentage', + 'unit_symbol': '%'}, + 'mslp': {'value': 103080, + 'description': 'Mean Sea Level Pressure', + 'unit_name': 'pascals', + 'unit_symbol': 'Pa'}, + 'uvIndex': {'value': 1, + 'description': 'UV Index', + 'unit_name': 'dimensionless', + 'unit_symbol': '1'}, + 'significantWeatherCode': {'value': 'Cloudy', + 'description': 'Significant Weather Code', + 'unit_name': 'dimensionless', + 'unit_symbol': '1'}, + 'precipitationRate': {'value': 0.0, + 'description': 'Precipitation Rate', + 'unit_name': 'millimetres per hour', + 'unit_symbol': 'mm/h'}, + 'probOfPrecipitation': {'value': 21, + 'description': 'Probability of Precipitation', + 'unit_name': 'percentage', + 'unit_symbol': '%'}} """ def __init__(self, frequency, api_data): @@ -48,13 +100,13 @@ def __init__(self, frequency, api_data): forecasts = api_data["features"][0]["properties"]["timeSeries"] parameters = api_data["parameters"][0] if frequency == "daily": - self.timesteps = self.__build_timesteps_from_daily(forecasts, parameters) + self.timesteps = self._build_timesteps_from_daily(forecasts, parameters) else: self.timesteps = [] for forecast in forecasts: - self.timesteps.append(self.__build_timestep(forecast, parameters)) + self.timesteps.append(self._build_timestep(forecast, parameters)) - def __build_timesteps_from_daily(self, forecasts, parameters): + def _build_timesteps_from_daily(self, forecasts, parameters): """Build individual timesteps from forecasts and metadata Take the forecast data from DataHub and combine with unit information @@ -119,7 +171,7 @@ def __build_timesteps_from_daily(self, forecasts, parameters): timesteps = sorted(timesteps, key=lambda t: t["time"]) return timesteps - def __build_timestep(self, forecast, parameters): + def _build_timestep(self, forecast, parameters): """Build individual timestep from forecast and metadata Take the forecast data from DataHub for a single time and combine with @@ -154,7 +206,7 @@ def __build_timestep(self, forecast, parameters): return timestep - def __check_requested_time(self, target): + def _check_requested_time(self, target): """Check that a forecast for the requested time can be provided :parameter target: The requested time for the forecast @@ -239,7 +291,7 @@ def at_datetime(self, target): target.date(), target.time(), self.timesteps[0]["time"].tzinfo ) - self.__check_requested_time(target) + self._check_requested_time(target) # Loop over all timesteps # Calculate the first time difference @@ -257,6 +309,7 @@ def at_datetime(self, target): if abs(td.total_seconds()) > abs(prev_td.total_seconds()): # We are further from the target to_return = prev_ts + break if i == len(self.timesteps): to_return = timestep diff --git a/src/datapoint/Manager.py b/src/datapoint/Manager.py index e81068a..cfb4b1b 100644 --- a/src/datapoint/Manager.py +++ b/src/datapoint/Manager.py @@ -24,7 +24,59 @@ class Manager: >>> m = datapoint.Manager(api_key = "blah") >>> f = m.get_forecast(latitude=50, longitude=0, frequency="hourly") >>> f.now() - + {'time': datetime.datetime(2024, 2, 19, 13, 0, tzinfo=datetime.timezone.utc), + 'screenTemperature': {'value': 10.09, + 'description': 'Screen Air Temperature', + 'unit_name': 'degrees Celsius', + 'unit_symbol': 'Cel'}, + 'screenDewPointTemperature': {'value': 8.08, + 'description': 'Screen Dew Point Temperature', + 'unit_name': 'degrees Celsius', + 'unit_symbol': 'Cel'}, + 'feelsLikeTemperature': {'value': 6.85, + 'description': 'Feels Like Temperature', + 'unit_name': 'degrees Celsius', + 'unit_symbol': 'Cel'}, + 'windSpeed10m': {'value': 7.57, + 'description': '10m Wind Speed', + 'unit_name': 'metres per second', + 'unit_symbol': 'm/s'}, + 'windDirectionFrom10m': {'value': 263, + 'description': '10m Wind From Direction', + 'unit_name': 'degrees', + 'unit_symbol': 'deg'}, + 'windGustSpeed10m': {'value': 12.31, + 'description': '10m Wind Gust Speed', + 'unit_name': 'metres per second', + 'unit_symbol': 'm/s'}, + 'visibility': {'value': 21201, + 'description': 'Visibility', + 'unit_name': 'metres', + 'unit_symbol': 'm'}, + 'screenRelativeHumidity': {'value': 87.81, + 'description': 'Screen Relative Humidity', + 'unit_name': 'percentage', + 'unit_symbol': '%'}, + 'mslp': {'value': 103080, + 'description': 'Mean Sea Level Pressure', + 'unit_name': 'pascals', + 'unit_symbol': 'Pa'}, + 'uvIndex': {'value': 1, + 'description': 'UV Index', + 'unit_name': 'dimensionless', + 'unit_symbol': '1'}, + 'significantWeatherCode': {'value': 'Cloudy', + 'description': 'Significant Weather Code', + 'unit_name': 'dimensionless', + 'unit_symbol': '1'}, + 'precipitationRate': {'value': 0.0, + 'description': 'Precipitation Rate', + 'unit_name': 'millimetres per hour', + 'unit_symbol': 'mm/h'}, + 'probOfPrecipitation': {'value': 21, + 'description': 'Probability of Precipitation', + 'unit_name': 'percentage', + 'unit_symbol': '%'}} """ def __init__(self, api_key=""): @@ -49,7 +101,7 @@ def __get_retry_session( :parameter session: Existing session to use :return: Session object - :rtype: TBD + :rtype: """ # requests.Session allows finer control, which is needed to use the @@ -111,6 +163,9 @@ def __call_api(self, latitude, longitude, frequency): print(params) print(headers) sess = self.__get_retry_session() + print("get_retry_session returns a :") + print(type(sess)) + print("----------------") req = sess.get( request_url, params=params, @@ -146,7 +201,7 @@ def get_forecast(self, latitude, longitude, frequency="daily"): "frequency must be set to one of 'hourly', 'three-hourly', 'daily'" ) data = self.__call_api(latitude, longitude, frequency) - #with open('./hourly_api_data.json', 'w') as f: + #with open('./three_hourly_api_data.json', 'w') as f: # geojson.dump(data, f) forecast = Forecast(frequency=frequency, api_data=data) From b0a8a6fc92a3aa3c5950e63c3200dfb108a19db2 Mon Sep 17 00:00:00 2001 From: Emlyn Price Date: Sat, 17 Feb 2024 17:18:11 +0000 Subject: [PATCH 16/51] Remove unneeded prints --- src/datapoint/Manager.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/datapoint/Manager.py b/src/datapoint/Manager.py index cfb4b1b..fdc08be 100644 --- a/src/datapoint/Manager.py +++ b/src/datapoint/Manager.py @@ -159,13 +159,7 @@ def __call_api(self, latitude, longitude, frequency): # object. This has a .get() function like requests.get(), so the use # doesn't change here. - print(request_url) - print(params) - print(headers) sess = self.__get_retry_session() - print("get_retry_session returns a :") - print(type(sess)) - print("----------------") req = sess.get( request_url, params=params, @@ -201,8 +195,6 @@ def get_forecast(self, latitude, longitude, frequency="daily"): "frequency must be set to one of 'hourly', 'three-hourly', 'daily'" ) data = self.__call_api(latitude, longitude, frequency) - #with open('./three_hourly_api_data.json', 'w') as f: - # geojson.dump(data, f) forecast = Forecast(frequency=frequency, api_data=data) return forecast From 3931147c5bcff2dd7b8a607cff3a798e2ea16c64 Mon Sep 17 00:00:00 2001 From: Emlyn Price Date: Sat, 2 Mar 2024 17:18:15 +0000 Subject: [PATCH 17/51] Remove redundant test file --- tests/unit/test_manager.py | 30 ------------------------------ 1 file changed, 30 deletions(-) delete mode 100644 tests/unit/test_manager.py diff --git a/tests/unit/test_manager.py b/tests/unit/test_manager.py deleted file mode 100644 index f830056..0000000 --- a/tests/unit/test_manager.py +++ /dev/null @@ -1,30 +0,0 @@ -import unittest - -import datapoint - - -class ManagerTestCase(unittest.TestCase): - def setUp(self): - self.manager = datapoint.Manager(api_key="") - - def test_weather_to_text_is_string(self): - weather_text = self.manager._weather_to_text(0) - self.assertIsInstance(weather_text, type("")) - - def test_weather_to_text_invalid_input_None(self): - self.assertRaises(ValueError, self.manager._weather_to_text, None) - - def test_weather_to_text_invalid_input_out_of_bounds(self): - self.assertRaises(ValueError, self.manager._weather_to_text, 31) - - def test_weather_to_text_invalid_input_String(self): - self.assertRaises(ValueError, self.manager._weather_to_text, "1") - - def test_visbility_to_text_invalid_input_None(self): - self.assertRaises(ValueError, self.manager._weather_to_text, None) - - def test_visibility_to_text_invalid_input_out_of_bounds(self): - self.assertRaises(ValueError, self.manager._weather_to_text, -1) - - def test_visibility_to_text_invalid_input_String(self): - self.assertRaises(ValueError, self.manager._weather_to_text, "1") From 2f7b512c1fb10b9058595564679c293cccda7bd7 Mon Sep 17 00:00:00 2001 From: Emlyn Price Date: Sat, 2 Mar 2024 17:57:00 +0000 Subject: [PATCH 18/51] Add integration tests, move reference data files --- tests/integration/datapoint.json | 1499 ----------------- tests/integration/test_datapoint.py | 199 --- tests/integration/test_manager.py | 451 ++--- tests/reference_data/__init__.py | 0 .../daily_api_data.json | 0 .../hourly_api_data.json | 0 .../reference_data_test_forecast.py | 0 .../three_hourly_api_data.json | 0 tests/unit/test_forecast.py | 12 +- 9 files changed, 173 insertions(+), 1988 deletions(-) delete mode 100644 tests/integration/datapoint.json delete mode 100644 tests/integration/test_datapoint.py create mode 100644 tests/reference_data/__init__.py rename tests/{unit => reference_data}/daily_api_data.json (100%) rename tests/{unit => reference_data}/hourly_api_data.json (100%) rename tests/{unit => reference_data}/reference_data_test_forecast.py (100%) rename tests/{unit => reference_data}/three_hourly_api_data.json (100%) diff --git a/tests/integration/datapoint.json b/tests/integration/datapoint.json deleted file mode 100644 index c2b8707..0000000 --- a/tests/integration/datapoint.json +++ /dev/null @@ -1,1499 +0,0 @@ -{ - "all_sites": { - "Locations": { - "Location": [ - { - "elevation": "47.0", - "id": "354107", - "latitude": "53.3986", - "longitude": "-2.9256", - "name": "Wavertree", - "region": "nw", - "unitaryAuthArea": "Merseyside" - }, - { - "elevation": "5.0", - "id": "322380", - "latitude": "52.7561", - "longitude": "0.4019", - "name": "King's Lynn", - "region": "ee", - "unitaryAuthArea": "Norfolk" - } - ] - } - }, - "wavertree_hourly": { - "SiteRep": { - "Wx": { - "Param": [ - { - "name": "F", - "units": "C", - "$": "Feels Like Temperature" - }, - { - "name": "G", - "units": "mph", - "$": "Wind Gust" - }, - { - "name": "H", - "units": "%", - "$": "Screen Relative Humidity" - }, - { - "name": "T", - "units": "C", - "$": "Temperature" - }, - { - "name": "V", - "units": "", - "$": "Visibility" - }, - { - "name": "D", - "units": "compass", - "$": "Wind Direction" - }, - { - "name": "S", - "units": "mph", - "$": "Wind Speed" - }, - { - "name": "U", - "units": "", - "$": "Max UV Index" - }, - { - "name": "W", - "units": "", - "$": "Weather Type" - }, - { - "name": "Pp", - "units": "%", - "$": "Precipitation Probability" - } - ] - }, - "DV": { - "dataDate": "2020-04-25T08:00:00Z", - "type": "Forecast", - "Location": { - "i": "354107", - "lat": "53.3986", - "lon": "-2.9256", - "name": "WAVERTREE", - "country": "ENGLAND", - "continent": "EUROPE", - "elevation": "47.0", - "Period": [ - { - "type": "Day", - "value": "2020-04-25Z", - "Rep": [ - { - "D": "SE", - "F": "7", - "G": "25", - "H": "63", - "Pp": "0", - "S": "9", - "T": "9", - "V": "VG", - "W": "0", - "U": "0", - "$": "180" - }, - { - "D": "ESE", - "F": "4", - "G": "22", - "H": "76", - "Pp": "0", - "S": "11", - "T": "7", - "V": "GO", - "W": "1", - "U": "1", - "$": "360" - }, - { - "D": "SSE", - "F": "8", - "G": "18", - "H": "70", - "Pp": "0", - "S": "9", - "T": "10", - "V": "MO", - "W": "1", - "U": "3", - "$": "540" - }, - { - "D": "SSE", - "F": "14", - "G": "16", - "H": "50", - "Pp": "0", - "S": "9", - "T": "17", - "V": "GO", - "W": "1", - "U": "5", - "$": "720" - }, - { - "D": "S", - "F": "17", - "G": "9", - "H": "43", - "Pp": "1", - "S": "4", - "T": "19", - "V": "GO", - "W": "1", - "U": "2", - "$": "900" - }, - { - "D": "WNW", - "F": "15", - "G": "13", - "H": "55", - "Pp": "2", - "S": "7", - "T": "17", - "V": "GO", - "W": "3", - "U": "1", - "$": "1080" - }, - { - "D": "NW", - "F": "14", - "G": "7", - "H": "64", - "Pp": "1", - "S": "2", - "T": "14", - "V": "GO", - "W": "2", - "U": "0", - "$": "1260" - } - ] - }, - { - "type": "Day", - "value": "2020-04-26Z", - "Rep": [ - { - "D": "WSW", - "F": "13", - "G": "4", - "H": "73", - "Pp": "1", - "S": "2", - "T": "13", - "V": "GO", - "W": "2", - "U": "0", - "$": "0" - }, - { - "D": "WNW", - "F": "12", - "G": "9", - "H": "77", - "Pp": "2", - "S": "4", - "T": "12", - "V": "GO", - "W": "2", - "U": "0", - "$": "180" - }, - { - "D": "NW", - "F": "10", - "G": "9", - "H": "82", - "Pp": "5", - "S": "4", - "T": "11", - "V": "MO", - "W": "7", - "U": "1", - "$": "360" - }, - { - "D": "WNW", - "F": "11", - "G": "7", - "H": "79", - "Pp": "5", - "S": "4", - "T": "12", - "V": "MO", - "W": "7", - "U": "3", - "$": "540" - }, - { - "D": "WNW", - "F": "10", - "G": "18", - "H": "78", - "Pp": "6", - "S": "9", - "T": "12", - "V": "MO", - "W": "7", - "U": "4", - "$": "720" - }, - { - "D": "NW", - "F": "10", - "G": "18", - "H": "71", - "Pp": "5", - "S": "9", - "T": "12", - "V": "GO", - "W": "7", - "U": "2", - "$": "900" - }, - { - "D": "NW", - "F": "9", - "G": "16", - "H": "68", - "Pp": "9", - "S": "9", - "T": "11", - "V": "VG", - "W": "7", - "U": "1", - "$": "1080" - }, - { - "D": "NW", - "F": "8", - "G": "11", - "H": "68", - "Pp": "9", - "S": "7", - "T": "10", - "V": "VG", - "W": "8", - "U": "0", - "$": "1260" - } - ] - }, - { - "type": "Day", - "value": "2020-04-27Z", - "Rep": [ - { - "D": "WNW", - "F": "8", - "G": "9", - "H": "72", - "Pp": "11", - "S": "4", - "T": "9", - "V": "VG", - "W": "8", - "U": "0", - "$": "0" - }, - { - "D": "WNW", - "F": "7", - "G": "11", - "H": "77", - "Pp": "12", - "S": "7", - "T": "8", - "V": "VG", - "W": "7", - "U": "0", - "$": "180" - }, - { - "D": "NW", - "F": "7", - "G": "9", - "H": "80", - "Pp": "14", - "S": "4", - "T": "8", - "V": "GO", - "W": "7", - "U": "1", - "$": "360" - }, - { - "D": "NW", - "F": "7", - "G": "18", - "H": "73", - "Pp": "6", - "S": "9", - "T": "9", - "V": "VG", - "W": "3", - "U": "2", - "$": "540" - }, - { - "D": "NW", - "F": "8", - "G": "20", - "H": "59", - "Pp": "4", - "S": "9", - "T": "10", - "V": "VG", - "W": "3", - "U": "3", - "$": "720" - }, - { - "D": "NW", - "F": "8", - "G": "20", - "H": "58", - "Pp": "1", - "S": "9", - "T": "10", - "V": "VG", - "W": "1", - "U": "2", - "$": "900" - }, - { - "D": "NW", - "F": "8", - "G": "16", - "H": "57", - "Pp": "1", - "S": "7", - "T": "10", - "V": "VG", - "W": "1", - "U": "1", - "$": "1080" - }, - { - "D": "NW", - "F": "8", - "G": "11", - "H": "67", - "Pp": "1", - "S": "4", - "T": "9", - "V": "VG", - "W": "0", - "U": "0", - "$": "1260" - } - ] - }, - { - "type": "Day", - "value": "2020-04-28Z", - "Rep": [ - { - "D": "NNW", - "F": "7", - "G": "7", - "H": "80", - "Pp": "2", - "S": "4", - "T": "8", - "V": "VG", - "W": "0", - "U": "0", - "$": "0" - }, - { - "D": "W", - "F": "6", - "G": "7", - "H": "86", - "Pp": "3", - "S": "4", - "T": "7", - "V": "GO", - "W": "0", - "U": "0", - "$": "180" - }, - { - "D": "S", - "F": "5", - "G": "9", - "H": "86", - "Pp": "5", - "S": "4", - "T": "6", - "V": "GO", - "W": "1", - "U": "1", - "$": "360" - }, - { - "D": "ENE", - "F": "7", - "G": "13", - "H": "72", - "Pp": "6", - "S": "7", - "T": "9", - "V": "GO", - "W": "3", - "U": "3", - "$": "540" - }, - { - "D": "ENE", - "F": "10", - "G": "16", - "H": "57", - "Pp": "10", - "S": "7", - "T": "11", - "V": "GO", - "W": "7", - "U": "4", - "$": "720" - }, - { - "D": "N", - "F": "11", - "G": "16", - "H": "58", - "Pp": "10", - "S": "7", - "T": "12", - "V": "GO", - "W": "7", - "U": "2", - "$": "900" - }, - { - "D": "N", - "F": "10", - "G": "16", - "H": "63", - "Pp": "10", - "S": "7", - "T": "11", - "V": "VG", - "W": "7", - "U": "1", - "$": "1080" - }, - { - "D": "NNE", - "F": "9", - "G": "11", - "H": "72", - "Pp": "9", - "S": "4", - "T": "10", - "V": "VG", - "W": "7", - "U": "0", - "$": "1260" - } - ] - }, - { - "type": "Day", - "value": "2020-04-29Z", - "Rep": [ - { - "D": "E", - "F": "8", - "G": "9", - "H": "79", - "Pp": "6", - "S": "4", - "T": "9", - "V": "VG", - "W": "7", - "U": "0", - "$": "0" - }, - { - "D": "SSE", - "F": "7", - "G": "11", - "H": "81", - "Pp": "3", - "S": "7", - "T": "8", - "V": "GO", - "W": "2", - "U": "0", - "$": "180" - }, - { - "D": "SE", - "F": "5", - "G": "16", - "H": "86", - "Pp": "9", - "S": "9", - "T": "8", - "V": "GO", - "W": "7", - "U": "1", - "$": "360" - }, - { - "D": "SE", - "F": "8", - "G": "22", - "H": "74", - "Pp": "12", - "S": "11", - "T": "10", - "V": "GO", - "W": "7", - "U": "3", - "$": "540" - }, - { - "D": "SE", - "F": "10", - "G": "27", - "H": "72", - "Pp": "47", - "S": "13", - "T": "12", - "V": "GO", - "W": "12", - "U": "3", - "$": "720" - }, - { - "D": "SSE", - "F": "10", - "G": "29", - "H": "73", - "Pp": "59", - "S": "13", - "T": "13", - "V": "GO", - "W": "14", - "U": "2", - "$": "900" - }, - { - "D": "SSE", - "F": "10", - "G": "20", - "H": "69", - "Pp": "39", - "S": "11", - "T": "12", - "V": "VG", - "W": "10", - "U": "1", - "$": "1080" - }, - { - "D": "SSE", - "F": "9", - "G": "22", - "H": "79", - "Pp": "19", - "S": "13", - "T": "11", - "V": "GO", - "W": "7", - "U": "0", - "$": "1260" - } - ] - } - ] - } - } - } - }, - "wavertree_daily": { - "SiteRep": { - "Wx": { - "Param": [ - { - "name": "FDm", - "units": "C", - "$": "Feels Like Day Maximum Temperature" - }, - { - "name": "FNm", - "units": "C", - "$": "Feels Like Night Minimum Temperature" - }, - { - "name": "Dm", - "units": "C", - "$": "Day Maximum Temperature" - }, - { - "name": "Nm", - "units": "C", - "$": "Night Minimum Temperature" - }, - { - "name": "Gn", - "units": "mph", - "$": "Wind Gust Noon" - }, - { - "name": "Gm", - "units": "mph", - "$": "Wind Gust Midnight" - }, - { - "name": "Hn", - "units": "%", - "$": "Screen Relative Humidity Noon" - }, - { - "name": "Hm", - "units": "%", - "$": "Screen Relative Humidity Midnight" - }, - { - "name": "V", - "units": "", - "$": "Visibility" - }, - { - "name": "D", - "units": "compass", - "$": "Wind Direction" - }, - { - "name": "S", - "units": "mph", - "$": "Wind Speed" - }, - { - "name": "U", - "units": "", - "$": "Max UV Index" - }, - { - "name": "W", - "units": "", - "$": "Weather Type" - }, - { - "name": "PPd", - "units": "%", - "$": "Precipitation Probability Day" - }, - { - "name": "PPn", - "units": "%", - "$": "Precipitation Probability Night" - } - ] - }, - "DV": { - "dataDate": "2020-04-25T08:00:00Z", - "type": "Forecast", - "Location": { - "i": "354107", - "lat": "53.3986", - "lon": "-2.9256", - "name": "WAVERTREE", - "country": "ENGLAND", - "continent": "EUROPE", - "elevation": "47.0", - "Period": [ - { - "type": "Day", - "value": "2020-04-25Z", - "Rep": [ - { - "D": "SSE", - "Gn": "16", - "Hn": "50", - "PPd": "2", - "S": "9", - "V": "GO", - "Dm": "19", - "FDm": "18", - "W": "1", - "U": "5", - "$": "Day" - }, - { - "D": "WSW", - "Gm": "4", - "Hm": "73", - "PPn": "2", - "S": "2", - "V": "GO", - "Nm": "11", - "FNm": "11", - "W": "2", - "$": "Night" - } - ] - }, - { - "type": "Day", - "value": "2020-04-26Z", - "Rep": [ - { - "D": "WNW", - "Gn": "18", - "Hn": "78", - "PPd": "9", - "S": "9", - "V": "MO", - "Dm": "13", - "FDm": "11", - "W": "7", - "U": "4", - "$": "Day" - }, - { - "D": "WNW", - "Gm": "9", - "Hm": "72", - "PPn": "12", - "S": "4", - "V": "VG", - "Nm": "8", - "FNm": "7", - "W": "8", - "$": "Night" - } - ] - }, - { - "type": "Day", - "value": "2020-04-27Z", - "Rep": [ - { - "D": "NW", - "Gn": "20", - "Hn": "59", - "PPd": "14", - "S": "9", - "V": "VG", - "Dm": "11", - "FDm": "8", - "W": "3", - "U": "3", - "$": "Day" - }, - { - "D": "NNW", - "Gm": "7", - "Hm": "80", - "PPn": "3", - "S": "4", - "V": "VG", - "Nm": "6", - "FNm": "5", - "W": "0", - "$": "Night" - } - ] - }, - { - "type": "Day", - "value": "2020-04-28Z", - "Rep": [ - { - "D": "ENE", - "Gn": "16", - "Hn": "57", - "PPd": "10", - "S": "7", - "V": "GO", - "Dm": "12", - "FDm": "11", - "W": "7", - "U": "4", - "$": "Day" - }, - { - "D": "E", - "Gm": "9", - "Hm": "79", - "PPn": "9", - "S": "4", - "V": "VG", - "Nm": "7", - "FNm": "6", - "W": "7", - "$": "Night" - } - ] - }, - { - "type": "Day", - "value": "2020-04-29Z", - "Rep": [ - { - "D": "SE", - "Gn": "27", - "Hn": "72", - "PPd": "59", - "S": "13", - "V": "GO", - "Dm": "13", - "FDm": "10", - "W": "12", - "U": "3", - "$": "Day" - }, - { - "D": "SSE", - "Gm": "18", - "Hm": "85", - "PPn": "19", - "S": "11", - "V": "VG", - "Nm": "8", - "FNm": "6", - "W": "7", - "$": "Night" - } - ] - } - ] - } - } - } - }, - "kingslynn_hourly": { - "SiteRep": { - "Wx": { - "Param": [ - { - "name": "F", - "units": "C", - "$": "Feels Like Temperature" - }, - { - "name": "G", - "units": "mph", - "$": "Wind Gust" - }, - { - "name": "H", - "units": "%", - "$": "Screen Relative Humidity" - }, - { - "name": "T", - "units": "C", - "$": "Temperature" - }, - { - "name": "V", - "units": "", - "$": "Visibility" - }, - { - "name": "D", - "units": "compass", - "$": "Wind Direction" - }, - { - "name": "S", - "units": "mph", - "$": "Wind Speed" - }, - { - "name": "U", - "units": "", - "$": "Max UV Index" - }, - { - "name": "W", - "units": "", - "$": "Weather Type" - }, - { - "name": "Pp", - "units": "%", - "$": "Precipitation Probability" - } - ] - }, - "DV": { - "dataDate": "2020-04-25T08:00:00Z", - "type": "Forecast", - "Location": { - "i": "322380", - "lat": "52.7561", - "lon": "0.4019", - "name": "KING'S LYNN", - "country": "ENGLAND", - "continent": "EUROPE", - "elevation": "5.0", - "Period": [ - { - "type": "Day", - "value": "2020-04-25Z", - "Rep": [ - { - "D": "SSE", - "F": "4", - "G": "9", - "H": "88", - "Pp": "7", - "S": "9", - "T": "7", - "V": "GO", - "W": "8", - "U": "0", - "$": "180" - }, - { - "D": "ESE", - "F": "5", - "G": "7", - "H": "86", - "Pp": "9", - "S": "4", - "T": "7", - "V": "GO", - "W": "8", - "U": "1", - "$": "360" - }, - { - "D": "ESE", - "F": "8", - "G": "4", - "H": "75", - "Pp": "9", - "S": "4", - "T": "9", - "V": "VG", - "W": "8", - "U": "3", - "$": "540" - }, - { - "D": "E", - "F": "13", - "G": "7", - "H": "60", - "Pp": "0", - "S": "2", - "T": "14", - "V": "VG", - "W": "1", - "U": "6", - "$": "720" - }, - { - "D": "NNW", - "F": "14", - "G": "9", - "H": "57", - "Pp": "0", - "S": "4", - "T": "15", - "V": "VG", - "W": "1", - "U": "3", - "$": "900" - }, - { - "D": "ENE", - "F": "14", - "G": "9", - "H": "58", - "Pp": "0", - "S": "4", - "T": "14", - "V": "VG", - "W": "1", - "U": "1", - "$": "1080" - }, - { - "D": "SE", - "F": "8", - "G": "18", - "H": "76", - "Pp": "0", - "S": "9", - "T": "10", - "V": "VG", - "W": "0", - "U": "0", - "$": "1260" - } - ] - }, - { - "type": "Day", - "value": "2020-04-26Z", - "Rep": [ - { - "D": "SSE", - "F": "5", - "G": "16", - "H": "84", - "Pp": "0", - "S": "7", - "T": "7", - "V": "VG", - "W": "0", - "U": "0", - "$": "0" - }, - { - "D": "S", - "F": "4", - "G": "16", - "H": "89", - "Pp": "0", - "S": "7", - "T": "6", - "V": "GO", - "W": "0", - "U": "0", - "$": "180" - }, - { - "D": "S", - "F": "4", - "G": "16", - "H": "87", - "Pp": "0", - "S": "7", - "T": "7", - "V": "GO", - "W": "1", - "U": "1", - "$": "360" - }, - { - "D": "SSW", - "F": "11", - "G": "13", - "H": "69", - "Pp": "0", - "S": "9", - "T": "13", - "V": "VG", - "W": "1", - "U": "4", - "$": "540" - }, - { - "D": "SW", - "F": "15", - "G": "18", - "H": "50", - "Pp": "8", - "S": "9", - "T": "17", - "V": "VG", - "W": "1", - "U": "5", - "$": "720" - }, - { - "D": "SW", - "F": "16", - "G": "16", - "H": "47", - "Pp": "8", - "S": "7", - "T": "18", - "V": "VG", - "W": "7", - "U": "2", - "$": "900" - }, - { - "D": "SW", - "F": "15", - "G": "13", - "H": "56", - "Pp": "3", - "S": "7", - "T": "17", - "V": "VG", - "W": "3", - "U": "1", - "$": "1080" - }, - { - "D": "SW", - "F": "13", - "G": "11", - "H": "76", - "Pp": "4", - "S": "4", - "T": "13", - "V": "VG", - "W": "7", - "U": "0", - "$": "1260" - } - ] - }, - { - "type": "Day", - "value": "2020-04-27Z", - "Rep": [ - { - "D": "SSW", - "F": "10", - "G": "13", - "H": "75", - "Pp": "5", - "S": "7", - "T": "11", - "V": "GO", - "W": "7", - "U": "0", - "$": "0" - }, - { - "D": "W", - "F": "9", - "G": "13", - "H": "84", - "Pp": "9", - "S": "7", - "T": "10", - "V": "GO", - "W": "7", - "U": "0", - "$": "180" - }, - { - "D": "NW", - "F": "7", - "G": "16", - "H": "85", - "Pp": "50", - "S": "9", - "T": "9", - "V": "GO", - "W": "12", - "U": "1", - "$": "360" - }, - { - "D": "NW", - "F": "9", - "G": "11", - "H": "78", - "Pp": "36", - "S": "4", - "T": "10", - "V": "VG", - "W": "7", - "U": "3", - "$": "540" - }, - { - "D": "WNW", - "F": "11", - "G": "11", - "H": "66", - "Pp": "9", - "S": "4", - "T": "12", - "V": "VG", - "W": "7", - "U": "4", - "$": "720" - }, - { - "D": "W", - "F": "11", - "G": "13", - "H": "62", - "Pp": "9", - "S": "7", - "T": "13", - "V": "VG", - "W": "7", - "U": "2", - "$": "900" - }, - { - "D": "E", - "F": "11", - "G": "11", - "H": "64", - "Pp": "10", - "S": "7", - "T": "12", - "V": "VG", - "W": "7", - "U": "1", - "$": "1080" - }, - { - "D": "SE", - "F": "9", - "G": "13", - "H": "78", - "Pp": "9", - "S": "7", - "T": "10", - "V": "VG", - "W": "7", - "U": "0", - "$": "1260" - } - ] - }, - { - "type": "Day", - "value": "2020-04-28Z", - "Rep": [ - { - "D": "SE", - "F": "7", - "G": "13", - "H": "85", - "Pp": "9", - "S": "7", - "T": "9", - "V": "VG", - "W": "7", - "U": "0", - "$": "0" - }, - { - "D": "E", - "F": "7", - "G": "9", - "H": "91", - "Pp": "11", - "S": "4", - "T": "8", - "V": "GO", - "W": "7", - "U": "0", - "$": "180" - }, - { - "D": "ESE", - "F": "7", - "G": "9", - "H": "92", - "Pp": "12", - "S": "4", - "T": "8", - "V": "GO", - "W": "7", - "U": "1", - "$": "360" - }, - { - "D": "ESE", - "F": "9", - "G": "13", - "H": "77", - "Pp": "14", - "S": "7", - "T": "11", - "V": "GO", - "W": "7", - "U": "3", - "$": "540" - }, - { - "D": "ESE", - "F": "12", - "G": "16", - "H": "64", - "Pp": "14", - "S": "7", - "T": "13", - "V": "GO", - "W": "7", - "U": "3", - "$": "720" - }, - { - "D": "ESE", - "F": "12", - "G": "18", - "H": "66", - "Pp": "15", - "S": "9", - "T": "13", - "V": "GO", - "W": "7", - "U": "2", - "$": "900" - }, - { - "D": "SSE", - "F": "11", - "G": "13", - "H": "73", - "Pp": "15", - "S": "7", - "T": "12", - "V": "GO", - "W": "7", - "U": "1", - "$": "1080" - }, - { - "D": "SE", - "F": "9", - "G": "13", - "H": "81", - "Pp": "13", - "S": "7", - "T": "10", - "V": "GO", - "W": "7", - "U": "0", - "$": "1260" - } - ] - }, - { - "type": "Day", - "value": "2020-04-29Z", - "Rep": [ - { - "D": "SSE", - "F": "7", - "G": "13", - "H": "87", - "Pp": "11", - "S": "7", - "T": "9", - "V": "GO", - "W": "7", - "U": "0", - "$": "0" - }, - { - "D": "SSE", - "F": "7", - "G": "13", - "H": "91", - "Pp": "15", - "S": "7", - "T": "9", - "V": "GO", - "W": "8", - "U": "0", - "$": "180" - }, - { - "D": "ESE", - "F": "7", - "G": "13", - "H": "89", - "Pp": "8", - "S": "7", - "T": "9", - "V": "GO", - "W": "7", - "U": "1", - "$": "360" - }, - { - "D": "SSE", - "F": "10", - "G": "20", - "H": "75", - "Pp": "8", - "S": "11", - "T": "12", - "V": "VG", - "W": "7", - "U": "3", - "$": "540" - }, - { - "D": "S", - "F": "12", - "G": "22", - "H": "68", - "Pp": "11", - "S": "11", - "T": "14", - "V": "GO", - "W": "7", - "U": "3", - "$": "720" - }, - { - "D": "S", - "F": "12", - "G": "27", - "H": "68", - "Pp": "55", - "S": "13", - "T": "14", - "V": "GO", - "W": "12", - "U": "1", - "$": "900" - }, - { - "D": "SSE", - "F": "11", - "G": "22", - "H": "76", - "Pp": "34", - "S": "11", - "T": "13", - "V": "VG", - "W": "10", - "U": "1", - "$": "1080" - }, - { - "D": "SSE", - "F": "9", - "G": "20", - "H": "86", - "Pp": "20", - "S": "11", - "T": "11", - "V": "VG", - "W": "7", - "U": "0", - "$": "1260" - } - ] - } - ] - } - } - } - } -} \ No newline at end of file diff --git a/tests/integration/test_datapoint.py b/tests/integration/test_datapoint.py deleted file mode 100644 index 514881d..0000000 --- a/tests/integration/test_datapoint.py +++ /dev/null @@ -1,199 +0,0 @@ -import json -import pathlib -import unittest -from datetime import date, datetime -from unittest.mock import patch - -import requests -from requests_mock import Mocker - -import datapoint - -DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S%z" - - -class MockDateTime(datetime): - """Replacement for datetime that can be mocked for testing.""" - - def __new__(cls, *args, **kwargs): - return datetime.__new__(datetime, *args, **kwargs) - - -class TestDataPoint(unittest.TestCase): - @Mocker() - def setUp(self, mock_request): - with open( - "{}/datapoint.json".format(pathlib.Path(__file__).parent.absolute()) - ) as f: - mock_json = json.load(f) - - self.all_sites = json.dumps(mock_json["all_sites"]) - self.wavertree_hourly = json.dumps(mock_json["wavertree_hourly"]) - self.wavertree_daily = json.dumps(mock_json["wavertree_daily"]) - self.kingslynn_hourly = json.dumps(mock_json["kingslynn_hourly"]) - - self.conn = datapoint.connection(api_key="abcdefgh-acbd-abcd-abcd-abcdefghijkl") - - mock_request.get( - "/public/data/val/wxfcs/all/json/sitelist/", text=self.all_sites - ) - self.wavertree = self.conn.get_nearest_forecast_site(53.38374, -2.90929) - self.kingslynn = self.conn.get_nearest_forecast_site(52.75556, 0.44231) - - @Mocker() - @patch("datetime.datetime", MockDateTime) - def test_wavertree_hourly(self, mock_request): - from datetime import datetime, timezone - - MockDateTime.now = classmethod( - lambda cls, **kwargs: datetime(2020, 4, 25, 12, tzinfo=timezone.utc) - ) - mock_request.get( - "/public/data/val/wxfcs/all/json/354107?res=3hourly", - text=self.wavertree_hourly, - ) - - forecast = self.conn.get_forecast_for_site( - self.wavertree.location_id, "3hourly" - ) - now = forecast.now() - - self.assertEqual(self.wavertree.location_id, "354107") - self.assertEqual(self.wavertree.name, "Wavertree") - - self.assertEqual(now.date.strftime(DATETIME_FORMAT), "2020-04-25 12:00:00+0000") - self.assertEqual(now.weather.value, "1") - self.assertEqual(now.temperature.value, 17) - self.assertEqual(now.feels_like_temperature.value, 14) - self.assertEqual(now.wind_speed.value, 9) - self.assertEqual(now.wind_direction.value, "SSE") - self.assertEqual(now.wind_gust.value, 16) - self.assertEqual(now.visibility.value, "GO") - self.assertEqual(now.uv.value, "5") - self.assertEqual(now.precipitation.value, 0) - self.assertEqual(now.humidity.value, 50) - - self.assertEqual(len(forecast.days), 5) - self.assertEqual(len(forecast.days[0].timesteps), 7) - self.assertEqual(len(forecast.days[3].timesteps), 8) - - self.assertEqual( - forecast.days[3].timesteps[7].date.strftime(DATETIME_FORMAT), - "2020-04-28 21:00:00+0000", - ) - self.assertEqual(forecast.days[3].timesteps[7].weather.value, "7") - self.assertEqual(forecast.days[3].timesteps[7].temperature.value, 10) - self.assertEqual(forecast.days[3].timesteps[7].feels_like_temperature.value, 9) - self.assertEqual(forecast.days[3].timesteps[7].wind_speed.value, 4) - self.assertEqual(forecast.days[3].timesteps[7].wind_direction.value, "NNE") - self.assertEqual(forecast.days[3].timesteps[7].wind_gust.value, 11) - self.assertEqual(forecast.days[3].timesteps[7].visibility.value, "VG") - self.assertEqual(forecast.days[3].timesteps[7].uv.value, "0") - self.assertEqual(forecast.days[3].timesteps[7].precipitation.value, 9) - self.assertEqual(forecast.days[3].timesteps[7].humidity.value, 72) - - @Mocker() - @patch("datetime.datetime", MockDateTime) - def test_wavertree_daily(self, mock_request): - from datetime import datetime, timezone - - MockDateTime.now = classmethod( - lambda cls, **kwargs: datetime(2020, 4, 25, 12, tzinfo=timezone.utc) - ) - mock_request.get( - "/public/data/val/wxfcs/all/json/354107?res=daily", - text=self.wavertree_daily, - ) - - forecast = self.conn.get_forecast_for_site(self.wavertree.location_id, "daily") - now = forecast.now() - - self.assertEqual(self.wavertree.location_id, "354107") - self.assertEqual(self.wavertree.name, "Wavertree") - - self.assertEqual(now.date.strftime(DATETIME_FORMAT), "2020-04-25 12:00:00+0000") - self.assertEqual(now.weather.value, "1") - self.assertEqual(now.temperature.value, 19) - self.assertEqual(now.feels_like_temperature.value, 18) - self.assertEqual(now.wind_speed.value, 9) - self.assertEqual(now.wind_direction.value, "SSE") - self.assertEqual(now.wind_gust.value, 16) - self.assertEqual(now.visibility.value, "GO") - self.assertEqual(now.uv.value, "5") - self.assertEqual(now.precipitation.value, 2) - self.assertEqual(now.humidity.value, 50) - - self.assertEqual(len(forecast.days), 5) - self.assertEqual(len(forecast.days[0].timesteps), 2) - self.assertEqual(len(forecast.days[4].timesteps), 2) - - self.assertEqual( - forecast.days[4].timesteps[1].date.strftime(DATETIME_FORMAT), - "2020-04-29 12:00:00+0000", - ) - self.assertEqual(forecast.days[4].timesteps[1].weather.value, "12") - self.assertEqual(forecast.days[4].timesteps[1].temperature.value, 13) - self.assertEqual(forecast.days[4].timesteps[1].feels_like_temperature.value, 10) - self.assertEqual(forecast.days[4].timesteps[1].wind_speed.value, 13) - self.assertEqual(forecast.days[4].timesteps[1].wind_direction.value, "SE") - self.assertEqual(forecast.days[4].timesteps[1].wind_gust.value, 27) - self.assertEqual(forecast.days[4].timesteps[1].visibility.value, "GO") - self.assertEqual(forecast.days[4].timesteps[1].uv.value, "3") - self.assertEqual(forecast.days[4].timesteps[1].precipitation.value, 59) - self.assertEqual(forecast.days[4].timesteps[1].humidity.value, 72) - - @Mocker() - @patch("datetime.datetime", MockDateTime) - def test_kingslynn_hourly(self, mock_request): - from datetime import datetime, timezone - - MockDateTime.now = classmethod( - lambda cls, **kwargs: datetime(2020, 4, 25, 12, tzinfo=timezone.utc) - ) - mock_request.get( - "/public/data/val/wxfcs/all/json/322380?res=3hourly", - text=self.kingslynn_hourly, - ) - - forecast = self.conn.get_forecast_for_site( - self.kingslynn.location_id, "3hourly" - ) - now = forecast.now() - - self.assertEqual(self.kingslynn.location_id, "322380") - self.assertEqual(self.kingslynn.name, "King's Lynn") - - self.assertEqual(now.date.strftime(DATETIME_FORMAT), "2020-04-25 12:00:00+0000") - self.assertEqual(now.weather.value, "1") - self.assertEqual(now.temperature.value, 14) - self.assertEqual(now.feels_like_temperature.value, 13) - self.assertEqual(now.wind_speed.value, 2) - self.assertEqual(now.wind_direction.value, "E") - self.assertEqual(now.wind_gust.value, 7) - self.assertEqual(now.visibility.value, "VG") - self.assertEqual(now.uv.value, "6") - self.assertEqual(now.precipitation.value, 0) - self.assertEqual(now.humidity.value, 60) - - self.assertEqual(len(forecast.days), 5) - self.assertEqual(len(forecast.days[0].timesteps), 7) - self.assertEqual(len(forecast.days[4].timesteps), 8) - - self.assertEqual( - forecast.days[4].timesteps[5].date.strftime(DATETIME_FORMAT), - "2020-04-29 15:00:00+0000", - ) - self.assertEqual(forecast.days[4].timesteps[5].weather.value, "12") - self.assertEqual(forecast.days[4].timesteps[5].temperature.value, 14) - self.assertEqual(forecast.days[4].timesteps[5].feels_like_temperature.value, 12) - self.assertEqual(forecast.days[4].timesteps[5].wind_speed.value, 13) - self.assertEqual(forecast.days[4].timesteps[5].wind_direction.value, "S") - self.assertEqual(forecast.days[4].timesteps[5].wind_gust.value, 27) - self.assertEqual(forecast.days[4].timesteps[5].visibility.value, "GO") - self.assertEqual(forecast.days[4].timesteps[5].uv.value, "1") - self.assertEqual(forecast.days[4].timesteps[5].precipitation.value, 55) - self.assertEqual(forecast.days[4].timesteps[5].humidity.value, 68) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/integration/test_manager.py b/tests/integration/test_manager.py index 3d8d063..1000fe8 100644 --- a/tests/integration/test_manager.py +++ b/tests/integration/test_manager.py @@ -1,291 +1,178 @@ -import datetime -import os -import unittest +import pytest +import requests -import datapoint +import tests.reference_data.reference_data_test_forecast as reference_data_test_forecast +from datapoint import Manager -class ManagerIntegrationTestCase(unittest.TestCase): - def setUp(self): - self.manager = datapoint.Manager(api_key=os.environ["API_KEY"]) +class MockResponseHourly: + def __init__(self): + with open("./tests/reference_data/hourly_api_data.json") as f: + my_json = f.read() - def test_site(self): - site = self.manager.get_nearest_forecast_site( - latitude=51.500728, longitude=-0.124626 - ) - self.assertEqual(site.name.upper(), "HORSEGUARDS PARADE") + self.text = my_json - def test_get_forecast_sites(self): - sites = self.manager.get_forecast_sites() - self.assertIsInstance(sites, list) - # What is this assert testing - assert sites + @staticmethod + def raise_for_status(): + pass - def test_get_daily_forecast(self): - site = self.manager.get_nearest_forecast_site( - latitude=51.500728, longitude=-0.124626 - ) - forecast = self.manager.get_forecast_for_site(site.location_id, "daily") - self.assertIsInstance(forecast, datapoint.Forecast.Forecast) - self.assertEqual(forecast.continent.upper(), "EUROPE") - self.assertEqual(forecast.country.upper(), "ENGLAND") - self.assertEqual(forecast.name.upper(), "HORSEGUARDS PARADE") - self.assertLess(abs(float(forecast.latitude) - 51.500728), 0.1) - self.assertLess(abs(float(forecast.longitude) - (-0.124626)), 0.1) - # Forecast should have been made within last 3 hours - tz = forecast.data_date.tzinfo - self.assertLess( - forecast.data_date - datetime.datetime.now(tz=tz), - datetime.timedelta(hours=3), - ) - # First forecast should be less than 12 hours away - tz = forecast.days[0].timesteps[0].date.tzinfo - self.assertLess( - forecast.days[0].timesteps[0].date - datetime.datetime.now(tz=tz), - datetime.timedelta(hours=12), - ) - for day in forecast.days: - for timestep in day.timesteps: - self.assertIn(timestep.name, ["Day", "Night"]) - self.assertEqual( - self.manager._weather_to_text(int(timestep.weather.value)), - timestep.weather.text, - ) - - self.assertGreater(timestep.temperature.value, -100) - self.assertLess(timestep.temperature.value, 100) - self.assertEqual(timestep.temperature.units, "C") - - self.assertGreater(timestep.feels_like_temperature.value, -100) - self.assertLess(timestep.feels_like_temperature.value, 100) - self.assertEqual(timestep.feels_like_temperature.units, "C") - - self.assertGreaterEqual(timestep.wind_speed.value, 0) - self.assertLess(timestep.wind_speed.value, 300) - self.assertEqual(timestep.wind_speed.units, "mph") - - for char in timestep.wind_direction.value: - self.assertIn(char, ["N", "E", "S", "W"]) - self.assertEqual(timestep.wind_direction.units, "compass") - - self.assertGreaterEqual(timestep.wind_gust.value, 0) - self.assertLess(timestep.wind_gust.value, 300) - self.assertEqual(timestep.wind_gust.units, "mph") - - self.assertIn( - timestep.visibility.value, - ["UN", "VP", "PO", "MO", "GO", "VG", "EX"], - ) - - self.assertGreaterEqual(timestep.precipitation.value, 0) - self.assertLessEqual(timestep.precipitation.value, 100) - self.assertEqual(timestep.precipitation.units, "%") - - self.assertGreaterEqual(timestep.humidity.value, 0) - self.assertLessEqual(timestep.humidity.value, 100) - self.assertEqual(timestep.humidity.units, "%") - - if hasattr(timestep.uv, "value"): - self.assertGreaterEqual(int(timestep.uv.value), 0) - self.assertLess(int(timestep.uv.value), 30) - - def test_get_3hour_forecast(self): - site = self.manager.get_nearest_forecast_site( - latitude=51.500728, longitude=-0.124626 - ) - forecast = self.manager.get_forecast_for_site(site.location_id, "3hourly") - self.assertIsInstance(forecast, datapoint.Forecast.Forecast) - self.assertEqual(forecast.continent.upper(), "EUROPE") - self.assertEqual(forecast.country.upper(), "ENGLAND") - self.assertEqual(forecast.name.upper(), "HORSEGUARDS PARADE") - self.assertLess(abs(float(forecast.latitude) - 51.500728), 0.1) - self.assertLess(abs(float(forecast.longitude) - (-0.124626)), 0.1) - # Forecast should have been made within last 3 hours - tz = forecast.data_date.tzinfo - self.assertLess( - forecast.data_date - datetime.datetime.now(tz=tz), - datetime.timedelta(hours=3), - ) - # First forecast should be less than 12 hours away - tz = forecast.days[0].timesteps[0].date.tzinfo - self.assertLess( - forecast.days[0].timesteps[0].date - datetime.datetime.now(tz=tz), - datetime.timedelta(hours=12), - ) - for day in forecast.days: - for timestep in day.timesteps: - self.assertIsInstance(timestep.name, int) - self.assertEqual( - self.manager._weather_to_text(int(timestep.weather.value)), - timestep.weather.text, - ) - - self.assertGreater(timestep.temperature.value, -100) - self.assertLess(timestep.temperature.value, 100) - self.assertEqual(timestep.temperature.units, "C") - - self.assertGreater(timestep.feels_like_temperature.value, -100) - self.assertLess(timestep.feels_like_temperature.value, 100) - self.assertEqual(timestep.feels_like_temperature.units, "C") - - self.assertGreaterEqual(timestep.wind_speed.value, 0) - self.assertLess(timestep.wind_speed.value, 300) - self.assertEqual(timestep.wind_speed.units, "mph") - - for char in timestep.wind_direction.value: - self.assertIn(char, ["N", "E", "S", "W"]) - self.assertEqual(timestep.wind_direction.units, "compass") - - self.assertGreaterEqual(timestep.wind_gust.value, 0) - self.assertLess(timestep.wind_gust.value, 300) - self.assertEqual(timestep.wind_gust.units, "mph") - - self.assertIn( - timestep.visibility.value, - ["UN", "VP", "PO", "MO", "GO", "VG", "EX"], - ) - - self.assertGreaterEqual(timestep.precipitation.value, 0) - self.assertLessEqual(timestep.precipitation.value, 100) - self.assertEqual(timestep.precipitation.units, "%") - - self.assertGreaterEqual(timestep.humidity.value, 0) - self.assertLessEqual(timestep.humidity.value, 100) - self.assertEqual(timestep.humidity.units, "%") - - if hasattr(timestep.uv, "value"): - self.assertGreaterEqual(int(timestep.uv.value), 0) - self.assertLess(int(timestep.uv.value), 30) - - def test_get_nearest_observation_site(self): - site = self.manager.get_nearest_observation_site( - longitude=-0.1025, latitude=51.3263 - ) - self.assertEqual(site.name.upper(), "KENLEY") - - def test_get_observation_sites(self): - sites = self.manager.get_observation_sites() - self.assertIsInstance(sites, list) - # Mystery assert - assert sites - - def test_get_observation_with_wind_data(self): - observation = self.manager.get_observations_for_site(3840) - self.assertIsInstance(observation, datapoint.Observation.Observation) - self.assertEqual(observation.continent.upper(), "EUROPE") - self.assertEqual(observation.country.upper(), "ENGLAND") - self.assertEqual(observation.name.upper(), "DUNKESWELL AERODROME") - - # Observation should be from within the last hour - tz = observation.data_date.tzinfo - self.assertLess( - observation.data_date - datetime.datetime.now(tz=tz), - datetime.timedelta(hours=1), - ) - # First observation should be between 24 and 25 hours old - tz = observation.days[0].timesteps[0].date.tzinfo - self.assertGreater( - datetime.datetime.now(tz=tz) - observation.days[0].timesteps[0].date, - datetime.timedelta(hours=24), - ) - self.assertLess( - datetime.datetime.now(tz=tz) - observation.days[0].timesteps[0].date, - datetime.timedelta(hours=25), +@pytest.fixture +def mock_response_hourly(monkeypatch): + def mock_get(*args, **kwargs): + return MockResponseHourly() + + monkeypatch.setattr(requests.Session, "get", mock_get) + + +@pytest.fixture +def hourly_forecast(mock_response_hourly): + m = Manager(api_key="aaaaaaaaaaaaaaaaaaaaaaaaa") + f = m.get_forecast(50.9992, 0.0154, frequency="hourly") + return f + + +@pytest.fixture +def expected_first_hourly_timestep(): + return reference_data_test_forecast.EXPECTED_FIRST_HOURLY_TIMESTEP + + +class MockResponseThreeHourly: + def __init__(self): + with open("./tests/reference_data/three_hourly_api_data.json") as f: + my_json = f.read() + + self.text = my_json + + @staticmethod + def raise_for_status(): + pass + + +@pytest.fixture +def mock_response_three_hourly(monkeypatch): + def mock_get(*args, **kwargs): + return MockResponseThreeHourly() + + monkeypatch.setattr(requests.Session, "get", mock_get) + + +@pytest.fixture +def three_hourly_forecast(mock_response_three_hourly): + m = Manager(api_key="aaaaaaaaaaaaaaaaaaaaaaaaa") + f = m.get_forecast(50.9992, 0.0154, frequency="three-hourly") + return f + + +@pytest.fixture +def expected_first_three_hourly_timestep(): + return reference_data_test_forecast.EXPECTED_FIRST_THREE_HOURLY_TIMESTEP + + +class MockResponseDaily: + def __init__(self): + with open("./tests/reference_data/daily_api_data.json") as f: + my_json = f.read() + + self.text = my_json + + @staticmethod + def raise_for_status(): + pass + + +@pytest.fixture +def mock_response_daily(monkeypatch): + def mock_get(*args, **kwargs): + return MockResponseDaily() + + monkeypatch.setattr(requests.Session, "get", mock_get) + + +@pytest.fixture +def daily_forecast(mock_response_daily): + m = Manager(api_key="aaaaaaaaaaaaaaaaaaaaaaaaa") + f = m.get_forecast(50.9992, 0.0154, frequency="daily") + return f + + +@pytest.fixture +def expected_first_daily_timestep(): + return reference_data_test_forecast.EXPECTED_FIRST_DAILY_TIMESTEP + + +class TestHourly: + def test_location_name(self, hourly_forecast): + assert hourly_forecast.name == "Sheffield Park" + + def test_forecast_frequency(self, hourly_forecast): + assert hourly_forecast.frequency == "hourly" + + def test_forecast_location_latitude(self, hourly_forecast): + assert hourly_forecast.forecast_latitude == 50.9992 + + def test_forecast_location_longitude(self, hourly_forecast): + assert hourly_forecast.forecast_longitude == 0.0154 + + def test_forecast_distance_from_request(self, hourly_forecast): + assert hourly_forecast.distance_from_requested_location == 1081.5349 + + def test_forecast_elevation(self, hourly_forecast): + assert hourly_forecast.elevation == 37.0 + + def test_forecast_first_timestep( + self, hourly_forecast, expected_first_hourly_timestep + ): + assert hourly_forecast.timesteps[0] == expected_first_hourly_timestep + + +class TestThreeHourly: + def test_forecast_frequency(self, three_hourly_forecast): + assert three_hourly_forecast.frequency == "three-hourly" + + def test_forecast_location_name(self, three_hourly_forecast): + assert three_hourly_forecast.name == "Sheffield Park" + + def test_forecast_location_latitude(self, three_hourly_forecast): + assert three_hourly_forecast.forecast_latitude == 50.9992 + + def test_forecast_location_longitude(self, three_hourly_forecast): + assert three_hourly_forecast.forecast_longitude == 0.0154 + + def test_forecast_distance_from_request(self, three_hourly_forecast): + assert three_hourly_forecast.distance_from_requested_location == 1081.5349 + + def test_forecast_elevation(self, three_hourly_forecast): + assert three_hourly_forecast.elevation == 37.0 + + def test_forecast_first_timestep( + self, three_hourly_forecast, expected_first_three_hourly_timestep + ): + assert ( + three_hourly_forecast.timesteps[0] == expected_first_three_hourly_timestep ) - # Should have total 25 observations across all days - number_of_timesteps = 0 - for day in observation.days: - number_of_timesteps += len(day.timesteps) - self.assertEqual(number_of_timesteps, 25) - - for day in observation.days: - for timestep in day.timesteps: - self.assertIsInstance(timestep.name, int) - if timestep.weather.value != "Not reported": - self.assertEqual( - self.manager._weather_to_text(int(timestep.weather.value)), - timestep.weather.text, - ) - self.assertGreater(timestep.temperature.value, -100) - self.assertLess(timestep.temperature.value, 100) - self.assertEqual(timestep.temperature.units, "C") - - if timestep.wind_speed.value != "Not reported": - self.assertGreaterEqual(timestep.wind_speed.value, 0) - self.assertLess(timestep.wind_speed.value, 300) - self.assertEqual(timestep.wind_speed.units, "mph") - - if timestep.wind_direction.value != "Not reported": - for char in timestep.wind_direction.value: - self.assertIn(char, ["N", "E", "S", "W"]) - self.assertEqual(timestep.wind_direction.units, "compass") - - self.assertGreaterEqual(timestep.visibility.value, 0) - self.assertIn( - timestep.visibility.text, ["UN", "VP", "PO", "MO", "GO", "VG", "EX"] - ) - - self.assertGreaterEqual(timestep.humidity.value, 0) - self.assertLessEqual(timestep.humidity.value, 100) - self.assertEqual(timestep.humidity.units, "%") - - self.assertGreaterEqual(timestep.dew_point.value, 0) - self.assertLessEqual(timestep.dew_point.value, 100) - self.assertEqual(timestep.dew_point.units, "C") - - if timestep.pressure.value != "Not reported": - self.assertGreaterEqual(timestep.pressure.value, 900) - self.assertLessEqual(timestep.pressure.value, 1100) - self.assertEqual(timestep.pressure.units, "hpa") - - if timestep.pressure_tendency.value != "Not reported": - self.assertIn(timestep.pressure_tendency.value, ["R", "F", "S"]) - self.assertEqual(timestep.pressure_tendency.units, "Pa/s") - - # def test_get_observation_without_wind_data(self): - # observation = self.manager.get_observations_for_site(3220) - # assert isinstance(observation, datapoint.Observation.Observation) - # assert observation.continent.upper() == 'EUROPE' - # assert observation.country.upper() == 'ENGLAND' - # assert observation.name.upper() == 'CARLISLE' - - # # Observation should be from within the last hour - # tz = observation.data_date.tzinfo - # assert (observation.data_date - # - datetime.datetime.now(tz=tz) < datetime.timedelta(hours=1)) - - # # First observation should be between 24 and 25 hours old - # tz = observation.days[0].timesteps[0].date.tzinfo - # assert (datetime.datetime.now(tz=tz) - observation.days[0].timesteps[0].date > datetime.timedelta(hours=24)) - # assert (datetime.datetime.now(tz=tz) - observation.days[0].timesteps[0].date < datetime.timedelta(hours=25)) - - # # Should have total 25 observations across all days - # number_of_timesteps = 0 - # for day in observation.days: - # number_of_timesteps += len(day.timesteps) - # assert number_of_timesteps == 25 - - # for day in observation.days: - # for timestep in day.timesteps: - # assert isinstance(timestep.name, int) - # if timestep.weather.value != 'Not reported': - # assert self.manager._weather_to_text( - # int(timestep.weather.value)) == timestep.weather.text - # assert -100 < timestep.temperature.value < 100 - # assert timestep.temperature.units == 'C' - # assert timestep.wind_speed is None - # assert timestep.wind_gust is None - # assert timestep.wind_direction is None - # assert 0 <= timestep.visibility.value - # assert (timestep.visibility.text in - # ['UN', 'VP', 'PO', 'MO', 'GO', 'VG', 'EX']) - # assert 0 <= timestep.humidity.value <= 100 - # assert timestep.humidity.units == '%' - # assert -100 < timestep.dew_point.value < 100 - # assert timestep.dew_point.units == 'C' - # assert 900 < timestep.pressure.value < 1100 - # assert timestep.pressure.units == 'hpa' - # assert timestep.pressure_tendency.value in ('R','F','S') - # assert timestep.pressure_tendency.units == 'Pa/s' + +class TestDaily: + def test_forecast_frequency(self, daily_forecast): + assert daily_forecast.frequency == "daily" + + def test_forecast_location_name(self, daily_forecast): + assert daily_forecast.name == "Sheffield Park" + + def test_forecast_location_latitude(self, daily_forecast): + assert daily_forecast.forecast_latitude == 50.9992 + + def test_forecast_location_longitude(self, daily_forecast): + assert daily_forecast.forecast_longitude == 0.0154 + + def test_forecast_distance_from_request(self, daily_forecast): + assert daily_forecast.distance_from_requested_location == 1081.5349 + + def test_forecast_elevation(self, daily_forecast): + assert daily_forecast.elevation == 37.0 + + def test_forecast_first_timestep( + self, daily_forecast, expected_first_daily_timestep + ): + assert daily_forecast.timesteps[0] == expected_first_daily_timestep diff --git a/tests/reference_data/__init__.py b/tests/reference_data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/daily_api_data.json b/tests/reference_data/daily_api_data.json similarity index 100% rename from tests/unit/daily_api_data.json rename to tests/reference_data/daily_api_data.json diff --git a/tests/unit/hourly_api_data.json b/tests/reference_data/hourly_api_data.json similarity index 100% rename from tests/unit/hourly_api_data.json rename to tests/reference_data/hourly_api_data.json diff --git a/tests/unit/reference_data_test_forecast.py b/tests/reference_data/reference_data_test_forecast.py similarity index 100% rename from tests/unit/reference_data_test_forecast.py rename to tests/reference_data/reference_data_test_forecast.py diff --git a/tests/unit/three_hourly_api_data.json b/tests/reference_data/three_hourly_api_data.json similarity index 100% rename from tests/unit/three_hourly_api_data.json rename to tests/reference_data/three_hourly_api_data.json diff --git a/tests/unit/test_forecast.py b/tests/unit/test_forecast.py index 551f88c..18b05ee 100644 --- a/tests/unit/test_forecast.py +++ b/tests/unit/test_forecast.py @@ -3,7 +3,7 @@ import geojson import pytest -import tests.unit.reference_data_test_forecast as reference_data_test_forecast +import tests.reference_data.reference_data_test_forecast as reference_data_test_forecast from datapoint import Forecast from datapoint.exceptions import APIException @@ -12,21 +12,21 @@ @pytest.fixture def load_hourly_json(): - with open("./tests/unit/hourly_api_data.json") as f: + with open("./tests/reference_data/hourly_api_data.json") as f: my_json = geojson.load(f) return my_json @pytest.fixture def load_daily_json(): - with open("./tests/unit/daily_api_data.json") as f: + with open("./tests/reference_data/daily_api_data.json") as f: my_json = geojson.load(f) return my_json @pytest.fixture def load_three_hourly_json(): - with open("./tests/unit/three_hourly_api_data.json") as f: + with open("./tests/reference_data/three_hourly_api_data.json") as f: my_json = geojson.load(f) return my_json @@ -93,10 +93,6 @@ def expected_at_datetime_daily_final_timestep(): def expected_first_three_hourly_timestep(): return reference_data_test_forecast.EXPECTED_FIRST_THREE_HOURLY_TIMESTEP -@pytest.fixture -def expected_first_three_hourly_timestep(): - return reference_data_test_forecast.EXPECTED_FIRST_THREE_HOURLY_TIMESTEP - @pytest.fixture def expected_at_datetime_three_hourly_timestep(): return reference_data_test_forecast.EXPECTED_AT_DATETIME_THREE_HOURLY_TIMESTEP From 0a70fd4db55fca6c2d945fa949977c837a637154 Mon Sep 17 00:00:00 2001 From: Emlyn Price Date: Sat, 2 Mar 2024 18:31:46 +0000 Subject: [PATCH 19/51] Add test build action --- .github/workflows/publish-to-test-pypi.yml | 45 ++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/publish-to-test-pypi.yml diff --git a/.github/workflows/publish-to-test-pypi.yml b/.github/workflows/publish-to-test-pypi.yml new file mode 100644 index 0000000..1534ca2 --- /dev/null +++ b/.github/workflows/publish-to-test-pypi.yml @@ -0,0 +1,45 @@ +name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI +on: push +jobs: + build: + name: Build distribution 📦 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.x" + - name: Install pypa/build + run: >- + python3 -m + pip install + build + --user + - name: Build a binary wheel and a source tarball + run: python3 -m build + - name: Store the distribution packages + uses: actions/upload-artifact@v3 + with: + name: python-package-distributions + path: dist/ + publish-to-testpypi: + name: Publish Python 🐍 distribution 📦 to TestPyPI + needs: + - build + runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/p/datapoint + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + steps: + - name: Download all the dists + uses: actions/download-artifact@v3 + with: + name: python-package-distributions + path: dist/ + - name: Publish distribution 📦 to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ From b91208956b082070e30e0d2b6684af8092670c88 Mon Sep 17 00:00:00 2001 From: Emlyn Price Date: Sat, 2 Mar 2024 18:33:42 +0000 Subject: [PATCH 20/51] Fetch tags in build --- .github/workflows/publish-to-test-pypi.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish-to-test-pypi.yml b/.github/workflows/publish-to-test-pypi.yml index 1534ca2..c42aca1 100644 --- a/.github/workflows/publish-to-test-pypi.yml +++ b/.github/workflows/publish-to-test-pypi.yml @@ -6,6 +6,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + fetch-tags: true - name: Set up Python uses: actions/setup-python@v4 with: From 33fe1aa5e5b3016d2ba8ebef2d4f6575da84ad93 Mon Sep 17 00:00:00 2001 From: Emlyn Price Date: Sat, 2 Mar 2024 18:34:26 +0000 Subject: [PATCH 21/51] Correct build yaml --- .github/workflows/publish-to-test-pypi.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-to-test-pypi.yml b/.github/workflows/publish-to-test-pypi.yml index c42aca1..4252200 100644 --- a/.github/workflows/publish-to-test-pypi.yml +++ b/.github/workflows/publish-to-test-pypi.yml @@ -6,7 +6,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - fetch-tags: true + with: + fetch-tags: true - name: Set up Python uses: actions/setup-python@v4 with: From 0a26b20a8d8343467a160eac24a413abf60aed9a Mon Sep 17 00:00:00 2001 From: Emlyn Price Date: Sat, 2 Mar 2024 18:41:13 +0000 Subject: [PATCH 22/51] Just set fetch-depth to 0 --- .github/workflows/publish-to-test-pypi.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish-to-test-pypi.yml b/.github/workflows/publish-to-test-pypi.yml index 4252200..c12cf70 100644 --- a/.github/workflows/publish-to-test-pypi.yml +++ b/.github/workflows/publish-to-test-pypi.yml @@ -7,6 +7,7 @@ jobs: steps: - uses: actions/checkout@v4 with: + fetch-depth: 0 fetch-tags: true - name: Set up Python uses: actions/setup-python@v4 From 485156754bfb8939a228609d2f7b406bbf20b0d1 Mon Sep 17 00:00:00 2001 From: Emily Price Date: Tue, 12 Nov 2024 18:27:35 +0000 Subject: [PATCH 23/51] Update documentation config --- .readthedocs.yaml | 27 + Pipfile | 19 + Pipfile.lock | 535 ++++++++++++++++++++ docs/Makefile | 20 + docs/conf.py | 182 ------- docs/index.rst | 10 - docs/make.bat | 35 ++ docs/requirements.txt | 2 + docs/source/conf.py | 34 ++ docs/{ => source}/forecast_sites_map.png | Bin docs/{ => source}/getting-started.rst | 0 docs/source/index.rst | 16 + docs/{ => source}/install.rst | 36 -- docs/{ => source}/locations.rst | 0 docs/{ => source}/objects.rst | 0 docs/{ => source}/observation_sites_map.png | Bin pyproject.toml | 2 +- 17 files changed, 689 insertions(+), 229 deletions(-) create mode 100644 .readthedocs.yaml create mode 100644 Pipfile create mode 100644 Pipfile.lock create mode 100644 docs/Makefile delete mode 100644 docs/conf.py delete mode 100644 docs/index.rst create mode 100644 docs/make.bat create mode 100644 docs/requirements.txt create mode 100644 docs/source/conf.py rename docs/{ => source}/forecast_sites_map.png (100%) rename docs/{ => source}/getting-started.rst (100%) create mode 100644 docs/source/index.rst rename docs/{ => source}/install.rst (50%) rename docs/{ => source}/locations.rst (100%) rename docs/{ => source}/objects.rst (100%) rename docs/{ => source}/observation_sites_map.png (100%) diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..f50fc0f --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,27 @@ +# Read the Docs configuration file for Sphinx projects +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the OS, Python version and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.12" + +# Build documentation in the "docs/" directory with Sphinx +sphinx: + configuration: docs/conf.py + # You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs + # builder: "dirhtml" + # Fail on all warnings to avoid broken references + # fail_on_warning: true + + +# Optional but recommended, declare the Python requirements required +# to build your documentation +# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html +# python: +# install: +# - requirements: docs/requirements.txt diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..7449655 --- /dev/null +++ b/Pipfile @@ -0,0 +1,19 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +requests = "==2.31.0" +appdirs = "==1.4.4" +requests-mock = "==1.11.0" +geojson = "==3.1.0" +datapoint = {file = ".", editable = true} + +[dev-packages] + +[documentation] +sphinx = "*" + +[requires] +python_version = "3.11" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..7636b9e --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,535 @@ +{ + "_meta": { + "hash": { + "sha256": "1469f56039acabc431f12f5410c951f266f341ce34ccb472935cd44c953f830b" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.11" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "appdirs": { + "hashes": [ + "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", + "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" + ], + "index": "pypi", + "version": "==1.4.4" + }, + "certifi": { + "hashes": [ + "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", + "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9" + ], + "markers": "python_version >= '3.6'", + "version": "==2024.8.30" + }, + "charset-normalizer": { + "hashes": [ + "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621", + "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", + "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", + "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912", + "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c", + "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b", + "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d", + "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d", + "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95", + "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e", + "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", + "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", + "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab", + "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be", + "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", + "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907", + "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0", + "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2", + "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62", + "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62", + "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", + "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc", + "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", + "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca", + "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455", + "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858", + "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", + "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594", + "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", + "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", + "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", + "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea", + "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6", + "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", + "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749", + "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", + "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd", + "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99", + "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242", + "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee", + "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129", + "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", + "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51", + "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee", + "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8", + "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", + "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613", + "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742", + "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe", + "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3", + "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5", + "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", + "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7", + "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", + "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c", + "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea", + "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417", + "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", + "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", + "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca", + "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa", + "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99", + "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149", + "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41", + "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574", + "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0", + "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f", + "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", + "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654", + "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3", + "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19", + "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", + "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578", + "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", + "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", + "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51", + "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", + "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236", + "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a", + "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c", + "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade", + "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944", + "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc", + "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6", + "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6", + "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27", + "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6", + "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2", + "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12", + "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf", + "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", + "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7", + "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", + "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", + "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b", + "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", + "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", + "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4", + "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", + "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365", + "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a", + "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748", + "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b", + "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", + "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482" + ], + "markers": "python_full_version >= '3.7.0'", + "version": "==3.4.0" + }, + "datapoint": { + "editable": true, + "file": "." + }, + "geojson": { + "hashes": [ + "sha256:58a7fa40727ea058efc28b0e9ff0099eadf6d0965e04690830208d3ef571adac", + "sha256:68a9771827237adb8c0c71f8527509c8f5bef61733aa434cefc9c9d4f0ebe8f3" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==3.1.0" + }, + "idna": { + "hashes": [ + "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", + "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" + ], + "markers": "python_version >= '3.6'", + "version": "==3.10" + }, + "requests": { + "hashes": [ + "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", + "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==2.31.0" + }, + "requests-mock": { + "hashes": [ + "sha256:ef10b572b489a5f28e09b708697208c4a3b2b89ef80a9f01584340ea357ec3c4", + "sha256:f7fae383f228633f6bececebdab236c478ace2284d6292c6e7e2867b9ab74d15" + ], + "index": "pypi", + "version": "==1.11.0" + }, + "six": { + "hashes": [ + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "version": "==1.16.0" + }, + "urllib3": { + "hashes": [ + "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", + "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9" + ], + "markers": "python_version >= '3.8'", + "version": "==2.2.3" + } + }, + "develop": {}, + "documentation": { + "alabaster": { + "hashes": [ + "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", + "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b" + ], + "markers": "python_version >= '3.10'", + "version": "==1.0.0" + }, + "babel": { + "hashes": [ + "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b", + "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316" + ], + "markers": "python_version >= '3.8'", + "version": "==2.16.0" + }, + "certifi": { + "hashes": [ + "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", + "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9" + ], + "markers": "python_version >= '3.6'", + "version": "==2024.8.30" + }, + "charset-normalizer": { + "hashes": [ + "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621", + "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", + "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", + "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912", + "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c", + "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b", + "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d", + "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d", + "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95", + "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e", + "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", + "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", + "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab", + "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be", + "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", + "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907", + "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0", + "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2", + "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62", + "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62", + "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", + "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc", + "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", + "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca", + "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455", + "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858", + "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", + "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594", + "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", + "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", + "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", + "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea", + "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6", + "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", + "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749", + "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", + "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd", + "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99", + "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242", + "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee", + "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129", + "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", + "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51", + "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee", + "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8", + "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", + "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613", + "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742", + "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe", + "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3", + "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5", + "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", + "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7", + "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", + "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c", + "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea", + "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417", + "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", + "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", + "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca", + "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa", + "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99", + "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149", + "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41", + "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574", + "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0", + "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f", + "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", + "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654", + "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3", + "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19", + "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", + "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578", + "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", + "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", + "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51", + "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", + "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236", + "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a", + "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c", + "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade", + "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944", + "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc", + "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6", + "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6", + "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27", + "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6", + "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2", + "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12", + "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf", + "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", + "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7", + "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", + "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", + "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b", + "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", + "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", + "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4", + "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", + "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365", + "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a", + "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748", + "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b", + "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", + "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482" + ], + "markers": "python_full_version >= '3.7.0'", + "version": "==3.4.0" + }, + "docutils": { + "hashes": [ + "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", + "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2" + ], + "markers": "python_version >= '3.9'", + "version": "==0.21.2" + }, + "idna": { + "hashes": [ + "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", + "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" + ], + "markers": "python_version >= '3.6'", + "version": "==3.10" + }, + "imagesize": { + "hashes": [ + "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", + "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.4.1" + }, + "jinja2": { + "hashes": [ + "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", + "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d" + ], + "markers": "python_version >= '3.7'", + "version": "==3.1.4" + }, + "markupsafe": { + "hashes": [ + "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", + "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", + "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0", + "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", + "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", + "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13", + "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", + "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", + "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", + "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", + "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0", + "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", + "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", + "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", + "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", + "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff", + "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", + "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", + "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", + "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", + "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", + "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", + "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", + "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", + "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", + "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", + "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", + "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", + "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", + "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144", + "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f", + "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", + "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", + "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", + "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", + "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", + "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", + "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", + "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", + "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", + "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", + "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", + "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", + "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", + "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", + "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", + "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", + "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", + "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29", + "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", + "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", + "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", + "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", + "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", + "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", + "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a", + "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178", + "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", + "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", + "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", + "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50" + ], + "markers": "python_version >= '3.9'", + "version": "==3.0.2" + }, + "packaging": { + "hashes": [ + "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", + "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f" + ], + "markers": "python_version >= '3.8'", + "version": "==24.2" + }, + "pygments": { + "hashes": [ + "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", + "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a" + ], + "markers": "python_version >= '3.8'", + "version": "==2.18.0" + }, + "requests": { + "hashes": [ + "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", + "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==2.31.0" + }, + "snowballstemmer": { + "hashes": [ + "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1", + "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a" + ], + "version": "==2.2.0" + }, + "sphinx": { + "hashes": [ + "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", + "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927" + ], + "index": "pypi", + "markers": "python_version >= '3.10'", + "version": "==8.1.3" + }, + "sphinxcontrib-applehelp": { + "hashes": [ + "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", + "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5" + ], + "markers": "python_version >= '3.9'", + "version": "==2.0.0" + }, + "sphinxcontrib-devhelp": { + "hashes": [ + "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", + "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2" + ], + "markers": "python_version >= '3.9'", + "version": "==2.0.0" + }, + "sphinxcontrib-htmlhelp": { + "hashes": [ + "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", + "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9" + ], + "markers": "python_version >= '3.9'", + "version": "==2.1.0" + }, + "sphinxcontrib-jsmath": { + "hashes": [ + "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", + "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" + ], + "markers": "python_version >= '3.5'", + "version": "==1.0.1" + }, + "sphinxcontrib-qthelp": { + "hashes": [ + "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", + "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb" + ], + "markers": "python_version >= '3.9'", + "version": "==2.0.0" + }, + "sphinxcontrib-serializinghtml": { + "hashes": [ + "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", + "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d" + ], + "markers": "python_version >= '3.9'", + "version": "==2.0.0" + }, + "urllib3": { + "hashes": [ + "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", + "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9" + ], + "markers": "python_version >= '3.8'", + "version": "==2.2.3" + } + } +} diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index e138db5..0000000 --- a/docs/conf.py +++ /dev/null @@ -1,182 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Configuration file for the Sphinx documentation builder. -# -# This file does only contain a selection of the most common options. For a -# full list see the documentation: -# http://www.sphinx-doc.org/en/master/config - -# -- Path setup -------------------------------------------------------------- - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# -import os -import sys - -sys.path.insert(0, os.path.abspath(".")) -# Need to change the place we put in path to work with readthedocs -sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) -import datapoint - -# -- Project information ----------------------------------------------------- - -project = "datapoint-python" -copyright = "2014, Jacon Tomlinson" -author = "Jacob Tomlinson, Emlyn Price" - -# The full version, including alpha/beta/rc tags -release = datapoint.__version__ -# The short X.Y version -version = ".".join(release.split(".")[:2]) - - -# -- General configuration --------------------------------------------------- - -# If your documentation needs a minimal Sphinx version, state it here. -# -# needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# -# source_suffix = ['.rst', '.md'] -source_suffix = ".rst" - -# The master toctree document. -master_doc = "index" - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = None - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = None - - -# -- Options for HTML output ------------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = "alabaster" - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -# -# html_theme_options = {} - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ["_static"] - -# Custom sidebar templates, must be a dictionary that maps document names -# to template names. -# -# The default sidebars (for documents that don't match any pattern) are -# defined by theme itself. Builtin themes are using these templates by -# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', -# 'searchbox.html']``. -# -# html_sidebars = {} - - -# -- Options for HTMLHelp output --------------------------------------------- - -# Output file base name for HTML help builder. -htmlhelp_basename = "datapoint-python-doc" - - -# -- Options for LaTeX output ------------------------------------------------ - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - ( - master_doc, - "datapoint-python.tex", - "datapoint-python Documentation", - "Jacob Tomlinson, Emlyn Price", - "manual", - ), -] - - -# -- Options for manual page output ------------------------------------------ - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, "datapoint-python", "datapoint-python Documentation", [author], 1) -] - - -# -- Options for Texinfo output ---------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ( - master_doc, - "datapoint-python", - "datapoint-python Documentation", - author, - "datapoint-python", - "One line description of project.", - "Miscellaneous", - ), -] - - -# -- Options for Epub output ------------------------------------------------- - -# Bibliographic Dublin Core info. -epub_title = project - -# The unique identifier of the text. This can be a ISBN number -# or the project homepage. -# -# epub_identifier = '' - -# A unique identification for the text. -# -# epub_uid = '' - -# A list of files that should not be packed into the epub file. -epub_exclude_files = ["search.html"] diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index c575334..0000000 --- a/docs/index.rst +++ /dev/null @@ -1,10 +0,0 @@ -datapoint-python -================ - -.. toctree:: - :maxdepth: 2 - - install - getting-started - locations - objects diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..84258ed --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,2 @@ +sphinx +datapoint-python diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..a267350 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,34 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = 'datapoint-python' +copyright = '2024, Emily Price, Jacob Tomlinson' +author = 'Emily Price, Jacob Tomlinson' + +import importlib + +import datapoint + +release = importlib.metadata.version('datapoint') +version = importlib.metadata.version('datapoint') + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [] + +templates_path = ['_templates'] +exclude_patterns = [] + + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = 'alabaster' +html_static_path = ['_static'] diff --git a/docs/forecast_sites_map.png b/docs/source/forecast_sites_map.png similarity index 100% rename from docs/forecast_sites_map.png rename to docs/source/forecast_sites_map.png diff --git a/docs/getting-started.rst b/docs/source/getting-started.rst similarity index 100% rename from docs/getting-started.rst rename to docs/source/getting-started.rst diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..8cccaf6 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,16 @@ +.. datapoint-python documentation master file, created by + sphinx-quickstart on Tue Nov 12 17:56:23 2024. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +datapoint-python documentation +============================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + install + getting-started + locations + objects diff --git a/docs/install.rst b/docs/source/install.rst similarity index 50% rename from docs/install.rst rename to docs/source/install.rst index 2961a7c..7bbf6d0 100644 --- a/docs/install.rst +++ b/docs/source/install.rst @@ -29,19 +29,6 @@ and to upgrade it in the future: pip install git+git://github.com/ejep/datapoint-python.git@master --upgrade -Easy Install ------------- - -Or if you really feel the need then you can use -`easy_install `__. - -:: - - easy_install datapoint - -But you `probably -shouldn’t `__. - Source ------ @@ -66,26 +53,3 @@ Then run the setup :: python setup.py install - -Windows -------- - -- Install `python `__ - you can see - supported versions in the - `readme `__ -- Install the appropriate - `setuptools `__ - python extension for your machine -- Install the appropriate - `pip `__ python - extension for your machine -- Add pip to your environment variables: - - - Run **Start** > **Edit the environment variables for your - account** - - Create a new variable: - - **name** pip - - **value** the path to **pip.exe** (this should be something like - :code:`C:\Python27\Scripts`) - -- From the command line run **pip install datapoint** diff --git a/docs/locations.rst b/docs/source/locations.rst similarity index 100% rename from docs/locations.rst rename to docs/source/locations.rst diff --git a/docs/objects.rst b/docs/source/objects.rst similarity index 100% rename from docs/objects.rst rename to docs/source/objects.rst diff --git a/docs/observation_sites_map.png b/docs/source/observation_sites_map.png similarity index 100% rename from docs/observation_sites_map.png rename to docs/source/observation_sites_map.png diff --git a/pyproject.toml b/pyproject.toml index 17d3a5d..a34ba1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" name = "datapoint" dynamic = ["version"] authors = [ - {name="Emlyn Price", email="emlyn.je.price@gmail.com"}, + {name="Emily Price", email="emily.j.price.nth@gmail.com"}, { name="Jacob Tomlinson"}, ] description = "Python interface to the Met Office's Datapoint API" From 3f44351f90b0fd861a308cddd12b689857dde129 Mon Sep 17 00:00:00 2001 From: Emily Price Date: Tue, 12 Nov 2024 19:31:40 +0000 Subject: [PATCH 24/51] Update docs --- Pipfile | 14 +- Pipfile.lock | 206 ++++++++++++++++-- docs/source/forecast_sites_map.png | Bin 149816 -> 0 bytes docs/source/getting-started.rst | 60 ++---- docs/source/index.rst | 3 +- docs/source/locations.rst | 30 --- docs/source/migration.rst | 34 +++ docs/source/objects.rst | 287 -------------------------- docs/source/observation_sites_map.png | Bin 102855 -> 0 bytes src/datapoint/Forecast.py | 7 +- 10 files changed, 258 insertions(+), 383 deletions(-) delete mode 100644 docs/source/forecast_sites_map.png delete mode 100644 docs/source/locations.rst create mode 100644 docs/source/migration.rst delete mode 100644 docs/source/objects.rst delete mode 100644 docs/source/observation_sites_map.png diff --git a/Pipfile b/Pipfile index 7449655..058a874 100644 --- a/Pipfile +++ b/Pipfile @@ -4,13 +4,19 @@ verify_ssl = true name = "pypi" [packages] -requests = "==2.31.0" -appdirs = "==1.4.4" -requests-mock = "==1.11.0" -geojson = "==3.1.0" +requests = "*" +appdirs = "*" +requests-mock = "*" +geojson = "*" datapoint = {file = ".", editable = true} [dev-packages] +black = "*" +isort = "*" +flake8 = "*" +flake8-bugbear = "*" +pytest = "*" +flake8-pytest-style = "*" [documentation] sphinx = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 7636b9e..c5e694a 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "1469f56039acabc431f12f5410c951f266f341ce34ccb472935cd44c953f830b" + "sha256": "dcdfc418c41047868fd968171a2446ce9cc046ce51073a3d20c019c9371abe56" }, "pipfile-spec": 6, "requires": { @@ -166,28 +166,21 @@ }, "requests": { "hashes": [ - "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", - "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" + "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", + "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6" ], "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==2.31.0" + "markers": "python_version >= '3.8'", + "version": "==2.32.3" }, "requests-mock": { "hashes": [ - "sha256:ef10b572b489a5f28e09b708697208c4a3b2b89ef80a9f01584340ea357ec3c4", - "sha256:f7fae383f228633f6bececebdab236c478ace2284d6292c6e7e2867b9ab74d15" + "sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563", + "sha256:e9e12e333b525156e82a3c852f22016b9158220d2f47454de9cae8a77d371401" ], "index": "pypi", - "version": "==1.11.0" - }, - "six": { - "hashes": [ - "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", - "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", - "version": "==1.16.0" + "markers": "python_version >= '3.5'", + "version": "==1.12.1" }, "urllib3": { "hashes": [ @@ -198,7 +191,178 @@ "version": "==2.2.3" } }, - "develop": {}, + "develop": { + "attrs": { + "hashes": [ + "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346", + "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2" + ], + "markers": "python_version >= '3.7'", + "version": "==24.2.0" + }, + "black": { + "hashes": [ + "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f", + "sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd", + "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea", + "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981", + "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b", + "sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7", + "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8", + "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175", + "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d", + "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392", + "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad", + "sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f", + "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f", + "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b", + "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875", + "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3", + "sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800", + "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65", + "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2", + "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812", + "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50", + "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==24.10.0" + }, + "click": { + "hashes": [ + "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", + "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" + ], + "markers": "python_version >= '3.7'", + "version": "==8.1.7" + }, + "flake8": { + "hashes": [ + "sha256:049d058491e228e03e67b390f311bbf88fce2dbaa8fa673e7aea87b7198b8d38", + "sha256:597477df7860daa5aa0fdd84bf5208a043ab96b8e96ab708770ae0364dd03213" + ], + "index": "pypi", + "markers": "python_full_version >= '3.8.1'", + "version": "==7.1.1" + }, + "flake8-bugbear": { + "hashes": [ + "sha256:435b531c72b27f8eff8d990419697956b9fd25c6463c5ba98b3991591de439db", + "sha256:cccf786ccf9b2e1052b1ecfa80fb8f80832d0880425bcbd4cd45d3c8128c2683" + ], + "index": "pypi", + "markers": "python_full_version >= '3.8.1'", + "version": "==24.10.31" + }, + "flake8-plugin-utils": { + "hashes": [ + "sha256:39f6f338d038b301c6fd344b06f2e81e382b68fa03c0560dff0d9b1791a11a2c", + "sha256:e4848c57d9d50f19100c2d75fa794b72df068666a9041b4b0409be923356a3ed" + ], + "markers": "python_version >= '3.6' and python_version < '4.0'", + "version": "==1.3.3" + }, + "flake8-pytest-style": { + "hashes": [ + "sha256:919c328cacd4bc4f873ea61ab4db0d8f2c32e0db09a3c73ab46b1de497556464", + "sha256:abcb9f56f277954014b749e5a0937fae215be01a21852e9d05e7600c3de6aae5" + ], + "index": "pypi", + "markers": "python_full_version >= '3.8.1' and python_full_version < '4.0.0'", + "version": "==2.0.0" + }, + "iniconfig": { + "hashes": [ + "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", + "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.0" + }, + "isort": { + "hashes": [ + "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109", + "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6" + ], + "index": "pypi", + "markers": "python_full_version >= '3.8.0'", + "version": "==5.13.2" + }, + "mccabe": { + "hashes": [ + "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", + "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" + ], + "markers": "python_version >= '3.6'", + "version": "==0.7.0" + }, + "mypy-extensions": { + "hashes": [ + "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", + "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" + ], + "markers": "python_version >= '3.5'", + "version": "==1.0.0" + }, + "packaging": { + "hashes": [ + "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", + "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f" + ], + "markers": "python_version >= '3.8'", + "version": "==24.2" + }, + "pathspec": { + "hashes": [ + "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", + "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712" + ], + "markers": "python_version >= '3.8'", + "version": "==0.12.1" + }, + "platformdirs": { + "hashes": [ + "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", + "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb" + ], + "markers": "python_version >= '3.8'", + "version": "==4.3.6" + }, + "pluggy": { + "hashes": [ + "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", + "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669" + ], + "markers": "python_version >= '3.8'", + "version": "==1.5.0" + }, + "pycodestyle": { + "hashes": [ + "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3", + "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521" + ], + "markers": "python_version >= '3.8'", + "version": "==2.12.1" + }, + "pyflakes": { + "hashes": [ + "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f", + "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a" + ], + "markers": "python_version >= '3.8'", + "version": "==3.2.0" + }, + "pytest": { + "hashes": [ + "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", + "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==8.3.3" + } + }, "documentation": { "alabaster": { "hashes": [ @@ -452,12 +616,12 @@ }, "requests": { "hashes": [ - "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", - "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" + "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", + "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6" ], "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==2.31.0" + "markers": "python_version >= '3.8'", + "version": "==2.32.3" }, "snowballstemmer": { "hashes": [ diff --git a/docs/source/forecast_sites_map.png b/docs/source/forecast_sites_map.png deleted file mode 100644 index 406489baf7d3acc0fb925bf026f5fc86f1540257..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 149816 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 diff --git a/docs/source/getting-started.rst b/docs/source/getting-started.rst index c20f7bd..fe64f2a 100644 --- a/docs/source/getting-started.rst +++ b/docs/source/getting-started.rst @@ -7,73 +7,57 @@ simple script which prints out data in just 6 lines of Python. API Key ------- -To access DataPoint you need to -`register `__ with the Met -Office and get yourself an API key. The process is simple and just -ensures that you don’t abuse the service. +To access DataPoint you need to `register `__ +with the Met Office and get yourself an API key. The process is simple and just +ensures that you don’t abuse the service. You will need access to the +Site-Specific forecast API. -Connecting to DataPoint +Connecting to DataHub ----------------------- -Now that we have an API key we can import the module: +Now that you have an API key you can import the module: :: import datapoint -And create a connection to DataPoint: +And create a connection to DataHub: :: - conn = datapoint.connection(api_key="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") + manager = datapoint.Manager(api_key="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") -This creates a :ref:`manager` object which manages our connection and interacts -with DataPoint for us, we’ll discuss Manager Objects in depth later but for now -you just need to know that it looks after your API key and has a load of methods -to return data from DataPoint. +This creates a `manager` object which manages the connection and interacts +with DataHub. -Getting data from DataPoint +Getting data from DataHub --------------------------- -So now that we have our Manager Object with a connection to DataPoint we can -request some data. Our goal is to request some forecast data but first we need -to know the site ID for the location we want data for. Luckily the Manager -Object has a method to return a :ref:`site` object, which contains the ID among -other things, from a specified latitude and longitude. - -We can simply request a Site Object like so: - -:: - - site = conn.get_nearest_forecast_site(51.500728, -0.124626) - -For now we’re just going to use this object to get us our forecast but -you’ll find more information about what the Site Object contains later. - -Let’s call another of the Manager Object’s methods to give us a -:ref:`forecast` object for our site: +So now that you have a Manager object with a connection to DataHub you can +request some data. To do this, use the `manager` object: :: - forecast = conn.get_forecast_for_site(site.location_id, "3hourly") + forecast = manager.get_forecast(51, 0, "hourly") -We’ve given this method two parameters, the site ID for the forecast we want and -also a forecast type of “3hourly”. We’ll discuss the forecast types later on. +This takes three parameters: the latitude and longitude of the location you want +a forecast for and also a forecast type of “hourly”. We’ll discuss the forecast +types later on. This Forecast Object which has been returned to us contains lots of information which we will cover in a later section, right now we’re just going to get the -:ref:`timestep` object which represents right this minute: +data for the current time: :: - current_timestep = forecast.now() + current_weather = forecast.now() -This Timestep Object contains many different details about the weather -but for now we’ll just print out the weather text. +This is a dict which contains many different details about the weather +but for now we’ll just print out one field. :: - print current_timestep.weather.text + print(current_weather["feelsLikeTemperature"]) And there you have it. If you followed all the steps you should have printed out the current weather for your chosen location. diff --git a/docs/source/index.rst b/docs/source/index.rst index 8cccaf6..9777706 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -12,5 +12,4 @@ datapoint-python documentation install getting-started - locations - objects + migration diff --git a/docs/source/locations.rst b/docs/source/locations.rst deleted file mode 100644 index d595449..0000000 --- a/docs/source/locations.rst +++ /dev/null @@ -1,30 +0,0 @@ -Locations -========= - -There are around 6000 sites available for forecasts, and around 150 sites available for observations. - -Forecast sites --------------- - -The locations of the sites available for which forecasts are shown on the map below. Forecasts are restricted to locations within 30 km of the nearest site. This includes all of the United Kingdom. Most of the UK is within 10 km of the nearest site. - -.. figure:: forecast_sites_map.png - :alt: Map showing the locations of the sites for which forecasts are available. - :name: forecast_map - - The locations of the sites for which forecasts are available. Produced using cartopy_. - - -Observation sites ------------------ -The locations of the sites available for which observations are shown on the map below. - -.. At present there is no restriction on the - -.. figure:: observation_sites_map.png - :alt: Map showing the locations of the sites for which observations are available. - :name: observation_map - - The locations of the sites for which observations are available. Produced using cartopy_. - -.. _cartopy: https://github.com/SciTools/cartopy diff --git a/docs/source/migration.rst b/docs/source/migration.rst new file mode 100644 index 0000000..bf81c13 --- /dev/null +++ b/docs/source/migration.rst @@ -0,0 +1,34 @@ +Migration from DataPoint +======================== + +The new APIs the Met Office provide via DataHub are very different in behaviour +to the old APIs which were provided via DataPoint. As such this library has +changed greatly. + +The main changes are below. + +No concept of 'sites' +--------------------- + +There is no concept of retrieving a site id for a location before requesting a +forecast. Now a latitude and longitude are provided to the library directly. + +No observations +--------------- + +The new API does not provide 'observations' like DataPoint. However, the current +state of the weather is returned as part of the forecast responses. As such, +this library no longer provides separate 'observations'. + +Simplified object hierarchy +--------------------------- + +Python dicts are used instead of classes to allow more flexibility with handling +data returned from the MetOffice API, and because new MetOffice API provides +data with a more convenient structure. + +Different data provided +----------------------- + +There are some differences in what data are provided in each weather forecast +compared to the old DataPoint API, and in the names of the features. diff --git a/docs/source/objects.rst b/docs/source/objects.rst deleted file mode 100644 index 12ce2a0..0000000 --- a/docs/source/objects.rst +++ /dev/null @@ -1,287 +0,0 @@ -Objects -======= - -DataPoint for Python makes use of objects for almost everything. There -are 6 different objects which will be returned by the module. - -The diagram below shows the main classes used by the library, and how to -move between them. - -.. figure:: https://user-images.githubusercontent.com/22224469/51768591-a54fb580-20d8-11e9-851a-cbc3dc434cca.png - :alt: classes - - classes - - -.. _manager: - -Manager -------- - -The object which stores your API key and has methods to access the API. - -.. _manager_attributes: - -Attributes -^^^^^^^^^^ - -============================== ==================== -attribute type ------------------------------- -------------------- -api_key string -call_response dict -forecast_sites_last_update float -forecast_sites_last_request list of Site objects -forecast_sites_update_time int -observation_sites_last_update float -observation_sites_last_request list of Site objects -observation_sites_update_time int -============================== ==================== - -.. _manager_methods: - -Methods -^^^^^^^ - -get_forecast_sites() -'''''''''''''''''''' - -Returns a list of available forecast sites. - -- returns: list of Site objects - -get_nearest_forecast_site(latitude=False, longitude=False) -'''''''''''''''''''''''''''''''''''''''''''''''''''''''''' - -Returns the nearest Site object to the specified coordinates which can provide a forecast. - -- param latitude: int or float -- param longitude: int or float - -- returns: Site - -get_forecast_for_site(site_id, frequency=“daily”) -'''''''''''''''''''''''''''''''''''''''''''''''''' - -Get a forecast for the provided site. A frequency of “daily” will return -two timesteps: “Day” and “Night”. A frequency of “3hourly” will return 8 -timesteps: 0, 180, 360 … 1260 (minutes since midnight UTC) - -- param site_id: string or unicode -- param frequency: string (“daily” or “3hourly”) - -- returns: Forecast - -get_observation_sites() -''''''''''''''''''''''' - -Returns a list of sites for which observations are available. - -- returns: list of Site objects - -get_nearest_observation_site(longitude=False, latitude=False) -''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' - -Returns the nearest Site object to the specified coordinates. - -- param longitude: int or float -- param latitude: int or float - -- returns: Site - -get_observations_for_site(site_id, frequency='hourly') -'''''''''''''''''''''''''''''''''''''''''''''''''''''' - -Get the observations for the provided site. -Only hourly observations are available, and provide the last 24 hours of data. - -- param site_id: string or unicode -- param frequency: string ("daily" or "3hourly") - -- returns: Observation - -.. _site: - -Site ----- - -An object containing details about a specific forecast site. - -.. _site_attributes: - -Attributes -^^^^^^^^^^ - -=============== ======= -attribute type ---------------- ------- -api_key string -name unicode -id unicode -elevation unicode -latitude unicode -longitude unicode -nationalPark unicode -region unicode -unitaryAuthArea unicode -=============== ======= - -.. _forecast: - -Forecast --------- - -An object with properties of a single forecast and a list of Day -objects. - -.. _forecast_attributes: - -Attributes -^^^^^^^^^^ - -========== =================== -attribute type ----------- ------------------- -api_key string -data_date datetime -continent unicode -country unicode -name unicode -longitude unicode -latitude unicode -id unicode -elevation unicode -days list of Day objects -========== =================== - -.. _forecast_methods: - -Methods -^^^^^^^ - -now() -''''' - -Get the current timestep from this forecast - -- returns: Timestep - -Observation ------------ - -An object with the properties of a single observation and a list of Day objects. - -.. _observation_attributes: - -Attributes -^^^^^^^^^^ - -========== =================== -attribute type ----------- ------------------- -api_key string -data_date datetime -continent unicode -country unicode -name unicode -longitude unicode -latitude unicode -id unicode -elevation unicode -days list of Day objects -========== =================== - - -.. _observation_methods: - -Methods -^^^^^^^ - -now() -''''' - -Get the current timestep from this observation - -- returns: Timestep - - -Day ---- - -An object with properties of a single day and a list of Timestep -objects. - -.. _day_attributes: - -Attributes -^^^^^^^^^^ - -========= ======================== -attribute type ---------- ------------------------ -api_key string -date datetime -timesteps list of Timestep objects -========= ======================== - -.. _timestep: - -Timestep --------- - -An object with each forecast property (wind, temp, etc) for a specific -time, in the form of Element objects. - -.. _timestep_attributes: - -Attributes -^^^^^^^^^^ - -====================== ======================== -attribute type ----------------------- ------------------------ -api_key string -name string -date datetime -weather Element -temperature Element -feels_like_temperature Element -wind_speed Element -wind_direction Element -wind_gust Element -visibility Element -uv Element -precipitation Element -humidity Element -====================== ======================== - -Methods -^^^^^^^ - -elements() -'''''''''' - -Get a list of element objects in the Timestep. - -- returns: List of element objects - - - -Element -------- - -An object with properties about a specific weather element. - -.. _element_attributes: - -Attributes -^^^^^^^^^^ - -========= ==================== -attribute type ---------- -------------------- -id string -value int, float or string -units unicode -text string or None -========= ==================== diff --git a/docs/source/observation_sites_map.png b/docs/source/observation_sites_map.png deleted file mode 100644 index fa4cd5b2019f31c13c25768c7021822f97595e2d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 102855 zcmeGEcRbhc`#z38kQ7Bk8dlOmW=drx87X@fX%a0fE2}cIB~r3wCsfF6h$gZsWM^gX z{XH(P*ZceVe*gOY^ZWhtyS-nx+w0~Tk8!_W*Lfc2aUAD)-F-FGl-AR4rKeCR>s6E$ zv?vrB9twrlVa;m%#=xbZ50O@ykQD z#|>@Hi|nBTm?BQ7K&Bqqpn#n#sHu(0s|d4bSP zYcpX<_am7U3J*m^;i!&7)V~%-BfVQo%ga4GpWJQp+QxsBe=Yy>2lJ18^1H$#A18%G zc8di(R_)t=>e;iThmq!yXF5aAgm>H!DoD~=7jpEm>W!5A_`7A3^9eT>WY+1$8V%dr z37Q}Lm^m6X5H?rEge#|r#0J;XkpIYc+rUG9`frybKQ62j$|L4dlni|9#{CE)L@0e661{VRV<@b$^`b$eodO;ys%sgs;KlpB>XJl*$*mf|3i+1JjwYA!^OEWu| zt(n$r*lpAPjX_fAeOi{5FX}t-lD6g9(F*R#t_SMre+RZ8>7Md{#={SBE{ zCpY5-a?u^TZ1MAd;cW%`M`YEarBdf62L$-|6wc(`c;&4=O4O?BNpK57o z)g>q~?cB91%^>?ie{FJ*kWv1*Ne8(*N8M30=KrMWrt73??W|ALR(f@{QP0-)rCx?X z9GigN9tjEE&W{1PP71MQJaVWrF8g-uIF_Ikq<3%i`iZx6+`Ww^ZuS0K#3%Af7f-pm zx?22xnTqQ%pM3N`AO2gobRm9b#$mCOd%2oHxG45Zgz(iecdAgn!j|VWbc}oU?5XV7 zd*|*wQqQJe)h4T}hEmhe>{JmF5{evP-~zOmnVGF^Y;wX?H;}5=>KH&TIdrOx4Xu5<|Ilj);=S!Bm)L)B93jU|}#&z+{eb&99vpp(L zI&7u(8fy1EkMQ&Jece0E$8M!;}x1h9Z#AX`iD&8!aeRiKs;8|Ur z&T%EBfx*1ihOoWbd@66>z70*}c3Tj&d4hFMN=zK-(|4z_T#c1sy>hi7qvk!6Y(`2- z%Gb*wAtAqW&4Tz+wUSPZO-$$*8AbihyD6*^c`%Wk|HH7wogzhd1PHaj&Yi~x8nYTx zG!t?JZ`P%R{QdSM`D)|yPfbl{aJdch!=+hWU6&s6>++Acdu-XVMbCdd>lLzBdtSW0 z+4>wW?WJ7pE!9kEH5wbnR?9g-7(Hu+%6L#LT8Ioa3hY6j-OgvTjM|7 zx$8#)g0|tY#8uV}8)COgSX?q(8n2T5QeQ72F2b+>Jjzieyyb?zwRO%vyTYR4;()4k zthOuP8H`^vWEdtqH!d{&Q#(4@Xd=MRe*!n4-~IXF@QjDelW))BkNZ95V$Z)xC{nE4 z+|vB;t+B38f)OT@!>uNtK7FEJyH<1fPw~De#(X#CmDilkH4Pxkje9o}Qay3v$ItAG zxh-LgWgIeR7_qdc08PwF&whJ>>49V1TE9=)ovgGYOx?Gnz-E-$+U1z)5 z{hm?tkk93bQC3#24&HtY;hbDnCP#8GNM%EFG~Qa)2V7O#xa(waR#sNDQ~zE^2Zvs7Zr5sd6X!URG#2$~hG&<*2$v!c zl%k`f4a4aWRJiP%roqSm2We|wkKFkC-H<7nQEmLEx~5&mBpd5(~Tlg#w@UspWS;g_bF;I*_k z?3QlX{w{LBwRGb1moEZMc1+R}!EbI2Jp1%ux4^!A`)p>14<;_RUZ#BIl|y*dK0bIe zdTOvKd5gGdWxd3}!_LMmlL*@$N=TyU#gDy}Av;c&I6MB$k@s|+uc^3^cLXco=1HPM zZSUT_D&(pksLMol8*-JZs;TLuEe(^%&)Z_ho#U)*tf^m%%>lxeB z$+DO2C2N^kau+%fNoc5m)} zljXUH&kqIcD;oKB?>3tcOJAlQoR zTF)gl!(?(MUSX|qk;AC^aL+Ie z#H8=!^k!~)I4k>Ng=yjF!ZJNQ{SREV?c9X9Zi+@blSyOtMJ3&=v&Q&xoyq=s-PhL) zXJ=;*b?l>5Qmt%@Cw8+RC|!q`)qQp{vebETn2GzOvhqwVWv@<3uwoEbW{JzZ#e1*y zTcsRCn6|GH{4lEoRZ;L@x4K2kn_F8C+g=ImI2Ov~JaUwcUq^ZE+O=-8zjsGF$h*xq zwN5nKRP_ONN*b-GnrT*I=}SR=eu{Dk@BRDtwJHKRmq$L1FMnxiF=mn)ac5y+u^sDD zDgW>x#q`_bvFYimsnh!U`h%^dt;`8Dl{^3Q0l0%c+2z?FlE7o#U(%f>`*9U1dRb>P z1@xaUwA<;X>(!G8KmE!g^zDFHOczVSC&*&ryNe=lyyr>TxML${X>3-`jWvtVekuHk${gu5>x9a?urV)4d zz>Q{yib>Q^o%yLjaoKu{{<_q|xBA1Loq7^J_(~~|L-W$5#L&=Cj>F>avP$k0GqDJ_#mPpenRagp`yr#Jl2-3DJE{M>$*=Gh z{sRZ}lGS6r*9=qyY}?Jm#1ybaToFrAn_-yCx@prcc6N4^xmq(Xilx*_V7xkT{dc8l zE{f)hP@-pc9UgZ0?1Gq)?Jk ze0HEZboYy8EI$kmjx8%IlZv7*Yaz76y>@(LMB_{NzTv}iq>vyXQq-Ovc9h!U-pq@? z%av@{wCNd1wL^yvH8v?KDXE5nPd;h9NL9I$EJzmvu2I**BF*mK&ya!Z2?+_^-Q7u` zMTgIcQ3_UAl5-r&HHu_i_`WmN#e;wZokF|oGI!Z6sTcGSJ5gtKbiyneGWOcr+k1L? zc00}Y_FfrUrbMk;3Cso%tNrK7D6ej|o{fu(yB`ps>)_zU3l) zp(t0jv`7a%qq7tymcuzCXXheBMJ!0c=h|9v8%GfLyZ7#mPZ;3)(txtjLmSIlIthP2 zXH;O_Z9D$=o8V~s8sIoqqSICUAS`?K?F&#ZqmEibR?~mr z_p5Z=)_$|^9Nt_#)p5u8foAf@j&VA1P!3hB6h}#6Vd3?gMF=vJr0VMIOaMh8Y6C!^ zfugbh_SCh4f`XP>tN4Tj-OSSx-X{IDJUuJ-;cNfLQR&;0gN=7-8M^xW)2zEce}ZxW zC4DS?`!(pPgzLi0?ZY1t1^LS~mSO}vUU+;n)kYfBW2dhTT)22~vQ}NTrFN;dww7(< z#sn^Dr{-E$O4q(2DpDdUzI;(7h?MN|uPINRToOQd{0cwC@eU!1Wqo0ShFaO#*`bNJ zf!^ZjKegDu?*4v$Bsc1O3K$#7H}Ia}{Gf*d`e|6sRcqQVk;`whePcJ6cHD0hAzk&z z)~><9%o{ClKGoOjVx>>0sMK_n`|0MIo(kA3%G)$$S7;1q)rk-?9-39@r;Ke{iC8sn zMovOXxLs$Cf=%e7MC!b}QX`nv*IHUis2gtnGj{*W8*qa@@Nms^z08w=>v`yA9#Vj% zhq<`8?gs_wBP3QcaYgNu;WPi>!%QGBT*&zG^%j&hJ{2k|D#mD5xf}nf$%Ortmv=KY z>Z7V%|NXKCF`S!u#C0K`ukQ{Wm*r1q$`B*DxUo#YDF5NZr|k-*ZGL4u9lakl`LMZJ zTIboxEey#tmb=M@ROVSUKF>rd^iK5EMt<3K{eEqdP4eaGsfXW8b1e^3?B(479UPIb z#LrT7>R)a6?vSqZX<}acJJMe~V*PakO(CVJ)EmW4dI7X;>Lf0Cl*osSCSE%Ao7HS` zPvm`eVIh?_*4-!m=D45P9p<_i8vgb1jd}yh(n``>U$Qmu;o8%eRC9j2UQp;>nWem3 zQ4}DJzd5;PRiD3oTZw4aDiRQf#+-9`q+10m`$cmkoo8Mo{-e@DJtqLdirfZ--I^IW zkGg;K&!0b$U%p36rz0p0fsZS|?Cv};(*8b8FY|QxWrRU934`F9%sg^S1h(Cs_wL(w z3dMWXn&7IH8!Zt zSH7;bz;5vI(tPW(>DpjFc43RI%8>rwuLRVO(nW1v+0Um$2|`*1Y?b(IG&NniYz%HT z*LIJ|f&OsY2ct|b3RXXFYN$nkn>*&KdfD%MtAtJaPA96%F6wlDiO6#}OmXufiNn+S ztI$4+%XQ{F8~;R8kQlyA58P;SqE`J3%0cPMo;#P{$TKUXT6}V|L=%u$FhO5Qx3Xec z97pU<`uh4tQduu8IPT=*OF=c?q`sPG_kaHU?^mu;s2I(q>7ka;#9gNz8ywi^9`&Ew z&%x~jMRb!+JPe&^>edHc3N9@zEq_2&xpt+*2q2%dAd$qDi*8TfkhszOXkOOc`R>ZP zozTMLE|8fr_8KCFaY~bN0B44iL zLhi5qHh)hpqJYo!$S$XXpCS)W>jlyBc;~NTKy5p6Nv7eE&P*;^_w6gl{WbBF)#2!{ z3}}4#RR8nK|LXXJ_wp;he6GiYwHY$0I>_*2{M%g^UhYRB5b(SIADZx+!$Lyhh*zK< zBSXJtO*M$Y#Ytt#IaXwi`}RV+K`jl9;L~|GGLb)(@ZJW0zp9OvzMa9f{y(m-NEi3= zjJLD1^Gll^HBK340~5E!S19obBO{iBErpH!qm;5WBs`Cu2-_0}ou2BtIG6M58vp5+ zDR3f^tE)9Uy}hq4FD>c?t)VdXtk@#W!a(0=;NRw-!}e}Hcq580^-4`Vq5QZY6x%b` z{}dTc4m4IxmSh1`Ipi|;JE4U0Ul?3#RZoJ7rv0kx8HiKfsx4-wk z`p#qR?*bdMzt%Tz-aLN%_+C)=^XJbWw(ZpjoOT@ld#ZTihbkf1OH0$G1I-0Cgyu_0 zNwIF*mX1H0|9o*C(2{=k_Mik^kpQKVlic0Cj{)0~0cq(did#i5c-;6?v{z+pbhHM) zW81trWodCWKU^^Yp6|pz*a3u&?X}PpfDlUpb~q zC_Kpo7uJo9jr{;_PBxsmY5wGwww;YNdbpSGt7Oa z_uMFz{1f>+*I2qV8DVymlw|mS=HPt48UX1oQ@_mMVyyU5Ao`z7a}V|Jqmm0GrPd%3 z#RICkpk#6C49qK)mO}eRVhatm6b_UwFJ|HMXT6)wzOhw-M5u-BA}lLgDmXx_MiA@1 zPrGAu3=AH1bad1~0MnIjqf~Ao>+DybWn!}Jz_n^HE?Xez^H|c2SRe@4&o%v~1(asvr<&C^AH}bB|P4t~ObxK!R*$2!>H{bGn!J9W0)v-tW zf8{2-&a~4>&-Etg;bSYkdfY*j6r5dLCKsn$1$cPmKt;(l;mV&8fmg3@LcAKM>7h(Q z1|g;%Jb7{^JPR2iVNqAvpGQS;^8t95`G)c9o?sU;s$9q|EG&fN2jjZB&)|ie{&S-c zK7!eL0c$AJ9;z=uig-&(O5Qk(UU+?5242pymoN9z($X64-$LnHPu6d@R+`S3a=cc>-NerBqHfT!yMnwg4D5Y-AZn}x18OisPp}5Djr!<5G;9L zS=9dk;gPufnYI48@fkRp8ozQb--5^U?UCp`@3qvG)A?&T@m=C5*2V?a$6?4eLh95Z z>E6-&$F{X(Qj3y|`;}`pP&izoU7x1Q#=()%)^^U*!y^Swv3+stXlKRMg_%)D$-NXw zI-$4Jls2I#Nh^8dH@2EswLiCqrEwePfFmLjVeklqoBYak(FsCG;LssMQBhH+=@$D}*M5oNt5uuwZss`|L7bB%D7ci@1a~Is zY_XF$QV2M8I13ffd}gE_%M|XZP2+AuRAOb)dvB(JnbFSs zK0Y(GjWAd(nqFUr0d(K=>!UdM3{W<%V*7VeBzI#k+>HpJ!Aye1^QF}JdYruHe&-oW zPALZsb8~Z|9sweKGux@kR7gg7Kly*r$c>tsnid4(;2n2fx2Q{r14r-gtx4E=$U2cG znTFD%h(+MI@#C2!r}~Ko)E~Vd>#FA_rO!E!*e!H->)UI=I3g+@^gB*WOpHT>lOidL z>uR$^L7I?L5e{Hv$aV_5ZP#19J?YuAv|9rW_OD!!H9HsW%r{Y*gNT|eYmdIUDY54X zt)aAHv@%>*J}}7bjZF~X+QX&GB?z+!h+w%AA{~Pn*oDvP;68OS46==G&iAKhkt;sz zFcM3%i^hH74x*h0fu0`=@zFiUlJ@hb0r2OP_Op|kNM6qUO*=*8QZ?@~{IfQ$Bo9!2OyE-5$uy>w+5rCtJ4p`7v)s*a`*o>_QpPHUiSp z#=^xDp`Rm!vmku>{Dey?bJQt&QixwKCxjc+3?+IN_doE|{!a~zZ9Wx-O@cG;d+zyf z7M0(j=wF#5C@QK6Bx+cg9j{NS>n{`o7h9PHP-UN;n@d9kj!jPL!X6<$m{sW-cWQ6K zK)d9DW~g$G#S3F!oCaStlCU8=+1}m`>Gi3;1XVNP{ClrH=+^tbzQhNDmL=9$@$%9< zu~1>vb;R0JQySU`AuG^GRAw$7|9<4YzkeMfdcV`e72?khHszHjFTy9&ez5D*=Y?GO z2KiQ<6JuY5Nm+El@;Eqbcc)(2wuoqljZ!I7XImhetP_J zuNsLfh>%~`4it}m+=hFrR~0T!gkVbn`EZ;OqLgi2;HRay7q$%zJ%?3|7piD2&P~3$ zJv~%f6tVzve7e9o>6zNoG>EQCLNWNviU*+*=GsQ#jj(Tyh3cS=O1aWe&JnP4mm2wb zF$j)~;XOJgJ$Rjbd$DWC{kf6%O!av;O{8bq?(v(H6uq$@ehwpe&;I>7XI@_m;X~lp z(CjSUe^`+3i>p2wRqpPT`ph$pGqgw1BSlk%e5=1s6ChuoZ*>ve+~B~IQ|i)_Vej6(TUk)_5E?=HayW!> zjo{Z>F)}V=d792?$;qC1WUXsityPzSvIPm~(3CPVt z;U9bKJ*n6g+-^qrPi@$Uq|GRC^N-OHyMJw+_EfBU76MjddH8njOob`@E1Zs&HdQHz z>#>w$4w8JxX&Gdv9>My`ymr^Vvqc6FKGn!cT>#MSE!!wjMC{8k+V)l-#hU-XBIagp z6F2oIONbiXGH&(zrFjCeqp`J}U+LVbeM^6ltM7@vt%fGf+`rtaoXm9N!q;ePW$o2| zW*~w}D@^Lhp*Bbc{6MoC*%E}k#4pS;+39+-jGSc=IDOywHIG&R! zaOQOet~x9%Y$9SlyJ>I^ErI@%TPw;qiId(Cl=n(>P z*s@J7{1*C85BNA`T^H1+N8!t-IE;3Xd?!9Us2dUK;PEhJ9q9h2c7wybJ-6O7l&Nc zzDY|FBvwE$1Df>>=wsOqH0+!F_e*>{!5-O~fu55x8uJNoFjIYee6B_45atOo&40Ei zf9rv3k7jKrNDm04*lD6hDWH^_A`(DuGJ;<>EfKv>vgg&nQLzYnEW-FwFm`JIV)WN* z$c}upvnF9iT^2$-$NYdJ#>F)>@{6j>g6xHT9Kzu7;ah2c9D6u%llrJCy5Am4u)(wq z4&JcOFh>DC(XZENQisk>4W{R{x?;6jLixZKzPx{b56r5WeZ?biU$DR>Ti?2+d+k~V zA^C7JK(_g%q>K(c8Dz@aCSegKvpAvsEk@RathBuR-87xlclYS1n%T*Q4~2d+t!;aI z&!*TIx{B-eKha{4`u@hO81xKuUs>qVom@lt?rri4qVJBriHk0QQ+R&F;ERprNmrt# z>>wq^ofg|Q2?|L%6aamKHT65F+?mPkZUP$*ASsJI@Ausu{Q{3W1fiI~c%(Y>))E7D06udEAJa9Tro|af( z`_8=nn$vtEZvZ8SsDi0~zYyE`7&*Z-!)KBNw+8e$B`xJhPlRO~JoAW_8&%GO!x382 zQ$H^{{mZVX1I{DDyXGDN=?Tigs{tH5jJQch86&r<5xeM`Fav!^h$(oiDp(%|2DGty zx+w0@0Lpk<)fDNgT{Am0q^GFpRkFNvn^>yqQ*@MBHelCuD%eVueHXcW53E zrnX{*FIHcFNKy)7D6+=t@7FDkPEO>Hg!>{to+Ej-Jtds<2fk<$0~X(Ut*FX=f!>OM zovOmcg@2Wb79T%wgG3Ghc)$4%zT&aZ0>r`x0+2S>9x*X385tS9pr`<}%eAyZ3AG2j zo6h#Y@3)06xP0kS3g~ZtOJN@78tO2#fa+mzU>rT(Zn#4b`=<}kP!QLvC__8I$aKLs zY~e-{_&MgaPBT*MGT)*d%@jJ^WZ|#3ywSb*(Z43@kj+cbKQN@7O7lV3Sp|;@35199(=J_P9>!x!yuF32U8;Psy` z+SP?QPpGLiKn6$vyB`V}pT?5clS@OfNP~mmb_l`J2uI-6rB5uG2HEnv)uX@Wl)zsH zRD>=pF0wH*Z+CNZo1LFG6B5vV=1{ZFz;Gc6AaDOj2tO2hAlci_&W6^MRav^ z#G@BSpgLAi6N~}| z7ex@Q#WI%*=xg(9#(O9Razu7shw<_p+AbXz`uLE|Gq3gA!V8ALn&&EHm*Qv{Hl<)0 zQCPLZRbLS@hnR(wx$M;8yIU-RTiQuSHC&K15ZK_jutX5cIO zt91?XxAu$vMum z)-4b?F7s2boLDKA^dwB^&~!*cDD{!{Rr+A7L+5&+$VtMT*Bk7;3=w zUOG)$U`@tLe3|<4)XT`u)n7!H6LP?PUJHp}`aLT3;2(!$Dk>_3{~-dB7w4ysOeXJb z$;{7}1RENNo*D%q89<-9YSM1nsd?c$hCtvm=><_y)DqfUB52)9ExSLn-5P2(gm}`$ z2C;E-XJPw$Q2^(L3WqbesUQ4W0}! z#f~c|D9;TZ+K9H#?-G|nGOsrp<~R%$M?0r@(+ql7P7f;1vels2iMRqh2M-=}0q2bz z-L8Ur*8}SzO-_d0w*DBS0wy(&ZsD?&;^yXlT5PHEKOLG1D#{nYJbe+SImieHn!37h z9npOFBUqI_V~kNkVs+x}1|mtF&4{GXH5>e9aP7F#|J&oGTgi(()j}pE8H5DFb9^jm z6^}@t@~(P|`1FK=oq!>>Ir>MoE7Jy{w>e%xK|%lWw7X1-UMPBd8b~uD!9kT-4ah1$ zw0@6~%>t67;)2}f=31-nlNL5QBm!%J4qa#>hSwyWq!K`zAm?KkzBXY+(QQ?5rj#wC)a}s#hnoB>kVTS@ zL?=*D2mMrz>$gGcRA`6kCgF9chH^ReZui?=u#9mPq7^U@^sqjG1X80Yh29^3;vLoCGg`_H^FZYj{0?Sv=wa$Ydcr6{IAY^c5e2fnA;4LuuQO@=f~= zp*Pv#CyW(PC4LFM#HKjtuJh`yIB%{{49e9fH0xs=iv+BX&l=iQtH{bpu7_h3oW%(& zA%>BHf(KxA5|bo%%5i&1nMI9i9Tb|W1^`P9{`iniA7UA1aeFF>AlXm;24fA|j!xV#|b5yp2qf@tNl z$d!c)n>>fJbt+oQ5KGnw2YM2D)6?%7bYym6yb4gbkIc0NJ-fw-0X>+D^_a)V;OZ_R zObEsKbh#gUBZd}kPD-NjiZz*41e!oM5-sHE<<(CnvOLyq?CS2mRW+}6@?;&EXxp;I zv1)tgHFYcGAVQ=|eq}u`(AB z%Q>igac+)B?4$`M?@6DE_;-A*ZpI8V8zQeSW_e-v^j%4VlPCQ@26Cq3uE^Ai9u%t@ z=9=2-o}VpV!F9L?PM9nqP>xd6)hCJ-k(uq<2qBYHX6!myNE}9BJ@D#reFCciP(mAh zELM!CxGm4Ou6!T{_1az%rHxq{P7EL1p88kwNc3V?dwWPv#V^1#Y+~GSWY4_aCM+S@ z2oi2!M(GuMHArxLdz^&-lR3`QrLHb_=J_Njm8wAPWKx2O9)=I3cjZdT`#^EbBoKZO zBsJ#qKCpe=S;uZRV)4RK(F@W#*NpZ)q-bsI(R=$LxURRB7=zZna1x|g5~Y#+3Z+59 z{D%To()y<;i6rb^Iy`mSrK2t=t99Dy(n(OnC;R&P6ax=kH7lb2nAHG(L^06p2l9ik zMi&$?dy199CRTxh6*Q(e9pPz!pnR&m!S zQOA;i5WVo_4_X(YyX+L37y(q;d?-2rO$TDkqQ~=qXuq4Kiw$uRbkuT3yGZY5LajZfCNc2Sq2SyYKYNJyp}!-yyNcn!6w-AS$e_tY&Mo5RYVj5(9sFNJ#&$ zGym;WS7vhzA~eW6F~*4!(VEhRcF~HHU*KY*Sg%9{JSNh4y$XU3pY=qvTTV4nCXEd2 zLUcI3F;QwNut_En3fLqT0YNC@;h_THM$il0IqtUHDvVMAJ*WythBf9#m?|_c#uy1Y zzB>PYHMrDAG&Ffyp>j5l&yOD!i~^`xFy|OXgIl&j|CSgA5TV# z9mjxVHJoYfG>Fn2Fc~J`pzj57C*wYV2jcZtRw`o+2GsKc zU;~1Bz;?)%ioA@7+Bww9lP5-%25KJtuQn_tWe=1DG;ediCVde)JL%BVWU?4<(#^S~ z0+qVr3bt8d6qgW9+y1(}m{BzNFVlhFO%S(uE5e9Jh#K9q1^`wud_t(l@viCrElYRs!COlWsm2bX> zswt^MW*dRVgvz4K>Of$LH^NXPelm=kmEez$e+e%GBa@oYf<#_{T<^l^C>0E4&q;q1 zgOx|hCmcg-n{4yHGZ91PZhA9|tzQttM5_Z`f?r?z6_0T#o%P!e5Zna_Uq<^>BNkH$ z92W30P8T_3%gp~1pB!pA(liGk?Mn!f`2lD36-WfE8?vTQ72)tp5m8B)d^eT3HFv`W z63uC-proh7eo!Ww|wgN>TzY0=BxQSCq7jkKS9oS5WXI90)Qf2Z0>$ zy=@J=md^{l7qD3A6`!X6et()y$}fR?{CqqtT!kJ5Y?L;Q%b&wEFI) z&?>#L>h$tjww+rRVmg%wSOgKts(D8CB5KPNWa<~t{2=M6M`Uu? zEXJi_yLz}Q;H@shP#hh(%d>|uwR6^a^f=P;1S)lix3^L>cT){oOea1Ue zcb$9`OAM~8qOtpAqI)AZ#yj&13JB`j8|h=v znT$D(j*g;LZAv~Jg^bK>+PAti_G^yqgb~$v_V%f)(xo?N-4<_w$A1Tax0QU{X+Yt4 z4h)z>?`Z3P%u~Pf#^*1V%mpx?R_l?eAGb2@>8N!X znx1!@H`2E`+f#hK0u=Ld!>~7JAcg@hH6-u%Ex&*mTMg$$SzVp|u$pH`c=+mV+qUHw z7GA{u8N4`mr#b|bSWwPK*$edD3zg=Ig+&K!46%Xfx>Riq9UTRnvcYG)M6qVaVTAXf zk^JS)&vx%Sa9|TmxQ}R?GH=*Gp`)XV+ri9SvwWUPUO~)idC6sBsAb!)U%&DlMtMQm z`Ey;-i}r=Els|Lk%r2L3Z`A9uswyV9sxw$+&%hmx^78W4AgqZ=N$qGXY)0UctLrr} zaNiJeQqcGf>xmO5$j4!jXzv4=;nq^7jT<-i_V$*cb%Iaat*WX@?hF;`Cr*@9TXh90 z@ZRzEru+KsTTPNGyHTOtdVu7H!<(3y7wH%_@P_(6dbD}})rOO%wXqmFpu_aVl`B_x z_V0iBgX2$6Z?6wVEiQu9AUrw=f+V`F0}ShlXef8RAV!9cDHc9KPR zS43R=7?7EngTo)JLZz4~4x{*-;Kq0F+@TWOx6cX;`h@s9Z7ukoW$5&UJa}*qvjc<< z_9`nVOt5iKZRd^La>$yGkxR-4g(V(n{j>OXo{qik8^`foxcX{%Fi}VBpKER6=0*dE z9rfxx$e1Xs3i`rwOT(G@`2^^k4#b?i=l}0WI@hh*ZtJ^E7prAudl0bw+G|ZjstBpeBTLIob zLASGq-4j0Y~2GyZm;p(yg^>$(c%UA5Rhqoc=IL3!L6S@#(+yyT$ z|MpEh3VeG|Lx2AIwSCvF)uyJVmUea@aWy3lRp>;BVwB|N^`=c2Q5hKJP5q=bWsOUk{t*yUlfoi1+(kE|>=MFW_%fa4|AKYrXzO4^B{yh=^q z)pdE%9=oEOHu%ujCyj+%a|*n(IPBifyqnxG%I-t7rlhB{&;9{9fR#ss3;BSMd12Wu zpOTvTNiutck7X@H)w;`AIXDT47~f9&{C zK9;vNHOwe0IQ3r!v)Kw2Qkjp1L&{+T{6V%k=A{0b1SLutwTDj%^+j@b!ODF3wtXy^ z3L39}>JPhc6lJ#qrSl4g#{5~rO98;wn)8`qhbtnPk)z|~jkpc6WEj{kd;h*O!7UGC zN^CqpC&a-TEhtUzK{`uj z{$L5u;l`kfS7&5oTnf`T@Xu6IE;ahV#K$A&y{wL;zDiF@BBymuWJ)`Z_15gbCdT1w z<(0PoccaAMI^O~N-hSxN791XVixXoq^MAg6J^AzZZ!#QZQ0iK|PCL950kUvwk#}T} zBR{54xSl#u>`P^(oTB1t%%I!>?8joIJki9KzG7~^mJ<+Pa+h`u^Cu643TRCmfER?d z4JlP!U%%Kf|Mcn8(UJCJIM$|%i2vsMn0y4hvVd`)(HI_^bFKFzW}>rv&cD0 ze0)5;2{!lxA}=;E(F1z*0#v9}Y_N}VU^ZoYyfxP2$j_#xPN3lDWv5VV@?*Ih+N_a67!E6H{Oyrmihb^@0;VulE+ zik7}tSa<|Y3NLIX&Q!=58XA@*0$~pURZ@)L1jd3dJIqh*W8{`uclGK(Rf#=J-jV6) z3K+5kWA|Ist!Z_g&s(ev>y!(`$j_Y13>ZK1Bpg>H?iwU)J0NQYLfbP?Yj1gZc^v+_8B-%p0gi~7wf6k`=MN-%|Mu-suf9vdSQ(s)+KeuKwI=^lv#RLx zs0EhR)_1+UysE9p$Djsk7GRuX8yuZ`F>Xs+(E3{HnuE=61uc?+7P!{F1H$)9OJz}5 z^3Oa70~tIIk`JIRL!-tc5PNJwGUw8}w{NMy>QG2I5whAd8*Y^qahIybQdpPj7!kjO78QBogn7utp5EJ?d|JZ%jx?+ zQR*Kaz6r;%?!o&dcmsjyf#1Mui_Og3XIo@#kCDT=bbY45-=oz>&5`^w09z|aEe=Ub zilU<8f2vrUkbwf9HhPX&;DZ~$pL`Xl1q1}3<8$A42k_YjG@uNOPxSmd4}NXuOP4RV!y4~x%FC+Oyx+Bj>z?UzrM4|x zJ8Z))7#7;?L!@V8!oi7jJ;-@N&3TagZ~ROCF%5*Zm;`T6s^>@(Rj;~y{jiw_3glJ&oXGV(QN1GDZW zEV$35HjFEwK5jw{-(wrbTkt5)`%JZT{-Zqh!=Kp~!S)^P6Ljr^GF#1CGfhKD> z{e1XE2=a;Hgw^U}$Bymc=cngPQm$Qh=F4KGl7_|xjA_z~>0@GC_Jt^0Vto1C-5pX= z9AJGEcewl=Abc1Upb-=lB*n`aZ3vTVK|U#aNX+u17J8Bbj7gP8=n8;-p15&C2)M}i z;O0MpbZpysTyAe`sVvIjm+oW3q!fd-{QLcip(;jJ3H^u>oc%cuH}W_a$Dw35>92jQ z-OEdjA{nP&(Zi`N$LZ`lXu6!og>CRF-z;g#OH|WWbF`T6e9P8g31OF^vU{ zM^`e%xbOguvjl)^9>uKz<4D~E)Q+GmT{1P@H2e9}r@b{c`~m{(hEaP21Uk{d1fg*U zJiW2)qXs|Uymf0lz8pgx+jDYqEZfScuC=^j!`O%-NDEnp1q@!L=ml`^^+|D9!<=aT8zE?2GW{sv8@HYxejl^T~rD zU}Nb7&%9m-z>u`<-2iXe121zMTS97H0QPMby|4A+I~ zuxMpy<;I~PBg8Jk>iAjWas(@eq2@Dyvo_Qu5<8IYG?;aeJaS|QYBHIOGP-`~Nm!T% z=yDvwM`Zv04>&?bXe++!A|_d})!jdCIy*~IVsQoCKU^!d z)u*JX$!uX^q4mX>gEwRi#w-+29I5#|h%{XJ&&;}R2BDJdyIo2`R%k;CP>&JP~MxX^kEiRz=rj?t*8sVS?eeFS0X zG7WUFwcSr4YO>XNd_5*4vK_}w1P z9#nGc)~%1kF4F@(NR2A^8NdJV;jIw5S6b#`m?GeLlB&OM-8vFas1@N~>asz~s3=QI z^EZih?H|;)14tYPv0+%|RP6+bv9!1MM#;dC7|AI3_Uw-zKlU6>RX)S~Wn+l)Dx69L zb=e39;5~F#+Sgeb1)alVqp$dwKLsGU8UH<$jKRibL1{s>tJDSg9Tjj>UU37@qJ3)o7n?b&GIndT0y@ z4QJf2|G{@|DF@2!+qZF=j~a(;4PIVhMvLYJpe5)&Q(H`?&#iu+ZdZW|j(nT^tT5+M zmK`HsIVl=={~Ieh4beKWwzLu{maB z#1SrNIQEOz2}M!LP&6!oJBc{1|5^&!2?vHTPI!y8KNu_<;P2?^)2Ab!y;*3&oo{FH zCBT#VQvbBDNr^{t@@^btTl__>aO%{0oS=iPK#fBqiP7g_8Y!PTRSxc*_{Z+D9mvv^ zt5*}SkR~oSL7Q>bQxs-6Eb_HvBXD&awryiTe$-*0b+vE$YY>BT;3NjGEZESK^(5DG zpIi^-`c?yqL~wcp=@-^V^8v13_VuO%_}7}8kvZfg@JAYMXaIP~MW9J9PO{@ar+?k{ z_xBgM_;Fo+em(&kt#7r@jgJsZ2O|FO9%WG#XdY=N*6{G~n3hw60GE-8i4SPnYr+v( z$0fBHpgA%*S&jq~fkTM-=t@b)`oC>$?$g7q@9_kV-aQI@$qFoLbzuU?aQEnFE`SMx zJsWRm65!<8@0V*~4U?xqv~_pWZru2QB~Ki8u`tzi6U8?B7rw~eZL(RURnh{q=HBr*A z8J(C6f|8U2n4<0E2|;-go(-gooNv_>jGxu8P=nB>QjxxiiqzKMPY<}q<7&woNxxf<9&+lF+f@vBXpn_U8YZ&7cXL(X0zC(HE?MHA+i}j zB)irDg!#2F*1Ca(g<4%*T@d~ix}!4~kP?BTll7Kxc-_hTFrpM+&dg}DiL zbj07m!P>QJ7islI{zijz=h93k6F7FN+XERyed4oc7cl+x^QHMl5H)&&=4g7RE$XbE zo11eO{~J&(n!<`{qczvBU&oNTFj!CTMk5+|g_xw+Saj)!xK~N1>rCcwT}Z zCQ?u(FXFvd{uCX8g4{D%;^PsYlH!ArTv9pUZEifw$oy$bp!ktH(zg!) z6?j6F!Fn`i%#I$VLchcd!+Xbf9bv)%AjZ^uS~m6N z>{|?by|Ad~H!5~w!@$=&#OX+jed|%6UfSY@wV8+w5(e-gM3I;`*2yV&O49oIoJhoJ z08*s>l(_g|=`b!$WIl0xi5C$SwGO^mn-dIriXSbIRf=$r>2%kiZ1+RyeXiq}R(JA>aK^rfpS*>~umd3knNoogDnz)ARo0$JL)de}*G<5B>&gEwe5Mp2We9wqPKf9ePer zpRQHJ^0|uOc#)fX41}J6=|CMsH45XGSFHdfu>g0>4$`7}C_)aYA4%rN8K*78V=OL~ zW@VM^yX&*`7Q(yhG^{Qpv?t8INEdf_m<}UtRG6>P?>~S7L$iMU`bUo*DT{8=EbsXH z*X!TEYY>MkSFolukcYRxZhFYrr~E>1fB)xaGn3D1J|q+0 z#E=aP3^ZC+P~avV1M5t;9a`RrLP2(lzL-;{TTudi*XtFMRi z$48b?oT#Mdw}$_4BIVwJXkThD)6rpt!=(94lbwc!2Jt`nAm+ALMQr2%vx8Sf*iPOM zqa!YJ`=OVzF1!X&L!7h|(RJwnyrQB%LiS>N-z6dT^*L6s$b(=*f@PO~Z0#Rm!#l%~15Tt8ju!$XJ-Z9X+ zf@$)M94Utp3I;loZY01tp}0f8xlGuwk55&n?(p(+oX7NPs1pAE;`M7{iN63qJio&m z&r3Y3ETD!r3MAoDu$bb};L_05J>FX4!py~Wc4!fU5f_lBkj`aD7y!rTSEbm)?cld4 zQ20h7-So4#FmpSq`N!NVbM#WV#!2yO`vOuFtdH@AoI{JlVP<4IayAyl&X$iiRGXl2 zI_f7KX#YA6$qhg`;#vc{M#s8U5d&d;_nfTEuU_rMW2MS5+SA_Mt^3@?1y2pq(ATd( zqN}!eDf7KcRC(|Md;{{J6K+E21$o|CJn>~0e5SXJjqET_SPAA5HaDTmTW*n`{+mA2 z%auG#;$3+;EgB6KP|1MLAh?&6d=;pcqBv7{^66nVtkT`wTyZ>vzz<=VI+c);LM(7G zHXaJj1_k20!H4p4w!bdROT}SYET4j4N}>R~|MhDN3Zs0*cHTo32>23yc6w#CFEf|g zZQ-rPWn>sVd&RP|yS<$X)3t;zV3m6tv$%l`G?1^*5^W$&T7AiYr+kQv$fhk2&R`PR zI4Fp}#=@#Yq6$o#UsA{kefZE5mFxY_pPSLn-h?~VrK~KhMM_$Hc+Q0ohGak}@a9tZ zai|24ox53C!kF^6!T=o-i+A6?^&o-A@pu4Cqzim~EWv>4oFo*83Ow?OQ7?(eN(>K? zrIm-w5;gt08C%na?9npEGrdZe<^;}`IQwFmW6}G^O~=!_1Jzt~2e+m@y8bi1(zEtL zw!yOV0CbD=K!8(vQFPwG@>9KM?1t}U58R-inG_||F zrsge(fF&m76%-XOKtO6;eWJVu#2U=`J@hNN>bTAgnzRI4O42~?D`60fJr1U=bgR!W z+^DCghgZ=7O-CURj_K>OlkTe38JvTnqHxH%8uiyE5dwrSAw5a0>k-Ngn9Cc5-9Q{) zaIbiJkChubzE^JP6dwy=Xa+f#*5cWF#vM=JfWw}w@T210=!31 zCH)o($+%=@M%snM>^q(J_w^429p{w|LjRP9#rJ;Et}NP0Ivm84gR7}pB&jF3i8x1+ zXiXsr$=;$fCudQB1|v~QnDA82JjH8>!S|2gc8BJUD(w77?p&ESqz!DKx}ia6%n3!g z>kUN{%+k+~0S6$oItK3^=Vc}|2ZgR3tOKv}dianH&p*5a$@fX~(88aU87j9YSYkLY^M)RT#PtaZ>glCpen?NGeLM&kW%*y54r{i7 zKhAVkifQ>`s`Nab-}Xr2>L!dZ1i}Lg&tH!gE@GB4W2V!Kb}P9g2m$6TTYMpQH*MO< z?GvV>k*jxVe?x{hVwm{W#E~E@7AanrWx|afns-6LAv~AE4;G7Zx1;Z8ZC6(r@BrfZ z!J}0K&C;PFNGi;)M#WpD{X*suL@cJK*J7s2!O`(yDh#?+#53;izW_fP4DtlTXfR+0 zn48e#wL!`yBH;I}5BY@`uCx~uy^@-`7X*V2&sjobF+t{gJ$SGYTWFL&zbT{$ha^8> zjma=&Tmr(sTmtxZ98E7zP$LW_pXr*sujLL=R0ab8@&rZpPF)kbufmmvj4ha$y8~5u zq1{~##bK#yz@VJQULC84bfOnF1SaDeF1 zg=LLT26(kSci9yaZB_ocz<)9*fi zrpKy%g#Y7rOC2l|fslsh?W4us->`6G3cq`u_%1HB`kPTu znYffj_p{}dmA7eH-qKdp8Bh+v2^X?+qW0pRYH zlQX8&*xAuhjTx7{(%b)!nZA}Y>D}y)(iT7t%FEYWS)Y`Xp!_xLIIqM8iwu(S;}N< zlTO5AMgXHjZxOO9M*kM^zX=0)))-04H9o8`;f1X?Qs&+WFE-&?m{6!ZK_@And{T1S z4isU9$Sp$t<6z@`*oQ9Gi>%zq_~X*}(jeHTfKKjXs{Bu?q8rlJ-N*yBqnPsI*wP8P^Ja zXBG5SxC^u8U27{hyr-CbGVX4hVrkr8zkaP_&GOJ2h5{kv=+10Hr(M@_r6l0laDK$K zN8>ieDE$PjPg)v(*u#f@64W{10faoF{@n`*_{QG44ax_8qHB)GQ|0I=Nh@k(q&IAT zG}}_{5{uAeCnzVBhr{jLWDbY>`my=*C&+Z6#^@zQR52vH$v!!l{j1E(%!vPnsF<@T zRF9TwpAZPW4H6(CCPohGc|c9EaWoPIN%#U(5J)vKhH?1A11r^r^IMc9!;G_RkODn^ zw3Qe;K>rMQL4#2#w)Ag6X(2gwcQc1|==;1aFOp`@a6_tW#YqAtfEz)Ton_2$2{vJZ z0dHvspga;ePmKg32RDc#ivg~q@L4?r_iRMnp8dBqcwxbrNI^W8QzqEZBI^8fKhFBl;^+S-ia5-U?F{6W4TLqY zhQQWZAc(?FjXKs0te5|`gNztBxa;Sqn%#t1acT6vUf_(Gc|{_2H6 zySlo%~V=owRVdK&<;2_V!hRVvu^HM`)R_juYiAlpWT1Pwu z49o4l!pNIuL`;f@8@6txN8}I^g-`kjr!^ZBsu&w-M~|lbMg{f{k5?3tknjYik~|$5 z8DV*P!F7J>I%!LR#&+g?=4%9ltHBdnAd&RR=KET*3-a>+z(Fb@w8*66NlmoBYBgO(%>mh%5%>OA1N-rxTJ)gmp5w#+iplG7;)=_{j!RJ2Ii zd!7bSQAUJltIVQJPMZdirb_d)Xm3g>mH+c{&VAqi`*Gio?%(;H_J*Xw#+ z`LnZ#*GOi}Ms$EYH8wUrGW%g$f=)pCeZANa10Dl?`uh60^f>81hbbP~yh|vso388-Ya1Ut4OQ&sNB~0Sar|;|Hi56(J`_#~XwDOQsgcclVL#u_x!1 zHDEruA=r5B&(7m#D-=ZSKYzn50E-5GS%CSGC=-Z0nw>Ul(Lg2BS#Gus{B3-P60GTA z&&DH0l+3>py>)9i+I(O5t?M^$cD|DnT2zEk4Kg8*nxHccdqyxvNK^5Ir^622c54WZ_t|ceY3)L!?0J|eNZ>7REe5>P!Ok2QmK(V51 z-K5MSC`c;X9tbWy_FgzWJ=Ez()wYmO#n0hOJob)kLP;HxlVgKI;?s}H83>pr0=8?P zU@Vfk%qIk9zKjin(uuowujTOQkOX-JW=B^r-}$t(bQNjxY)MJB@L_9%$2w(3FdpE;RyR{ZGKOC$^NxrhVhiodQtekSMnU zbR83B&mMmu0P3I5sEv-$kuZW5EcEvNkbcEm_w**f%tentb`rqdBaC{^AT~+F6x3Q= z@ZfFR+O^lwNksRw&^*Z(S<}ijYZ{QdI?4CTlUL?k%%Hx!-*$h_e!{S?rd3n7!_Qy5 z@THHUZ<-mhbH1~(B60@2e+np?8jR8!PjeEdTbQIi;npOI$j^IL`}1PZe=w;d(7Qy1 z=3z>t8u_KqpN9ZT%dL^N&LxVrE{2JnHu{kH!c>7IbVmz#3R%P}Pmh5+p7xmfVp%=@ zuy-`2zc_V3^FsTRxQ6auhEIM$5Iv^ ztN*ZtmCNx@U%yhoSzv+CMLuU8`JC}BX3UtObaWg5X$i~Wd2;do>oG<9G6OYg+W#+Z z!^LrWNaQNN`R0#3w~HIEa62gM%LoM}P11aigX2Zwr2YGJ(%#FudAWZ7Qb<}i8+X35 zv@4fgGO1Ci)sn~gCyKN;DvcV?K0iq5F%&>wT6t-`;UO-dxDAf-m`tu3b)wnR5nFg? za`$$>|6TPf#67;Vd6-jcN|ex|{w^-+NV_ypY1!8NY(bK0Zgs=B@A-*OS}tscQp(H2 zqd(2d89|_@78~sQxx&cocfI}w876`NI~9QyG^D!YOB-T%)-|Hpt~Uo>>giEc@aQC= z2yQ0*y!LrY0t3@!V1s0eXjG-(g~x50Jj_28@KjB0L>EY+3p7OS&dzLtv}-7~p7G(7wqiESR) z1mfo@Fbv`v2zy5#AD`5_<>6*Kp^*hd=gQ|34{Kk9mb?gGWVpKkguOe*v)i zp-Goo`ZI*ly4TFsQg1=aA`fh+(@Y_tHLCNc7?P@dy5`iRIQJLQEBLSwXj!s-CzPQX z=j4$F?y;uA^HnycM5R7~(Ar3KzR-8ePQ-7#K#f6I(8zNT&7ux&Jy8D`L zCHdTyJ!;%CDg0^nrS$;Nw0q^W6F4yl5JK=ZgaqF+dC+g;n8$Q#fg%Q`E!m2`0VeWB;uNT5kn#`95 z&3t~VGtx|X-Qm0T?rlUi$C9=gieRxs<9n%qJktSz?$#wjA3A(E=KE}WAt!)^G@+ky zosmX?nqf7y2N=4^O>?8iTqLPb(EBBmf(7)eQ!C7*`*iU=;6IVL`ZzqWl13owTD@Dj?%c^5Ut{{jwLJ-R1rdU%^ zj2$}`Fm|Qb(Hp6bXv|yV^{+v&D?Vl%pCo%8)}C`G=Ra2$Ar+!(rq(qOCY}RHTQ9;B zPSo=oyOWX(n4gK_*~bs6uBjvk3SdFn88~&S3HmIxCp)KngIVBUg04@B4KZ6o2o@0_ z8o~&wnoyf_;|_)Hv0wiCXCH#gr_%gJV^1t>Nc0~-pTPB3w@ulkM23t4AOw0u3>-IG zPHT>UG`(ZuI7hUu*R@NGg?* zS^M_dp51A%a3Rvo^}{|C zlPJNDHFb5nT`Blc$N`yvIsnz4=(@=aA$OSfm5LMXZOjbYSB4PN1t5B&$3A}lbY!Sn z?Wc%ClnREV!5PEiNroe^70BaL)B!tU_CRZI0_rAd|D>T#PBuh^h!}k>PeBlFMEaYp z|LQ8Qi1~7RgBYzx4=89AcBom8_nips3q~nG6P^4K3@JZhG0~u*r7Dzc?Mv+1?pdOtq~d>Qn02{U@Q{DXrIt-bBl}bLR%FUR@Uv(D4Z@e*1WP-Jwl3(W#S@!9FXT%gg6*!&g%i;LCXkn-fKllaY)s2n@m7O7Hg= zn$CyPf6<~vyt)8)e}x@{95W!_i}$D%b8l!|>@V-=x6N>((b)h#5J2TFVq;kaNk z>(@79yfXq~)R*qZ)!p4}q_seqd~xc+>*QG9hdp_CqLg9ENy$A_Ji#*|kF+Nbm0m6W z$8j4Hkd)@k1+GVU%5^(b_^ltb4z+351%Vz*MLR@U4t3NJ@X=5%={3=}Ylm|}K>6na zdfYlNPVgJSAK{?}9nq+k%KgX@C2W<;ts3Upczi2y{#PjE+KR}A&K(iK8^;++7c&cs z=2V2>!vme1)GYt_!?yYnZ|Vre&aD8q=bxFyEhFFF&7RTf)~#E@Vt(656_k(DZfuy;Sh5#|c>cju*tNAtR6 zaswHB@8w_3!7;1veWPJF=+$eZp`OPp$~VE@sV5`)B{?dMw7S|GC#g?C)ivpa_lObN z1YMvtlagNm>Omm3gyi}JLuBTnQWUBjrc%fR0hYmHNEF^PI~yCTb{@I!3|UhYP!tuS zsPjJck|LNqJVnJ`P`xFb`}QQr0`LyW`(H*#vuEZwZk!I3x`>OQ)P+w^ss?J8s{<0a z{>6Q3ZUKP1$Pqm`bYAwz}$ zf9zKtPp?=?USN_mg7Ye(7{GH;`Tj6NO5hfM1TIf_6-I`Fcka~1W>1_+eIGv-Egk=y zNZ~-?_T|f$>v(JNfG033wxr@&L&PEtr6oWhsX*D-m_9>VNAjPjWv^pMB%JQDWy`$Z z4~NBrq7w9+l1fa_-ED10_+y|iq$^+z%GfnLH_)#Znu zTYg8-Se_be_Pc9d$)4NC1nD62ssCy|Mca{QKda+hK4??Pf;4%i26QpxMO2llT|MW zapzbrB-J7^oQ>)G0WKxmqxqXHdUZ8Rtk0&&9Shr@)!1M=l0MXnMu03A# zHS!iE0Yq4<9HDEdtZ0aIIIp71fD5couR#Os{puKvfpl%JY$>0qe(JYswaMaWd~9qT z1(M^%=?`jEy0`Y6U0`drvS7WgVfXHb4n2`8qSyseEw!s0U4EMsADph1z05V;`Uo2H zMHk!^xFEhr&1wF8i=fQNpt-*s^F{NDoP*h#x`5lS7+D}~7w;X_O)3qR^=MidXvSRr zlsj3eteJ=3vm)9>1(>f6BNBTuGB&>X4ZSe-*xZ#Ty+^px$BFih&@ELv%(XA)#dY}b z7FKOFV|0lI2wmVf3i3f6Bl^Z1owX_TDFpW26N@?{k`s|5 zFpw%gRU}nB#?{d50rS@*@gl9Qg%zt6KcKLO`ImZAm~AlsI(-{zx_y^i`=#5Bu|Z9L z_N?Q+HQR_PTxlwq7(bCv4FO(%iIqW8MeT|8hNE(0i3QA|OX_L*OGf$r&ij@C zDFbG97Ju8W@$HQQz{k%65AsHLNxcjQ7o1OHDk6XJ| zmCB)MKtMqHGriY)sZD0ioau^@FYOcmOSbJ#Rs;~gUR-QC;$+C%i8EiGJyAKocb`6I z`HJwk^2}s%72!UjrBeDT^eX zP?2B1b<5nX9s3i&@6Qo^ToP6ndCOcPVvzKs)sHKC0FLDgri1cEScIlcn|k6V88&bP z5e>S?AY(qlxd{^|uBNpCcbrI4+qxRwNIPRB^))oUuOwV96P3VAY}z?ZK%h&m+VT(Ywu`iU{)(Ce$GTI ziM};ZKuCJ}w6G6Dh7KL*>DijRs@c5xDlQcVnAd#{KogA%&&cdV?eKR*eKiwgG>#*@CRr_~nb%{0^}hks|9+Jtx;lXU^FSqYStEqWlgth9WxxUz3S`XZ zc}`OL5QlI5{ruNf4vmUk*zPrMCCpp2I2Bma;Xj`3zaJ=W4LjFaP_yX4*8pVfLFTGA zd>8#>G4+bO&UmL01=I^F3OYW`-dy~P#13Ypu3j`RNV<#p{v?6>TT285qmy2!#}Tkwe$L6mA6 zXV{|5Vx+toC-PFZZfp)LQ0sQ>>e?M|zsgBz)JR~9XCkduQGj@xAGHB?WxG!kN~n2_ z?x=U7JEiI&-b?p;{C(a)rN<)ye=de0$YWMb$M^8DvG*S%Yd|MiS0NLcwr57_N%a;J zqe-BrmKdG3x@%Zy@CADDe}C7dNngmHb?Ivh==K!~{vQmy*50a4jOA+SkVp`1u1iTt zx%PW0sE!QvP{+x*cu3R`rAMdQ53O9PrNo)1M1)lkhw~zayGYBfHTC2CpUH8X2dI3*xstc`zq8pX8@dfRo6xLRDBj z(h1yx7miiFDjB*-K)|o{>P>0neh0$)w&j)^^btMTLaaN@W|3SIQ2`!X^tPp97r(yu zvM(kSL@76H;u+*eIMPTEHymf`a^YGq1rT$&ymL!_g#Lcb=%aHUpSyKSi)Us}E9Ht~ zR=oxdHojU|$yG??BW&)oVDgsF*K1zBc@xf%`viW3xlQPvA_3K7+5>jkF}1R#Q8c2( zM7)?}R8ufxvcVG?u&ESJVfXQvREIL}?Q7s*`^)z02dj*XFTbj*w)N7`R&6nH=3&zo zDhoW?TOZC{T(BoJa^<+Pb$u?})thKI(G7*(y0E(;;bC=RtG}OjTu~OPzQm{Q_=DMF zd+s=WEg&!|`|F|)TYIbq?&*%D3{3lh#qITjT_e{8sp%^l%w??c$%CtkrKt2!O#ra1 zVi0OBvx$gUwv=ut3GnwPRjB&lA(wvD@b@*)Nx@*iOxEdGk9^5`K;}&hzYt zRh-K_Z#;C9(DV{0vXB(Ld-sklZaSRhVe`fSc)ehX^z8NPFhK3Ltg4LU+g_yn|BgQ0 zy|P+ASi>u3@Y&|}YE4cCRQG~-xbW~{0whiX@qMD7pOQ``_7vubJNZdzOODjhL;6b` z{!&?4URgPboQ!HM)9+%e)|}@VCh9~}^8XsXs#q;+y}US;H5uo?VO+I3KCB)OT!`0_ z544)V1YNr9UwBO~SffpheBH$UqE;>{3MW^=5niNqhWdK~Eky&;>H7{onk-cy5xd_S zMTqAewr<hzz?7M#U!lzL|E9_ixPEi@tX zJ*T&mxcP}0Y|6cP3l|Qq=zoh9R3ueumK{kpYn!wafwIV(L)5hmEG%NF4tr9cskb!W zTzU;1ejC_hZb@Ti9EX*txeUZd3u3}rMkN@2z!571V=~c*vTw$6^g4zjwh*sGsY61| zr;2>8T%aH9wCd+2lVFX%Mq4jm6Q!|=9iMg1qf_5!+Ish#^Qf=OMssI6I<;oaYF_p9TMUZh2RiJr$5jPv z4O65%T&)frJ!;Hfkzb0`XaVV)Wa0kL3VZuHy!!le=T@<@Pli9ylTl7g`!p?B!{GCV zI!ZNtcV{;(Y!c2vbkS6&QlRYoCyN6C515X>dhD#e0YBo_ymAZqA1wg;RXU!$25c#R zi)a7+an0{dRm*&Q^ynXlZT}kIoZBapjKb)HFfxS6no{5ttr4}#hpnE%A6h;Uu=D)Zc2GTn!@YIT5TbYP*+dWjM?!lag6b(&#KTmf_ zMrPEGVKf|v2ng3~Ud;kMhXL}R*Z&(hDpM6z(*p&sLt4d_v|%l`*e?INj`FMH2cOE0 z%(7#NDZmUV2b66RXD`fp`mb$(#R#zgIAkeeUP4OdDsE~P^N`@Pa~Ca|#7va1N?4MNS%zaC9Qv%I$O>pL&}X!_HkQfWSeb#wk2x-rRQ$+Bg{QF|OhkkwD7+?07q zbxnQc8p9cOTq|vAN|o+^{yD7Thuo>ppOeY_-ZPE+80rb3Ojwgi-x)KiFILj|skpLM z&FFE>*Y{&sI69Q{Vd}a|!I>5b7o{F9NYQ@4=^&eMisuRoLkYSID9*c0zA_V@MA|9} z&N3YEWLsk`IYNC@qc#kL)851ReR3l>=QS)iyB-wXx-c!_jr zsZ*^Im=gi>dI@y-6NMF;-KR{}@l;Tl80hQwpSUzcqja08!GMP9>h6k;CMHhrP3%Lv ziOiI9qvC+i8Kyre&0DtoT3iV(VMgIL65V<3-6*tW73{8EQQiR>rM@eZRok8|!hN^C zs~|nkAdud64sypxZW2&;5uIM{*0;Iib64!QWMM@yMojc*o07gw9*LHqh`bj^Xf+e# zP~gdAziT_jsc3QHQ|&|4T?jsL+(t7*H?FkN!~ZED*lS~8$tH0}#!NGt<9!(Oj(bwX zN!GTuwyy7r-}F8TiPWlZOgxeJd!yXXR5u+;2F3-Z5WanKzs$RG^SM|recKu zrTEK!aOuFz@v4tLCdpDRxm6tLK1(Z72DUoP+|GnKbJjD7L!<%wPM~EYp4Z1z+=&Y^ zX`7FJ@bubz>Oo1WK|(V0&ha0-tyh=wN)&3++oO!G%>Tw?tLvYwfM(Oj?Q`ep-%tBn zq<2|x`qU{`tq>}y?-2n;GDt#En}W$XU3C_Y0hm&|AQi|t{{FK4s8O<8dN?sBZ`^sp zpcvP1BEmv0^4`PNuXptJ-pMzT`9@9~*gvKySsR#Pkb^rWs;yS1+Un*5I8it=RuqESWF=1;d!~w8tM>Df$JyDIFMknY`lS z9*3{<@OcmuiCe2z56HZ~_FyYL3}K}@WT-iu)Z&lS)=-aj6D(&ne+@s7Anar)3&`m!q@PHNy|rW?6-U|CI;V&zjxqABvBd=$Z5%JS)6#yX zG=C!$`HvshM0}GqU|a)t;`4@69aycwT)==WI1nf8-o0CX0KM3R%mqL1aIex8MMSRQ zKOa3`an_~W**cy-)8ANUnb=I*eeTH+`9~#DNj=CI!zptQz#5y>SJ!eCQ$>+8z4x^D zGpX2~)+Vz7FCvKJC3QEsUq(S0W!z1r7xpqh%mzSdhzgsZy}E$akLd5I!!O!Yy+3d8 z<=~K|#kX%?;C-`|+UVe0W|wBoy21$#jf_kM5zy*7w)Y6t>!#u7IQP>iji?-4%B!d7 zo;dfgmF8ueg&p%hUKzZ-?H0FxdK(Xo%)D@Ror3r#$&E{1Mt-3p6{#qnW`y-t4TFdn z&Q1BlF{hXP0Rx)G+4<+x(hWrF4ZK!%A)g`o9}BFpVPDb&&M(ieY{R$CHG%IP0Y7C$ zVrKP_4_f95_-QWI=X-DY_d8MXV=C-Q=jAu03cX@C!0tRU=kUtk`>K3)#HsX}biC|_ z*}a)*+wFZ??&J-ZTl-IPFs)m_@9%1t;fTt)6&zxU_8Lhgs_z9 z&&pO)+6%xV4ILlsr_b-xQk@|amC^|w^wP99_G*za4zKJ(Kc1r_9m1~0=D8OaEneKo z+N7kG50Um%V$pZ?TcSvZbD(o_(nK z9Gq>~{vc<3K4E|>be_@mDcXo5HZDC;z*^digm9}h?rGBu&l)3}{6oXSoHHE=;85sbm)r8?Qs}cdERERn zyL-;`g3IrIEp161I*H$ic~`IUv$8^!z9RL+d-8y|N}$4UCa1uEJ)^%=fXA<}aCpbd z0dp`Y`4teUrDxCwjdBr(rHby;QO*ME)b7UwYiwC3;8tb|CAi7``V0ZE}DEbG=+i zNrBABn{(fkPr2KAs_Ez5aZ=%w#G<@aDU9!Y$MGPw(oN`go@sD)_8)A^`{8SS;=~ED zxTJ5%yTchoTX@H5DE=S}&YmSyh!G2Yfe%QeM~27i4a-0Nb6gZ~dtz2>r~pD%*D{Ho z{61pec>pVlXNrzNJewOcC)U$f-ZrWqs$nqb$J`~mcparP=!6!&zEPhM3#Vkd*zU&aul>@^fer8Ct%t#SFd0#Xc4IECQLJaQ0)PGEKy>S~yK6dg0S zKF4Fy1Z~*wcTwZ0MU9M%d}hqpHn|O5RP1TPO&SXLHb4F{ctJQ9F+;~uEylU_$2e;OL&Tdc7>4TzS z;k&zv0Yj^-$h& zJhL00q0@;i0mk^_rl0!lP)$S5&lIrFon2tsmz)JVIehf!F#j%n`);RUFMaoJFt>?3 zs83-tbWv0@Rr4aXP^Tt50C|_>(JgEvyFU*!VLNC-sJStG2KgW2JfFUA3nrPQ=IU2F z0F~T11aLg@`+7lxwK)21UJ%#v_oY(jOwW& zVi$@AUQ@@GEliQFsXD2|mhAtOJ2dR#NzbQ&Kg4l07pKxjzBOCufXDU7PX@g462e@lFh#WSN`p|p(vicu)d&dJy zvbqOrTsyiPK86&oeV2U+B@KfX<-tj`{$sipPnD{`!eJ?|s4&6$PtKHqmDP_iHd_nN zO#Z*(WypP9P5~g42!nc)^O-+TXIk$i04P)e#)=j!|S?;{vnNwt5Sh6Ip(Q>R~Q z`WAoIhWaQb6nFu@-UI+^Fb#WX^0tCjx!Pdp*0_C$&HqI4QXC4BfmG>ftB(YwGS(iz zqkl%xi7>{I0%I!eL+rOd0V_E?w7Bibi5rpzv)bhh7%N>sF$GX$Z+ zH)zQH1mg{V&Jj396fP8IhUm+#{Ah0PH?+2j8bh9X!qK%nIHEG^u{wH`tvs6>3^}4D z`{Tw(R8hx~^3gy3cypTmNx?_TqE(_o0k`>8x_o;=!o?l^tOI|RJBDY;gNGp#nIBMH zN8_;(9_bkQ(JfO{_WQ}I6J(Klbez>8+Hone&VG49n5ZL0tm#x0}EF zjAzb#$kct0;oD=3hjl=~SW&Y4^y$;4<4#yo^vjgcF|PC^hIL&rnYc`5UHR?h^ub##!w{Kdk>#X@ zV=DZ0aq`-M&0bpLVTY_=TvfeKf1^9Y`a+wb9_r8nP@)avg77O+&?y63?Rc|~<_{DJ z17{(6H@sPF5q{*KRU`d(AoeEaOMoC~&c%Ts+pwX&N2l74vJhwk{Omkk$7Uu$0m6J4 zF~WOOpgs9f1fjI@70^(H8@seayLa(Kt1|$VNT(I9Aa{P1$-8$ua&<8Vs7_ndo_y;I zn}`(o-6>?-KD)#QkhKMA>bCN47o(#~zO*VUTWC z;l(D^RMv{Rb?Q($;4W9Q_uI1N%ZIZXkGw8$Ga}%Zyx5B$_9A3K`4WMVw#Uno8#hYD zwxYN=22iV0ONT9Pqv!PRoP#3Em*G`mBsnf!US3fv*Ae8seBAV#X-%;3dd6`_gDPEz z)TE~#6K08k@%xt7oPJ@*6q7iEIn)m#Wpj-Lr|LA_0DmLV4^m~ooL@d-Ju$VQ<&n}$ zWVw9Oac_tk;M}W60yn}(3?$J6I~mKRqguOYX4k>SAI)T;;|v?Xd049dx9;?z&KK?}ru@IIwbc8xqU}cH*2q7+XAN`H0=tbwU=m)j;wmk1IUqPSobKta zYk!8_`q4e(FTz}QGVeRA_I+P)pRY({R)BO?+su7~Pu>C;MlB{b-??|kBbC8*CeGZ; zBLDaXLCq*0#d!FS8#^@kdU$k+#P|RIkv4<7IL}t`ojrRf3Cni*HA-r{uQY%PFI>Ia zbjp;M*Jek^N-;&GwjPHfiSVyw>aH|=-%#bFV}E8`N}}Y_)PC2t)!$Ttz3+EPs|+1G8kG}*8q@SYDSMdo`{ejiVmAFwr@aal^!F2iFa#{aDn+Om>G%+7 zJ31Pd=hB?(q1Sr+vky@8U>0rjAj_Df2>KQ{3gu14{I>ea(X|BMjp!s3RE7HGiACu# zZL*q;bDn?Hgrh^{vxoN={zHN*J^zBO2QO6t($h)xB_a*Gxgzh|%O~f1AIWN0v8`H# zlA@}qq%VzWuV}?^%r)Lo)8kCqyD5K#i#176k&9un_SVH^7n*SV&QVO3L>Xl6>a+G> ztWSlZu?PNox-*fiw3Z4T_Nr<-lsS$@My99ewm$)q{By#VZm@wc5$ zifx%Q+uw{ldT_jyYPBI|bM28^12I7yuX*ZchQu+J6*w38d4D6pFcET*7@zKWIO+bp zyuZ%ezdw55|Nit|jQuOt?Y%YWQBo?k!`#xmhM?}juw)#A5q&0HxCtJN+sI|Q3}`Du z8=K$BZ|Qd@AY2pz0h(`boW(Rce0^}7@^9lpk4&C4$-&k2x^dtzpaq;qI|n=c4Qy}| ziV)|Nq06&g!-l#co8dnVqRgASaG?x&HEv^}e7~nn5v6%UhM6(KfTMKT)O85hDDx1d zdI!*@TKpVKTPJMM=h`%wM?NYQfKri z6n60Sn>U%PZe)?mMdoYK*o4na{oak_MbK^r0tjCwKHwk#7&^(2dgJ3czWmn=0m_tQ zcCw#Y=bs0M`&M)vS>-*-+9l18nJvJ(eRVYrA~tB!xrJ+)xGE^R5VZ6MobU1HpQ?5c zwtT&bFul|VOVQc#vOB_>m>FMfEhkn=ZMOQ}cguOh`7j@QEE)}{E4G8#WqxK{&8HtC z)M!5{?$tHUf5`xy8$|~!$Phw%Nt9u*lc5~I91|Ox9Sn@4jl70H4j+p4^c{|FTE8iE z^Xk;I!$pu$H^dEvbUV1gPoX6QHXo2bOwV1`8w)=H&zi2&@#ehgeiz;i7&UjgU)tih z!I%C$FNLLk>Kio-ocZ915X;Ys;v;BfN^#H!Fh732%jf<7rv>n(apz#HC5an-e?;ja zfeLx3nPNV}}e%u*b< znG)SAX?vhwJu0Lya5MlsC2xNV{4)sWZeoVaEZ&@;j^(uDJCSnkEJm#%041UwC2-wZfU+-mW%HL|j53(m}eFW{r)RvLy`YG`Zc^LWM7 zg$V6x9pwb-ec5F8_MYblx7+KUs*a9L)@MphU}*r7>Ng=g34_tIyvXL>mv-990+ydY z-X6qXLI4Wm@ynS4p<7LZK$KiUR-9(E7MhG#J_U64KWz%VSu+trIj&d4QvMK#p~17EKO~sjUO3d7l6lHP*56 z5D?E&x(2VYkl<9%Qkq*#DuN#$zr!X#sMgj1g^u+lv2v5}S9PA4U!aP@1cG`sM@M8W zV%fmCS+8q*`|eOL>Rmq`HC^k|h{ron8*QiVzJYNAs5cq!rc=!pFK`mEoS~4WGug|1 zf60;*FQii8Nrjv0+xzwB7Q}=wSm}lc1FEN{c3HM8pDl{5W7Tm5_INhAn+~&Pz0W&eH;Z=eb~%`kESOrXrBAgx2_$4JuYz}% zPX0=qARjz`%hs(GM7Uw&Yh0s2XKy4qGMhZJ_G1Lg^@v^Q1EC&&_)Q!iW!?|5}Q!{&`7r`K@}rGA z%%1AwV+S%Pe@U8%Q%hp#L@FRq#KlI8fN$PRCd2>7cTS)AYak_y&5!TuHE%EHY^?j> zc9dYAG{&w-A0ofR$A~fm0Gx>8$Rj4?E0aLx#9P@*)j-)&nus=c0XDp##}GVb7CuoOsXX z<@tMRF5h>W{`J{F9wM$jZO3%q>BiLy;X`LJG);wGu8CR_Zu|_OizzFf1&rL9653b< zB`R@Xe*8P}eOLVSWP#5i3&8q(#->FBA}lA$yFNpHL_|f61SyIqw3tu?yD26iLT)?F z+S1bFH6w_1Ou!0?2%60gg10)W#QEDc8?mvve&ClJOe)NX3ob0O9XLPb!u(vH)T)$( zBK~|1ZtX(*7suh+yV1A{Wr3-z+k=cq=Qi6&|L%ZUqZgJ9bYxCO*QRaNCxGvH#dD*- zXDe1mAlX7147Em$>gtg^VZ*l(n1=&ZSYFz7@Zc~mv9!*)ax z@Yor8!Vl=FFHywMz+e@EA`1{&0%r502Zn_4^JVE7pL4o9u!=w_`4 z_&oZj&5L?3x(StpyR;xOf;kP^u2TW)C#GY_mS`lEu|)NItOdK@BUq!YAVOwRFhjFH zL$I-VOM0~e++gXGU$37c;?Re~1&MG=cTd&I=9q#7Kch8-&lye{GWs}%8lF42Z)ZRK zoBPsns`I6W7hgjT6re%+;*>cR7#g5~`i{1V)+BGrz5Dmk={5(@+LZRPMMM1?qwdf8 z+AX-s+`&!kTbLcmXr-%w6jOadXbQ9Lm%u;Ge-8lj6cq;<)MkwTf4B{bkd4!*4gRGe zm`rkfZ}NC1RU2f-b&{JLdoSDz6qo{}Z&#`%I?KyoO-vwrx-X+EDv7FdhNixd0)Vso z>}tX`q7$Xmajk6nUYSp~hxikicFM;wCl;C#At|DtjhVRHnvF*c&2H#b5Rtbwd3s5V_@*K)(46D z8dNxwX7<`6n38sqSzw%OyjsP2$?O68a2$Y2qGG30n7=?c4Qw=$dd5F~|Ni~-R(?I? zyRD+}5vkvi?2=!d_n4@Yl#RP~jbi%OC`Vo^{CZ5%-}IoaTFkf<+N^(rS6b#&Fcwqai?602mQ zfJ;(6)$FQhF2S*e-+IJnMcLFCh^tfAt{;+TP--!DnLz1fvFMEl*xMf!ZEi(t-(Csi z?lWuFzlEwn-l%}qrW@Tf^fQ~1C!MAMX9}J?K1j-U$ml~TmC%e-7ykhm3g?9-_0Yx3 z(Q(3VZp3-;2I!qgfSDJU0V{^Vcu%scBP%N_&DP~a(Yv>H2-W(Kds*Y@#_hf6e#oak zchXo&uTUOoN-~8AQyi-qiKPe0m@Mcxkt7)mV&~Z9<)!sMt=Mnp!lPS_JX^+?m}Fxh zoZ>Zx+IAkDo={dX@1|e6{ML?HVVbPiN?os8B%-*@ry`KaASH1F+T4(ED7es`HrpVf zecvU~oNM$D+JwbbWY~ zckxWMPQ4I~Ej#5ktR@vopcE<7l2_gXMfQ#imPmmBU=99(kvx8pESLQCs;H3JYf+i< ztcX&3bzWw%URzf_he)}Ykf)TuAY`okUh41p1*zKnk#Qq%bmSSVJlrYsgcmDb9HQ|i z+PJa)4e1fYahV*!16&B^abdmgFhVT9I51N+b~9eLB!-q8sv80#JP+^A*>Qc0OE zg>6CpaDn>W!XxK0YXY{CsD;a?T}xZMT}ft7*ifA5TVKej(%pMGVzr;Qz zoCXj_?Yb0kk|9gmy#{~_-S>f9L#-+3es;2vdZR`Y*$x0HVmiWlTv$yRb3GLQEVVA| zZm3W1_Ui51K+7A<#tr}l9x*unI1OtHtg&$ZgA7H|l$@F>K6S+BFx#9jpKHcn+V*ER zz~~et9Q&2{=g*&~ zE)F&~4E+65dQ&z;0S_`4A96p2UI|id;QQ8FhSDwpc|GHHZ11}?ma+g#5+$UYyemEj z*=PfFXqDk{+x0l);Gs$UA@S!^Adzo!@9XxtIdASxW+7N2(C>R1se=qT}(n|6sta;T>Z4q#-DgJwB)!`oqJB8!wkfIsqM>1dr@C z?b=C*6pGPNK8o)Z@PwbMIj`htGPMaXAHS0@=`EWblj_)XIR&E&?0g z@FWh&0x&)eVbaME;8$8@5r0GYXO~f8bu-j66{HW9;dwC4^Y~5QJ-)ygj)XJZH$UGI ze2xI#xUFRtbT!~v=h_vgWAJ+8#ESeaXbDzK#e79r=;k#mw}uu54)dzhAy00Gbg zk51-a`lHVf(_#K7r3;0Eh!@@M-|u!Cwx6!sesh~_Y#G35VUUx_-%2y`x6(>hqSB(( zt6x7|#~~$=K4l_J2u~cRi|pxr!ZF$6O$fL&Al@ ziR@?vGcVLE9^R^$@|b%yXHnR_s8OMV2mcPyGSD#}e`+(sPvNb)9ayXta{;JbW&lxk z?;@*FTBR&+K=s3|4>@`A!JaW&4kU&%mLOKiec8M=N~s|ls=Hh+*b65l0v`|QPeoR?`@!ieZnE;Vkoq)f{%>2 zcs(azIpS~}nUc(YO{pgmtfE;%!24 z!({y6p__(HA$Q1t4M3NW7w-M#kYV)Ijwxe_d|Z2OV@fhHGm{PF(l*hX-aK8ybVM`+ zHC%B`$?{QE-n;XjjobGrcgNSWHUn-N{=qO-DY(I800_*>Vo1oi`b&d==*OvYkLno; z6Imev!*=fB!*K&`JY={L$KpwUn(JGjmIG^~6O^G2^lgXu)!)~Av9@Gp3cR6!xhN>t zL3eU0D=Q`0iee1C$?wwBH4oKCx@keri*k{DB2HP;x^f1w{9jx(^3mbShtBPaAc?m`c~zMiGtG?JQb)2AL0w);wy%%RF0NT}t5`hep=~P@ra}^`&2{;!8IyHsjA0-el~A8`(2PIUr8&gDN0AE8q(E zLCbCnQl<+JTBc8#Gv}ZT;k<8MYHc8G`Dgp!>(PWkV79tKk5OItU0GWdiq49{YsB-Q zbA)D^NZbd$mj)1FCxDR4t4llVjK@jPt6fpz&>T23rFvG{!vwN@L< zMvx(}IOy4iYADyUo(@D3R6{1E!YOvDu!-OI}4OpA`A*y1Aa z7l#z_wO8`2h&kw>&ycBTe`-mx`kd|3c4(@3YvO%GshvpEKHaf-n>eg+&FTv?yafX= zS=duh@G|QgjYAmYj#LMmo>hWBXVQ~MwMn7pz&((nAd^jf86IOg_%Qqsk!s$|J6r!=v^Qpy^+vu= z&$&kho4Hrc_}nKv={f;M$|cDm8@p^9_@LF}1EB74UBZbt3NKG_dI)*IMhF+fB&=mQ*AQfb8?Ja zUKP2#Og4mS-95FmCzIDyr$oCBg5>h6C2qAJ*eQo$=y4R{G7nH^W=w8_tt2O#35X7) z2mo}29gIWti8{~fB6N}PF8>$lH)1&L7Dc_dCGkJmWue`=TUz?j_=2%EA)_--l7!eP zZ&V?}GZ#e?#!iTf?85>Dw=* zd~q5&(If#Js6J|?$LBA@DA={nxC?Wc&_IUy~62867HV)tgZ{Io6QM({AtElHp|$ zPQ&Jk{|@0Zb+J}t)i<>%BWspFA|kRqMaG>F`owH_8&bF{^U(%;G;W>zYYq|`u@|D8 zNTT4Di7%*K|H?zXVvbXnUHzA2-S}oLlV3|@H!Qn8fK<%AGJ|POu*n!IsUtG7BbmH> z1!o)jHk5xyCP69UGTQs4(^(<{J@IXuCg5jds&7!G6h0mt?#8Mq)yuCRo#V~PlxanN zh`8X3%(y1n)YIOdaXS@ouNQo-?fsUwR8>`_l(a)1{T&g_J>WIU*XadXZFhw&>Q2un z&eLf9sMU^P=0cj31u`PyBgsfG0TjJQ>yrEHYdhMPB*Ni;!sV%-T7=!AJXR%c(imkY zPaQmY`_-!bAQjh;6}0ZVq&t-@V1zZ<43hyU*qFf_PmwgskKn-{!odM+0+W&Z?$%8F zc(rVLQH^a-?(9XKLp64Ax--loIr-2aaC^KRgkc|}iib)H{>Q%5b3 zi||aN;5}M5R$TkIe(9?}b2hp?{PEA2k1xyS>CXSy=VeaR0jrOn?{yc%kJ%^uIZCX0 z70eS5Lab_WQxxA3V0-ZlrXK9{tf#)W;kuJ~7zBx97%<^H^nt+URT2F!W6H;B=I_3X zQ+XL@P@1D@OWm@mwG}(>=xcT*C7rWUPgm)%UX@-;b{Ma=QZ!ub$v`*dF?8H-%q_4&OGcqYQ{94zYL zc;xorhnHCJLxCe}lkxS~xTw8>5fFp4&C~{KS*QSn(iFCdO%}c0PE|H8UbJ?#$qhy*J-#ef0luXUI| ze?D$eXK5y6NA86w1K+sg{c82shjkn;*($uR%(6FkM6MMh3~Ybd&{lU?>$33BPg>gA z`nVVq8yxUW#EL6Yd{9y5FS0Zk_{wTX?>!359$(J3oYTHQ)|bn7p;dDoJGQa75`*Em zO+7N_@$bF+jYuY9CdDi<<#VG3Q~G<(pEpm8>&VJen}l24W;kD5H8{=oea4^cXdJU; zROsI>6-zSDxtSCg{V{6n*mX2-P^q#fgYN1KfGD1lh4{49U6|0Ry`@@$hJI=J5~*vz zM%U6Om7>BlF*na6_-kru)k9vuX==@sMAh_iHY`cx^+odF;cw}B_3Bl2m*fG}csfpV z#X4B*cQ~Al-fvDimeW*)xBrxtd4ONyxNr>LUYuW_(B$KncDr0{n#LZ5e#%CQgVUzol$Y-Iuu9` zB^b~n_P{Y6G5j`8?wdc%?9?(;VORc{;KX_`#msSQEY8vg^ zH%4{0>{sBy#C@UTM_^ql8;bcuCihtkDSkT9H(xj1Ij+F7z%XTeQk`IIj`0s#X%~b- zI5WzAIx*+tCm!OQoS_d7y&1_qCS(YLB;9tK9^d`;iS%n#{x*ykmQOX+Lt+F1BXze~tq&}XG`irEk zU~lAE3OAvR@dVJ}Y!?s>Iv+*Zt%_>lwf*jm0c~5I9qzjSTYa_k4nU3~l0SwvO`aV9 z#RQCX#RgR;Qsb}#4{TlhrcJd=PD?VhA@Ds5_&wY`MNnRzQfyV}ucTLb^5VtF<3s3% zV-gY~g+mIzr{~|cRr#^OQR*5MfB4xpO&(raT zzGbquwJh--JXlG`!=TZikk0xgc6^CN`#9P_XH|G~Oz`zxb?Q`Kd3MzNe9v2&n2UkB z<8zQ$ahzh<9*bn!D@9O|SEhiqMiU2=^UJy|J`J4b=A{U?+FBgGpYa%o$>u!l0^nDH z2X4oxX?EuQyQ|vxR|S#UeUQR%#a4nb(F}>v4OF;{-|aJuwYtp?N5(-9R(xcvN-$GV zo3MBg`n$DaS98SmqNo-9d&Sh#e^xD5yd`N^ zohs6vn*1r(p9>)COxR^UaM>~|SxSbXvHP6{Xaf@_>iO#LNlz79U8xzPdFj& z&;$XeIPmk{ZC+yD*Rkj5Fu~W_vy2KQE_S^=-u4a-ZDM*`MJ;`O(WOg=!34zNkvlAW z+xG3-1rxhn92$yE8&~MulxwDVRPmAK&YP#QzF;*j0g;U|49T<7)*>&E*d;QdC}mzS zP6b>^Rq7gW@(Gmm`wTS=D)!V5*3e%L4n#J6!Zrt*B*T9Fbm*!){G+d7jvVL~q&P!$ zGOxVU0T<1YO_j!r&R@CGgepe%tq%ztoRJ6SO@lAZ{M5}8i0d`b0-w>+`v`1t+jmDw*j zJLm<@BLeqw{5}lQjzA)d2HbGk!+Xy#Gc!|=M1>E$gh|(~ledRz6w95oKg83Qj#gB5 zz&vbhyUI71_xR1K5j)lD)uEDa2SDDMMUt|H2NWje!=t>iavW9$ic6ggP_sdy4XZez z4VtseN*u|RKeC3Z4U+Ra03%P~h{R-^j4^;cQj3fXN_$6DztQ{hgLqwE)(Vs1QO>L* zF8eaNgx8`e+^|^~ zx>-8%+w(#<^VG%f8mKjj+HfKVNHRFUx|d^%aF7{P)_OQNL)=wiPL0|n6yD8_wXVF= zeTKhx!?mou_HrT_eP6Oh4@)p3`7Ct81F@8pGu)o6m99eK#ptPwn_HQdYm**8g$H>n zJ39b9Rsoja7^DqA>-%`lPpa0<49CeHU6zLKVk|fQAo4F6kMW(?KP@FCnC2D>Sq%i< zlX3hjWL%H)v9^C^27PN=Fl=EQ-&`7&^Q2vT_~ldf zRRdF3R4!ulf|T)uZvn}adQC&WMp9qNLcF0qefJ-|F#MM1(xrVsATfD4W91U*H+k|o zJ|;=nfghW5A`(+_;C-5me*QMP{G)S#MsxYwyr9r6b0!8+1Z|!(@za%Ayf|EHf*?R- z-_)YbQ5wYqOg!7nh}hcs0v)rj<3X`b-j)q zQ=4uWv2*|aE^^bzDB%vp$~RFF$D8ihH#a!q`<*+Vze}?($4>Gn$L_Y9mA$f^wq9^L zuT5%UN>BcW0Hm>P@*5gx1MH@G%(9r_ZExi|D%(VWM_GaQ)}&-H?#vS2NYJtmEcf_0 zswl2?o^Sg?V+g$^sNN8P=i%Qt9CToyY)dD)o;zpH@tqJg(%v|GsGf#QdEtjZDf)HF zdH||!f8zwK1c=79wjREiBNOHfN-$Kfk>TJ@aNvJIC*zQR^|A*Bz zZlVtlQEVjmy+yFIYp6xBBoA5?H{II5syh`37SF#1IK%-fdu9hIY8D;`$Tq>gW*q?Q z;Vv!nR|d|s@=tc}@%X|M+mnBWwEz%>NDWq9MG_64S$;4}wB>U{}xkOA4&{y)W5gkeiz(ikhddURB+T zJNX#l_bS{<=C)Vuuzu7@l@KLG1oK-fBO{xz7D6_o-;Q~+Kkyo6c3mzIHhjwA7kn15frUUnZFt>=~JjOyJ^!%p=Jt^hN$ekhJ%;GbJZkEWK(2g_EFF&PJdno!+<~Ko_}`p zo8s#`Ll{ve2hI6=BlSvMC{o`={T=SQusa*p{SZ_v(rSA*x0ppKW@Wvb@|*H#Ai>vu z=sr5gPml#x%pN=8?27ptW&=sR@mz@NP|&~w={cnT2`MBkegRadbD1GmOanz)S~9gddZ3_dlo z3T4^<XN;$0Ygm>S>>iaCIe+&*w^|L8)y^&*-i3Jn){XGiewu<+3wq`>N zj2jH@b=Z5cZtRN{(?@vhB#iDJj4hP>Wu#i((~0bZxa!RmNAalu#0Zo9;5*YgV_tiX zWow(Z@7(z})6Cy7@PZwW(q)A>#3Xgf+~0;nqf`26&P9C>{}NpU zYqPt4HVl>eE8hn7RCdM0Tv@$#?Q(`?Un#t3u-Yod3>y~ACY#OUo`n>TdYs?IJVRB! zpj-7Xi0o2@mm-63v0UhPi6|)u?pd`^&TWe+*OrcY8C1|dvWjk-Hq#FKetY{cSLFq4 z()5x4R77A#)TKj*T(JRs#H?rd zD6l?`gaS~QnWbNAwVR606eXF&CtYH%@e8NTt6 z6SxSis3_Pcwe#Q4U8b$U!z6v@oqIj0P4ybeASW&hrZ@llmLnX-L=v{_qv!cLJmpiG$37q!cgmx0CS zNb|6QZeZ!hKo z;8noca&37kTX<#h90i>_o3jX>^OHOF-j?#w49lJ#&^K-bKX~)w``#aj`ZrV>?AY~< z)EYEkV!1sXuBqZv$&L~|kj-HPH>2LYn{yc%k;rB%Oc?766!F(@*iV!h0u`FJ)2k+IV^$fu;}lO2-`?Z}2SaG4H_(V)|p%7UnsYhi3; zjPkf%MMZ^hI|jqvP%}qzZJI*c{+G;LMr+GQ1;8Cx~>^_x1p#h{u_&U_(yoy7lNEol&7P4J-by8yhv!(x6rnB@dwP$EW$d@+U!3 z(H^_cne)Z=@vi7-cfz4Sb8sJz4Rzk?Td%i-8B|5vBU@^qnWNKMwQA*#)`U{#G9}6u za04)o&&h?j*#?nC1xo;*Dw(v-SVUF%hYxjA zt5g&~B6`@Rio&UDYokMR=smmk?j3?I4v0ynt;S86QulJ+LUNOsDJ;PfS-U-8+nYpXK%z|LQo&~&~ox9uBS{&0R-u<7Nn^lS_N;| zpuA)-+m{aozS)NKUt6?T7ws+&K(^+A#`;8!>0kTfrL&~vW^HBGXlqPNC=&`#h-M)d z;x1t*s-PF2xp!;+7~cabm1~7g_~)NS^x4vBaQdmq|M#nDL{`=P8X`djGzR{f(GqQL zqK83vl*M+CUhZ5ecKc};m(O?(Rm8DYV*}rVm1pUt4J4Q_RU=E_e0K1uJ{YH1wrkEu zO}l^6s+D1^j(?{*w{zKDo5Z@@EAWZRi4B_$+7k|?Ib}@v*iiHMdO;H(AN3g^KZoY+ zC2)trA1^YS$&+`^>90)c0o({xmxBc(Cq1YA`5#OId2DGnL?3KlP_#8Vkc>cO%8M~>S*#kyM{|vkzKnA4MP+4^-$mec=b(ur`G;)t3&S-?CLOt# zNZ-B;Jd3!|24Z{s#EC(u7&a8WE^&W!{xo7mMK<-DtU3ZkgsyFXtj9THMZdn!%RQ=& z>?g_vFrEgnciH^pchORlKP#Ue3QzE~?9tz-b-KnQH3La(CRV@EBZFCoADl+xL}f(cX?@P)xFDK2r&61h?7{=d|YKo4;KHdZd!3PWsHeU zQt9)4TQ~i1hOy9w{+J~TUC>z=glNWbved(E*fM#({)oIsz;0q-L$x{!Hq#mEnR<;G zfK60~P};J3k(R~7AbJpBBhPHF&z3b^!1&0h$2~&#|J#4`2hzE$2y}U~t9aTU7dEd0 zZ%KI6#DIcS7?*bZj{!S-r7(jwwo2r5TPoU!>06U}7TI|p3&(fi}`#`x|& za+cG;WblV$(O)fU-{(dgKC3S&x9HQwJ&tu^3?AM>TH#2Hpe5#4sy0xa_ zh2x$J2ST^g=*h+xFgE;&{ZmtW34I7NDA+c92*`&Lw#(U}lg1UEG@t_8Dz92>evsJv z?&nMvR8Uc~*j=NtYf!O1aFndMUthawpOvpZU4(SFY*D+_NS%s7wuMt8V|TY;!RCwmUHKhHVC4qQTpY62H8i&=7V>hXiqo_xF8JdaiMifj}K zw~vkQKtxG+KQ<`J1aNjE_=``PvGHpP2Jl@Ok%E)i!fsvQQ#DX1y8AVoot(5~irU1&$oe?pA^CKMR!$BDsZ7VCRR-oc?k|;uqn#bfSU324 zNo7x;bokONrM~jlc4pduHO2i!PzZ&Bc-Vizod(jl;|(KoQ8X?E!yDBs&$r`4Tp5Ud$@DB@OmNVo3YJ3trV=m|Qas^CRX^R;;M`0R&yk15BEd-ZCD z3QB+t7S$YNWh0+0Uho;$h4bgzfWTjV7`5>ROST~vk9$O^nNT^)5A*hRr>!;zCX@We z*~#U{BGma7KK!Rm>};n_Ah_@rgQ;0%{SBQQfSWouL>`o+CW#|dA&9(lAU^1_>Tx#c zL65s;wF4Uo1a)Ihmz~XFf~exV`z3L!T7qmefv-eZj>us_!s?J`|EC3T0~U~hNhV28 zM=101YHL1mxF-)%+aC3=f1!8VLW>49e*Ztt^Jxb0T%aDAbmEl1AoCD&-`qXg934LE zjGK6Pl=Xi9fEXdcNfaAPo@a(rzsUk%u*unfbWVutMuki4r@#)T54$$?se0mKX2)b3 zCWSv+I%tA@u2>H+$Q+*teyz}5zTf2C4|Y(nu3R1d;7nf&kCzbl*Q7*k6I&F*rNCFE z53w24(3mw@&<-*mh!7;}(exg)L&%a*F2_R!D8|)vG4hd(*?9k4xqdw`EUd{JtEP$F ziP_|ifIt6SvHxN$=;ouIgBsO6_-wJ-x_U7m+KWf)52NWBgT?~z$3+0Bq{{?eT(4(Z^{+cYvVzpYz>Gb)Y z%WjVhT9_HG^2p?>M`rlY=x^_U4$zJ_fM$c}yQj2JR_K79cLRE&*QF7-Ki;u^eR zvxV|K9v`KqoIF{FH01Mjz1g`popN5yN)7DNy>0T7pA{_#rc$#4l`F^CJ3b}x_qhl9 z+)`J!gIVPh9awgpu2By>LIuGDNs|wCkb8lM!VVOGZf4_o-+Kq(I&5jO zc~tWZf}`}~>ID^V;_XRcNwAfLeu#0^P{GmGG=#$wNk(O8LcpDpk`P+uo}(Nq>>xT6 z3ewD+eF0XD>-K5+A8GUjP&{2leme;dX3`=p_69_

xAtit%c;)NCP1%vn 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 diff --git a/src/datapoint/Forecast.py b/src/datapoint/Forecast.py index 97eb83d..1c9ab45 100644 --- a/src/datapoint/Forecast.py +++ b/src/datapoint/Forecast.py @@ -116,10 +116,13 @@ def _build_timesteps_from_daily(self, forecasts, parameters): :parameter forecasts: Forecast data from DataHub :parameter parameters: Unit information from DataHub + :type forecasts: list + :type parameters: dict :return: List of timesteps :rtype: list """ + timesteps = [] for forecast in forecasts: night_step = {"time": datetime.datetime.fromisoformat(forecast["time"])} @@ -177,8 +180,10 @@ def _build_timestep(self, forecast, parameters): Take the forecast data from DataHub for a single time and combine with unit information in each timestep. - :parameter forecasts: Forecast data from DataHub + :parameter forecast: Forecast data from DataHub :parameter parameters: Unit information from DataHub + :type forecast: dict + :type parameters:dict :return: Individual forecast timestep :rtype: dict From 6db94b004ce8cce88bc628f681d252c5b9f448aa Mon Sep 17 00:00:00 2001 From: Emily Price Date: Tue, 12 Nov 2024 19:35:00 +0000 Subject: [PATCH 25/51] Update test build action --- .github/workflows/publish-to-test-pypi.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-to-test-pypi.yml b/.github/workflows/publish-to-test-pypi.yml index c12cf70..fecc6af 100644 --- a/.github/workflows/publish-to-test-pypi.yml +++ b/.github/workflows/publish-to-test-pypi.yml @@ -10,9 +10,9 @@ jobs: fetch-depth: 0 fetch-tags: true - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: "3.x" + python-version: "3.12" - name: Install pypa/build run: >- python3 -m @@ -22,7 +22,7 @@ jobs: - name: Build a binary wheel and a source tarball run: python3 -m build - name: Store the distribution packages - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: python-package-distributions path: dist/ @@ -38,7 +38,7 @@ jobs: id-token: write # IMPORTANT: mandatory for trusted publishing steps: - name: Download all the dists - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: python-package-distributions path: dist/ From 19f7216494bdcd685fb92c3d1c1060780d9da56e Mon Sep 17 00:00:00 2001 From: Emily Price Date: Tue, 12 Nov 2024 19:42:02 +0000 Subject: [PATCH 26/51] Use hatch for test build --- .github/workflows/publish-to-test-pypi.yml | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish-to-test-pypi.yml b/.github/workflows/publish-to-test-pypi.yml index fecc6af..625ad3a 100644 --- a/.github/workflows/publish-to-test-pypi.yml +++ b/.github/workflows/publish-to-test-pypi.yml @@ -13,14 +13,10 @@ jobs: uses: actions/setup-python@v5 with: python-version: "3.12" - - name: Install pypa/build - run: >- - python3 -m - pip install - build - --user + - name: Install hatch + uses: pypa/hatch@install - name: Build a binary wheel and a source tarball - run: python3 -m build + run: python3 -m hatch build - name: Store the distribution packages uses: actions/upload-artifact@v4 with: From fc27aaed7f4b047ee937e5e0dd963f5e11acb102 Mon Sep 17 00:00:00 2001 From: Emily Price Date: Tue, 12 Nov 2024 19:43:37 +0000 Subject: [PATCH 27/51] Use hatch as a standalone command --- .github/workflows/publish-to-test-pypi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-to-test-pypi.yml b/.github/workflows/publish-to-test-pypi.yml index 625ad3a..d32b33e 100644 --- a/.github/workflows/publish-to-test-pypi.yml +++ b/.github/workflows/publish-to-test-pypi.yml @@ -16,7 +16,7 @@ jobs: - name: Install hatch uses: pypa/hatch@install - name: Build a binary wheel and a source tarball - run: python3 -m hatch build + run: hatch build - name: Store the distribution packages uses: actions/upload-artifact@v4 with: From c7440426606d53f2d838e5c5d79c88716500de37 Mon Sep 17 00:00:00 2001 From: Emily Price Date: Thu, 14 Nov 2024 19:33:48 +0000 Subject: [PATCH 28/51] Remove pipenv --- .readthedocs.yaml | 6 +- Pipfile | 25 -- Pipfile.lock | 699 ------------------------------------------ docs/requirements.txt | 2 +- requirements-dev.txt | 1 + requirements.txt | 4 - 6 files changed, 5 insertions(+), 732 deletions(-) delete mode 100644 Pipfile delete mode 100644 Pipfile.lock delete mode 100644 requirements.txt diff --git a/.readthedocs.yaml b/.readthedocs.yaml index f50fc0f..a9460f3 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -22,6 +22,6 @@ sphinx: # Optional but recommended, declare the Python requirements required # to build your documentation # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html -# python: -# install: -# - requirements: docs/requirements.txt + python: + install: + - requirements: docs/requirements.txt diff --git a/Pipfile b/Pipfile deleted file mode 100644 index 058a874..0000000 --- a/Pipfile +++ /dev/null @@ -1,25 +0,0 @@ -[[source]] -url = "https://pypi.org/simple" -verify_ssl = true -name = "pypi" - -[packages] -requests = "*" -appdirs = "*" -requests-mock = "*" -geojson = "*" -datapoint = {file = ".", editable = true} - -[dev-packages] -black = "*" -isort = "*" -flake8 = "*" -flake8-bugbear = "*" -pytest = "*" -flake8-pytest-style = "*" - -[documentation] -sphinx = "*" - -[requires] -python_version = "3.11" diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index c5e694a..0000000 --- a/Pipfile.lock +++ /dev/null @@ -1,699 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "dcdfc418c41047868fd968171a2446ce9cc046ce51073a3d20c019c9371abe56" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.11" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "appdirs": { - "hashes": [ - "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", - "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" - ], - "index": "pypi", - "version": "==1.4.4" - }, - "certifi": { - "hashes": [ - "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", - "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9" - ], - "markers": "python_version >= '3.6'", - "version": "==2024.8.30" - }, - "charset-normalizer": { - "hashes": [ - "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621", - "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", - "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", - "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912", - "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c", - "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b", - "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d", - "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d", - "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95", - "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e", - "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", - "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", - "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab", - "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be", - "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", - "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907", - "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0", - "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2", - "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62", - "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62", - "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", - "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc", - "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", - "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca", - "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455", - "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858", - "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", - "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594", - "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", - "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", - "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", - "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea", - "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6", - "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", - "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749", - "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", - "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd", - "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99", - "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242", - "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee", - "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129", - "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", - "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51", - "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee", - "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8", - "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", - "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613", - "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742", - "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe", - "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3", - "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5", - "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", - "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7", - "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", - "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c", - "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea", - "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417", - "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", - "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", - "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca", - "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa", - "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99", - "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149", - "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41", - "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574", - "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0", - "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f", - "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", - "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654", - "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3", - "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19", - "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", - "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578", - "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", - "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", - "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51", - "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", - "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236", - "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a", - "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c", - "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade", - "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944", - "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc", - "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6", - "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6", - "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27", - "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6", - "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2", - "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12", - "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf", - "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", - "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7", - "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", - "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", - "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b", - "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", - "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", - "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4", - "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", - "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365", - "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a", - "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748", - "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b", - "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", - "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482" - ], - "markers": "python_full_version >= '3.7.0'", - "version": "==3.4.0" - }, - "datapoint": { - "editable": true, - "file": "." - }, - "geojson": { - "hashes": [ - "sha256:58a7fa40727ea058efc28b0e9ff0099eadf6d0965e04690830208d3ef571adac", - "sha256:68a9771827237adb8c0c71f8527509c8f5bef61733aa434cefc9c9d4f0ebe8f3" - ], - "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==3.1.0" - }, - "idna": { - "hashes": [ - "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", - "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" - ], - "markers": "python_version >= '3.6'", - "version": "==3.10" - }, - "requests": { - "hashes": [ - "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", - "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==2.32.3" - }, - "requests-mock": { - "hashes": [ - "sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563", - "sha256:e9e12e333b525156e82a3c852f22016b9158220d2f47454de9cae8a77d371401" - ], - "index": "pypi", - "markers": "python_version >= '3.5'", - "version": "==1.12.1" - }, - "urllib3": { - "hashes": [ - "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", - "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9" - ], - "markers": "python_version >= '3.8'", - "version": "==2.2.3" - } - }, - "develop": { - "attrs": { - "hashes": [ - "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346", - "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2" - ], - "markers": "python_version >= '3.7'", - "version": "==24.2.0" - }, - "black": { - "hashes": [ - "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f", - "sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd", - "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea", - "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981", - "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b", - "sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7", - "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8", - "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175", - "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d", - "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392", - "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad", - "sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f", - "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f", - "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b", - "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875", - "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3", - "sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800", - "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65", - "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2", - "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812", - "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50", - "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e" - ], - "index": "pypi", - "markers": "python_version >= '3.9'", - "version": "==24.10.0" - }, - "click": { - "hashes": [ - "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", - "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" - ], - "markers": "python_version >= '3.7'", - "version": "==8.1.7" - }, - "flake8": { - "hashes": [ - "sha256:049d058491e228e03e67b390f311bbf88fce2dbaa8fa673e7aea87b7198b8d38", - "sha256:597477df7860daa5aa0fdd84bf5208a043ab96b8e96ab708770ae0364dd03213" - ], - "index": "pypi", - "markers": "python_full_version >= '3.8.1'", - "version": "==7.1.1" - }, - "flake8-bugbear": { - "hashes": [ - "sha256:435b531c72b27f8eff8d990419697956b9fd25c6463c5ba98b3991591de439db", - "sha256:cccf786ccf9b2e1052b1ecfa80fb8f80832d0880425bcbd4cd45d3c8128c2683" - ], - "index": "pypi", - "markers": "python_full_version >= '3.8.1'", - "version": "==24.10.31" - }, - "flake8-plugin-utils": { - "hashes": [ - "sha256:39f6f338d038b301c6fd344b06f2e81e382b68fa03c0560dff0d9b1791a11a2c", - "sha256:e4848c57d9d50f19100c2d75fa794b72df068666a9041b4b0409be923356a3ed" - ], - "markers": "python_version >= '3.6' and python_version < '4.0'", - "version": "==1.3.3" - }, - "flake8-pytest-style": { - "hashes": [ - "sha256:919c328cacd4bc4f873ea61ab4db0d8f2c32e0db09a3c73ab46b1de497556464", - "sha256:abcb9f56f277954014b749e5a0937fae215be01a21852e9d05e7600c3de6aae5" - ], - "index": "pypi", - "markers": "python_full_version >= '3.8.1' and python_full_version < '4.0.0'", - "version": "==2.0.0" - }, - "iniconfig": { - "hashes": [ - "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", - "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" - ], - "markers": "python_version >= '3.7'", - "version": "==2.0.0" - }, - "isort": { - "hashes": [ - "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109", - "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6" - ], - "index": "pypi", - "markers": "python_full_version >= '3.8.0'", - "version": "==5.13.2" - }, - "mccabe": { - "hashes": [ - "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", - "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" - ], - "markers": "python_version >= '3.6'", - "version": "==0.7.0" - }, - "mypy-extensions": { - "hashes": [ - "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", - "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" - ], - "markers": "python_version >= '3.5'", - "version": "==1.0.0" - }, - "packaging": { - "hashes": [ - "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", - "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f" - ], - "markers": "python_version >= '3.8'", - "version": "==24.2" - }, - "pathspec": { - "hashes": [ - "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", - "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712" - ], - "markers": "python_version >= '3.8'", - "version": "==0.12.1" - }, - "platformdirs": { - "hashes": [ - "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", - "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb" - ], - "markers": "python_version >= '3.8'", - "version": "==4.3.6" - }, - "pluggy": { - "hashes": [ - "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", - "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669" - ], - "markers": "python_version >= '3.8'", - "version": "==1.5.0" - }, - "pycodestyle": { - "hashes": [ - "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3", - "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521" - ], - "markers": "python_version >= '3.8'", - "version": "==2.12.1" - }, - "pyflakes": { - "hashes": [ - "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f", - "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a" - ], - "markers": "python_version >= '3.8'", - "version": "==3.2.0" - }, - "pytest": { - "hashes": [ - "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", - "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==8.3.3" - } - }, - "documentation": { - "alabaster": { - "hashes": [ - "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", - "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b" - ], - "markers": "python_version >= '3.10'", - "version": "==1.0.0" - }, - "babel": { - "hashes": [ - "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b", - "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316" - ], - "markers": "python_version >= '3.8'", - "version": "==2.16.0" - }, - "certifi": { - "hashes": [ - "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", - "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9" - ], - "markers": "python_version >= '3.6'", - "version": "==2024.8.30" - }, - "charset-normalizer": { - "hashes": [ - "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621", - "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", - "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", - "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912", - "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c", - "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b", - "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d", - "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d", - "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95", - "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e", - "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", - "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", - "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab", - "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be", - "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", - "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907", - "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0", - "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2", - "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62", - "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62", - "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", - "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc", - "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", - "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca", - "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455", - "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858", - "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", - "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594", - "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", - "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", - "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", - "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea", - "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6", - "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", - "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749", - "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", - "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd", - "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99", - "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242", - "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee", - "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129", - "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", - "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51", - "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee", - "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8", - "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", - "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613", - "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742", - "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe", - "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3", - "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5", - "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", - "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7", - "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", - "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c", - "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea", - "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417", - "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", - "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", - "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca", - "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa", - "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99", - "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149", - "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41", - "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574", - "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0", - "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f", - "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", - "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654", - "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3", - "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19", - "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", - "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578", - "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", - "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", - "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51", - "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", - "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236", - "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a", - "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c", - "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade", - "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944", - "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc", - "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6", - "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6", - "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27", - "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6", - "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2", - "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12", - "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf", - "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", - "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7", - "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", - "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", - "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b", - "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", - "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", - "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4", - "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", - "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365", - "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a", - "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748", - "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b", - "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", - "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482" - ], - "markers": "python_full_version >= '3.7.0'", - "version": "==3.4.0" - }, - "docutils": { - "hashes": [ - "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", - "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2" - ], - "markers": "python_version >= '3.9'", - "version": "==0.21.2" - }, - "idna": { - "hashes": [ - "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", - "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" - ], - "markers": "python_version >= '3.6'", - "version": "==3.10" - }, - "imagesize": { - "hashes": [ - "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", - "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.4.1" - }, - "jinja2": { - "hashes": [ - "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", - "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d" - ], - "markers": "python_version >= '3.7'", - "version": "==3.1.4" - }, - "markupsafe": { - "hashes": [ - "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", - "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", - "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0", - "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", - "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", - "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13", - "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", - "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", - "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", - "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", - "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0", - "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", - "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", - "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", - "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", - "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff", - "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", - "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", - "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", - "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", - "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", - "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", - "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", - "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", - "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", - "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", - "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", - "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", - "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", - "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144", - "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f", - "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", - "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", - "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", - "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", - "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", - "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", - "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", - "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", - "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", - "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", - "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", - "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", - "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", - "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", - "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", - "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", - "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", - "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29", - "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", - "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", - "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", - "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", - "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", - "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", - "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a", - "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178", - "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", - "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", - "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", - "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50" - ], - "markers": "python_version >= '3.9'", - "version": "==3.0.2" - }, - "packaging": { - "hashes": [ - "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", - "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f" - ], - "markers": "python_version >= '3.8'", - "version": "==24.2" - }, - "pygments": { - "hashes": [ - "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", - "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a" - ], - "markers": "python_version >= '3.8'", - "version": "==2.18.0" - }, - "requests": { - "hashes": [ - "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", - "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==2.32.3" - }, - "snowballstemmer": { - "hashes": [ - "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1", - "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a" - ], - "version": "==2.2.0" - }, - "sphinx": { - "hashes": [ - "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", - "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927" - ], - "index": "pypi", - "markers": "python_version >= '3.10'", - "version": "==8.1.3" - }, - "sphinxcontrib-applehelp": { - "hashes": [ - "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", - "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5" - ], - "markers": "python_version >= '3.9'", - "version": "==2.0.0" - }, - "sphinxcontrib-devhelp": { - "hashes": [ - "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", - "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2" - ], - "markers": "python_version >= '3.9'", - "version": "==2.0.0" - }, - "sphinxcontrib-htmlhelp": { - "hashes": [ - "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", - "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9" - ], - "markers": "python_version >= '3.9'", - "version": "==2.1.0" - }, - "sphinxcontrib-jsmath": { - "hashes": [ - "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", - "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" - ], - "markers": "python_version >= '3.5'", - "version": "==1.0.1" - }, - "sphinxcontrib-qthelp": { - "hashes": [ - "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", - "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb" - ], - "markers": "python_version >= '3.9'", - "version": "==2.0.0" - }, - "sphinxcontrib-serializinghtml": { - "hashes": [ - "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", - "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d" - ], - "markers": "python_version >= '3.9'", - "version": "==2.0.0" - }, - "urllib3": { - "hashes": [ - "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", - "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9" - ], - "markers": "python_version >= '3.8'", - "version": "==2.2.3" - } - } -} diff --git a/docs/requirements.txt b/docs/requirements.txt index 84258ed..3ad934d 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,2 @@ sphinx -datapoint-python +-e . diff --git a/requirements-dev.txt b/requirements-dev.txt index 1e85ea9..3061c53 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,3 +4,4 @@ flake8==7.* flake8-bugbear==24.* flake8-pytest-style==1.* pytest==8.* +. diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index b5cc6d6..0000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -requests==2.31.0 -appdirs==1.4.4 -requests-mock==1.11.0 -geojson==3.1.0 From c3490278d49e27adc8a3e4a06c2f92831699186e Mon Sep 17 00:00:00 2001 From: Emily Price Date: Thu, 14 Nov 2024 19:34:05 +0000 Subject: [PATCH 29/51] Update docs formatting --- docs/source/conf.py | 2 +- docs/source/index.rst | 1 + src/datapoint/Forecast.py | 164 ++++++++++++++++++++++---------------- src/datapoint/Manager.py | 159 ++++++++++++++++++++---------------- 4 files changed, 190 insertions(+), 136 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index a267350..cff2f6e 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -20,7 +20,7 @@ # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -extensions = [] +extensions = ['sphinx.ext.autodoc',] templates_path = ['_templates'] exclude_patterns = [] diff --git a/docs/source/index.rst b/docs/source/index.rst index 9777706..9e5288a 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -13,3 +13,4 @@ datapoint-python documentation install getting-started migration + api-reference diff --git a/src/datapoint/Forecast.py b/src/datapoint/Forecast.py index 1c9ab45..0aea250 100644 --- a/src/datapoint/Forecast.py +++ b/src/datapoint/Forecast.py @@ -5,72 +5,100 @@ class Forecast: - """Forecast data returned from DataPoint - - Provides access to forecasts as far ahead as provided by DataPoint: - + x for hourly forecasts - + y for three-hourly forecasts - + z for daily forecasts - - Basic Usage:: - - >>> import datapoint - >>> m = datapoint.Manager(api_key = "blah") - >>> f = m.get_forecast(latitude=50, longitude=0, frequency="hourly") - >>> f.now() - {'time': datetime.datetime(2024, 2, 19, 13, 0, tzinfo=datetime.timezone.utc), - 'screenTemperature': {'value': 10.09, - 'description': 'Screen Air Temperature', - 'unit_name': 'degrees Celsius', - 'unit_symbol': 'Cel'}, - 'screenDewPointTemperature': {'value': 8.08, - 'description': 'Screen Dew Point Temperature', - 'unit_name': 'degrees Celsius', - 'unit_symbol': 'Cel'}, - 'feelsLikeTemperature': {'value': 6.85, - 'description': 'Feels Like Temperature', - 'unit_name': 'degrees Celsius', - 'unit_symbol': 'Cel'}, - 'windSpeed10m': {'value': 7.57, - 'description': '10m Wind Speed', - 'unit_name': 'metres per second', - 'unit_symbol': 'm/s'}, - 'windDirectionFrom10m': {'value': 263, - 'description': '10m Wind From Direction', - 'unit_name': 'degrees', - 'unit_symbol': 'deg'}, - 'windGustSpeed10m': {'value': 12.31, - 'description': '10m Wind Gust Speed', - 'unit_name': 'metres per second', - 'unit_symbol': 'm/s'}, - 'visibility': {'value': 21201, - 'description': 'Visibility', - 'unit_name': 'metres', - 'unit_symbol': 'm'}, - 'screenRelativeHumidity': {'value': 87.81, - 'description': 'Screen Relative Humidity', - 'unit_name': 'percentage', - 'unit_symbol': '%'}, - 'mslp': {'value': 103080, - 'description': 'Mean Sea Level Pressure', - 'unit_name': 'pascals', - 'unit_symbol': 'Pa'}, - 'uvIndex': {'value': 1, - 'description': 'UV Index', - 'unit_name': 'dimensionless', - 'unit_symbol': '1'}, - 'significantWeatherCode': {'value': 'Cloudy', - 'description': 'Significant Weather Code', - 'unit_name': 'dimensionless', - 'unit_symbol': '1'}, - 'precipitationRate': {'value': 0.0, - 'description': 'Precipitation Rate', - 'unit_name': 'millimetres per hour', - 'unit_symbol': 'mm/h'}, - 'probOfPrecipitation': {'value': 21, - 'description': 'Probability of Precipitation', - 'unit_name': 'percentage', - 'unit_symbol': '%'}} + """Forecast data returned from DataHub + + Provides access to forecasts as far ahead as provided by DataHub. See the + DataHub documentation for the latest limits on the forecast range. The + values of data from DataHub are combined with the unit information and + description and returned as a dict. + + Basic Usage:: + + >>> import datapoint + >>> m = datapoint.Manager(api_key = "blah") + >>> f = m.get_forecast(latitude=50, longitude=0, frequency="hourly") + >>> f.now() + { + 'time': datetime.datetime(2024, 2, 19, 13, 0, tzinfo=datetime.timezone.utc), + 'screenTemperature': { + 'value': 10.09, + 'description': 'Screen Air Temperature', + 'unit_name': 'degrees Celsius', + 'unit_symbol': 'Cel' + }, + 'screenDewPointTemperature': { + 'value': 8.08, + 'description': 'Screen Dew Point Temperature', + 'unit_name': 'degrees Celsius', + 'unit_symbol': 'Cel' + }, + 'feelsLikeTemperature': { + 'value': 6.85, + 'description': 'Feels Like Temperature', + 'unit_name': 'degrees Celsius', + 'unit_symbol': 'Cel' + }, + 'windSpeed10m': { + 'value': 7.57, + 'description': '10m Wind Speed', + 'unit_name': 'metres per second', + 'unit_symbol': 'm/s' + }, + 'windDirectionFrom10m': { + 'value': 263, + 'description': '10m Wind From Direction', + 'unit_name': 'degrees', + 'unit_symbol': 'deg' + }, + 'windGustSpeed10m': { + 'value': 12.31, + 'description': '10m Wind Gust Speed', + 'unit_name': 'metres per second', + 'unit_symbol': 'm/s' + }, + 'visibility': { + 'value': 21201, + 'description': 'Visibility', + 'unit_name': 'metres', + 'unit_symbol': 'm' + }, + 'screenRelativeHumidity': { + 'value': 87.81, + 'description': 'Screen Relative Humidity', + 'unit_name': 'percentage', + 'unit_symbol': '%' + }, + 'mslp': { + 'value': 103080, + 'description': 'Mean Sea Level Pressure', + 'unit_name': 'pascals', + 'unit_symbol': 'Pa' + }, + 'uvIndex': { + 'value': 1, + 'description': 'UV Index', + 'unit_name': 'dimensionless', + 'unit_symbol': '1' + }, + 'significantWeatherCode': { + 'value': 'Cloudy', + 'description': 'Significant Weather Code', + 'unit_name': 'dimensionless', + 'unit_symbol': '1' + }, + 'precipitationRate': { + 'value': 0.0, + 'description': 'Precipitation Rate', + 'unit_name': 'millimetres per hour', + 'unit_symbol': 'mm/h' + }, + 'probOfPrecipitation': { + 'value': 21, + 'description': 'Probability of Precipitation', + 'unit_name': 'percentage', + 'unit_symbol': '%' + } + } """ def __init__(self, frequency, api_data): @@ -84,7 +112,7 @@ def __init__(self, frequency, api_data): self.data_date = datetime.datetime.fromisoformat( api_data["features"][0]["properties"]["modelRunDate"] ) - self.name = api_data["features"][0]["properties"]["location"]["name"] + self.forecast_longitude = api_data["features"][0]["geometry"]["coordinates"][0] self.forecast_latitude = api_data["features"][0]["geometry"]["coordinates"][1] self.distance_from_requested_location = api_data["features"][0]["properties"][ @@ -323,7 +351,7 @@ def at_datetime(self, target): return to_return def now(self): - """Function to return the closest timestep to the current time + """Return the closest timestep to the current time :return: Individual forecast timestep :rtype: dict diff --git a/src/datapoint/Manager.py b/src/datapoint/Manager.py index fdc08be..c609129 100644 --- a/src/datapoint/Manager.py +++ b/src/datapoint/Manager.py @@ -1,7 +1,3 @@ -""" -Datapoint python module -""" - import geojson import requests from requests.adapters import HTTPAdapter @@ -14,69 +10,98 @@ class Manager: - """ - Datapoint Manager object - - Wraps calls to DataHub API, and provides Forecast objects - Basic Usage:: - - >>> import datapoint - >>> m = datapoint.Manager(api_key = "blah") - >>> f = m.get_forecast(latitude=50, longitude=0, frequency="hourly") - >>> f.now() - {'time': datetime.datetime(2024, 2, 19, 13, 0, tzinfo=datetime.timezone.utc), - 'screenTemperature': {'value': 10.09, - 'description': 'Screen Air Temperature', - 'unit_name': 'degrees Celsius', - 'unit_symbol': 'Cel'}, - 'screenDewPointTemperature': {'value': 8.08, - 'description': 'Screen Dew Point Temperature', - 'unit_name': 'degrees Celsius', - 'unit_symbol': 'Cel'}, - 'feelsLikeTemperature': {'value': 6.85, - 'description': 'Feels Like Temperature', - 'unit_name': 'degrees Celsius', - 'unit_symbol': 'Cel'}, - 'windSpeed10m': {'value': 7.57, - 'description': '10m Wind Speed', - 'unit_name': 'metres per second', - 'unit_symbol': 'm/s'}, - 'windDirectionFrom10m': {'value': 263, - 'description': '10m Wind From Direction', - 'unit_name': 'degrees', - 'unit_symbol': 'deg'}, - 'windGustSpeed10m': {'value': 12.31, - 'description': '10m Wind Gust Speed', - 'unit_name': 'metres per second', - 'unit_symbol': 'm/s'}, - 'visibility': {'value': 21201, - 'description': 'Visibility', - 'unit_name': 'metres', - 'unit_symbol': 'm'}, - 'screenRelativeHumidity': {'value': 87.81, - 'description': 'Screen Relative Humidity', - 'unit_name': 'percentage', - 'unit_symbol': '%'}, - 'mslp': {'value': 103080, - 'description': 'Mean Sea Level Pressure', - 'unit_name': 'pascals', - 'unit_symbol': 'Pa'}, - 'uvIndex': {'value': 1, - 'description': 'UV Index', - 'unit_name': 'dimensionless', - 'unit_symbol': '1'}, - 'significantWeatherCode': {'value': 'Cloudy', - 'description': 'Significant Weather Code', - 'unit_name': 'dimensionless', - 'unit_symbol': '1'}, - 'precipitationRate': {'value': 0.0, - 'description': 'Precipitation Rate', - 'unit_name': 'millimetres per hour', - 'unit_symbol': 'mm/h'}, - 'probOfPrecipitation': {'value': 21, - 'description': 'Probability of Precipitation', - 'unit_name': 'percentage', - 'unit_symbol': '%'}} + """Manager for DataHub connection. + + Wraps calls to DataHub API, and provides Forecast objects. Basic Usage: + + :: + + >>> import datapoint + >>> m = datapoint.Manager(api_key = "blah") + >>> f = m.get_forecast(latitude=50, longitude=0, frequency="hourly") + >>> f.now() + { + 'time': datetime.datetime(2024, 2, 19, 13, 0, tzinfo=datetime.timezone.utc), + 'screenTemperature': { + 'value': 10.09, + 'description': 'Screen Air Temperature', + 'unit_name': 'degrees Celsius', + 'unit_symbol': 'Cel' + }, + 'screenDewPointTemperature': { + 'value': 8.08, + 'description': 'Screen Dew Point Temperature', + 'unit_name': 'degrees Celsius', + 'unit_symbol': 'Cel' + }, + 'feelsLikeTemperature': { + 'value': 6.85, + 'description': 'Feels Like Temperature', + 'unit_name': 'degrees Celsius', + 'unit_symbol': 'Cel' + }, + 'windSpeed10m': { + 'value': 7.57, + 'description': '10m Wind Speed', + 'unit_name': 'metres per second', + 'unit_symbol': 'm/s' + }, + 'windDirectionFrom10m': { + 'value': 263, + 'description': '10m Wind From Direction', + 'unit_name': 'degrees', + 'unit_symbol': 'deg' + }, + 'windGustSpeed10m': { + 'value': 12.31, + 'description': '10m Wind Gust Speed', + 'unit_name': 'metres per second', + 'unit_symbol': 'm/s' + }, + 'visibility': { + 'value': 21201, + 'description': 'Visibility', + 'unit_name': 'metres', + 'unit_symbol': 'm' + }, + 'screenRelativeHumidity': { + 'value': 87.81, + 'description': 'Screen Relative Humidity', + 'unit_name': 'percentage', + 'unit_symbol': '%' + }, + 'mslp': { + 'value': 103080, + 'description': 'Mean Sea Level Pressure', + 'unit_name': 'pascals', + 'unit_symbol': 'Pa' + }, + 'uvIndex': { + 'value': 1, + 'description': 'UV Index', + 'unit_name': 'dimensionless', + 'unit_symbol': '1' + }, + 'significantWeatherCode': { + 'value': 'Cloudy', + 'description': 'Significant Weather Code', + 'unit_name': 'dimensionless', + 'unit_symbol': '1' + }, + 'precipitationRate': { + 'value': 0.0, + 'description': 'Precipitation Rate', + 'unit_name': 'millimetres per hour', + 'unit_symbol': 'mm/h' + }, + 'probOfPrecipitation': { + 'value': 21, + 'description': 'Probability of Precipitation', + 'unit_name': 'percentage', + 'unit_symbol': '%' + } + } + """ def __init__(self, api_key=""): From c819e4cc07b55ed56425dd3217a5a6c78b9da3de Mon Sep 17 00:00:00 2001 From: Emily Price Date: Thu, 14 Nov 2024 19:53:39 +0000 Subject: [PATCH 30/51] Update examples --- examples/current_weather/current_weather.py | 22 +++--------- .../postcodes_to_lat_lng.py | 19 +++------- examples/text_forecast/text_forecast.py | 35 ------------------- examples/tube_bike/tube_bike.py | 20 +++++------ examples/umbrella/umbrella.py | 19 +++++----- examples/washing/washing.py | 35 ++++++++++++------- 6 files changed, 49 insertions(+), 101 deletions(-) delete mode 100644 examples/text_forecast/text_forecast.py diff --git a/examples/current_weather/current_weather.py b/examples/current_weather/current_weather.py index 4dab522..b00db6e 100644 --- a/examples/current_weather/current_weather.py +++ b/examples/current_weather/current_weather.py @@ -7,24 +7,12 @@ import datapoint # Create datapoint connection -conn = datapoint.Manager(api_key="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") +manager = datapoint.Manager(api_key="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") -# Get nearest site and print out its name - -site = conn.get_nearest_forecast_site(51.500728, -0.124626) -print(site.name) - -# Get a forecast for the nearest site -forecast = conn.get_forecast_for_site(site.location_id, "3hourly") +# Get a forecast for the nearest location +forecast = manager.get_forecast(51.500728, -0.124626, "hourly") # Get the current timestep using now() and print out some info now = forecast.now() -print(now.weather.text) -print( - "%s%s%s" - % ( - now.temperature.value, - "\xb0", # Unicode character for degree symbol - now.temperature.units, - ) -) +print(now["significantWeatherCode"]) +print(f"{now['screenTemperature']['value']} {now['screenTemperature']['unit_symbol']}") diff --git a/examples/postcodes_to_lat_lng/postcodes_to_lat_lng.py b/examples/postcodes_to_lat_lng/postcodes_to_lat_lng.py index 8f9af82..a17f81b 100644 --- a/examples/postcodes_to_lat_lng/postcodes_to_lat_lng.py +++ b/examples/postcodes_to_lat_lng/postcodes_to_lat_lng.py @@ -8,7 +8,7 @@ import datapoint # Create datapoint connection -conn = datapoint.Manager(api_key="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") +manager = datapoint.Manager(api_key="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") # Get longitude and latitude from postcode @@ -17,21 +17,10 @@ latitude = postcode["result"]["latitude"] longitude = postcode["result"]["longitude"] -# Get nearest site and print out its name -site = conn.get_nearest_forecast_site(latitude, longitude) -print(site.name) - # Get a forecast for the nearest site -forecast = conn.get_forecast_for_site(site.location_id, "3hourly") +forecast = manager.get_forecast(longitude, latitude, "hourly") # Get the current timestep using now() and print out some info now = forecast.now() -print(now.weather.text) -print( - "%s%s%s" - % ( - now.temperature.value, - "\xb0", # Unicode character for degree symbol - now.temperature.units, - ) -) +print(now["significantWeatherCode"]) +print(f"{now['screenTemperature']['value']} {now['screenTemperature']['unit_symbol']}") diff --git a/examples/text_forecast/text_forecast.py b/examples/text_forecast/text_forecast.py deleted file mode 100644 index aaf68f8..0000000 --- a/examples/text_forecast/text_forecast.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python -""" -This example will print out the 30 day text forecast for a region of the UK. -""" - -import datapoint - -# Create datapoint connection -conn = datapoint.Manager(api_key="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") - -# Get all regions and print out their details -regions = conn.regions.get_all_regions() -for region in regions: - print((region.name, region.location_id, region.region)) - -# Get all forecasts for a specific region -my_region = regions[0] -forecast = conn.regions.get_raw_forecast(my_region.location_id)["RegionalFcst"] - -# Print the forecast details -print("Forecast for {} (issued at {}):".format(my_region.name, forecast["issuedAt"])) - -sections = forecast["FcstPeriods"]["Period"] -for section in forecast["FcstPeriods"]["Period"]: - paragraph = [] - content = section["Paragraph"] - - # Some paragraphs have multiple sections - if isinstance(content, dict): - paragraph.append(content) - else: - paragraph = content - - for line in paragraph: - print("{}\n{}\n".format(line["title"], line["$"])) diff --git a/examples/tube_bike/tube_bike.py b/examples/tube_bike/tube_bike.py index 97896fe..e485899 100644 --- a/examples/tube_bike/tube_bike.py +++ b/examples/tube_bike/tube_bike.py @@ -10,15 +10,11 @@ import datapoint # Create datapoint connection -conn = datapoint.Manager(api_key="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") - -# Get nearest site to my house and to work -my_house = conn.get_nearest_forecast_site(51.5016730, 0.0057500) -work = conn.get_nearest_forecast_site(51.5031650, -0.1123050) +manager = datapoint.Manager(api_key="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") # Get a forecast for my house and work -my_house_forecast = conn.get_forecast_for_site(my_house.location_id, "3hourly") -work_forecast = conn.get_forecast_for_site(work.location_id, "3hourly") +my_house_forecast = manager.get_forecast(51.5016730, 0.0057500, "hourly") +work_forecast = manager.get_forecast(51.5031650, -0.1123050, "hourly") # Get the current timestep for both locations my_house_now = my_house_forecast.now() @@ -32,22 +28,22 @@ # Check whether there are any problems with rain or the tube if ( - my_house_now.precipitation.value < 40 - and work_now.precipitation.value < 40 + my_house_now['probOfPrecipitation']['value'] < 40 + and work_now['probOfPrecipitation']['value'] < 40 and waterloo_status.description == "Good Service" ): print("Rain is unlikely and tube service is good, the decision is yours.") # If it is going to rain then suggest the tube elif ( - my_house_now.precipitation.value >= 40 or work_now.precipitation.value >= 40 + my_house_now['probOfPrecipitation']['value'] >= 40 or work_now['probOfPrecipitation']['value'] >= 40 ) and waterloo_status.description == "Good Service": print("Looks like rain, better get the tube") # If the tube isn't running then suggest cycling elif ( - my_house_now.precipitation.value < 40 - and work_now.precipitation.value < 40 + my_house_now['probOfPrecipitation']['value'] < 40 + and work_now['probOfPrecipitation']['value'] < 40 and waterloo_status.description != "Good Service" ): print("Bad service on the tube, cycling it is!") diff --git a/examples/umbrella/umbrella.py b/examples/umbrella/umbrella.py index 19c0fbe..55cabbe 100755 --- a/examples/umbrella/umbrella.py +++ b/examples/umbrella/umbrella.py @@ -4,29 +4,30 @@ today and then decides if we need to take an umbrella. """ +import datetime + import datapoint # Create umbrella variable to use later umbrella = False # Create datapoint connection -conn = datapoint.Manager(api_key="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") - -# Get nearest site and print out its name -site = conn.get_nearest_forecast_site(51.500728, -0.124626) -print(site.name) +manager = datapoint.Manager(api_key="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") # Get a forecast for the nearest site -forecast = conn.get_forecast_for_site(site.location_id, "3hourly") +forecast = manager.get_forecast(51.500728, -0.124626, "hourly") # Loop through all the timesteps in day 0 (today) -for timestep in forecast.days[0].timesteps: +for timestep in forecast.timesteps: # Check to see if the chance of rain is more than 20% at any point - if timestep.precipitation.value > 20: + if ( + timestep["probOfPrecipitation"]["value"] > 20 + and timestep["time"].date == datetime.date.now() + ): umbrella = True # Print out the results -if umbrella == True: +if umbrella is True: print("Looks like rain! Better take an umbrella.") else: print("Don't worry you don't need an umbrella today.") diff --git a/examples/washing/washing.py b/examples/washing/washing.py index cfc9eb8..8307254 100644 --- a/examples/washing/washing.py +++ b/examples/washing/washing.py @@ -17,19 +17,14 @@ MAX_PRECIPITATION = 20 # Max chance of rain we will accept # Variables for later -best_day = None -best_day_score = 0 # For simplicity the score will be temperature + wind speed +best_time = None +best_score = 0 # For simplicity the score will be temperature + wind speed # Create datapoint connection -conn = datapoint.Manager(api_key="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") - -# Get nearest site and print out its name -site = conn.get_nearest_forecast_site(51.500728, -0.124626) -print(site.name) - +manager = datapoint.Manager(api_key="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") # Get a forecast for the nearest site -forecast = conn.get_forecast_for_site(site.location_id, "daily") +forecast = manager.get_forecast(51.500728, -0.124626, "daily") # Loop through days for day in forecast.days: @@ -51,14 +46,28 @@ best_day_score = timestep_score best_day = day.date +for timestep in forecast.timesteps: + # If precipitation, wind speed and gust are less than their threshold + if ( + timestep.precipitation.value < MAX_PRECIPITATION + and timestep.wind_speed.value < MAX_WIND + and timestep.wind_gust.value < MAX_WIND + ): + # Calculate the score for this timestep + timestep_score = timestep['windSpeed10m']['value'] + timestep['screenTemperature']['value'] + + # If this timestep scores better than the current best replace it + if timestep_score > best_day_score: + best_score = timestep_score + best_time = timestep['time'] + + # If best_day is still None then there are no good days -if best_day is None: +if best_time is None: print("Better use the tumble dryer") # Otherwise print out the day else: - # Get the day of the week from the datetime object - best_day_formatted = datetime.strftime(best_day, "%A") print( - "%s is the best day with a score of %s" % (best_day_formatted, best_day_score) + f"{best_time} is the best day with a score of {best_score}" ) From 093f0bcb46539486d1dfa5d268ba19185fc920cc Mon Sep 17 00:00:00 2001 From: Emily Price Date: Thu, 14 Nov 2024 19:53:53 +0000 Subject: [PATCH 31/51] Add api-reference to docs --- docs/source/api-reference.rst | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 docs/source/api-reference.rst diff --git a/docs/source/api-reference.rst b/docs/source/api-reference.rst new file mode 100644 index 0000000..da16937 --- /dev/null +++ b/docs/source/api-reference.rst @@ -0,0 +1,8 @@ +API reference +============= + +.. automodule:: datapoint.Manager + :members: + +.. automodule:: datapoint.Forecast + :members: From dc2e67489cce6e5b8c123d849b357558560c5519 Mon Sep 17 00:00:00 2001 From: Emily Price Date: Thu, 14 Nov 2024 19:59:09 +0000 Subject: [PATCH 32/51] Add tests workflow --- .github/workflows/tests.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..38b11b4 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,21 @@ +name: Run tests +on: pull_request +jobs: + run-tests: + name: Build distribution 📦 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: | + 3.9 + 3.10 + 3.11 + 3.12 + 3.13 + - name: Install requirements + run: pip install -r requirements-dev.txt + - name: Run tests + run: python -m pytest tests From ba8aa437db5982664bec2a8bf8fe6c13ba9ced61 Mon Sep 17 00:00:00 2001 From: Emily Price Date: Thu, 14 Nov 2024 20:00:18 +0000 Subject: [PATCH 33/51] Install pytest directly in workflow --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 38b11b4..ddf0251 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,6 +16,6 @@ jobs: 3.12 3.13 - name: Install requirements - run: pip install -r requirements-dev.txt + run: pip install pytest - name: Run tests run: python -m pytest tests From 0217f43f638afebc4aeb2c60948975fce24c4cd6 Mon Sep 17 00:00:00 2001 From: Emily Price Date: Thu, 14 Nov 2024 20:04:35 +0000 Subject: [PATCH 34/51] Provide default tag to versioningit --- .github/workflows/tests.yml | 4 ++-- pyproject.toml | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ddf0251..85824ba 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,7 +2,7 @@ name: Run tests on: pull_request jobs: run-tests: - name: Build distribution 📦 + name: Run tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -16,6 +16,6 @@ jobs: 3.12 3.13 - name: Install requirements - run: pip install pytest + run: pip install -r requirements-dev.txt - name: Run tests run: python -m pytest tests diff --git a/pyproject.toml b/pyproject.toml index a34ba1d..1d29a1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,9 @@ distance = "{base_version}" dirty = "{base_version}" distance-dirty = "{base_version}" +[tool.versioningit.vcs] +default-tag = "test" + [tool.pytest.ini_options] addopts = [ "--import-mode=importlib", From 7e1dd828f9d1dea06b91a3bc165052e8d7285753 Mon Sep 17 00:00:00 2001 From: Emily Price Date: Thu, 14 Nov 2024 20:05:37 +0000 Subject: [PATCH 35/51] Correct format of default tag --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1d29a1c..01879ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dirty = "{base_version}" distance-dirty = "{base_version}" [tool.versioningit.vcs] -default-tag = "test" +default-tag = "0.0.1" [tool.pytest.ini_options] addopts = [ From 7aabff8ed29f14b9b16532185a28bb59aedb5d5e Mon Sep 17 00:00:00 2001 From: Emily Price Date: Thu, 14 Nov 2024 20:10:58 +0000 Subject: [PATCH 36/51] Add name to Forecast --- src/datapoint/Forecast.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/datapoint/Forecast.py b/src/datapoint/Forecast.py index 0aea250..444b9a1 100644 --- a/src/datapoint/Forecast.py +++ b/src/datapoint/Forecast.py @@ -118,6 +118,8 @@ def __init__(self, frequency, api_data): self.distance_from_requested_location = api_data["features"][0]["properties"][ "requestPointDistance" ] + self.name = api_data["features"][0]["properties"]["location"]["name"] + # N.B. Elevation is in metres above or below the WGS 84 reference # ellipsoid as per GeoJSON spec. self.elevation = api_data["features"][0]["geometry"]["coordinates"][2] From d156642e32765815e1f926a442ea61bdc9766915 Mon Sep 17 00:00:00 2001 From: Emily Price Date: Thu, 14 Nov 2024 20:19:09 +0000 Subject: [PATCH 37/51] Document members of Forecast class --- src/datapoint/Forecast.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/datapoint/Forecast.py b/src/datapoint/Forecast.py index 444b9a1..408c02b 100644 --- a/src/datapoint/Forecast.py +++ b/src/datapoint/Forecast.py @@ -111,21 +111,26 @@ def __init__(self, frequency, api_data): self.frequency = frequency self.data_date = datetime.datetime.fromisoformat( api_data["features"][0]["properties"]["modelRunDate"] - ) - - self.forecast_longitude = api_data["features"][0]["geometry"]["coordinates"][0] - self.forecast_latitude = api_data["features"][0]["geometry"]["coordinates"][1] + ) #: The date the provided forecast was generated. + + self.forecast_longitude = api_data["features"][0]["geometry"]["coordinates"][ + 0 + ] #: The longitude of the provided forecast. + self.forecast_latitude = api_data["features"][0]["geometry"]["coordinates"][ + 1 + ] #: The latitude of the provided forecast. self.distance_from_requested_location = api_data["features"][0]["properties"][ "requestPointDistance" - ] - self.name = api_data["features"][0]["properties"]["location"]["name"] + ] #: The distance of the location of the provided forecast from the requested location + self.name = api_data["features"][0]["properties"]["location"][ + "name" + ] #: The name of the location of the provided forecast # N.B. Elevation is in metres above or below the WGS 84 reference # ellipsoid as per GeoJSON spec. - self.elevation = api_data["features"][0]["geometry"]["coordinates"][2] - - # Need different parsing to cope with daily vs. hourly/three-hourly - # forecasts. Do hourly first + self.elevation = api_data["features"][0]["geometry"]["coordinates"][ + 2 + ] #: The elevation of the location of the provided forecast forecasts = api_data["features"][0]["properties"]["timeSeries"] parameters = api_data["parameters"][0] From dd99933830b85208956ddb28f83e30ed9f44f66b Mon Sep 17 00:00:00 2001 From: Emily Price Date: Sat, 16 Nov 2024 20:45:14 +0000 Subject: [PATCH 38/51] Add pre-commit and linting actions --- .coveragerc | 2 +- .flake8 | 4 ++-- .readthedocs.yaml | 8 ++++---- CHANGELOG.md | 2 +- docs/source/conf.py | 24 +++++++++++------------- examples/tube_bike/tube_bike.py | 14 ++++++++------ examples/washing/washing.py | 18 ++++++++---------- pyproject.toml | 2 +- src/datapoint/Forecast.py | 31 +++++++++++++++++++++++-------- src/datapoint/__init__.py | 1 - tests/unit/test_forecast.py | 24 +++++++++++++++--------- 11 files changed, 74 insertions(+), 56 deletions(-) diff --git a/.coveragerc b/.coveragerc index 34bb1ef..beeaca5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -11,4 +11,4 @@ exclude_lines = if __name__ == .__main__.: ignore_errors = True omit = - tests/* \ No newline at end of file + tests/* diff --git a/.flake8 b/.flake8 index 1884c6b..150fee0 100644 --- a/.flake8 +++ b/.flake8 @@ -1,6 +1,6 @@ [flake8] max-complexity = 10 -max-line-length = 80 +max-line-length = 88 extend-select = B950 extend-ignore = E203,E501,E701 -exclude = .git,__pycache__,build,dist \ No newline at end of file +exclude = .git,__pycache__,build,dist diff --git a/.readthedocs.yaml b/.readthedocs.yaml index a9460f3..e6832b1 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -12,7 +12,7 @@ build: # Build documentation in the "docs/" directory with Sphinx sphinx: - configuration: docs/conf.py + configuration: "docs/conf.py" # You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs # builder: "dirhtml" # Fail on all warnings to avoid broken references @@ -22,6 +22,6 @@ sphinx: # Optional but recommended, declare the Python requirements required # to build your documentation # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html - python: - install: - - requirements: docs/requirements.txt +python: + install: + - requirements: "docs/requirements.txt" diff --git a/CHANGELOG.md b/CHANGELOG.md index 227f66b..cdfb6d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [0.9.8] - 2020-07-03 + Remove f-string in test - + ## [0.9.7] - 2020-07-03 + Bugfix for `get_observation_sites` diff --git a/docs/source/conf.py b/docs/source/conf.py index cff2f6e..ce3dae7 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -5,30 +5,28 @@ # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information - -project = 'datapoint-python' -copyright = '2024, Emily Price, Jacob Tomlinson' -author = 'Emily Price, Jacob Tomlinson' - import importlib -import datapoint +project = "datapoint-python" +copyright = "2024, Emily Price, Jacob Tomlinson" +author = "Emily Price, Jacob Tomlinson" -release = importlib.metadata.version('datapoint') -version = importlib.metadata.version('datapoint') +release = importlib.metadata.version("datapoint") +version = importlib.metadata.version("datapoint") # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -extensions = ['sphinx.ext.autodoc',] +extensions = [ + "sphinx.ext.autodoc", +] -templates_path = ['_templates'] +templates_path = ["_templates"] exclude_patterns = [] - # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -html_theme = 'alabaster' -html_static_path = ['_static'] +html_theme = "alabaster" +html_static_path = ["_static"] diff --git a/examples/tube_bike/tube_bike.py b/examples/tube_bike/tube_bike.py index e485899..bdfde48 100644 --- a/examples/tube_bike/tube_bike.py +++ b/examples/tube_bike/tube_bike.py @@ -28,22 +28,23 @@ # Check whether there are any problems with rain or the tube if ( - my_house_now['probOfPrecipitation']['value'] < 40 - and work_now['probOfPrecipitation']['value'] < 40 + my_house_now["probOfPrecipitation"]["value"] < 40 + and work_now["probOfPrecipitation"]["value"] < 40 and waterloo_status.description == "Good Service" ): print("Rain is unlikely and tube service is good, the decision is yours.") # If it is going to rain then suggest the tube elif ( - my_house_now['probOfPrecipitation']['value'] >= 40 or work_now['probOfPrecipitation']['value'] >= 40 + my_house_now["probOfPrecipitation"]["value"] >= 40 + or work_now["probOfPrecipitation"]["value"] >= 40 ) and waterloo_status.description == "Good Service": print("Looks like rain, better get the tube") # If the tube isn't running then suggest cycling elif ( - my_house_now['probOfPrecipitation']['value'] < 40 - and work_now['probOfPrecipitation']['value'] < 40 + my_house_now["probOfPrecipitation"]["value"] < 40 + and work_now["probOfPrecipitation"]["value"] < 40 and waterloo_status.description != "Good Service" ): print("Bad service on the tube, cycling it is!") @@ -51,5 +52,6 @@ # Else if both are bad then suggest cycling in the rain else: print( - "The tube has poor service so you'll have to cycle, but it's raining so take your waterproofs." + "The tube has poor service so you'll have to cycle," + " but it's raining so take your waterproofs." ) diff --git a/examples/washing/washing.py b/examples/washing/washing.py index 8307254..c4df6ed 100644 --- a/examples/washing/washing.py +++ b/examples/washing/washing.py @@ -8,8 +8,6 @@ them and print out the best. """ -from datetime import datetime - import datapoint # Set thresholds @@ -42,8 +40,8 @@ timestep_score = timestep.wind_speed.value + timestep.temperature.value # If this timestep scores better than the current best replace it - if timestep_score > best_day_score: - best_day_score = timestep_score + if timestep_score > best_score: + best_score = timestep_score best_day = day.date for timestep in forecast.timesteps: @@ -54,12 +52,14 @@ and timestep.wind_gust.value < MAX_WIND ): # Calculate the score for this timestep - timestep_score = timestep['windSpeed10m']['value'] + timestep['screenTemperature']['value'] + timestep_score = ( + timestep["windSpeed10m"]["value"] + timestep["screenTemperature"]["value"] + ) # If this timestep scores better than the current best replace it - if timestep_score > best_day_score: + if timestep_score > best_score: best_score = timestep_score - best_time = timestep['time'] + best_time = timestep["time"] # If best_day is still None then there are no good days @@ -68,6 +68,4 @@ # Otherwise print out the day else: - print( - f"{best_time} is the best day with a score of {best_score}" - ) + print(f"{best_time} is the best day with a score of {best_score}") diff --git a/pyproject.toml b/pyproject.toml index 01879ca..f854103 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,4 +58,4 @@ default-tag = "0.0.1" [tool.pytest.ini_options] addopts = [ "--import-mode=importlib", -] \ No newline at end of file +] diff --git a/src/datapoint/Forecast.py b/src/datapoint/Forecast.py index 408c02b..003ac7e 100644 --- a/src/datapoint/Forecast.py +++ b/src/datapoint/Forecast.py @@ -124,13 +124,13 @@ def __init__(self, frequency, api_data): ] #: The distance of the location of the provided forecast from the requested location self.name = api_data["features"][0]["properties"]["location"][ "name" - ] #: The name of the location of the provided forecast + ] #: The name of the location of the provided forecast # N.B. Elevation is in metres above or below the WGS 84 reference # ellipsoid as per GeoJSON spec. self.elevation = api_data["features"][0]["geometry"]["coordinates"][ 2 - ] #: The elevation of the location of the provided forecast + ] #: The elevation of the location of the provided forecast forecasts = api_data["features"][0]["properties"]["timeSeries"] parameters = api_data["parameters"][0] @@ -260,7 +260,8 @@ def _check_requested_time(self, target): ] - datetime.timedelta(hours=0, minutes=30): err_str = ( "There is no forecast available for the requested time. " - + "The requested time is more than 30 minutes before the first available forecast" + "The requested time is more than 30 minutes before the " + "first available forecast." ) raise APIException(err_str) @@ -271,7 +272,8 @@ def _check_requested_time(self, target): ] - datetime.timedelta(hours=1, minutes=30): err_str = ( "There is no forecast available for the requested time. " - + "The requested time is more than 1 hour and 30 minutes before the first available forecast" + "The requested time is more than 1 hour and 30 minutes " + "before the first available forecast." ) raise APIException(err_str) @@ -282,7 +284,8 @@ def _check_requested_time(self, target): ] - datetime.timedelta(hours=6): err_str = ( "There is no forecast available for the requested time. " - + "The requested time is more than 6 hours before the first available forecast" + "The requested time is more than 6 hours before the first " + "available forecast." ) raise APIException(err_str) @@ -292,7 +295,11 @@ def _check_requested_time(self, target): if self.frequency == "hourly" and target > ( self.timesteps[-1]["time"] + datetime.timedelta(hours=0, minutes=30) ): - err_str = "There is no forecast available for the requested time. The requested time is more than 30 minutes after the first available forecast" + err_str = ( + "There is no forecast available for the requested time. The " + "requested time is more than 30 minutes after the first " + "available forecast" + ) raise APIException(err_str) @@ -301,7 +308,11 @@ def _check_requested_time(self, target): if self.frequency == "three-hourly" and target > ( self.timesteps[-1]["time"] + datetime.timedelta(hours=1, minutes=30) ): - err_str = "There is no forecast available for the requested time. The requested time is more than 1.5 hours after the first available forecast" + err_str = ( + "There is no forecast available for the requested time. The " + "requested time is more than 1.5 hours after the first " + "available forecast." + ) raise APIException(err_str) @@ -310,7 +321,11 @@ def _check_requested_time(self, target): if self.frequency == "daily" and target > ( self.timesteps[-1]["time"] + datetime.timedelta(hours=6) ): - err_str = "There is no forecast available for the requested time. The requested time is more than 6 hours after the first available forecast" + err_str = ( + "There is no forecast available for the requested time. The " + "requested time is more than 6 hours after the first available " + "forecast." + ) raise APIException(err_str) diff --git a/src/datapoint/__init__.py b/src/datapoint/__init__.py index 97b1ec8..e69de29 100644 --- a/src/datapoint/__init__.py +++ b/src/datapoint/__init__.py @@ -1 +0,0 @@ -from datapoint.Manager import Manager diff --git a/tests/unit/test_forecast.py b/tests/unit/test_forecast.py index 18b05ee..2a498b1 100644 --- a/tests/unit/test_forecast.py +++ b/tests/unit/test_forecast.py @@ -52,6 +52,7 @@ def hourly_first_forecast_and_parameters(load_hourly_json): forecast = load_hourly_json["features"][0]["properties"]["timeSeries"][0] return (forecast, parameters) + @pytest.fixture def three_hourly_first_forecast_and_parameters(load_three_hourly_json): parameters = load_three_hourly_json["parameters"][0] @@ -59,7 +60,6 @@ def three_hourly_first_forecast_and_parameters(load_three_hourly_json): return (forecast, parameters) - @pytest.fixture def expected_first_hourly_timestep(): return reference_data_test_forecast.EXPECTED_FIRST_HOURLY_TIMESTEP @@ -89,17 +89,21 @@ def expected_at_datetime_daily_timestep(): def expected_at_datetime_daily_final_timestep(): return reference_data_test_forecast.EXPECTED_AT_DATETIME_DAILY_FINAL_TIMESTEP + @pytest.fixture def expected_first_three_hourly_timestep(): return reference_data_test_forecast.EXPECTED_FIRST_THREE_HOURLY_TIMESTEP + @pytest.fixture def expected_at_datetime_three_hourly_timestep(): return reference_data_test_forecast.EXPECTED_AT_DATETIME_THREE_HOURLY_TIMESTEP + @pytest.fixture -def expected_at_datetime_three_hourly_final_timestep (): - return reference_data_test_forecast.EXPECTED_AT_DATETIME_THREE_HOURLY_FINAL_TIMESTEP +def expected_at_datetime_three_hourly_final_timestep(): + return reference_data_test_forecast.EXPECTED_AT_DATETIME_THREE_HOURLY_FINAL_TIMESTEP + class TestHourlyForecast: def test_forecast_frequency(self, hourly_forecast): @@ -231,7 +235,9 @@ def test_forecast_elevation(self, three_hourly_forecast): def test_forecast_first_timestep( self, three_hourly_forecast, expected_first_three_hourly_timestep ): - assert three_hourly_forecast.timesteps[0] == expected_first_three_hourly_timestep + assert ( + three_hourly_forecast.timesteps[0] == expected_first_three_hourly_timestep + ) def test_build_timestep( self, @@ -246,17 +252,17 @@ def test_build_timestep( assert built_timestep == expected_first_three_hourly_timestep - def test_at_datetime(self, three_hourly_forecast, - expected_at_datetime_three_hourly_timestep - ): + def test_at_datetime( + self, three_hourly_forecast, expected_at_datetime_three_hourly_timestep + ): ts = three_hourly_forecast.at_datetime(datetime.datetime(2024, 2, 22, 19, 15)) assert ts == expected_at_datetime_three_hourly_timestep def test_at_datetime_final_timestamp( self, three_hourly_forecast, expected_at_datetime_three_hourly_final_timestep ): - ts = three_hourly_forecast.at_datetime(datetime.datetime(2024, 2, 24, 16)) - assert ts == expected_at_datetime_three_hourly_final_timestep + ts = three_hourly_forecast.at_datetime(datetime.datetime(2024, 2, 24, 16)) + assert ts == expected_at_datetime_three_hourly_final_timestep def test_requested_time_too_early(self, three_hourly_forecast): with pytest.raises(APIException): From e0f76185fa56f25e750416fef4efb1f01a864c96 Mon Sep 17 00:00:00 2001 From: Emily Price Date: Sat, 16 Nov 2024 20:50:19 +0000 Subject: [PATCH 39/51] Add example data structure to docs --- docs/source/migration.rst | 86 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/docs/source/migration.rst b/docs/source/migration.rst index bf81c13..37501c1 100644 --- a/docs/source/migration.rst +++ b/docs/source/migration.rst @@ -25,7 +25,91 @@ Simplified object hierarchy Python dicts are used instead of classes to allow more flexibility with handling data returned from the MetOffice API, and because new MetOffice API provides -data with a more convenient structure. +data with a more convenient structure. The concept of 'Days' has also been +removed from the library and instead all time steps are provided in one list. +The data structure for a single time step is:: + + { + 'time': datetime.datetime(2024, 2, 19, 13, 0, tzinfo=datetime.timezone.utc), + 'screenTemperature': { + 'value': 10.09, + 'description': 'Screen Air Temperature', + 'unit_name': 'degrees Celsius', + 'unit_symbol': 'Cel' + }, + 'screenDewPointTemperature': { + 'value': 8.08, + 'description': 'Screen Dew Point Temperature', + 'unit_name': 'degrees Celsius', + 'unit_symbol': 'Cel' + }, + 'feelsLikeTemperature': { + 'value': 6.85, + 'description': 'Feels Like Temperature', + 'unit_name': 'degrees Celsius', + 'unit_symbol': 'Cel' + }, + 'windSpeed10m': { + 'value': 7.57, + 'description': '10m Wind Speed', + 'unit_name': 'metres per second', + 'unit_symbol': 'm/s' + }, + 'windDirectionFrom10m': { + 'value': 263, + 'description': '10m Wind From Direction', + 'unit_name': 'degrees', + 'unit_symbol': 'deg' + }, + 'windGustSpeed10m': { + 'value': 12.31, + 'description': '10m Wind Gust Speed', + 'unit_name': 'metres per second', + 'unit_symbol': 'm/s' + }, + 'visibility': { + 'value': 21201, + 'description': 'Visibility', + 'unit_name': 'metres', + 'unit_symbol': 'm' + }, + 'screenRelativeHumidity': { + 'value': 87.81, + 'description': 'Screen Relative Humidity', + 'unit_name': 'percentage', + 'unit_symbol': '%' + }, + 'mslp': { + 'value': 103080, + 'description': 'Mean Sea Level Pressure', + 'unit_name': 'pascals', + 'unit_symbol': 'Pa' + }, + 'uvIndex': { + 'value': 1, + 'description': 'UV Index', + 'unit_name': 'dimensionless', + 'unit_symbol': '1' + }, + 'significantWeatherCode': { + 'value': 'Cloudy', + 'description': 'Significant Weather Code', + 'unit_name': 'dimensionless', + 'unit_symbol': '1' + }, + 'precipitationRate': { + 'value': 0.0, + 'description': 'Precipitation Rate', + 'unit_name': 'millimetres per hour', + 'unit_symbol': 'mm/h' + }, + 'probOfPrecipitation': { + 'value': 21, + 'description': 'Probability of Precipitation', + 'unit_name': 'percentage', + 'unit_symbol': '%' + } + } Different data provided ----------------------- From 0c193ec26f484e08a580aa22bfdc271666901e76 Mon Sep 17 00:00:00 2001 From: Emily Price Date: Sat, 16 Nov 2024 20:52:54 +0000 Subject: [PATCH 40/51] Correct syntax in tests --- tests/integration/test_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_manager.py b/tests/integration/test_manager.py index 1000fe8..38d83a5 100644 --- a/tests/integration/test_manager.py +++ b/tests/integration/test_manager.py @@ -2,7 +2,7 @@ import requests import tests.reference_data.reference_data_test_forecast as reference_data_test_forecast -from datapoint import Manager +from datapoint.Manager import Manager class MockResponseHourly: From 0df199debee3446065b4c58e3ae55074b4f1be9d Mon Sep 17 00:00:00 2001 From: Emily Price Date: Sat, 16 Nov 2024 20:54:50 +0000 Subject: [PATCH 41/51] Correct imports in docstrings --- src/datapoint/Forecast.py | 2 +- src/datapoint/Manager.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/datapoint/Forecast.py b/src/datapoint/Forecast.py index 003ac7e..daf0877 100644 --- a/src/datapoint/Forecast.py +++ b/src/datapoint/Forecast.py @@ -15,7 +15,7 @@ class Forecast: Basic Usage:: >>> import datapoint - >>> m = datapoint.Manager(api_key = "blah") + >>> m = datapoint.Manager.Manager(api_key = "blah") >>> f = m.get_forecast(latitude=50, longitude=0, frequency="hourly") >>> f.now() { diff --git a/src/datapoint/Manager.py b/src/datapoint/Manager.py index c609129..b2f01de 100644 --- a/src/datapoint/Manager.py +++ b/src/datapoint/Manager.py @@ -17,7 +17,7 @@ class Manager: :: >>> import datapoint - >>> m = datapoint.Manager(api_key = "blah") + >>> m = datapoint.Manager.Manager(api_key = "blah") >>> f = m.get_forecast(latitude=50, longitude=0, frequency="hourly") >>> f.now() { From 11ae560b50dcde2080ecaa638ba25af9375fdce2 Mon Sep 17 00:00:00 2001 From: Emily Price Date: Sat, 16 Nov 2024 20:58:07 +0000 Subject: [PATCH 42/51] Use default versioningit format --- pyproject.toml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f854103..8d0c612 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,11 +47,6 @@ source = "versioningit" profile = "black" src_paths = ["src", "tests"] -[tool.versioningit.format] -distance = "{base_version}" -dirty = "{base_version}" -distance-dirty = "{base_version}" - [tool.versioningit.vcs] default-tag = "0.0.1" From 37a80b03f0adda553f69f93bad2b4fa4ec13a310 Mon Sep 17 00:00:00 2001 From: Emily Price Date: Sat, 16 Nov 2024 21:01:13 +0000 Subject: [PATCH 43/51] Set versioningit distance dirty to put 'post' in local version segment --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 8d0c612..21c484e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,9 @@ source = "versioningit" profile = "black" src_paths = ["src", "tests"] +[tool.versioningit.format] +distance-dirty = "{base_version}+post{distance}{vcs}{rev}.d{build_date:%Y%m%d}" + [tool.versioningit.vcs] default-tag = "0.0.1" From 693437720a295c6e3d2e3cd471cad076e20063d9 Mon Sep 17 00:00:00 2001 From: Emily Price Date: Sat, 16 Nov 2024 21:02:57 +0000 Subject: [PATCH 44/51] Put 'post' in local segment for versioningit distance --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 21c484e..1e1c812 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,8 @@ profile = "black" src_paths = ["src", "tests"] [tool.versioningit.format] + +distance = "{base_version}+post{distance}{vcs}{rev}" distance-dirty = "{base_version}+post{distance}{vcs}{rev}.d{build_date:%Y%m%d}" [tool.versioningit.vcs] From 24060ecfda76cabb9f954d46f6b93a7d618b3883 Mon Sep 17 00:00:00 2001 From: Emily Price Date: Sat, 16 Nov 2024 21:07:47 +0000 Subject: [PATCH 45/51] Use matrix strategy for tests --- .github/workflows/tests.yml | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 85824ba..c251080 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,20 +2,17 @@ name: Run tests on: pull_request jobs: run-tests: - name: Run tests + name: Run tests on python ${{ matrix.python-version }} runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: | - 3.9 - 3.10 - 3.11 - 3.12 - 3.13 - - name: Install requirements - run: pip install -r requirements-dev.txt - - name: Run tests - run: python -m pytest tests + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + python-version: ${{ matrix.python-version }} + - name: Install requirements + run: pip install -r requirements-dev.txt + - name: Run tests + run: python -m pytest tests From 7353653600aa4da85fde13d94e342868641e14ff Mon Sep 17 00:00:00 2001 From: Emily Price Date: Sat, 16 Nov 2024 21:08:15 +0000 Subject: [PATCH 46/51] Remove travis badge from README --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 25e83d8..2290fb1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # _DataPoint for Python_ [![PyPi version](https://img.shields.io/pypi/v/datapoint.svg)](https://pypi.python.org/pypi/datapoint/) [![Supported Python versions](https://img.shields.io/pypi/pyversions/datapoint.svg)](https://pypi.python.org/pypi/datapoint/) -[![Build Status](http://img.shields.io/travis/ejep/datapoint-python.svg?style=flat)](https://travis-ci.org/ejep/datapoint-python) [![Documentation Status](https://readthedocs.org/projects/datapoint-python/badge/?version=latest)](https://readthedocs.org/projects/datapoint-python/) From 6a3a9633c76e3861741439cfe65872a24a69b6f1 Mon Sep 17 00:00:00 2001 From: Emily Price Date: Sat, 16 Nov 2024 21:10:05 +0000 Subject: [PATCH 47/51] Correct tests workflow syntax --- .github/workflows/tests.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c251080..e12942f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,7 +11,8 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 - python-version: ${{ matrix.python-version }} + with: + python-version: ${{ matrix.python-version }} - name: Install requirements run: pip install -r requirements-dev.txt - name: Run tests From 66ecef723b42935146772e16f722ffd15e3333d2 Mon Sep 17 00:00:00 2001 From: Emily Price Date: Sat, 16 Nov 2024 21:25:39 +0000 Subject: [PATCH 48/51] Use explicit strptime for old python --- src/datapoint/Forecast.py | 58 +++++++++++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/src/datapoint/Forecast.py b/src/datapoint/Forecast.py index daf0877..9a5680e 100644 --- a/src/datapoint/Forecast.py +++ b/src/datapoint/Forecast.py @@ -1,4 +1,5 @@ import datetime +from platform import python_version from datapoint.exceptions import APIException from datapoint.weather_codes import WEATHER_CODES @@ -109,9 +110,21 @@ def __init__(self, frequency, api_data): :type api_data: dict """ self.frequency = frequency - self.data_date = datetime.datetime.fromisoformat( - api_data["features"][0]["properties"]["modelRunDate"] - ) #: The date the provided forecast was generated. + if python_version() < "3.11": + # Need to parse format like 2024-02-17T15:00Z. This can only be + # done with datetime.datetime.fromisoformat from python 3.11 + # onwards. Using this if statement to remember to remove the + # explicit strptime in the future, and it annoyed me that + # fromisoformat couldn't handle all iso-formatted datetimes. + data_date = datetime.datetime.strptime( + api_data["features"][0]["properties"]["modelRunDate"], + "%Y-%m-%dT%H:%M%z", + ) + else: + data_date = datetime.datetime.fromisoformat( + api_data["features"][0]["properties"]["modelRunDate"] + ) + self.data_date = data_date #: The date the provided forecast was generated. self.forecast_longitude = api_data["features"][0]["geometry"]["coordinates"][ 0 @@ -160,11 +173,28 @@ def _build_timesteps_from_daily(self, forecasts, parameters): timesteps = [] for forecast in forecasts: - night_step = {"time": datetime.datetime.fromisoformat(forecast["time"])} - day_step = { - "time": datetime.datetime.fromisoformat(forecast["time"]) - + datetime.timedelta(hours=12) - } + if python_version() < "3.11": + # Need to parse format like 2024-02-17T15:00Z. This can only be + # done with datetime.datetime.fromisoformat from python 3.11 + # onwards. Using this if statement to remember to remove the + # explicit strptime in the future, and it annoyed me that + # fromisoformat couldn't handle all iso-formatted datetimes. + night_step = datetime.datetime.strptime( + forecast["time"], "%Y-%m-%dT%H:%M%z" + ) + day_step = { + "time": datetime.datetime.strptime( + forecast["time"], "%Y-%m-%dT%H:%M%z" + ) + + datetime.timedelta(hours=12) + } + + else: + night_step = {"time": datetime.datetime.fromisoformat(forecast["time"])} + day_step = { + "time": datetime.datetime.fromisoformat(forecast["time"]) + + datetime.timedelta(hours=12) + } for element, value in forecast.items(): if element.startswith("midday"): @@ -228,7 +258,17 @@ def _build_timestep(self, forecast, parameters): timestep = {} for element, value in forecast.items(): if element == "time": - timestep["time"] = datetime.datetime.fromisoformat(forecast["time"]) + if python_version() < "3.11": + # Need to parse format like 2024-02-17T15:00Z. This can only be + # done with datetime.datetime.fromisoformat from python 3.11 + # onwards. Using this if statement to remember to remove the + # explicit strptime in the future. + timestep = datetime.datetime.strptime( + forecast["time"], "%Y-%m-%dT%H:%M%z" + ) + else: + timestep["time"] = datetime.datetime.fromisoformat(forecast["time"]) + elif element == "significantWeatherCode": timestep[element] = { "value": WEATHER_CODES[str(value)], From 943f98ba7d149950bd5a7b87353c01c763d024a8 Mon Sep 17 00:00:00 2001 From: Emily Price Date: Sat, 16 Nov 2024 21:27:24 +0000 Subject: [PATCH 49/51] Correct syntax error --- src/datapoint/Forecast.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/datapoint/Forecast.py b/src/datapoint/Forecast.py index 9a5680e..8347b89 100644 --- a/src/datapoint/Forecast.py +++ b/src/datapoint/Forecast.py @@ -262,8 +262,9 @@ def _build_timestep(self, forecast, parameters): # Need to parse format like 2024-02-17T15:00Z. This can only be # done with datetime.datetime.fromisoformat from python 3.11 # onwards. Using this if statement to remember to remove the - # explicit strptime in the future. - timestep = datetime.datetime.strptime( + # explicit strptime in the future, and it annoyed me that + # fromisoformat couldn't handle all iso-formatted datetimes. + timestep["time"] = datetime.datetime.strptime( forecast["time"], "%Y-%m-%dT%H:%M%z" ) else: From f237fcb9c79038f116915455a7094f4718e14447 Mon Sep 17 00:00:00 2001 From: Emily Price Date: Sat, 16 Nov 2024 21:28:49 +0000 Subject: [PATCH 50/51] Correct syntax error --- src/datapoint/Forecast.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/datapoint/Forecast.py b/src/datapoint/Forecast.py index 8347b89..6e680a8 100644 --- a/src/datapoint/Forecast.py +++ b/src/datapoint/Forecast.py @@ -179,9 +179,11 @@ def _build_timesteps_from_daily(self, forecasts, parameters): # onwards. Using this if statement to remember to remove the # explicit strptime in the future, and it annoyed me that # fromisoformat couldn't handle all iso-formatted datetimes. - night_step = datetime.datetime.strptime( - forecast["time"], "%Y-%m-%dT%H:%M%z" - ) + night_step = { + "time": datetime.datetime.strptime( + forecast["time"], "%Y-%m-%dT%H:%M%z" + ) + } day_step = { "time": datetime.datetime.strptime( forecast["time"], "%Y-%m-%dT%H:%M%z" From 8b39ec794817755859f9e4f1920b0c118e7310f6 Mon Sep 17 00:00:00 2001 From: Emily Price Date: Sat, 16 Nov 2024 21:35:33 +0000 Subject: [PATCH 51/51] Just use strptime for all python versions --- src/datapoint/Forecast.py | 74 ++++++++++++--------------------------- 1 file changed, 23 insertions(+), 51 deletions(-) diff --git a/src/datapoint/Forecast.py b/src/datapoint/Forecast.py index 6e680a8..e38594b 100644 --- a/src/datapoint/Forecast.py +++ b/src/datapoint/Forecast.py @@ -1,5 +1,4 @@ import datetime -from platform import python_version from datapoint.exceptions import APIException from datapoint.weather_codes import WEATHER_CODES @@ -110,21 +109,13 @@ def __init__(self, frequency, api_data): :type api_data: dict """ self.frequency = frequency - if python_version() < "3.11": - # Need to parse format like 2024-02-17T15:00Z. This can only be - # done with datetime.datetime.fromisoformat from python 3.11 - # onwards. Using this if statement to remember to remove the - # explicit strptime in the future, and it annoyed me that - # fromisoformat couldn't handle all iso-formatted datetimes. - data_date = datetime.datetime.strptime( - api_data["features"][0]["properties"]["modelRunDate"], - "%Y-%m-%dT%H:%M%z", - ) - else: - data_date = datetime.datetime.fromisoformat( - api_data["features"][0]["properties"]["modelRunDate"] - ) - self.data_date = data_date #: The date the provided forecast was generated. + # Need to parse format like 2024-02-17T15:00Z. This can only be + # done with datetime.datetime.fromisoformat from python 3.11 + # onwards. + self.data_date = datetime.datetime.strptime( + api_data["features"][0]["properties"]["modelRunDate"], + "%Y-%m-%dT%H:%M%z", + ) #: The date the provided forecast was generated. self.forecast_longitude = api_data["features"][0]["geometry"]["coordinates"][ 0 @@ -173,30 +164,16 @@ def _build_timesteps_from_daily(self, forecasts, parameters): timesteps = [] for forecast in forecasts: - if python_version() < "3.11": - # Need to parse format like 2024-02-17T15:00Z. This can only be - # done with datetime.datetime.fromisoformat from python 3.11 - # onwards. Using this if statement to remember to remove the - # explicit strptime in the future, and it annoyed me that - # fromisoformat couldn't handle all iso-formatted datetimes. - night_step = { - "time": datetime.datetime.strptime( - forecast["time"], "%Y-%m-%dT%H:%M%z" - ) - } - day_step = { - "time": datetime.datetime.strptime( - forecast["time"], "%Y-%m-%dT%H:%M%z" - ) - + datetime.timedelta(hours=12) - } - - else: - night_step = {"time": datetime.datetime.fromisoformat(forecast["time"])} - day_step = { - "time": datetime.datetime.fromisoformat(forecast["time"]) - + datetime.timedelta(hours=12) - } + # Need to parse format like 2024-02-17T15:00Z. This can only be + # done with datetime.datetime.fromisoformat from python 3.11 + # onwards. + night_step = { + "time": datetime.datetime.strptime(forecast["time"], "%Y-%m-%dT%H:%M%z") + } + day_step = { + "time": datetime.datetime.strptime(forecast["time"], "%Y-%m-%dT%H:%M%z") + + datetime.timedelta(hours=12) + } for element, value in forecast.items(): if element.startswith("midday"): @@ -260,17 +237,12 @@ def _build_timestep(self, forecast, parameters): timestep = {} for element, value in forecast.items(): if element == "time": - if python_version() < "3.11": - # Need to parse format like 2024-02-17T15:00Z. This can only be - # done with datetime.datetime.fromisoformat from python 3.11 - # onwards. Using this if statement to remember to remove the - # explicit strptime in the future, and it annoyed me that - # fromisoformat couldn't handle all iso-formatted datetimes. - timestep["time"] = datetime.datetime.strptime( - forecast["time"], "%Y-%m-%dT%H:%M%z" - ) - else: - timestep["time"] = datetime.datetime.fromisoformat(forecast["time"]) + # Need to parse format like 2024-02-17T15:00Z. This can only be + # done with datetime.datetime.fromisoformat from python 3.11 + # onwards. + timestep["time"] = datetime.datetime.strptime( + forecast["time"], "%Y-%m-%dT%H:%M%z" + ) elif element == "significantWeatherCode": timestep[element] = {