From 7ca85bb063f760eaee443a2d601802f144e6b2de Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Tue, 15 Jul 2025 09:44:36 +0200 Subject: [PATCH 01/23] Create meteonorm.py --- pvlib/iotools/meteonorm.py | 179 +++++++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 pvlib/iotools/meteonorm.py diff --git a/pvlib/iotools/meteonorm.py b/pvlib/iotools/meteonorm.py new file mode 100644 index 0000000000..d9897b8ceb --- /dev/null +++ b/pvlib/iotools/meteonorm.py @@ -0,0 +1,179 @@ +"""Functions for reading and retrieving data from Meteonorm.""" + +import pandas as pd +import requests +from urllib.parse import urljoin + +URL = 'https://api.meteonorm.com/v1/' + +VARIABLE_MAP = { + 'global_horizontal_irradiance': 'ghi', + 'diffuse_horizontal_irradiance': 'dhi', + 'direct_normal_irradiance': 'dni', + 'direct_horizontal_irradiance': 'bhi', + 'global_clear_sky_irradiance': 'ghi_clear', + 'diffuse_tilted_irradiance': 'poa_diffuse', + 'direct_tilted_irradiance': 'poa_direct', + 'global_tilted_irradiance': 'poa', + 'temperature': 'temp_air', + 'dew_point_temperature': 'temp_dew', +} + +time_step_map = { + '1h': '1_hour', + 'h': '1_hour', + '15min': '15_minutes', + '1min': '1_minute', + 'min': '1_minute', +} + + +def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, + parameters="all", *, surface_tilt=0, surface_azimuth=180, + time_step='15min', horizon='auto', interval_index=False, + map_variables=True, url=URL): + """ + Retrieve irradiance and weather data from Meteonorm. + + The Meteonorm data options are described in [1]_ and the API is described + in [2]_. A detailed list of API options can be found in [3]_. + + This function supports the end points 'realtime' for data for the past 7 + days, 'training' for historical data with a delay of 7 days. The function + does not support TMY climate data. + + Parameters + ---------- + latitude: float + In decimal degrees, north is positive (ISO 19115). + longitude: float + In decimal degrees, east is positive (ISO 19115). + start: datetime like, optional + First timestamp of the requested period. If a timezone is not + specified, UTC is assumed. A relative datetime string is also allowed. + end: datetime like, optional + Last timestamp of the requested period. If a timezone is not + specified, UTC is assumed. A relative datetime string is also allowed. + api_key: str + Meteonorm API key. + endpoint : str + API end point, see [3]_. Must be one of: + + * '/observation/training' + * '/observation/realtime' + * '/forecast/basic' + * '/forecast/precision' + + parameters : list, optional + List of parameters to request or "all" to get all parameters. The + default is "all". + surface_tilt: float, default: 0 + Tilt angle from horizontal plane. + surface_azimuth: float, default: 180 + Orientation (azimuth angle) of the (fixed) plane. Clockwise from north + (north=0, east=90, south=180, west=270). + time_step : {'1min', '15min', '1h'}, optional + ime step of the time series. The default is '15min'. Ignored if + requesting forecast data. + horizon : optional + Specification of the hoirzon line. Can be either 'flat' or 'auto', or + specified as a list of 360 horizon elevation angles. The default is + 'auto'. + interval_index: bool, optional + Whether the index of the returned data object is of the type + pd.DatetimeIndex or pd.IntervalIndex. This is an experimental feature + which may be removed without warning. The default is False. + map_variables: bool, default: True + When true, renames columns of the Dataframe to pvlib variable names + where applicable. The default is True. See variable + :const:`VARIABLE_MAP`. + url: str, default: :const:`pvlib.iotools.meteonorm.URL` + Base url of the Meteonorm API. The ``endpoint`` parameter is + appended to the url. + + Raises + ------ + requests.HTTPError + Raises an error when an incorrect request is made. + + Returns + ------- + data : pd.DataFrame + Time series data. The index corresponds to the start (left) of the + interval. + meta : dict + Metadata. + + See Also + -------- + pvlib.iotools.get_meteonorm_tmy + + References + ---------- + .. [1] `Meteonorm + `_ + .. [2] `Meteonorm API + `_ + .. [3] `Meteonorm API reference + `_ + """ + start = pd.Timestamp(start) + end = pd.Timestamp(end) + start = start.tz_localize('UTC') if start.tzinfo is None else start + end = end.tz_localize('UTC') if end.tzinfo is None else end + + params = { + 'lat': latitude, + 'lon': longitude, + 'start': start.strftime('%Y-%m-%dT%H:%M:%SZ'), + 'end': end.strftime('%Y-%m-%dT%H:%M:%SZ'), + 'surface_tilt': surface_tilt, + 'surface_azimuth': surface_azimuth, + 'horizon': horizon, + 'parameters': parameters, + } + + if 'forecast' not in endpoint.lower(): + params['frequency'] = time_step_map.get(time_step, time_step) + + # convert list to string with values separated by commas + if not isinstance(params['parameters'], (str, type(None))): + # allow the use of pvlib parameter names + parameter_dict = {v: k for k, v in VARIABLE_MAP.items()} + parameters = [parameter_dict.get(p, p) for p in parameters] + params['parameters'] = ','.join(parameters) + + headers = {"Authorization": f"Bearer {api_key}"} + + response = requests.get(urljoin(url, endpoint), headers=headers, params=params) + + if not response.ok: + # response.raise_for_status() does not give a useful error message + raise requests.HTTPError(response.json()) + + data_json = response.json()['values'] + # identify empty columns + empty_columns = [k for k, v in data_json.items() if v is None] + # remove empty columns + _ = [data_json.pop(k) for k in empty_columns] + + data = pd.DataFrame(data_json) + + # xxx: experimental feature - see parameter description + if interval_index: + data.index = pd.IntervalIndex.from_arrays( + left=pd.to_datetime(response.json()['start_times']), + right=pd.to_datetime(response.json()['end_times']), + closed='both', + ) + else: + data.index = pd.to_datetime(response.json()['start_times']) + + meta = response.json()['meta'] + + if map_variables: + data = data.rename(columns=VARIABLE_MAP) + meta['latitude'] = meta.pop('lat') + meta['longitude'] = meta.pop('lon') + + return data, meta From 95c260245976c42dd9a62c372529d92f20f445bb Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Tue, 15 Jul 2025 13:07:02 +0200 Subject: [PATCH 02/23] Add get_meteonorm_tmy --- docs/sphinx/source/reference/iotools.rst | 10 ++ docs/sphinx/source/whatsnew/v0.13.1.rst | 4 +- pvlib/iotools/__init__.py | 2 + pvlib/iotools/meteonorm.py | 208 +++++++++++++++++++++-- 4 files changed, 205 insertions(+), 19 deletions(-) diff --git a/docs/sphinx/source/reference/iotools.rst b/docs/sphinx/source/reference/iotools.rst index cbf89c71a7..39ec0f70e9 100644 --- a/docs/sphinx/source/reference/iotools.rst +++ b/docs/sphinx/source/reference/iotools.rst @@ -81,6 +81,16 @@ Commercial datasets Accessing these APIs typically requires payment. Datasets provide near-global coverage. +Meteonorm +********* + +.. autosummary:: + :toctree: generated/ + + iotools.get_meteonorm + iotools.get_meteonorm_tmy + + SolarAnywhere ************* diff --git a/docs/sphinx/source/whatsnew/v0.13.1.rst b/docs/sphinx/source/whatsnew/v0.13.1.rst index 9c50d00bbb..fd6b89cbcb 100644 --- a/docs/sphinx/source/whatsnew/v0.13.1.rst +++ b/docs/sphinx/source/whatsnew/v0.13.1.rst @@ -19,7 +19,9 @@ Bug fixes Enhancements ~~~~~~~~~~~~ - +* Add iotools functions to retrieve irradiance and weather data from Meteonorm: + :py:func:`~pvlib.iotools.get_meteonorm` and :py:func:`~pvlib.iotools.get_meteonorm_tmy`. + (:pull:`2499`) Documentation ~~~~~~~~~~~~~ diff --git a/pvlib/iotools/__init__.py b/pvlib/iotools/__init__.py index 352044e5cd..e3ecd441cd 100644 --- a/pvlib/iotools/__init__.py +++ b/pvlib/iotools/__init__.py @@ -39,3 +39,5 @@ from pvlib.iotools.solcast import get_solcast_historic # noqa: F401 from pvlib.iotools.solcast import get_solcast_tmy # noqa: F401 from pvlib.iotools.solargis import get_solargis # noqa: F401 +from pvlib.iotools.meteonorm import get_meteonorm # noqa: F401 +from pvlib.iotools.meteonorm import get_meteonorm_tmy # noqa: F401 diff --git a/pvlib/iotools/meteonorm.py b/pvlib/iotools/meteonorm.py index d9897b8ceb..08a94ea2a5 100644 --- a/pvlib/iotools/meteonorm.py +++ b/pvlib/iotools/meteonorm.py @@ -29,7 +29,7 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, - parameters="all", *, surface_tilt=0, surface_azimuth=180, + parameters='all', *, surface_tilt=0, surface_azimuth=180, time_step='15min', horizon='auto', interval_index=False, map_variables=True, url=URL): """ @@ -38,9 +38,7 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, The Meteonorm data options are described in [1]_ and the API is described in [2]_. A detailed list of API options can be found in [3]_. - This function supports the end points 'realtime' for data for the past 7 - days, 'training' for historical data with a delay of 7 days. The function - does not support TMY climate data. + This function supports both historical and forecast data, but not TMY. Parameters ---------- @@ -57,15 +55,15 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, api_key: str Meteonorm API key. endpoint : str - API end point, see [3]_. Must be one of: + API endpoint, see [3]_. Must be one of: - * '/observation/training' - * '/observation/realtime' - * '/forecast/basic' - * '/forecast/precision' + * '/observation/training' - historical data with a 7-day delay + * '/observation/realtime' - near-real time (past 7-days) + * '/forecast/basic' - forcasts with hourly resolution + * '/forecast/precision' - forecsat with 15-min resolution parameters : list, optional - List of parameters to request or "all" to get all parameters. The + List of parameters to request or 'all' to get all parameters. The default is "all". surface_tilt: float, default: 0 Tilt angle from horizontal plane. @@ -73,12 +71,11 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270). time_step : {'1min', '15min', '1h'}, optional - ime step of the time series. The default is '15min'. Ignored if - requesting forecast data. + Frequency of the time series. The default is '15min'. The parameter is + ignored if forcasting data is requested. horizon : optional - Specification of the hoirzon line. Can be either 'flat' or 'auto', or - specified as a list of 360 horizon elevation angles. The default is - 'auto'. + Specification of the horizon line. Can be either a flat, 'auto', or + a list of 360 horizon elevation angles. The default is 'auto'. interval_index: bool, optional Whether the index of the returned data object is of the type pd.DatetimeIndex or pd.IntervalIndex. This is an experimental feature @@ -87,9 +84,10 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, When true, renames columns of the Dataframe to pvlib variable names where applicable. The default is True. See variable :const:`VARIABLE_MAP`. - url: str, default: :const:`pvlib.iotools.meteonorm.URL` + url: str, optional Base url of the Meteonorm API. The ``endpoint`` parameter is - appended to the url. + appended to the url. The default is + :const:`pvlib.iotools.meteonorm.URL`. Raises ------ @@ -145,7 +143,181 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, headers = {"Authorization": f"Bearer {api_key}"} - response = requests.get(urljoin(url, endpoint), headers=headers, params=params) + response = requests.get( + urljoin(url, endpoint), headers=headers, params=params) + + if not response.ok: + # response.raise_for_status() does not give a useful error message + raise requests.HTTPError(response.json()) + + data_json = response.json()['values'] + # identify empty columns + empty_columns = [k for k, v in data_json.items() if v is None] + # remove empty columns + _ = [data_json.pop(k) for k in empty_columns] + + data = pd.DataFrame(data_json) + + # xxx: experimental feature - see parameter description + if interval_index: + data.index = pd.IntervalIndex.from_arrays( + left=pd.to_datetime(response.json()['start_times']), + right=pd.to_datetime(response.json()['end_times']), + closed='both', + ) + else: + data.index = pd.to_datetime(response.json()['start_times']) + + meta = response.json()['meta'] + + if map_variables: + data = data.rename(columns=VARIABLE_MAP) + meta['latitude'] = meta.pop('lat') + meta['longitude'] = meta.pop('lon') + + return data, meta + + +def get_meteonorm_tmy(latitude, longitude, api_key, + parameters='all', *, surface_tilt=0, + surface_azimuth=180, time_step='15min', horizon='auto', + terrain='open', albedo=0.2, turbidity='auto', + random_seed=None, clear_sky_radiation_model='esra', + data_version='latest', future_scenario=None, + future_year=None, interval_index=False, + map_variables=True, url=URL): + """ + Retrieve irradiance and weather data from Meteonorm. + + The Meteonorm data options are described in [1]_ and the API is described + in [2]_. A detailed list of API options can be found in [3]_. + + This function supports the endpoints 'realtime' for data for the past 7 + days, 'training' for historical data with a delay of 7 days. The function + does not support TMY climate data. + + Parameters + ---------- + latitude: float + In decimal degrees, north is positive (ISO 19115). + longitude: float + In decimal degrees, east is positive (ISO 19115). + api_key: str + Meteonorm API key. + parameters: list, optional + List of parameters to request or 'all' to get all parameters. The + default is 'all'. + surface_tilt: float, default: 0 + Tilt angle from horizontal plane. + surface_azimuth : float, default: 180 + Orientation (azimuth angle) of the (fixed) plane. Clockwise from north + (north=0, east=90, south=180, west=270). + time_step: {'1min', '1h'}, optional + Frequency of the time series. The default is '1h'. + horizon: optional + Specification of the hoirzon line. Can be either 'flat' or 'auto', or + specified as a list of 360 horizon elevation angles. The default is + 'auto'. + terrain: string, optional + Local terrain situation. Must be one of: ['open', 'depression', + 'cold_air_lake', 'sea_lake', 'city', 'slope_south', + 'slope_west_east']. The default is 'open'. + albedo: float, optional + Ground albedo. Albedo changes due to snow fall are modelled. The + default is 0.2. + turbidity: list or 'auto', optional + List of 12 monthly mean atmospheric Linke turbidity values. The default + is 'auto'. + random_seed: int, optional + Random seed to be used for stochastic processes. Two identical requests + with the same random seed will yield identical results. + clear_sky_radiation_model : {'esra', 'solis'} + Which clearsky model to use. The default is 'esra'. + data_version : string, optional + Version of Meteonorm climatological data to be used. The default is + 'latest'. + future_scenario: string, optional + Future climate scenario. + future_year : integer, optional + Central year for a 20-year reference period in the future. + interval_index: bool, optional + Whether the index of the returned data object is of the type + pd.DatetimeIndex or pd.IntervalIndex. This is an experimental feature + which may be removed without warning. The default is False. + map_variables: bool, default: True + When true, renames columns of the Dataframe to pvlib variable names + where applicable. The default is True. See variable + :const:`VARIABLE_MAP`. + url: str, optional. + Base url of the Meteonorm API. 'climate/tmy'` is + appended to the url. The default is: + :const:`pvlib.iotools.meteonorm.URL`. + + Raises + ------ + requests.HTTPError + Raises an error when an incorrect request is made. + + Returns + ------- + data : pd.DataFrame + Time series data. The index corresponds to the start (left) of the + interval. + meta : dict + Metadata. + + See Also + -------- + pvlib.iotools.get_meteonorm + + References + ---------- + .. [1] `Meteonorm + `_ + .. [2] `Meteonorm API + `_ + .. [3] `Meteonorm API reference + `_ + """ + params = { + 'lat': latitude, + 'lon': longitude, + 'surface_tilt': surface_tilt, + 'surface_azimuth': surface_azimuth, + 'frequency': time_step, + 'parameters': parameters, + 'horizon': horizon, + 'terrain': terrain, + 'turbidity': turbidity, + 'clear_sky_radiation_model': clear_sky_radiation_model, + 'data_version': data_version, + } + + if turbidity != 'auto': + params['turbidity'] = ','.join(turbidity) + + if random_seed is not None: + params['random_seed'] = random_seed + + if future_scenario is not None: + params['future_scenario'] = future_scenario + + if future_year is not None: + params['future_year'] = future_year + + # convert list to string with values separated by commas + if not isinstance(params['parameters'], (str, type(None))): + # allow the use of pvlib parameter names + parameter_dict = {v: k for k, v in VARIABLE_MAP.items()} + parameters = [parameter_dict.get(p, p) for p in parameters] + params['parameters'] = ','.join(parameters) + + headers = {"Authorization": f"Bearer {api_key}"} + + endpoint = 'climate/tmy' + + response = requests.get( + urljoin(url, endpoint), headers=headers, params=params) if not response.ok: # response.raise_for_status() does not give a useful error message From 611294ec7909345c2314a4f80d4c2c23e08721e5 Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Wed, 16 Jul 2025 13:41:21 +0200 Subject: [PATCH 03/23] Add private shared parse function --- pvlib/iotools/meteonorm.py | 126 +++++++++++++++++-------------------- 1 file changed, 56 insertions(+), 70 deletions(-) diff --git a/pvlib/iotools/meteonorm.py b/pvlib/iotools/meteonorm.py index 08a94ea2a5..33a4e4dc9d 100644 --- a/pvlib/iotools/meteonorm.py +++ b/pvlib/iotools/meteonorm.py @@ -1,4 +1,4 @@ -"""Functions for reading and retrieving data from Meteonorm.""" +"""Functions for retrieving data from Meteonorm.""" import pandas as pd import requests @@ -38,7 +38,7 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, The Meteonorm data options are described in [1]_ and the API is described in [2]_. A detailed list of API options can be found in [3]_. - This function supports both historical and forecast data, but not TMY. + This function supports historical and forecast data, but not TMY. Parameters ---------- @@ -57,35 +57,35 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, endpoint : str API endpoint, see [3]_. Must be one of: - * '/observation/training' - historical data with a 7-day delay - * '/observation/realtime' - near-real time (past 7-days) - * '/forecast/basic' - forcasts with hourly resolution - * '/forecast/precision' - forecsat with 15-min resolution + * ``'/observation/training'`` - historical data with a 7-day delay + * ``'/observation/realtime'`` - near-real time (past 7-days) + * ``'/forecast/basic'`` - forcast with hourly resolution + * ``'/forecast/precision'`` - forecast with 15-min resolution parameters : list, optional List of parameters to request or 'all' to get all parameters. The - default is "all". - surface_tilt: float, default: 0 - Tilt angle from horizontal plane. - surface_azimuth: float, default: 180 + default is 'all'. + surface_tilt: float, optional + Tilt angle from horizontal plane. The default is 0. + surface_azimuth: float, optional Orientation (azimuth angle) of the (fixed) plane. Clockwise from north - (north=0, east=90, south=180, west=270). + (north=0, east=90, south=180, west=270). The default is 180. time_step : {'1min', '15min', '1h'}, optional - Frequency of the time series. The default is '15min'. The parameter is - ignored if forcasting data is requested. + Frequency of the time series. The parameter is ignored when requesting + forcasting data. The default is '15min'. horizon : optional - Specification of the horizon line. Can be either a flat, 'auto', or + Specification of the horizon line. Can be either a 'flat', 'auto', or a list of 360 horizon elevation angles. The default is 'auto'. interval_index: bool, optional Whether the index of the returned data object is of the type pd.DatetimeIndex or pd.IntervalIndex. This is an experimental feature which may be removed without warning. The default is False. - map_variables: bool, default: True + map_variables: bool, optional When true, renames columns of the Dataframe to pvlib variable names where applicable. The default is True. See variable :const:`VARIABLE_MAP`. url: str, optional - Base url of the Meteonorm API. The ``endpoint`` parameter is + Base URL of the Meteonorm API. The ``endpoint`` parameter is appended to the url. The default is :const:`pvlib.iotools.meteonorm.URL`. @@ -98,7 +98,7 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, ------- data : pd.DataFrame Time series data. The index corresponds to the start (left) of the - interval. + interval unless ``interval_index`` is set to False. meta : dict Metadata. @@ -125,15 +125,12 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, 'lon': longitude, 'start': start.strftime('%Y-%m-%dT%H:%M:%SZ'), 'end': end.strftime('%Y-%m-%dT%H:%M:%SZ'), + 'parameters': parameters, 'surface_tilt': surface_tilt, 'surface_azimuth': surface_azimuth, 'horizon': horizon, - 'parameters': parameters, } - if 'forecast' not in endpoint.lower(): - params['frequency'] = time_step_map.get(time_step, time_step) - # convert list to string with values separated by commas if not isinstance(params['parameters'], (str, type(None))): # allow the use of pvlib parameter names @@ -141,41 +138,27 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, parameters = [parameter_dict.get(p, p) for p in parameters] params['parameters'] = ','.join(parameters) + if horizon not in ['auto', 'flat']: + params['horizon'] = ','.join(horizon) + + if 'forecast' not in endpoint.lower(): + params['frequency'] = time_step_map.get(time_step, time_step) + headers = {"Authorization": f"Bearer {api_key}"} response = requests.get( urljoin(url, endpoint), headers=headers, params=params) - + print(response) if not response.ok: # response.raise_for_status() does not give a useful error message raise requests.HTTPError(response.json()) - data_json = response.json()['values'] - # identify empty columns - empty_columns = [k for k, v in data_json.items() if v is None] - # remove empty columns - _ = [data_json.pop(k) for k in empty_columns] - - data = pd.DataFrame(data_json) + data, meta = _parse_meteonorm(response, interval_index, map_variables) - # xxx: experimental feature - see parameter description - if interval_index: - data.index = pd.IntervalIndex.from_arrays( - left=pd.to_datetime(response.json()['start_times']), - right=pd.to_datetime(response.json()['end_times']), - closed='both', - ) - else: - data.index = pd.to_datetime(response.json()['start_times']) - - meta = response.json()['meta'] + return data, meta - if map_variables: - data = data.rename(columns=VARIABLE_MAP) - meta['latitude'] = meta.pop('lat') - meta['longitude'] = meta.pop('lon') - return data, meta +TMY_ENDPOINT = 'climate/tmy' def get_meteonorm_tmy(latitude, longitude, api_key, @@ -187,15 +170,11 @@ def get_meteonorm_tmy(latitude, longitude, api_key, future_year=None, interval_index=False, map_variables=True, url=URL): """ - Retrieve irradiance and weather data from Meteonorm. + Retrieve TMY irradiance and weather data from Meteonorm. The Meteonorm data options are described in [1]_ and the API is described in [2]_. A detailed list of API options can be found in [3]_. - This function supports the endpoints 'realtime' for data for the past 7 - days, 'training' for historical data with a delay of 7 days. The function - does not support TMY climate data. - Parameters ---------- latitude: float @@ -207,11 +186,11 @@ def get_meteonorm_tmy(latitude, longitude, api_key, parameters: list, optional List of parameters to request or 'all' to get all parameters. The default is 'all'. - surface_tilt: float, default: 0 - Tilt angle from horizontal plane. - surface_azimuth : float, default: 180 + surface_tilt: float, optional + Tilt angle from horizontal plane. The default is 0. + surface_azimuth : float, optional Orientation (azimuth angle) of the (fixed) plane. Clockwise from north - (north=0, east=90, south=180, west=270). + (north=0, east=90, south=180, west=270). The default is 180. time_step: {'1min', '1h'}, optional Frequency of the time series. The default is '1h'. horizon: optional @@ -244,13 +223,13 @@ def get_meteonorm_tmy(latitude, longitude, api_key, Whether the index of the returned data object is of the type pd.DatetimeIndex or pd.IntervalIndex. This is an experimental feature which may be removed without warning. The default is False. - map_variables: bool, default: True + map_variables: bool, optional When true, renames columns of the Dataframe to pvlib variable names - where applicable. The default is True. See variable - :const:`VARIABLE_MAP`. + where applicable. See variable :const:`VARIABLE_MAP`. The default is + True. url: str, optional. - Base url of the Meteonorm API. 'climate/tmy'` is - appended to the url. The default is: + Base URL of the Meteonorm API. 'climate/tmy'` is + appended to the URL. The default is: :const:`pvlib.iotools.meteonorm.URL`. Raises @@ -262,7 +241,7 @@ def get_meteonorm_tmy(latitude, longitude, api_key, ------- data : pd.DataFrame Time series data. The index corresponds to the start (left) of the - interval. + interval unless ``interval_index`` is set to False. meta : dict Metadata. @@ -293,6 +272,16 @@ def get_meteonorm_tmy(latitude, longitude, api_key, 'data_version': data_version, } + # convert list to string with values separated by commas + if not isinstance(params['parameters'], (str, type(None))): + # allow the use of pvlib parameter names + parameter_dict = {v: k for k, v in VARIABLE_MAP.items()} + parameters = [parameter_dict.get(p, p) for p in parameters] + params['parameters'] = ','.join(parameters) + + if horizon not in ['auto', 'flat']: + params['horizon'] = ','.join(horizon) + if turbidity != 'auto': params['turbidity'] = ','.join(turbidity) @@ -305,24 +294,21 @@ def get_meteonorm_tmy(latitude, longitude, api_key, if future_year is not None: params['future_year'] = future_year - # convert list to string with values separated by commas - if not isinstance(params['parameters'], (str, type(None))): - # allow the use of pvlib parameter names - parameter_dict = {v: k for k, v in VARIABLE_MAP.items()} - parameters = [parameter_dict.get(p, p) for p in parameters] - params['parameters'] = ','.join(parameters) - headers = {"Authorization": f"Bearer {api_key}"} - endpoint = 'climate/tmy' - response = requests.get( - urljoin(url, endpoint), headers=headers, params=params) + urljoin(url, TMY_ENDPOINT), headers=headers, params=params) if not response.ok: # response.raise_for_status() does not give a useful error message raise requests.HTTPError(response.json()) + data, meta = _parse_meteonorm(response, interval_index, map_variables) + + return data, meta + + +def _parse_meteonorm(response, interval_index, map_variables): data_json = response.json()['values'] # identify empty columns empty_columns = [k for k, v in data_json.items() if v is None] From 5c3c9bf65e0c9ee8a567178e3018150e479b0e8c Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Sat, 2 Aug 2025 15:50:23 +0200 Subject: [PATCH 04/23] Apply suggestions from code review Co-authored-by: Ioannis Sifnaios <88548539+IoannisSifnaios@users.noreply.github.com> --- pvlib/iotools/meteonorm.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pvlib/iotools/meteonorm.py b/pvlib/iotools/meteonorm.py index 33a4e4dc9d..df5106208a 100644 --- a/pvlib/iotools/meteonorm.py +++ b/pvlib/iotools/meteonorm.py @@ -212,12 +212,12 @@ def get_meteonorm_tmy(latitude, longitude, api_key, with the same random seed will yield identical results. clear_sky_radiation_model : {'esra', 'solis'} Which clearsky model to use. The default is 'esra'. - data_version : string, optional + data_version : str, optional Version of Meteonorm climatological data to be used. The default is 'latest'. - future_scenario: string, optional + future_scenario: str, optional Future climate scenario. - future_year : integer, optional + future_year : int, optional Central year for a 20-year reference period in the future. interval_index: bool, optional Whether the index of the returned data object is of the type @@ -228,7 +228,7 @@ def get_meteonorm_tmy(latitude, longitude, api_key, where applicable. See variable :const:`VARIABLE_MAP`. The default is True. url: str, optional. - Base URL of the Meteonorm API. 'climate/tmy'` is + Base URL of the Meteonorm API. `'climate/tmy'` is appended to the URL. The default is: :const:`pvlib.iotools.meteonorm.URL`. From 4a0e1b3dd469fac84ff28aa6e069101e5db3a10e Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Sat, 2 Aug 2025 15:52:41 +0200 Subject: [PATCH 05/23] Apply suggestions from code review Co-authored-by: Ioannis Sifnaios <88548539+IoannisSifnaios@users.noreply.github.com> --- pvlib/iotools/meteonorm.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pvlib/iotools/meteonorm.py b/pvlib/iotools/meteonorm.py index df5106208a..1c1446a98d 100644 --- a/pvlib/iotools/meteonorm.py +++ b/pvlib/iotools/meteonorm.py @@ -73,7 +73,7 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, time_step : {'1min', '15min', '1h'}, optional Frequency of the time series. The parameter is ignored when requesting forcasting data. The default is '15min'. - horizon : optional + horizon : str, optional Specification of the horizon line. Can be either a 'flat', 'auto', or a list of 360 horizon elevation angles. The default is 'auto'. interval_index: bool, optional @@ -193,11 +193,11 @@ def get_meteonorm_tmy(latitude, longitude, api_key, (north=0, east=90, south=180, west=270). The default is 180. time_step: {'1min', '1h'}, optional Frequency of the time series. The default is '1h'. - horizon: optional + horizon: str, optional Specification of the hoirzon line. Can be either 'flat' or 'auto', or specified as a list of 360 horizon elevation angles. The default is 'auto'. - terrain: string, optional + terrain: str, optional Local terrain situation. Must be one of: ['open', 'depression', 'cold_air_lake', 'sea_lake', 'city', 'slope_south', 'slope_west_east']. The default is 'open'. From 10618a28597ab6093a24d75052ffddc28608b47f Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Sat, 2 Aug 2025 19:20:02 +0200 Subject: [PATCH 06/23] Improve docstring --- pvlib/iotools/meteonorm.py | 92 ++++++++++++++++++-------------------- 1 file changed, 43 insertions(+), 49 deletions(-) diff --git a/pvlib/iotools/meteonorm.py b/pvlib/iotools/meteonorm.py index 1c1446a98d..ce5a91bb62 100644 --- a/pvlib/iotools/meteonorm.py +++ b/pvlib/iotools/meteonorm.py @@ -19,7 +19,7 @@ 'dew_point_temperature': 'temp_dew', } -time_step_map = { +TIME_STEP_MAP = { '1h': '1_hour', 'h': '1_hour', '15min': '15_minutes', @@ -38,7 +38,8 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, The Meteonorm data options are described in [1]_ and the API is described in [2]_. A detailed list of API options can be found in [3]_. - This function supports historical and forecast data, but not TMY. + This function supports retrieval of historical and forecast data, but not + TMY. Parameters ---------- @@ -46,12 +47,12 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, In decimal degrees, north is positive (ISO 19115). longitude: float In decimal degrees, east is positive (ISO 19115). - start: datetime like, optional + start: datetime like First timestamp of the requested period. If a timezone is not - specified, UTC is assumed. A relative datetime string is also allowed. - end: datetime like, optional + specified, UTC is assumed. Relative datetime strings are supported. + end: datetime like Last timestamp of the requested period. If a timezone is not - specified, UTC is assumed. A relative datetime string is also allowed. + specified, UTC is assumed. Relative datetime strings are supported. api_key: str Meteonorm API key. endpoint : str @@ -62,28 +63,26 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, * ``'/forecast/basic'`` - forcast with hourly resolution * ``'/forecast/precision'`` - forecast with 15-min resolution - parameters : list, optional - List of parameters to request or 'all' to get all parameters. The - default is 'all'. - surface_tilt: float, optional - Tilt angle from horizontal plane. The default is 0. - surface_azimuth: float, optional + parameters: list or 'all', default 'all' + List of parameters to request or `'all'` to get all parameters. + surface_tilt: float, default : 0 + Tilt angle from horizontal plane. + surface_azimuth: float, default : 180 Orientation (azimuth angle) of the (fixed) plane. Clockwise from north - (north=0, east=90, south=180, west=270). The default is 180. - time_step : {'1min', '15min', '1h'}, optional + (north=0, east=90, south=180, west=270). + time_step : {'1min', '15min', '1h'}, default : '15min' Frequency of the time series. The parameter is ignored when requesting - forcasting data. The default is '15min'. - horizon : str, optional + forcasting data. + horizon : str or list, default : 'auto' Specification of the horizon line. Can be either a 'flat', 'auto', or - a list of 360 horizon elevation angles. The default is 'auto'. - interval_index: bool, optional + a list of 360 horizon elevation angles. + interval_index: bool, default : False Whether the index of the returned data object is of the type pd.DatetimeIndex or pd.IntervalIndex. This is an experimental feature - which may be removed without warning. The default is False. - map_variables: bool, optional + which may be removed without warning. + map_variables: bool, default : True When true, renames columns of the Dataframe to pvlib variable names - where applicable. The default is True. See variable - :const:`VARIABLE_MAP`. + where applicable. See variable :const:`VARIABLE_MAP`. url: str, optional Base URL of the Meteonorm API. The ``endpoint`` parameter is appended to the url. The default is @@ -142,13 +141,12 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, params['horizon'] = ','.join(horizon) if 'forecast' not in endpoint.lower(): - params['frequency'] = time_step_map.get(time_step, time_step) + params['frequency'] = TIME_STEP_MAP.get(time_step, time_step) headers = {"Authorization": f"Bearer {api_key}"} response = requests.get( urljoin(url, endpoint), headers=headers, params=params) - print(response) if not response.ok: # response.raise_for_status() does not give a useful error message raise requests.HTTPError(response.json()) @@ -183,50 +181,46 @@ def get_meteonorm_tmy(latitude, longitude, api_key, In decimal degrees, east is positive (ISO 19115). api_key: str Meteonorm API key. - parameters: list, optional - List of parameters to request or 'all' to get all parameters. The - default is 'all'. - surface_tilt: float, optional - Tilt angle from horizontal plane. The default is 0. - surface_azimuth : float, optional + parameters: list or 'all', default 'all' + List of parameters to request or `'all'` to get all parameters. + surface_tilt: float, default : 0 + Tilt angle from horizontal plane. + surface_azimuth : float, default : 180 Orientation (azimuth angle) of the (fixed) plane. Clockwise from north - (north=0, east=90, south=180, west=270). The default is 180. - time_step: {'1min', '1h'}, optional - Frequency of the time series. The default is '1h'. + (north=0, east=90, south=180, west=270). + time_step: {'1min', '1h'}, default : '1h' + Frequency of the time series. horizon: str, optional Specification of the hoirzon line. Can be either 'flat' or 'auto', or - specified as a list of 360 horizon elevation angles. The default is + specified as a list of 360 horizon elevation angles. 'auto'. - terrain: str, optional + terrain: str, default : 'open' Local terrain situation. Must be one of: ['open', 'depression', 'cold_air_lake', 'sea_lake', 'city', 'slope_south', - 'slope_west_east']. The default is 'open'. - albedo: float, optional - Ground albedo. Albedo changes due to snow fall are modelled. The - default is 0.2. + 'slope_west_east']. + albedo: float, default : 0.2 + Ground albedo. Albedo changes due to snow fall are modelled. turbidity: list or 'auto', optional List of 12 monthly mean atmospheric Linke turbidity values. The default is 'auto'. random_seed: int, optional Random seed to be used for stochastic processes. Two identical requests with the same random seed will yield identical results. - clear_sky_radiation_model : {'esra', 'solis'} - Which clearsky model to use. The default is 'esra'. - data_version : str, optional - Version of Meteonorm climatological data to be used. The default is - 'latest'. + clear_sky_radiation_model : str, default : 'esra' + Which clearsky model to use. Must be either `'esra'` or `'solis'`. + data_version : str, default : 'latest' + Version of Meteonorm climatological data to be used. future_scenario: str, optional Future climate scenario. future_year : int, optional Central year for a 20-year reference period in the future. - interval_index: bool, optional + interval_index: bool, default : False Whether the index of the returned data object is of the type pd.DatetimeIndex or pd.IntervalIndex. This is an experimental feature - which may be removed without warning. The default is False. - map_variables: bool, optional + which may be removed without warning. + map_variables: bool, default : True When true, renames columns of the Dataframe to pvlib variable names - where applicable. See variable :const:`VARIABLE_MAP`. The default is - True. + where applicable. See variable :const:`VARIABLE_MAP`. url: str, optional. Base URL of the Meteonorm API. `'climate/tmy'` is appended to the URL. The default is: From a42e68f14604a4a1482216e2395746711c7407b5 Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Sat, 2 Aug 2025 20:44:19 +0200 Subject: [PATCH 07/23] Improve functions --- pvlib/iotools/meteonorm.py | 99 ++++++++++++++++++-------------------- 1 file changed, 46 insertions(+), 53 deletions(-) diff --git a/pvlib/iotools/meteonorm.py b/pvlib/iotools/meteonorm.py index ce5a91bb62..1514831eb1 100644 --- a/pvlib/iotools/meteonorm.py +++ b/pvlib/iotools/meteonorm.py @@ -43,31 +43,31 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, Parameters ---------- - latitude: float + latitude : float In decimal degrees, north is positive (ISO 19115). longitude: float In decimal degrees, east is positive (ISO 19115). - start: datetime like + start : datetime like First timestamp of the requested period. If a timezone is not specified, UTC is assumed. Relative datetime strings are supported. - end: datetime like + end : datetime like Last timestamp of the requested period. If a timezone is not specified, UTC is assumed. Relative datetime strings are supported. - api_key: str + api_key : str Meteonorm API key. endpoint : str API endpoint, see [3]_. Must be one of: - * ``'/observation/training'`` - historical data with a 7-day delay - * ``'/observation/realtime'`` - near-real time (past 7-days) - * ``'/forecast/basic'`` - forcast with hourly resolution - * ``'/forecast/precision'`` - forecast with 15-min resolution + * ``'observation/training'`` - historical data with a 7-day delay + * ``'observation/realtime'`` - near-real time (past 7-days) + * ``'forecast/basic'`` - forecast with hourly resolution + * ``'forecast/precision'`` - forecast with 15-min resolution - parameters: list or 'all', default 'all' + parameters : list or 'all', default : 'all' List of parameters to request or `'all'` to get all parameters. - surface_tilt: float, default : 0 + surface_tilt : float, default : 0 Tilt angle from horizontal plane. - surface_azimuth: float, default : 180 + surface_azimuth : float, default : 180 Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270). time_step : {'1min', '15min', '1h'}, default : '15min' @@ -76,14 +76,13 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, horizon : str or list, default : 'auto' Specification of the horizon line. Can be either a 'flat', 'auto', or a list of 360 horizon elevation angles. - interval_index: bool, default : False - Whether the index of the returned data object is of the type - pd.DatetimeIndex or pd.IntervalIndex. This is an experimental feature - which may be removed without warning. - map_variables: bool, default : True + interval_index : bool, default : False + Index is pd.DatetimeIndex when False, and pd.IntervalIndex when True. + This is an experimental feature which may be removed without warning. + map_variables : bool, default : True When true, renames columns of the Dataframe to pvlib variable names where applicable. See variable :const:`VARIABLE_MAP`. - url: str, optional + url : str, optional Base URL of the Meteonorm API. The ``endpoint`` parameter is appended to the url. The default is :const:`pvlib.iotools.meteonorm.URL`. @@ -137,8 +136,8 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, parameters = [parameter_dict.get(p, p) for p in parameters] params['parameters'] = ','.join(parameters) - if horizon not in ['auto', 'flat']: - params['horizon'] = ','.join(horizon) + if not isinstance(horizon, str): + params['horizon'] = ','.join(map(str, horizon)) if 'forecast' not in endpoint.lower(): params['frequency'] = TIME_STEP_MAP.get(time_step, time_step) @@ -146,7 +145,8 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, headers = {"Authorization": f"Bearer {api_key}"} response = requests.get( - urljoin(url, endpoint), headers=headers, params=params) + urljoin(url, endpoint.lstrip('/')), headers=headers, params=params) + if not response.ok: # response.raise_for_status() does not give a useful error message raise requests.HTTPError(response.json()) @@ -175,53 +175,52 @@ def get_meteonorm_tmy(latitude, longitude, api_key, Parameters ---------- - latitude: float + latitude : float In decimal degrees, north is positive (ISO 19115). - longitude: float + longitude : float In decimal degrees, east is positive (ISO 19115). - api_key: str + api_key : str Meteonorm API key. - parameters: list or 'all', default 'all' + parameters : list or 'all', default : 'all' List of parameters to request or `'all'` to get all parameters. - surface_tilt: float, default : 0 + surface_tilt : float, default : 0 Tilt angle from horizontal plane. surface_azimuth : float, default : 180 Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270). - time_step: {'1min', '1h'}, default : '1h' + time_step : {'1min', '1h'}, default : '1h' Frequency of the time series. - horizon: str, optional - Specification of the hoirzon line. Can be either 'flat' or 'auto', or + horizon : str, optional + Specification of the horizon line. Can be either 'flat' or 'auto', or specified as a list of 360 horizon elevation angles. 'auto'. - terrain: str, default : 'open' + terrain : str, default : 'open' Local terrain situation. Must be one of: ['open', 'depression', 'cold_air_lake', 'sea_lake', 'city', 'slope_south', 'slope_west_east']. - albedo: float, default : 0.2 + albedo : float, default : 0.2 Ground albedo. Albedo changes due to snow fall are modelled. - turbidity: list or 'auto', optional + turbidity : list or 'auto', optional List of 12 monthly mean atmospheric Linke turbidity values. The default is 'auto'. - random_seed: int, optional + random_seed : int, optional Random seed to be used for stochastic processes. Two identical requests with the same random seed will yield identical results. clear_sky_radiation_model : str, default : 'esra' Which clearsky model to use. Must be either `'esra'` or `'solis'`. data_version : str, default : 'latest' Version of Meteonorm climatological data to be used. - future_scenario: str, optional + future_scenario : str, optional Future climate scenario. future_year : int, optional Central year for a 20-year reference period in the future. - interval_index: bool, default : False - Whether the index of the returned data object is of the type - pd.DatetimeIndex or pd.IntervalIndex. This is an experimental feature - which may be removed without warning. - map_variables: bool, default : True + interval_index : bool, default : False + Index is pd.DatetimeIndex when False, and pd.IntervalIndex when True. + This is an experimental feature which may be removed without warning. + map_variables : bool, default : True When true, renames columns of the Dataframe to pvlib variable names where applicable. See variable :const:`VARIABLE_MAP`. - url: str, optional. + url : str, optional. Base URL of the Meteonorm API. `'climate/tmy'` is appended to the URL. The default is: :const:`pvlib.iotools.meteonorm.URL`. @@ -264,6 +263,9 @@ def get_meteonorm_tmy(latitude, longitude, api_key, 'turbidity': turbidity, 'clear_sky_radiation_model': clear_sky_radiation_model, 'data_version': data_version, + 'random_seed': random_seed, + 'future_scenario': future_scenario, + 'future_year': future_year, } # convert list to string with values separated by commas @@ -273,25 +275,16 @@ def get_meteonorm_tmy(latitude, longitude, api_key, parameters = [parameter_dict.get(p, p) for p in parameters] params['parameters'] = ','.join(parameters) - if horizon not in ['auto', 'flat']: - params['horizon'] = ','.join(horizon) - - if turbidity != 'auto': - params['turbidity'] = ','.join(turbidity) - - if random_seed is not None: - params['random_seed'] = random_seed - - if future_scenario is not None: - params['future_scenario'] = future_scenario + if isinstance(horizon, str): + params['horizon'] = ','.join(map(str, horizon)) - if future_year is not None: - params['future_year'] = future_year + if isinstance(turbidity, str): + params['turbidity'] = ','.join(map(str, turbidity)) headers = {"Authorization": f"Bearer {api_key}"} response = requests.get( - urljoin(url, TMY_ENDPOINT), headers=headers, params=params) + urljoin(url, TMY_ENDPOINT.lstrip('/')), headers=headers, params=params) if not response.ok: # response.raise_for_status() does not give a useful error message From acbdafac3eda01b34423291f92333ae029619ab3 Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Sat, 2 Aug 2025 20:44:27 +0200 Subject: [PATCH 08/23] Add first round of tests --- tests/iotools/test_meteonorm.py | 213 ++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 tests/iotools/test_meteonorm.py diff --git a/tests/iotools/test_meteonorm.py b/tests/iotools/test_meteonorm.py new file mode 100644 index 0000000000..0735b0f5be --- /dev/null +++ b/tests/iotools/test_meteonorm.py @@ -0,0 +1,213 @@ +import pandas as pd +import numpy as np +import pytest +import pvlib +from tests.conftest import RERUNS, RERUNS_DELAY + + +@pytest.fixture +def demo_api_key(): + # Demo locations: + # lat=50, lon=10 (Germany) + # lat=21, lon=79 (India) + # lat=-3, lon=-60 (Brazil) + # lat=51, lon=-114 (Canada) + # lat=24, lon=33 (Egypt) + return 'demo0000-0000-0000-0000-000000000000' + + +@pytest.fixture +def demo_url(): + return 'https://demo.meteonorm.com/v1/' + + +@pytest.fixture +def expected_meta(): + meta = { + 'altitude': 290, + 'frequency': '1_hour', + 'parameters': [ + {'aggregation_method': 'average', + 'description': 'Diffuse horizontal irradiance', + 'name': 'diffuse_horizontal_irradiance', + 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, + {'aggregation_method': 'average', + 'description': 'Diffuse horizontal irradiance with shading taken into account', + 'name': 'diffuse_horizontal_irradiance_with_shading', + 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, + {'aggregation_method': 'average', + 'description': 'Diffuse tilted irradiance', + 'name': 'diffuse_tilted_irradiance', + 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, + {'aggregation_method': 'average', + 'description': 'Diffuse tilted irradiance with shading taken into account', + 'name': 'diffuse_tilted_irradiance_with_shading', + 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, + {'aggregation_method': 'average', + 'description': 'Direct horizontal irradiance', + 'name': 'direct_horizontal_irradiance', + 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, + {'aggregation_method': 'average', + 'description': 'Direct horizontal irradiance with shading taken into account', + 'name': 'direct_horizontal_irradiance_with_shading', + 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, + {'aggregation_method': 'average', + 'description': 'Direct normal irradiance', + 'name': 'direct_normal_irradiance', + 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, + {'aggregation_method': 'average', + 'description': 'Direct normal irradiance with shading taken into account', + 'name': 'direct_normal_irradiance_with_shading', + 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, + {'aggregation_method': 'average', + 'description': 'Direct tilted irradiance', + 'name': 'direct_tilted_irradiance', + 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, + {'aggregation_method': 'average', + 'description': 'Direct tilted irradiance with shading taken into account', + 'name': 'direct_tilted_irradiance_with_shading', + 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, + {'aggregation_method': 'average', + 'description': 'Global horizontal clear sky irradiance', + 'name': 'global_clear_sky_irradiance', + 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, + {'aggregation_method': 'average', + 'description': 'Global horizontal irradiance', + 'name': 'global_horizontal_irradiance', + 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, + {'aggregation_method': 'average', + 'description': 'Global horizontal irradiance with shading taken into account', + 'name': 'global_horizontal_irradiance_with_shading', + 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, + {'aggregation_method': 'average', + 'description': 'Global tilted irradiance', + 'name': 'global_tilted_irradiance', + 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, + {'aggregation_method': 'average', + 'description': 'Global tilted irradiance with shading taken into account', + 'name': 'global_tilted_irradiance_with_shading', + 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, + {'aggregation_method': 'average', + 'description': 'Power output per kWp installed', + 'name': 'pv_production', + 'unit': {'description': 'Watts per kilowatt peak', 'name': 'W/kWp'}}, + {'aggregation_method': 'average', + 'description': 'Power output per kWp installed, with shading taken into account', + 'name': 'pv_production_with_shading', + 'unit': {'description': 'Watts per kilowatt peak', 'name': 'W/kWp'}}, + {'aggregation_method': 'average', + 'description': 'Snow depth', + 'name': 'snow_depth', + 'unit': {'description': 'millimeters', 'name': 'mm'}}, + {'aggregation_method': 'average', + 'description': 'Air temperature, 2 m above ground.', + 'name': 'temperature', + 'unit': {'description': 'degrees Celsius', 'name': '°C'}}], + 'surface_azimuth': 180, + 'surface_tilt': 0, + 'time_zone': 0, + 'latitude': 50, + 'longitude': 10, + } + return meta + + +@pytest.fixture +def expected_meteonorm_index(): + expected_meteonorm_index = \ + pd.date_range('2023-01-01', '2024-12-31 23:59', freq='1h', tz='UTC') + expected_meteonorm_index.freq = None + return expected_meteonorm_index + + +@pytest.fixture +def expected_metenorm_data(): + # The first 12 rows of data + columns = ['dhi', 'diffuse_horizontal_irradiance_with_shading', 'poa_diffuse', + 'diffuse_tilted_irradiance_with_shading', 'bhi', + 'direct_horizontal_irradiance_with_shading', 'dni', + 'direct_normal_irradiance_with_shading', 'poa_direct', + 'direct_tilted_irradiance_with_shading', 'ghi_clear', 'ghi', + 'global_horizontal_irradiance_with_shading', 'poa', + 'global_tilted_irradiance_with_shading', 'pv_production', + 'pv_production_with_shading', 'snow_depth', 'temp_air'] + expected = [ + [0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 12.25], + [0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 11.75], + [0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 11.75], + [0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 11.5], + [0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 11.25], + [0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 11.], + [0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 11.], + [2.5, 2.68309898, 2.67538201, 2.68309898, 0., 0., 0., 0., 0., 0., 0., 2.5, + 2.68309898, 2.67538201, 2.68309898, 2.34649978, 2.35326557, 0., 11.], + [40.43632435, 40.41304027, 40.43632435, 40.41304027, 37.06367565, 37.06367565, + 288.7781947, 288.7781947, 37.06367565, 37.06367565, 98.10113439, 77.5, + 77.47671591, 77.5, 77.47671591, 67.02141875, 67.00150474, 0., 11.75], + [60.52591348, 60.51498257, 60.52591348, 60.51498257, 104.47408652, 104.47408652, + 478.10101591, 478.10101591, 104.47408652, 104.47408652, 191.27910925, 165., + 164.98906908, 165., 164.98906908, 140.23845, 140.22938131, 0., 12.75], + [71.90169306, 71.89757085, 71.90169306, 71.89757085, 138.84830694, 138.84830694, + 508.02986044, 508.02986044, 138.84830694, 138.84830694, 253.85597777, 210.75, + 210.7458778, 210.75, 210.7458778, 177.07272956, 177.06937293, 0., 13.75], + [78.20403711, 78.19681926, 78.20403711, 78.19681926, 142.79596289, 142.79596289, + 494.06576548, 494.06576548, 142.79596289, 142.79596289, 272.34275335, 221., + 220.99278214, 221., 220.99278214, 185.179657, 185.17380523, 0., 14.], + ] + index = pd.date_range('2023-01-01', periods=12, freq='1h', tz='UTC') + index.freq = None + expected = pd.DataFrame(expected, index=index, columns=columns) + expected['snow_depth'] = expected['snow_depth'].astype(np.int64) + return expected + + +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_meteonorm_training( + demo_api_key, demo_url, expected_meta, expected_meteonorm_index, + expected_metenorm_data): + data, meta = pvlib.iotools.get_meteonorm( + latitude=50, longitude=10, + start='2023-01-01', end='2025-01-01', + api_key=demo_api_key, + endpoint='observation/training', + time_step='1h', + url=demo_url) + + assert meta == expected_meta + pd.testing.assert_index_equal(data.index, expected_meteonorm_index) + pd.testing.assert_frame_equal(data.iloc[:12], expected_metenorm_data) + + +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_meteonorm_realtime( + demo_api_key, demo_url, expected_meta, expected_meteonorm_index, + expected_metenorm_data): + data, meta = pvlib.iotools.get_meteonorm( + latitude=21, longitude=79, + start=pd.Timestamp.now(tz='UTC') - pd.Timedelta(hours=5), + end=pd.Timestamp.now(tz='UTC') - pd.Timedelta(hours=1), + surface_tilt=20, surface_azimuth=10, + parameters=['ghi', 'global_horizontal_irradiance_with_shading'], + api_key=demo_api_key, + endpoint='/observation/realtime', + time_step='1min', + horizon='flat', + map_variables=False, + interval_index=True, + url=demo_url, + ) + assert meta['frequency'] == '1_minute' + assert meta['lat'] == 21 + assert meta['lon'] == 79 + assert meta['surface_tilt'] == 20 + assert meta['surface_azimuth'] == 10 + + assert all(data.columns == pd.Index([ + 'global_horizontal_irradiance', + 'global_horizontal_irradiance_with_shading'])) + assert data.shape == (241, 2) + # can't test the specific index as it varies due to the + # use of pd.Timestamp.now + assert type(data.index) is pd.core.indexes.interval.IntervalIndex From ef4f83865055344f1db969a2aa0b14d94fdda034 Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Sat, 2 Aug 2025 21:10:16 +0200 Subject: [PATCH 09/23] Update tests --- pvlib/iotools/meteonorm.py | 16 ++- tests/iotools/test_meteonorm.py | 189 ++++++++++++++------------------ 2 files changed, 96 insertions(+), 109 deletions(-) diff --git a/pvlib/iotools/meteonorm.py b/pvlib/iotools/meteonorm.py index 1514831eb1..d58ce6e894 100644 --- a/pvlib/iotools/meteonorm.py +++ b/pvlib/iotools/meteonorm.py @@ -129,8 +129,14 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, 'horizon': horizon, } + # Allow specifying single parameters as string + if isinstance(parameters, str): + parameter_list = list(VARIABLE_MAP.keys()) + list(VARIABLE_MAP.values()) + if parameters in parameter_list: + parameters = [parameters] + # convert list to string with values separated by commas - if not isinstance(params['parameters'], (str, type(None))): + if not isinstance(parameters, (str, type(None))): # allow the use of pvlib parameter names parameter_dict = {v: k for k, v in VARIABLE_MAP.items()} parameters = [parameter_dict.get(p, p) for p in parameters] @@ -268,8 +274,14 @@ def get_meteonorm_tmy(latitude, longitude, api_key, 'future_year': future_year, } + # Allow specifying single parameters as string + if isinstance(parameters, str): + parameter_list = list(VARIABLE_MAP.keys()) + list(VARIABLE_MAP.values()) + if parameters in parameter_list: + parameters = [parameters] + # convert list to string with values separated by commas - if not isinstance(params['parameters'], (str, type(None))): + if not isinstance(parameters, (str, type(None))): # allow the use of pvlib parameter names parameter_dict = {v: k for k, v in VARIABLE_MAP.items()} parameters = [parameter_dict.get(p, p) for p in parameters] diff --git a/tests/iotools/test_meteonorm.py b/tests/iotools/test_meteonorm.py index 0735b0f5be..e809d2a41f 100644 --- a/tests/iotools/test_meteonorm.py +++ b/tests/iotools/test_meteonorm.py @@ -1,5 +1,4 @@ import pandas as pd -import numpy as np import pytest import pvlib from tests.conftest import RERUNS, RERUNS_DELAY @@ -27,50 +26,6 @@ def expected_meta(): 'altitude': 290, 'frequency': '1_hour', 'parameters': [ - {'aggregation_method': 'average', - 'description': 'Diffuse horizontal irradiance', - 'name': 'diffuse_horizontal_irradiance', - 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, - {'aggregation_method': 'average', - 'description': 'Diffuse horizontal irradiance with shading taken into account', - 'name': 'diffuse_horizontal_irradiance_with_shading', - 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, - {'aggregation_method': 'average', - 'description': 'Diffuse tilted irradiance', - 'name': 'diffuse_tilted_irradiance', - 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, - {'aggregation_method': 'average', - 'description': 'Diffuse tilted irradiance with shading taken into account', - 'name': 'diffuse_tilted_irradiance_with_shading', - 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, - {'aggregation_method': 'average', - 'description': 'Direct horizontal irradiance', - 'name': 'direct_horizontal_irradiance', - 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, - {'aggregation_method': 'average', - 'description': 'Direct horizontal irradiance with shading taken into account', - 'name': 'direct_horizontal_irradiance_with_shading', - 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, - {'aggregation_method': 'average', - 'description': 'Direct normal irradiance', - 'name': 'direct_normal_irradiance', - 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, - {'aggregation_method': 'average', - 'description': 'Direct normal irradiance with shading taken into account', - 'name': 'direct_normal_irradiance_with_shading', - 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, - {'aggregation_method': 'average', - 'description': 'Direct tilted irradiance', - 'name': 'direct_tilted_irradiance', - 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, - {'aggregation_method': 'average', - 'description': 'Direct tilted irradiance with shading taken into account', - 'name': 'direct_tilted_irradiance_with_shading', - 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, - {'aggregation_method': 'average', - 'description': 'Global horizontal clear sky irradiance', - 'name': 'global_clear_sky_irradiance', - 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, {'aggregation_method': 'average', 'description': 'Global horizontal irradiance', 'name': 'global_horizontal_irradiance', @@ -79,30 +34,7 @@ def expected_meta(): 'description': 'Global horizontal irradiance with shading taken into account', 'name': 'global_horizontal_irradiance_with_shading', 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, - {'aggregation_method': 'average', - 'description': 'Global tilted irradiance', - 'name': 'global_tilted_irradiance', - 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, - {'aggregation_method': 'average', - 'description': 'Global tilted irradiance with shading taken into account', - 'name': 'global_tilted_irradiance_with_shading', - 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, - {'aggregation_method': 'average', - 'description': 'Power output per kWp installed', - 'name': 'pv_production', - 'unit': {'description': 'Watts per kilowatt peak', 'name': 'W/kWp'}}, - {'aggregation_method': 'average', - 'description': 'Power output per kWp installed, with shading taken into account', - 'name': 'pv_production_with_shading', - 'unit': {'description': 'Watts per kilowatt peak', 'name': 'W/kWp'}}, - {'aggregation_method': 'average', - 'description': 'Snow depth', - 'name': 'snow_depth', - 'unit': {'description': 'millimeters', 'name': 'mm'}}, - {'aggregation_method': 'average', - 'description': 'Air temperature, 2 m above ground.', - 'name': 'temperature', - 'unit': {'description': 'degrees Celsius', 'name': '°C'}}], + ], 'surface_azimuth': 180, 'surface_tilt': 0, 'time_zone': 0, @@ -123,44 +55,53 @@ def expected_meteonorm_index(): @pytest.fixture def expected_metenorm_data(): # The first 12 rows of data - columns = ['dhi', 'diffuse_horizontal_irradiance_with_shading', 'poa_diffuse', - 'diffuse_tilted_irradiance_with_shading', 'bhi', - 'direct_horizontal_irradiance_with_shading', 'dni', - 'direct_normal_irradiance_with_shading', 'poa_direct', - 'direct_tilted_irradiance_with_shading', 'ghi_clear', 'ghi', - 'global_horizontal_irradiance_with_shading', 'poa', - 'global_tilted_irradiance_with_shading', 'pv_production', - 'pv_production_with_shading', 'snow_depth', 'temp_air'] + columns = ['ghi', 'global_horizontal_irradiance_with_shading'] expected = [ - [0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 12.25], - [0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 11.75], - [0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 11.75], - [0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 11.5], - [0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 11.25], - [0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 11.], - [0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 11.], - [2.5, 2.68309898, 2.67538201, 2.68309898, 0., 0., 0., 0., 0., 0., 0., 2.5, - 2.68309898, 2.67538201, 2.68309898, 2.34649978, 2.35326557, 0., 11.], - [40.43632435, 40.41304027, 40.43632435, 40.41304027, 37.06367565, 37.06367565, - 288.7781947, 288.7781947, 37.06367565, 37.06367565, 98.10113439, 77.5, - 77.47671591, 77.5, 77.47671591, 67.02141875, 67.00150474, 0., 11.75], - [60.52591348, 60.51498257, 60.52591348, 60.51498257, 104.47408652, 104.47408652, - 478.10101591, 478.10101591, 104.47408652, 104.47408652, 191.27910925, 165., - 164.98906908, 165., 164.98906908, 140.23845, 140.22938131, 0., 12.75], - [71.90169306, 71.89757085, 71.90169306, 71.89757085, 138.84830694, 138.84830694, - 508.02986044, 508.02986044, 138.84830694, 138.84830694, 253.85597777, 210.75, - 210.7458778, 210.75, 210.7458778, 177.07272956, 177.06937293, 0., 13.75], - [78.20403711, 78.19681926, 78.20403711, 78.19681926, 142.79596289, 142.79596289, - 494.06576548, 494.06576548, 142.79596289, 142.79596289, 272.34275335, 221., - 220.99278214, 221., 220.99278214, 185.179657, 185.17380523, 0., 14.], + [0.0, 0.0], + [0.0, 0.0], + [0.0, 0.0], + [0.0, 0.0], + [0.0, 0.0], + [0.0, 0.0], + [0.0, 0.0], + [2.5, 2.68309898], + [77.5, 77.47671591], + [165.0, 164.98906908], + [210.75, 210.7458778], + [221.0, 220.99278214], ] index = pd.date_range('2023-01-01', periods=12, freq='1h', tz='UTC') index.freq = None expected = pd.DataFrame(expected, index=index, columns=columns) - expected['snow_depth'] = expected['snow_depth'].astype(np.int64) return expected +@pytest.fixture +def expected_columns_all(): + columns = [ + 'diffuse_horizontal_irradiance', + 'diffuse_horizontal_irradiance_with_shading', + 'diffuse_tilted_irradiance', + 'diffuse_tilted_irradiance_with_shading', + 'direct_horizontal_irradiance', + 'direct_horizontal_irradiance_with_shading', + 'direct_normal_irradiance', + 'direct_normal_irradiance_with_shading', + 'direct_tilted_irradiance', + 'direct_tilted_irradiance_with_shading', + 'global_clear_sky_irradiance', + 'global_horizontal_irradiance', + 'global_horizontal_irradiance_with_shading', + 'global_tilted_irradiance', + 'global_tilted_irradiance_with_shading', + 'pv_production', + 'pv_production_with_shading', + 'snow_depth', + 'temperature', + ] + return columns + + @pytest.mark.remote_data @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) def test_get_meteonorm_training( @@ -170,6 +111,7 @@ def test_get_meteonorm_training( latitude=50, longitude=10, start='2023-01-01', end='2025-01-01', api_key=demo_api_key, + parameters=['ghi', 'global_horizontal_irradiance_with_shading'], endpoint='observation/training', time_step='1h', url=demo_url) @@ -181,15 +123,13 @@ def test_get_meteonorm_training( @pytest.mark.remote_data @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) -def test_get_meteonorm_realtime( - demo_api_key, demo_url, expected_meta, expected_meteonorm_index, - expected_metenorm_data): +def test_get_meteonorm_realtime(demo_api_key, demo_url, expected_columns_all): data, meta = pvlib.iotools.get_meteonorm( latitude=21, longitude=79, start=pd.Timestamp.now(tz='UTC') - pd.Timedelta(hours=5), end=pd.Timestamp.now(tz='UTC') - pd.Timedelta(hours=1), surface_tilt=20, surface_azimuth=10, - parameters=['ghi', 'global_horizontal_irradiance_with_shading'], + parameters=['all'], api_key=demo_api_key, endpoint='/observation/realtime', time_step='1min', @@ -204,10 +144,45 @@ def test_get_meteonorm_realtime( assert meta['surface_tilt'] == 20 assert meta['surface_azimuth'] == 10 - assert all(data.columns == pd.Index([ - 'global_horizontal_irradiance', - 'global_horizontal_irradiance_with_shading'])) - assert data.shape == (241, 2) + assert list(data.columns) == expected_columns_all + assert data.shape == (241, 19) # can't test the specific index as it varies due to the # use of pd.Timestamp.now assert type(data.index) is pd.core.indexes.interval.IntervalIndex + + +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_meteonorm_forecast_basic(demo_api_key, demo_url): + data, meta = pvlib.iotools.get_meteonorm( + latitude=50, longitude=10, + start=pd.Timestamp.now(tz='UTC'), + end=pd.Timestamp.now(tz='UTC') + pd.Timedelta(hours=5), + api_key=demo_api_key, + parameters='ghi', + endpoint='forecast/basic', + url=demo_url) + + assert data.shape == (6, 1) + assert data.columns == pd.Index(['ghi']) + assert data.index[1] - data.index[0] == pd.Timedelta(hours=1) + assert meta['frequency'] == '1_hour' + + +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_meteonorm_forecast_precision(demo_api_key, demo_url): + data, meta = pvlib.iotools.get_meteonorm( + latitude=50, longitude=10, + start=pd.Timestamp.now(tz='UTC') + pd.Timedelta(hours=5), + end=pd.Timestamp.now(tz='UTC') + pd.Timedelta(hours=6), + api_key=demo_api_key, + parameters='ghi', + endpoint='forecast/precision', + # test that the time_step parameter is ignored + time_step='1h', + url=demo_url) + + assert data.index[1] - data.index[0] == pd.Timedelta(minutes=15) + assert data.shape == (5, 1) + assert meta['frequency'] == '15_minutes' From adda4cfe760d6f1b52a9953023cc4889b0b004d8 Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Sat, 2 Aug 2025 21:38:11 +0200 Subject: [PATCH 10/23] Full test coverage --- pvlib/iotools/meteonorm.py | 18 +++-- tests/iotools/test_meteonorm.py | 122 +++++++++++++++++++++++++++++++- 2 files changed, 130 insertions(+), 10 deletions(-) diff --git a/pvlib/iotools/meteonorm.py b/pvlib/iotools/meteonorm.py index d58ce6e894..b61bbb0306 100644 --- a/pvlib/iotools/meteonorm.py +++ b/pvlib/iotools/meteonorm.py @@ -75,7 +75,7 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, forcasting data. horizon : str or list, default : 'auto' Specification of the horizon line. Can be either a 'flat', 'auto', or - a list of 360 horizon elevation angles. + a list of 360 integer horizon elevation angles. interval_index : bool, default : False Index is pd.DatetimeIndex when False, and pd.IntervalIndex when True. This is an experimental feature which may be removed without warning. @@ -131,7 +131,8 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, # Allow specifying single parameters as string if isinstance(parameters, str): - parameter_list = list(VARIABLE_MAP.keys()) + list(VARIABLE_MAP.values()) + parameter_list = \ + list(VARIABLE_MAP.keys()) + list(VARIABLE_MAP.values()) if parameters in parameter_list: parameters = [parameters] @@ -198,7 +199,7 @@ def get_meteonorm_tmy(latitude, longitude, api_key, Frequency of the time series. horizon : str, optional Specification of the horizon line. Can be either 'flat' or 'auto', or - specified as a list of 360 horizon elevation angles. + specified as a list of 360 integer horizon elevation angles. 'auto'. terrain : str, default : 'open' Local terrain situation. Must be one of: ['open', 'depression', @@ -265,7 +266,7 @@ def get_meteonorm_tmy(latitude, longitude, api_key, 'frequency': time_step, 'parameters': parameters, 'horizon': horizon, - 'terrain': terrain, + 'situation': terrain, 'turbidity': turbidity, 'clear_sky_radiation_model': clear_sky_radiation_model, 'data_version': data_version, @@ -276,7 +277,8 @@ def get_meteonorm_tmy(latitude, longitude, api_key, # Allow specifying single parameters as string if isinstance(parameters, str): - parameter_list = list(VARIABLE_MAP.keys()) + list(VARIABLE_MAP.values()) + parameter_list = \ + list(VARIABLE_MAP.keys()) + list(VARIABLE_MAP.values()) if parameters in parameter_list: parameters = [parameters] @@ -287,12 +289,14 @@ def get_meteonorm_tmy(latitude, longitude, api_key, parameters = [parameter_dict.get(p, p) for p in parameters] params['parameters'] = ','.join(parameters) - if isinstance(horizon, str): + if not isinstance(horizon, str): params['horizon'] = ','.join(map(str, horizon)) - if isinstance(turbidity, str): + if not isinstance(turbidity, str): params['turbidity'] = ','.join(map(str, turbidity)) + params['frequency'] = TIME_STEP_MAP.get(time_step, time_step) + headers = {"Authorization": f"Bearer {api_key}"} response = requests.get( diff --git a/tests/iotools/test_meteonorm.py b/tests/iotools/test_meteonorm.py index e809d2a41f..8a4a04541a 100644 --- a/tests/iotools/test_meteonorm.py +++ b/tests/iotools/test_meteonorm.py @@ -1,7 +1,9 @@ import pandas as pd +import numpy as np import pytest import pvlib from tests.conftest import RERUNS, RERUNS_DELAY +from requests.exceptions import HTTPError @pytest.fixture @@ -29,11 +31,13 @@ def expected_meta(): {'aggregation_method': 'average', 'description': 'Global horizontal irradiance', 'name': 'global_horizontal_irradiance', - 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, + 'unit': { + 'description': 'Watt per square meter', 'name': 'W/m**2'}}, {'aggregation_method': 'average', - 'description': 'Global horizontal irradiance with shading taken into account', + 'description': 'Global horizontal irradiance with shading taken into account', # noqa: E501 'name': 'global_horizontal_irradiance_with_shading', - 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, + 'unit': {'description': 'Watt per square meter', + 'name': 'W/m**2'}}, ], 'surface_azimuth': 180, 'surface_tilt': 0, @@ -186,3 +190,115 @@ def test_get_meteonorm_forecast_precision(demo_api_key, demo_url): assert data.index[1] - data.index[0] == pd.Timedelta(minutes=15) assert data.shape == (5, 1) assert meta['frequency'] == '15_minutes' + + +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_meteonorm_custom_horizon(demo_api_key, demo_url): + data, meta = pvlib.iotools.get_meteonorm( + latitude=50, longitude=10, + start=pd.Timestamp.now(tz='UTC'), + end=pd.Timestamp.now(tz='UTC') + pd.Timedelta(hours=5), + api_key=demo_api_key, + parameters='ghi', + endpoint='forecast/basic', + horizon=list(np.ones(360).astype(int)*80), + url=demo_url) + + +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_meteonorm_HTTPError(demo_api_key, demo_url): + with pytest.raises( + HTTPError, match="unknown parameter: not_a_real_parameter'"): + _ = pvlib.iotools.get_meteonorm( + latitude=50, longitude=10, + start=pd.Timestamp.now(tz='UTC'), + end=pd.Timestamp.now(tz='UTC') + pd.Timedelta(hours=5), + api_key=demo_api_key, + parameters='not_a_real_parameter', + endpoint='forecast/basic', + url=demo_url) + + +@pytest.fixture +def expected_meteonorm_tmy_meta(): + meta = { + 'altitude': 290, + 'frequency': '1_hour', + 'parameters': [{ + 'aggregation_method': 'average', + 'description': 'Diffuse horizontal irradiance', + 'name': 'diffuse_horizontal_irradiance', + 'unit': {'description': 'Watt per square meter', + 'name': 'W/m**2'}, + }], + 'surface_azimuth': 90, + 'surface_tilt': 20, + 'time_zone': 1, + 'latitude': 50, + 'longitude': 10, + } + return meta + + +@pytest.fixture +def expected_meteonorm_tmy_index(): + index = pd.date_range( + '2005-01-01', periods=8760, freq='1h', tz=3600) + index.freq = None + return index + + +@pytest.fixture +def expected_metenorm_tmy_data(): + # The first 12 rows of data + columns = ['diffuse_horizontal_irradiance'] + expected = [ + [0.], + [0.], + [0.], + [0.], + [0.], + [0.], + [0.], + [0.], + [9.], + [8.4], + [86.6], + [110.5], + ] + index = pd.date_range( + '2005-01-01', periods=12, freq='1h', tz=3600) + index.freq = None + expected = pd.DataFrame(expected, index=index, columns=columns) + return expected + + +# @pytest.mark.remote_data +# @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_meteonorm_tmy( + demo_api_key, demo_url, expected_meteonorm_tmy_meta, + expected_metenorm_tmy_data, expected_meteonorm_tmy_index): + data, meta = pvlib.iotools.get_meteonorm_tmy( + latitude=50, longitude=10, + api_key=demo_api_key, + parameters='dhi', + surface_tilt=20, + surface_azimuth=90, + time_step='1h', + horizon=list(np.ones(360).astype(int)*2), + terrain='open', + albedo=0.5, + turbidity='auto', + random_seed=100, + clear_sky_radiation_model='solis', + data_version='v9.0', # fix version + future_scenario='ssp1_26', + future_year=2030, + interval_index=True, + map_variables=False, + url=demo_url) + assert meta == expected_meteonorm_tmy_meta + pd.testing.assert_frame_equal(data.iloc[:12], expected_metenorm_tmy_data) + pd.testing.assert_index_equal(data.index, expected_meteonorm_tmy_index) From ea48630a27d48bfc9f2e85c45a6fe9ea50b0b5a9 Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Sat, 2 Aug 2025 21:40:02 +0200 Subject: [PATCH 11/23] Fix linter --- tests/iotools/test_meteonorm.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/iotools/test_meteonorm.py b/tests/iotools/test_meteonorm.py index 8a4a04541a..36f95bb02f 100644 --- a/tests/iotools/test_meteonorm.py +++ b/tests/iotools/test_meteonorm.py @@ -38,7 +38,7 @@ def expected_meta(): 'name': 'global_horizontal_irradiance_with_shading', 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}}, - ], + ], 'surface_azimuth': 180, 'surface_tilt': 0, 'time_zone': 0, @@ -141,7 +141,7 @@ def test_get_meteonorm_realtime(demo_api_key, demo_url, expected_columns_all): map_variables=False, interval_index=True, url=demo_url, - ) + ) assert meta['frequency'] == '1_minute' assert meta['lat'] == 21 assert meta['lon'] == 79 @@ -232,7 +232,7 @@ def expected_meteonorm_tmy_meta(): 'name': 'diffuse_horizontal_irradiance', 'unit': {'description': 'Watt per square meter', 'name': 'W/m**2'}, - }], + }], 'surface_azimuth': 90, 'surface_tilt': 20, 'time_zone': 1, @@ -275,8 +275,8 @@ def expected_metenorm_tmy_data(): return expected -# @pytest.mark.remote_data -# @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) def test_get_meteonorm_tmy( demo_api_key, demo_url, expected_meteonorm_tmy_meta, expected_metenorm_tmy_data, expected_meteonorm_tmy_index): From ba0042375d9cf238710fd4b6ac2831c42d5ca2a7 Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Sat, 2 Aug 2025 22:04:23 +0200 Subject: [PATCH 12/23] Fix tests --- pvlib/iotools/meteonorm.py | 2 +- tests/iotools/test_meteonorm.py | 19 ++++++++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/pvlib/iotools/meteonorm.py b/pvlib/iotools/meteonorm.py index b61bbb0306..e3cc7b4684 100644 --- a/pvlib/iotools/meteonorm.py +++ b/pvlib/iotools/meteonorm.py @@ -325,7 +325,7 @@ def _parse_meteonorm(response, interval_index, map_variables): data.index = pd.IntervalIndex.from_arrays( left=pd.to_datetime(response.json()['start_times']), right=pd.to_datetime(response.json()['end_times']), - closed='both', + closed='left', ) else: data.index = pd.to_datetime(response.json()['start_times']) diff --git a/tests/iotools/test_meteonorm.py b/tests/iotools/test_meteonorm.py index 36f95bb02f..42a10316b2 100644 --- a/tests/iotools/test_meteonorm.py +++ b/tests/iotools/test_meteonorm.py @@ -236,18 +236,20 @@ def expected_meteonorm_tmy_meta(): 'surface_azimuth': 90, 'surface_tilt': 20, 'time_zone': 1, - 'latitude': 50, - 'longitude': 10, + 'lat': 50, + 'lon': 10, } return meta @pytest.fixture -def expected_meteonorm_tmy_index(): +def expected_meteonorm_tmy_interval_index(): index = pd.date_range( '2005-01-01', periods=8760, freq='1h', tz=3600) index.freq = None - return index + interval_index = pd.IntervalIndex.from_arrays( + index, index + pd.Timedelta(hours=1), closed='left') + return interval_index @pytest.fixture @@ -271,7 +273,9 @@ def expected_metenorm_tmy_data(): index = pd.date_range( '2005-01-01', periods=12, freq='1h', tz=3600) index.freq = None - expected = pd.DataFrame(expected, index=index, columns=columns) + interval_index = pd.IntervalIndex.from_arrays( + index, index + pd.Timedelta(hours=1), closed='left') + expected = pd.DataFrame(expected, index=interval_index, columns=columns) return expected @@ -279,7 +283,7 @@ def expected_metenorm_tmy_data(): @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) def test_get_meteonorm_tmy( demo_api_key, demo_url, expected_meteonorm_tmy_meta, - expected_metenorm_tmy_data, expected_meteonorm_tmy_index): + expected_metenorm_tmy_data, expected_meteonorm_tmy_interval_index): data, meta = pvlib.iotools.get_meteonorm_tmy( latitude=50, longitude=10, api_key=demo_api_key, @@ -301,4 +305,5 @@ def test_get_meteonorm_tmy( url=demo_url) assert meta == expected_meteonorm_tmy_meta pd.testing.assert_frame_equal(data.iloc[:12], expected_metenorm_tmy_data) - pd.testing.assert_index_equal(data.index, expected_meteonorm_tmy_index) + pd.testing.assert_index_equal( + data.index, expected_meteonorm_tmy_interval_index) From 83d00cbd2afe915bd2d09203df0d6a8c594e44ee Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Sat, 2 Aug 2025 22:19:37 +0200 Subject: [PATCH 13/23] Increase test coverage --- pvlib/iotools/meteonorm.py | 9 +++++++++ tests/iotools/test_meteonorm.py | 16 +++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/pvlib/iotools/meteonorm.py b/pvlib/iotools/meteonorm.py index e3cc7b4684..d4716b29a3 100644 --- a/pvlib/iotools/meteonorm.py +++ b/pvlib/iotools/meteonorm.py @@ -100,6 +100,15 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, meta : dict Metadata. + Examples + -------- + >>> # Retrieve historical time series data + >>> df, meta = get_meteonorm( # doctest: +SKIP + ... latitude=50, longitude=10, # doctest: +SKIP + ... start='2023-01-01', end='2025-01-01', # doctest: +SKIP + ... api_key='redacted', # doctest: +SKIP + ... endpoint='observation/training') # doctest: +SKIP + See Also -------- pvlib.iotools.get_meteonorm_tmy diff --git a/tests/iotools/test_meteonorm.py b/tests/iotools/test_meteonorm.py index 42a10316b2..c16fafdf64 100644 --- a/tests/iotools/test_meteonorm.py +++ b/tests/iotools/test_meteonorm.py @@ -221,6 +221,20 @@ def test_get_meteonorm_HTTPError(demo_api_key, demo_url): url=demo_url) +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_meteonorm_tmy_HTTPError(demo_api_key, demo_url): + with pytest.raises( + HTTPError, match='parameter "surface_azimuth"'): + _ = pvlib.iotools.get_meteonorm_tmy( + latitude=50, longitude=10, + api_key=demo_api_key, + parameters='dhi', + # Infeasible surface_titl + surface_azimuth=400, + url=demo_url) + + @pytest.fixture def expected_meteonorm_tmy_meta(): meta = { @@ -294,7 +308,7 @@ def test_get_meteonorm_tmy( horizon=list(np.ones(360).astype(int)*2), terrain='open', albedo=0.5, - turbidity='auto', + turbidity=[5.2, 4, 3, 3.1, 3.0, 2.8, 3.14, 3.0, 3, 3, 4, 5], random_seed=100, clear_sky_radiation_model='solis', data_version='v9.0', # fix version From 239cff1b6234745e2a0dd78638ed290e527b1a54 Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Mon, 4 Aug 2025 17:41:01 +0200 Subject: [PATCH 14/23] Implement feedback from Meteonorm review --- pvlib/iotools/meteonorm.py | 49 ++++++++++++++++----------------- tests/iotools/test_meteonorm.py | 7 ++--- 2 files changed, 27 insertions(+), 29 deletions(-) diff --git a/pvlib/iotools/meteonorm.py b/pvlib/iotools/meteonorm.py index d4716b29a3..22b7721d77 100644 --- a/pvlib/iotools/meteonorm.py +++ b/pvlib/iotools/meteonorm.py @@ -49,10 +49,10 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, In decimal degrees, east is positive (ISO 19115). start : datetime like First timestamp of the requested period. If a timezone is not - specified, UTC is assumed. Relative datetime strings are supported. + specified, UTC is assumed. end : datetime like Last timestamp of the requested period. If a timezone is not - specified, UTC is assumed. Relative datetime strings are supported. + specified, UTC is assumed. api_key : str Meteonorm API key. endpoint : str @@ -61,7 +61,8 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, * ``'observation/training'`` - historical data with a 7-day delay * ``'observation/realtime'`` - near-real time (past 7-days) * ``'forecast/basic'`` - forecast with hourly resolution - * ``'forecast/precision'`` - forecast with 15-min resolution + * ``'forecast/precision'`` - forecast with 1-min, 15-min, or hourly + resolution parameters : list or 'all', default : 'all' List of parameters to request or `'all'` to get all parameters. @@ -71,10 +72,9 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270). time_step : {'1min', '15min', '1h'}, default : '15min' - Frequency of the time series. The parameter is ignored when requesting - forcasting data. + Frequency of the time series. horizon : str or list, default : 'auto' - Specification of the horizon line. Can be either a 'flat', 'auto', or + Specification of the horizon line. Can be either 'flat', 'auto', or a list of 360 integer horizon elevation angles. interval_index : bool, default : False Index is pd.DatetimeIndex when False, and pd.IntervalIndex when True. @@ -103,7 +103,7 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, Examples -------- >>> # Retrieve historical time series data - >>> df, meta = get_meteonorm( # doctest: +SKIP + >>> df, meta = pvlib.iotools.get_meteonorm( # doctest: +SKIP ... latitude=50, longitude=10, # doctest: +SKIP ... start='2023-01-01', end='2025-01-01', # doctest: +SKIP ... api_key='redacted', # doctest: +SKIP @@ -122,6 +122,7 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, .. [3] `Meteonorm API reference `_ """ + # Relative date strings are not yet supported start = pd.Timestamp(start) end = pd.Timestamp(end) start = start.tz_localize('UTC') if start.tzinfo is None else start @@ -136,14 +137,12 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, 'surface_tilt': surface_tilt, 'surface_azimuth': surface_azimuth, 'horizon': horizon, + 'response_format': 'json', } # Allow specifying single parameters as string if isinstance(parameters, str): - parameter_list = \ - list(VARIABLE_MAP.keys()) + list(VARIABLE_MAP.values()) - if parameters in parameter_list: - parameters = [parameters] + parameters = [parameters] # convert list to string with values separated by commas if not isinstance(parameters, (str, type(None))): @@ -155,7 +154,7 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, if not isinstance(horizon, str): params['horizon'] = ','.join(map(str, horizon)) - if 'forecast' not in endpoint.lower(): + if 'basic' not in endpoint: params['frequency'] = TIME_STEP_MAP.get(time_step, time_step) headers = {"Authorization": f"Bearer {api_key}"} @@ -165,7 +164,8 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, if not response.ok: # response.raise_for_status() does not give a useful error message - raise requests.HTTPError(response.json()) + raise requests.HTTPError("Meteonorm API returned an error: " + + response.json()['error']['message']) data, meta = _parse_meteonorm(response, interval_index, map_variables) @@ -177,8 +177,8 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, def get_meteonorm_tmy(latitude, longitude, api_key, parameters='all', *, surface_tilt=0, - surface_azimuth=180, time_step='15min', horizon='auto', - terrain='open', albedo=0.2, turbidity='auto', + surface_azimuth=180, time_step='1h', horizon='auto', + terrain='open', albedo=None, turbidity='auto', random_seed=None, clear_sky_radiation_model='esra', data_version='latest', future_scenario=None, future_year=None, interval_index=False, @@ -214,8 +214,10 @@ def get_meteonorm_tmy(latitude, longitude, api_key, Local terrain situation. Must be one of: ['open', 'depression', 'cold_air_lake', 'sea_lake', 'city', 'slope_south', 'slope_west_east']. - albedo : float, default : 0.2 - Ground albedo. Albedo changes due to snow fall are modelled. + albedo : float, optional + Constant ground albedo. If no value is specified a baseline albedo of + 0.2 is used and albedo cahnges due to snow fall is modeled. If a value + is specified, then snow fall is not modeled. turbidity : list or 'auto', optional List of 12 monthly mean atmospheric Linke turbidity values. The default is 'auto'. @@ -272,7 +274,7 @@ def get_meteonorm_tmy(latitude, longitude, api_key, 'lon': longitude, 'surface_tilt': surface_tilt, 'surface_azimuth': surface_azimuth, - 'frequency': time_step, + 'frequency': TIME_STEP_MAP.get(time_step, time_step), 'parameters': parameters, 'horizon': horizon, 'situation': terrain, @@ -282,14 +284,12 @@ def get_meteonorm_tmy(latitude, longitude, api_key, 'random_seed': random_seed, 'future_scenario': future_scenario, 'future_year': future_year, + 'response_format': 'json', } # Allow specifying single parameters as string if isinstance(parameters, str): - parameter_list = \ - list(VARIABLE_MAP.keys()) + list(VARIABLE_MAP.values()) - if parameters in parameter_list: - parameters = [parameters] + parameters = [parameters] # convert list to string with values separated by commas if not isinstance(parameters, (str, type(None))): @@ -304,8 +304,6 @@ def get_meteonorm_tmy(latitude, longitude, api_key, if not isinstance(turbidity, str): params['turbidity'] = ','.join(map(str, turbidity)) - params['frequency'] = TIME_STEP_MAP.get(time_step, time_step) - headers = {"Authorization": f"Bearer {api_key}"} response = requests.get( @@ -313,7 +311,8 @@ def get_meteonorm_tmy(latitude, longitude, api_key, if not response.ok: # response.raise_for_status() does not give a useful error message - raise requests.HTTPError(response.json()) + raise requests.HTTPError("Meteonorm API returned an error: " + + response.json()['error']['message']) data, meta = _parse_meteonorm(response, interval_index, map_variables) diff --git a/tests/iotools/test_meteonorm.py b/tests/iotools/test_meteonorm.py index c16fafdf64..b37c99a8fe 100644 --- a/tests/iotools/test_meteonorm.py +++ b/tests/iotools/test_meteonorm.py @@ -183,8 +183,7 @@ def test_get_meteonorm_forecast_precision(demo_api_key, demo_url): api_key=demo_api_key, parameters='ghi', endpoint='forecast/precision', - # test that the time_step parameter is ignored - time_step='1h', + time_step='15min', url=demo_url) assert data.index[1] - data.index[0] == pd.Timedelta(minutes=15) @@ -210,7 +209,7 @@ def test_get_meteonorm_custom_horizon(demo_api_key, demo_url): @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) def test_get_meteonorm_HTTPError(demo_api_key, demo_url): with pytest.raises( - HTTPError, match="unknown parameter: not_a_real_parameter'"): + HTTPError, match="unknown parameter: not_a_real_parameter"): _ = pvlib.iotools.get_meteonorm( latitude=50, longitude=10, start=pd.Timestamp.now(tz='UTC'), @@ -230,7 +229,7 @@ def test_get_meteonorm_tmy_HTTPError(demo_api_key, demo_url): latitude=50, longitude=10, api_key=demo_api_key, parameters='dhi', - # Infeasible surface_titl + # Infeasible surface_tilt surface_azimuth=400, url=demo_url) From 8e3b1ece9318f27b8b9388a4848fd8346ff67731 Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Mon, 4 Aug 2025 22:03:27 +0200 Subject: [PATCH 15/23] Implement feedback from code review from kandersolar --- pvlib/iotools/meteonorm.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/pvlib/iotools/meteonorm.py b/pvlib/iotools/meteonorm.py index 22b7721d77..327edfa563 100644 --- a/pvlib/iotools/meteonorm.py +++ b/pvlib/iotools/meteonorm.py @@ -12,6 +12,9 @@ 'direct_normal_irradiance': 'dni', 'direct_horizontal_irradiance': 'bhi', 'global_clear_sky_irradiance': 'ghi_clear', + 'diffuse_clear_sky_irradiance': 'dhi_clear', + 'direct_normal_clear_sky_irradiance': 'dni_clear', + 'direct_horizontal_clear_sky_irradiance': 'bhi_clear', 'diffuse_tilted_irradiance': 'poa_diffuse', 'direct_tilted_irradiance': 'poa_direct', 'global_tilted_irradiance': 'poa', @@ -96,7 +99,7 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, ------- data : pd.DataFrame Time series data. The index corresponds to the start (left) of the - interval unless ``interval_index`` is set to False. + interval unless ``interval_index`` is set to True. meta : dict Metadata. @@ -144,12 +147,11 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, if isinstance(parameters, str): parameters = [parameters] + # allow the use of pvlib parameter names + parameter_dict = {v: k for k, v in VARIABLE_MAP.items()} + parameters = [parameter_dict.get(p, p) for p in parameters] # convert list to string with values separated by commas - if not isinstance(parameters, (str, type(None))): - # allow the use of pvlib parameter names - parameter_dict = {v: k for k, v in VARIABLE_MAP.items()} - parameters = [parameter_dict.get(p, p) for p in parameters] - params['parameters'] = ','.join(parameters) + params['parameters'] = ','.join(parameters) if not isinstance(horizon, str): params['horizon'] = ','.join(map(str, horizon)) @@ -216,7 +218,7 @@ def get_meteonorm_tmy(latitude, longitude, api_key, 'slope_west_east']. albedo : float, optional Constant ground albedo. If no value is specified a baseline albedo of - 0.2 is used and albedo cahnges due to snow fall is modeled. If a value + 0.2 is used and albedo changes due to snow fall are modeled. If a value is specified, then snow fall is not modeled. turbidity : list or 'auto', optional List of 12 monthly mean atmospheric Linke turbidity values. The default @@ -252,7 +254,7 @@ def get_meteonorm_tmy(latitude, longitude, api_key, ------- data : pd.DataFrame Time series data. The index corresponds to the start (left) of the - interval unless ``interval_index`` is set to False. + interval unless ``interval_index`` is set to True. meta : dict Metadata. @@ -291,12 +293,11 @@ def get_meteonorm_tmy(latitude, longitude, api_key, if isinstance(parameters, str): parameters = [parameters] + # allow the use of pvlib parameter names + parameter_dict = {v: k for k, v in VARIABLE_MAP.items()} + parameters = [parameter_dict.get(p, p) for p in parameters] # convert list to string with values separated by commas - if not isinstance(parameters, (str, type(None))): - # allow the use of pvlib parameter names - parameter_dict = {v: k for k, v in VARIABLE_MAP.items()} - parameters = [parameter_dict.get(p, p) for p in parameters] - params['parameters'] = ','.join(parameters) + params['parameters'] = ','.join(parameters) if not isinstance(horizon, str): params['horizon'] = ','.join(map(str, horizon)) From b15a17002045b2fee8277d698325b223d84e6bd0 Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Tue, 5 Aug 2025 11:10:57 +0200 Subject: [PATCH 16/23] basic endpoint only support '1h', rename terrain_situation --- pvlib/iotools/meteonorm.py | 13 +++++++++---- tests/iotools/test_meteonorm.py | 19 ++++++++++++++++++- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/pvlib/iotools/meteonorm.py b/pvlib/iotools/meteonorm.py index 327edfa563..bca82c734e 100644 --- a/pvlib/iotools/meteonorm.py +++ b/pvlib/iotools/meteonorm.py @@ -75,7 +75,8 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270). time_step : {'1min', '15min', '1h'}, default : '15min' - Frequency of the time series. + Frequency of the time series. The endpoint ``'forecast/basic'`` only + supports ``time_step='1h'``. horizon : str or list, default : 'auto' Specification of the horizon line. Can be either 'flat', 'auto', or a list of 360 integer horizon elevation angles. @@ -158,6 +159,10 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, if 'basic' not in endpoint: params['frequency'] = TIME_STEP_MAP.get(time_step, time_step) + else: + if time_step not in ['1h', '1_hour']: + raise ValueError("The 'forecast/basic' api endpoint only " + "supports ``time_step='1h'``.") headers = {"Authorization": f"Bearer {api_key}"} @@ -180,7 +185,7 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, def get_meteonorm_tmy(latitude, longitude, api_key, parameters='all', *, surface_tilt=0, surface_azimuth=180, time_step='1h', horizon='auto', - terrain='open', albedo=None, turbidity='auto', + terrain_situation='open', albedo=None, turbidity='auto', random_seed=None, clear_sky_radiation_model='esra', data_version='latest', future_scenario=None, future_year=None, interval_index=False, @@ -212,7 +217,7 @@ def get_meteonorm_tmy(latitude, longitude, api_key, Specification of the horizon line. Can be either 'flat' or 'auto', or specified as a list of 360 integer horizon elevation angles. 'auto'. - terrain : str, default : 'open' + terrain_situation : str, default : 'open' Local terrain situation. Must be one of: ['open', 'depression', 'cold_air_lake', 'sea_lake', 'city', 'slope_south', 'slope_west_east']. @@ -279,7 +284,7 @@ def get_meteonorm_tmy(latitude, longitude, api_key, 'frequency': TIME_STEP_MAP.get(time_step, time_step), 'parameters': parameters, 'horizon': horizon, - 'situation': terrain, + 'situation': terrain_situation, 'turbidity': turbidity, 'clear_sky_radiation_model': clear_sky_radiation_model, 'data_version': data_version, diff --git a/tests/iotools/test_meteonorm.py b/tests/iotools/test_meteonorm.py index b37c99a8fe..13153242a4 100644 --- a/tests/iotools/test_meteonorm.py +++ b/tests/iotools/test_meteonorm.py @@ -162,6 +162,7 @@ def test_get_meteonorm_forecast_basic(demo_api_key, demo_url): latitude=50, longitude=10, start=pd.Timestamp.now(tz='UTC'), end=pd.Timestamp.now(tz='UTC') + pd.Timedelta(hours=5), + time_step='1h', api_key=demo_api_key, parameters='ghi', endpoint='forecast/basic', @@ -200,6 +201,7 @@ def test_get_meteonorm_custom_horizon(demo_api_key, demo_url): end=pd.Timestamp.now(tz='UTC') + pd.Timedelta(hours=5), api_key=demo_api_key, parameters='ghi', + time_step='1h', endpoint='forecast/basic', horizon=list(np.ones(360).astype(int)*80), url=demo_url) @@ -214,12 +216,27 @@ def test_get_meteonorm_HTTPError(demo_api_key, demo_url): latitude=50, longitude=10, start=pd.Timestamp.now(tz='UTC'), end=pd.Timestamp.now(tz='UTC') + pd.Timedelta(hours=5), + time_step='1h', api_key=demo_api_key, parameters='not_a_real_parameter', endpoint='forecast/basic', url=demo_url) +def test_get_meteonorm_basic_forecast_incorrect_time_step( + demo_api_key, demo_url): + with pytest.raises( + ValueError, match="only supports ``time_step='1h'``"): + _ = pvlib.iotools.get_meteonorm( + latitude=50, longitude=10, + start=pd.Timestamp.now(tz='UTC'), + end=pd.Timestamp.now(tz='UTC') + pd.Timedelta(hours=5), + time_step='15min', # only '1h' is supported for tmy + api_key=demo_api_key, + endpoint='forecast/basic', + url=demo_url) + + @pytest.mark.remote_data @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) def test_get_meteonorm_tmy_HTTPError(demo_api_key, demo_url): @@ -305,7 +322,7 @@ def test_get_meteonorm_tmy( surface_azimuth=90, time_step='1h', horizon=list(np.ones(360).astype(int)*2), - terrain='open', + terrain_situation='open', albedo=0.5, turbidity=[5.2, 4, 3, 3.1, 3.0, 2.8, 3.14, 3.0, 3, 3, 4, 5], random_seed=100, From 9129ad2b480f8eac1f4bb60641cbaa984a3b4800 Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Wed, 6 Aug 2025 00:47:42 +0200 Subject: [PATCH 17/23] Split get_meteonorm into forecast and observation --- docs/sphinx/source/reference/iotools.rst | 3 +- docs/sphinx/source/whatsnew/v0.13.1.rst | 3 +- pvlib/iotools/__init__.py | 3 +- pvlib/iotools/meteonorm.py | 154 +++++++++++++++++++---- tests/iotools/test_meteonorm.py | 30 ++--- 5 files changed, 154 insertions(+), 39 deletions(-) diff --git a/docs/sphinx/source/reference/iotools.rst b/docs/sphinx/source/reference/iotools.rst index 75d18372a1..4fd53f1b40 100644 --- a/docs/sphinx/source/reference/iotools.rst +++ b/docs/sphinx/source/reference/iotools.rst @@ -98,7 +98,8 @@ Meteonorm .. autosummary:: :toctree: generated/ - iotools.get_meteonorm + iotools.get_meteonorm_observation + iotools.get_meteonorm_forecast iotools.get_meteonorm_tmy diff --git a/docs/sphinx/source/whatsnew/v0.13.1.rst b/docs/sphinx/source/whatsnew/v0.13.1.rst index 57a5baf73f..8ceac6af08 100644 --- a/docs/sphinx/source/whatsnew/v0.13.1.rst +++ b/docs/sphinx/source/whatsnew/v0.13.1.rst @@ -21,7 +21,8 @@ Bug fixes Enhancements ~~~~~~~~~~~~ * Add iotools functions to retrieve irradiance and weather data from Meteonorm: - :py:func:`~pvlib.iotools.get_meteonorm` and :py:func:`~pvlib.iotools.get_meteonorm_tmy`. + :py:func:`~pvlib.iotools.get_meteonorm_observation`, :py:func:`~pvlib.iotools.get_meteonorm_forecast`, + and :py:func:`~pvlib.iotools.get_meteonorm_tmy`. (:pull:`2499`) * Add :py:func:`pvlib.iotools.get_nasa_power` to retrieve data from NASA POWER free API. (:pull:`2500`) diff --git a/pvlib/iotools/__init__.py b/pvlib/iotools/__init__.py index 5b510f2b8c..0907e83b9b 100644 --- a/pvlib/iotools/__init__.py +++ b/pvlib/iotools/__init__.py @@ -39,6 +39,7 @@ from pvlib.iotools.solcast import get_solcast_historic # noqa: F401 from pvlib.iotools.solcast import get_solcast_tmy # noqa: F401 from pvlib.iotools.solargis import get_solargis # noqa: F401 -from pvlib.iotools.meteonorm import get_meteonorm # noqa: F401 +from pvlib.iotools.meteonorm import get_meteonorm_observation # noqa: F401 +from pvlib.iotools.meteonorm import get_meteonorm_forecast # noqa: F401 from pvlib.iotools.meteonorm import get_meteonorm_tmy # noqa: F401 from pvlib.iotools.nasa_power import get_nasa_power # noqa: F401 diff --git a/pvlib/iotools/meteonorm.py b/pvlib/iotools/meteonorm.py index bca82c734e..c8819507a7 100644 --- a/pvlib/iotools/meteonorm.py +++ b/pvlib/iotools/meteonorm.py @@ -31,18 +31,19 @@ } -def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, - parameters='all', *, surface_tilt=0, surface_azimuth=180, - time_step='15min', horizon='auto', interval_index=False, - map_variables=True, url=URL): +def get_meteonorm_observation( + latitude, longitude, start, end, api_key, endpoint='training', + parameters='all', *, surface_tilt=0, surface_azimuth=180, + time_step='15min', horizon='auto', interval_index=False, + map_variables=True, url=URL): """ - Retrieve irradiance and weather data from Meteonorm. + Retrieve historical and near real-time observational data from Meteonorm. The Meteonorm data options are described in [1]_ and the API is described in [2]_. A detailed list of API options can be found in [3]_. - This function supports retrieval of historical and forecast data, but not - TMY. + This function supports retrieval of observation data, either the + 'training' or the 'realtime' endpoints. Parameters ---------- @@ -58,14 +59,11 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, specified, UTC is assumed. api_key : str Meteonorm API key. - endpoint : str + endpoint : str, default : training API endpoint, see [3]_. Must be one of: - * ``'observation/training'`` - historical data with a 7-day delay - * ``'observation/realtime'`` - near-real time (past 7-days) - * ``'forecast/basic'`` - forecast with hourly resolution - * ``'forecast/precision'`` - forecast with 1-min, 15-min, or hourly - resolution + * ``'training'`` - historical data with a 7-day delay + * ``'realtime'`` - near-real time (past 7-days) parameters : list or 'all', default : 'all' List of parameters to request or `'all'` to get all parameters. @@ -75,8 +73,7 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270). time_step : {'1min', '15min', '1h'}, default : '15min' - Frequency of the time series. The endpoint ``'forecast/basic'`` only - supports ``time_step='1h'``. + Frequency of the time series. horizon : str or list, default : 'auto' Specification of the horizon line. Can be either 'flat', 'auto', or a list of 360 integer horizon elevation angles. @@ -87,8 +84,7 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, When true, renames columns of the Dataframe to pvlib variable names where applicable. See variable :const:`VARIABLE_MAP`. url : str, optional - Base URL of the Meteonorm API. The ``endpoint`` parameter is - appended to the url. The default is + Base URL of the Meteonorm API. The default is :const:`pvlib.iotools.meteonorm.URL`. Raises @@ -107,11 +103,11 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, Examples -------- >>> # Retrieve historical time series data - >>> df, meta = pvlib.iotools.get_meteonorm( # doctest: +SKIP + >>> df, meta = pvlib.iotools.get_meteonorm_observatrion( # doctest: +SKIP ... latitude=50, longitude=10, # doctest: +SKIP ... start='2023-01-01', end='2025-01-01', # doctest: +SKIP ... api_key='redacted', # doctest: +SKIP - ... endpoint='observation/training') # doctest: +SKIP + ... endpoint='training') # doctest: +SKIP See Also -------- @@ -126,6 +122,120 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, .. [3] `Meteonorm API reference `_ """ + endpoint_base = 'observation/' + + data, meta = _get_meteonorm( + latitude, longitude, start, end, api_key, + endpoint_base, endpoint, + parameters, surface_tilt, surface_azimuth, + time_step, horizon, interval_index, + map_variables, url) + return data, meta + + +def get_meteonorm_forecast( + latitude, longitude, start, end, api_key, endpoint='precision', + parameters='all', *, surface_tilt=0, surface_azimuth=180, + time_step='15min', horizon='auto', interval_index=False, + map_variables=True, url=URL): + """ + Retrieve historical and near real-time observational data from Meteonorm. + + The Meteonorm data options are described in [1]_ and the API is described + in [2]_. A detailed list of API options can be found in [3]_. + + This function supports retrieval of forecasting data, either the + 'training' or the 'basic' endpoints. + + Parameters + ---------- + latitude : float + In decimal degrees, north is positive (ISO 19115). + longitude: float + In decimal degrees, east is positive (ISO 19115). + start : datetime like + First timestamp of the requested period. If a timezone is not + specified, UTC is assumed. + end : datetime like + Last timestamp of the requested period. If a timezone is not + specified, UTC is assumed. + api_key : str + Meteonorm API key. + endpoint : str, default : precision + API endpoint, see [3]_. Must be one of: + + * ``'precision'`` - forecast with 1-min, 15-min, or hourly + resolution + * ``'basic'`` - forecast with hourly resolution + + parameters : list or 'all', default : 'all' + List of parameters to request or `'all'` to get all parameters. + surface_tilt : float, default : 0 + Tilt angle from horizontal plane. + surface_azimuth : float, default : 180 + Orientation (azimuth angle) of the (fixed) plane. Clockwise from north + (north=0, east=90, south=180, west=270). + time_step : {'1min', '15min', '1h'}, default : '15min' + Frequency of the time series. The endpoint ``'basic'`` only + supports ``time_step='1h'``. + horizon : str or list, default : 'auto' + Specification of the horizon line. Can be either 'flat', 'auto', or + a list of 360 integer horizon elevation angles. + interval_index : bool, default : False + Index is pd.DatetimeIndex when False, and pd.IntervalIndex when True. + This is an experimental feature which may be removed without warning. + map_variables : bool, default : True + When true, renames columns of the Dataframe to pvlib variable names + where applicable. See variable :const:`VARIABLE_MAP`. + url : str, optional + Base URL of the Meteonorm API. The default is + :const:`pvlib.iotools.meteonorm.URL`. + + Raises + ------ + requests.HTTPError + Raises an error when an incorrect request is made. + + Returns + ------- + data : pd.DataFrame + Time series data. The index corresponds to the start (left) of the + interval unless ``interval_index`` is set to True. + meta : dict + Metadata. + + See Also + -------- + pvlib.iotools.get_meteonorm_observation, + pvlib.iotools.get_meteonorm_tmy + + References + ---------- + .. [1] `Meteonorm + `_ + .. [2] `Meteonorm API + `_ + .. [3] `Meteonorm API reference + `_ + """ + endpoint_base = 'forecast/' + + data, meta = _get_meteonorm( + latitude, longitude, start, end, api_key, + endpoint_base, endpoint, + parameters, surface_tilt, surface_azimuth, + time_step, horizon, interval_index, + map_variables, url) + return data, meta + + +def _get_meteonorm( + latitude, longitude, start, end, api_key, + endpoint_base, endpoint, + parameters, surface_tilt, surface_azimuth, + time_step, horizon, interval_index, + map_variables, url): + # Relative date strings are not yet supported start = pd.Timestamp(start) end = pd.Timestamp(end) @@ -167,7 +277,8 @@ def get_meteonorm(latitude, longitude, start, end, api_key, endpoint, headers = {"Authorization": f"Bearer {api_key}"} response = requests.get( - urljoin(url, endpoint.lstrip('/')), headers=headers, params=params) + urljoin(url, endpoint_base + endpoint.lstrip('/')), + headers=headers, params=params) if not response.ok: # response.raise_for_status() does not give a useful error message @@ -265,7 +376,8 @@ def get_meteonorm_tmy(latitude, longitude, api_key, See Also -------- - pvlib.iotools.get_meteonorm + pvlib.iotools.get_meteonorm_observation, + pvlib.iotools.get_meteonorm_forecast References ---------- diff --git a/tests/iotools/test_meteonorm.py b/tests/iotools/test_meteonorm.py index 13153242a4..78b9f39f8c 100644 --- a/tests/iotools/test_meteonorm.py +++ b/tests/iotools/test_meteonorm.py @@ -111,12 +111,12 @@ def expected_columns_all(): def test_get_meteonorm_training( demo_api_key, demo_url, expected_meta, expected_meteonorm_index, expected_metenorm_data): - data, meta = pvlib.iotools.get_meteonorm( + data, meta = pvlib.iotools.get_meteonorm_observation( latitude=50, longitude=10, start='2023-01-01', end='2025-01-01', api_key=demo_api_key, parameters=['ghi', 'global_horizontal_irradiance_with_shading'], - endpoint='observation/training', + endpoint='training', time_step='1h', url=demo_url) @@ -128,14 +128,14 @@ def test_get_meteonorm_training( @pytest.mark.remote_data @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) def test_get_meteonorm_realtime(demo_api_key, demo_url, expected_columns_all): - data, meta = pvlib.iotools.get_meteonorm( + data, meta = pvlib.iotools.get_meteonorm_observation( latitude=21, longitude=79, start=pd.Timestamp.now(tz='UTC') - pd.Timedelta(hours=5), end=pd.Timestamp.now(tz='UTC') - pd.Timedelta(hours=1), surface_tilt=20, surface_azimuth=10, parameters=['all'], api_key=demo_api_key, - endpoint='/observation/realtime', + endpoint='realtime', time_step='1min', horizon='flat', map_variables=False, @@ -158,14 +158,14 @@ def test_get_meteonorm_realtime(demo_api_key, demo_url, expected_columns_all): @pytest.mark.remote_data @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) def test_get_meteonorm_forecast_basic(demo_api_key, demo_url): - data, meta = pvlib.iotools.get_meteonorm( + data, meta = pvlib.iotools.get_meteonorm_forecast( latitude=50, longitude=10, start=pd.Timestamp.now(tz='UTC'), end=pd.Timestamp.now(tz='UTC') + pd.Timedelta(hours=5), time_step='1h', api_key=demo_api_key, parameters='ghi', - endpoint='forecast/basic', + endpoint='basic', url=demo_url) assert data.shape == (6, 1) @@ -177,13 +177,13 @@ def test_get_meteonorm_forecast_basic(demo_api_key, demo_url): @pytest.mark.remote_data @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) def test_get_meteonorm_forecast_precision(demo_api_key, demo_url): - data, meta = pvlib.iotools.get_meteonorm( + data, meta = pvlib.iotools.get_meteonorm_forecast( latitude=50, longitude=10, start=pd.Timestamp.now(tz='UTC') + pd.Timedelta(hours=5), end=pd.Timestamp.now(tz='UTC') + pd.Timedelta(hours=6), api_key=demo_api_key, parameters='ghi', - endpoint='forecast/precision', + endpoint='precision', time_step='15min', url=demo_url) @@ -195,31 +195,31 @@ def test_get_meteonorm_forecast_precision(demo_api_key, demo_url): @pytest.mark.remote_data @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) def test_get_meteonorm_custom_horizon(demo_api_key, demo_url): - data, meta = pvlib.iotools.get_meteonorm( + data, meta = pvlib.iotools.get_meteonorm_forecast( latitude=50, longitude=10, start=pd.Timestamp.now(tz='UTC'), end=pd.Timestamp.now(tz='UTC') + pd.Timedelta(hours=5), api_key=demo_api_key, parameters='ghi', time_step='1h', - endpoint='forecast/basic', + endpoint='basic', horizon=list(np.ones(360).astype(int)*80), url=demo_url) @pytest.mark.remote_data @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) -def test_get_meteonorm_HTTPError(demo_api_key, demo_url): +def test_get_meteonorm_forecast_HTTPError(demo_api_key, demo_url): with pytest.raises( HTTPError, match="unknown parameter: not_a_real_parameter"): - _ = pvlib.iotools.get_meteonorm( + _ = pvlib.iotools.get_meteonorm_forecast( latitude=50, longitude=10, start=pd.Timestamp.now(tz='UTC'), end=pd.Timestamp.now(tz='UTC') + pd.Timedelta(hours=5), time_step='1h', api_key=demo_api_key, parameters='not_a_real_parameter', - endpoint='forecast/basic', + endpoint='basic', url=demo_url) @@ -227,13 +227,13 @@ def test_get_meteonorm_basic_forecast_incorrect_time_step( demo_api_key, demo_url): with pytest.raises( ValueError, match="only supports ``time_step='1h'``"): - _ = pvlib.iotools.get_meteonorm( + _ = pvlib.iotools.get_meteonorm_forecast( latitude=50, longitude=10, start=pd.Timestamp.now(tz='UTC'), end=pd.Timestamp.now(tz='UTC') + pd.Timedelta(hours=5), time_step='15min', # only '1h' is supported for tmy api_key=demo_api_key, - endpoint='forecast/basic', + endpoint='basic', url=demo_url) From 3e9329f581d496448d8662487837de6f248dc7a4 Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Wed, 6 Aug 2025 00:53:16 +0200 Subject: [PATCH 18/23] Fix linter --- pvlib/iotools/meteonorm.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/pvlib/iotools/meteonorm.py b/pvlib/iotools/meteonorm.py index c8819507a7..de32d875f8 100644 --- a/pvlib/iotools/meteonorm.py +++ b/pvlib/iotools/meteonorm.py @@ -125,11 +125,11 @@ def get_meteonorm_observation( endpoint_base = 'observation/' data, meta = _get_meteonorm( - latitude, longitude, start, end, api_key, - endpoint_base, endpoint, - parameters, surface_tilt, surface_azimuth, - time_step, horizon, interval_index, - map_variables, url) + latitude, longitude, start, end, api_key, + endpoint_base, endpoint, + parameters, surface_tilt, surface_azimuth, + time_step, horizon, interval_index, + map_variables, url) return data, meta @@ -221,11 +221,11 @@ def get_meteonorm_forecast( endpoint_base = 'forecast/' data, meta = _get_meteonorm( - latitude, longitude, start, end, api_key, - endpoint_base, endpoint, - parameters, surface_tilt, surface_azimuth, - time_step, horizon, interval_index, - map_variables, url) + latitude, longitude, start, end, api_key, + endpoint_base, endpoint, + parameters, surface_tilt, surface_azimuth, + time_step, horizon, interval_index, + map_variables, url) return data, meta From df27bbc2bc2f1af3d439fcdbf6bfc71b9c87ed98 Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Thu, 7 Aug 2025 12:00:29 +0200 Subject: [PATCH 19/23] Split observation/forecast into four functions --- docs/sphinx/source/reference/iotools.rst | 6 +- pvlib/iotools/__init__.py | 6 +- pvlib/iotools/meteonorm.py | 538 +++++++++++++++++------ tests/iotools/test_meteonorm.py | 35 +- 4 files changed, 410 insertions(+), 175 deletions(-) diff --git a/docs/sphinx/source/reference/iotools.rst b/docs/sphinx/source/reference/iotools.rst index 4fd53f1b40..12db7d6818 100644 --- a/docs/sphinx/source/reference/iotools.rst +++ b/docs/sphinx/source/reference/iotools.rst @@ -98,8 +98,10 @@ Meteonorm .. autosummary:: :toctree: generated/ - iotools.get_meteonorm_observation - iotools.get_meteonorm_forecast + iotools.get_meteonorm_forecast_basic + iotools.get_meteonorm_forecast_precision + iotools.get_meteonorm_observation_training + iotools.get_meteonorm_observation_realtime iotools.get_meteonorm_tmy diff --git a/pvlib/iotools/__init__.py b/pvlib/iotools/__init__.py index 0907e83b9b..2858d5acce 100644 --- a/pvlib/iotools/__init__.py +++ b/pvlib/iotools/__init__.py @@ -39,7 +39,9 @@ from pvlib.iotools.solcast import get_solcast_historic # noqa: F401 from pvlib.iotools.solcast import get_solcast_tmy # noqa: F401 from pvlib.iotools.solargis import get_solargis # noqa: F401 -from pvlib.iotools.meteonorm import get_meteonorm_observation # noqa: F401 -from pvlib.iotools.meteonorm import get_meteonorm_forecast # noqa: F401 +from pvlib.iotools.meteonorm import get_meteonorm_forecast_basic # noqa: F401 +from pvlib.iotools.meteonorm import get_meteonorm_forecast_precision # noqa: F401 +from pvlib.iotools.meteonorm import get_meteonorm_observation_realtime # noqa: F401 +from pvlib.iotools.meteonorm import get_meteonorm_observation_training # noqa: F401 from pvlib.iotools.meteonorm import get_meteonorm_tmy # noqa: F401 from pvlib.iotools.nasa_power import get_nasa_power # noqa: F401 diff --git a/pvlib/iotools/meteonorm.py b/pvlib/iotools/meteonorm.py index de32d875f8..7c320dda53 100644 --- a/pvlib/iotools/meteonorm.py +++ b/pvlib/iotools/meteonorm.py @@ -4,46 +4,56 @@ import requests from urllib.parse import urljoin -URL = 'https://api.meteonorm.com/v1/' +URL = "https://api.meteonorm.com/v1/" VARIABLE_MAP = { - 'global_horizontal_irradiance': 'ghi', - 'diffuse_horizontal_irradiance': 'dhi', - 'direct_normal_irradiance': 'dni', - 'direct_horizontal_irradiance': 'bhi', - 'global_clear_sky_irradiance': 'ghi_clear', - 'diffuse_clear_sky_irradiance': 'dhi_clear', - 'direct_normal_clear_sky_irradiance': 'dni_clear', - 'direct_horizontal_clear_sky_irradiance': 'bhi_clear', - 'diffuse_tilted_irradiance': 'poa_diffuse', - 'direct_tilted_irradiance': 'poa_direct', - 'global_tilted_irradiance': 'poa', - 'temperature': 'temp_air', - 'dew_point_temperature': 'temp_dew', + "global_horizontal_irradiance": "ghi", + "diffuse_horizontal_irradiance": "dhi", + "direct_normal_irradiance": "dni", + "direct_horizontal_irradiance": "bhi", + "global_clear_sky_irradiance": "ghi_clear", + "diffuse_clear_sky_irradiance": "dhi_clear", + "direct_normal_clear_sky_irradiance": "dni_clear", + "direct_horizontal_clear_sky_irradiance": "bhi_clear", + "diffuse_tilted_irradiance": "poa_diffuse", + "direct_tilted_irradiance": "poa_direct", + "global_tilted_irradiance": "poa", + "temperature": "temp_air", + "dew_point_temperature": "temp_dew", } TIME_STEP_MAP = { - '1h': '1_hour', - 'h': '1_hour', - '15min': '15_minutes', - '1min': '1_minute', - 'min': '1_minute', + "1h": "1_hour", + "h": "1_hour", + "15min": "15_minutes", + "1min": "1_minute", + "min": "1_minute", } -def get_meteonorm_observation( - latitude, longitude, start, end, api_key, endpoint='training', - parameters='all', *, surface_tilt=0, surface_azimuth=180, - time_step='15min', horizon='auto', interval_index=False, - map_variables=True, url=URL): +def get_meteonorm_forecast_basic( + latitude, + longitude, + start, + end, + api_key, + parameters="all", + *, + surface_tilt=0, + surface_azimuth=180, + horizon="auto", + interval_index=False, + map_variables=True, + url=URL, +): """ - Retrieve historical and near real-time observational data from Meteonorm. + Retrieve basic forecast data from Meteonorm. + + The basic forecast data only supports hourly time step. The Meteonorm data options are described in [1]_ and the API is described in [2]_. A detailed list of API options can be found in [3]_. - This function supports retrieval of observation data, either the - 'training' or the 'realtime' endpoints. Parameters ---------- @@ -59,12 +69,111 @@ def get_meteonorm_observation( specified, UTC is assumed. api_key : str Meteonorm API key. - endpoint : str, default : training - API endpoint, see [3]_. Must be one of: + parameters : list or 'all', default : 'all' + List of parameters to request or `'all'` to get all parameters. + surface_tilt : float, default : 0 + Tilt angle from horizontal plane. + surface_azimuth : float, default : 180 + Orientation (azimuth angle) of the (fixed) plane. Clockwise from north + (north=0, east=90, south=180, west=270). + horizon : str or list, default : 'auto' + Specification of the horizon line. Can be either 'flat', 'auto', or + a list of 360 integer horizon elevation angles. + interval_index : bool, default : False + Index is pd.DatetimeIndex when False, and pd.IntervalIndex when True. + This is an experimental feature which may be removed without warning. + map_variables : bool, default : True + When true, renames columns of the Dataframe to pvlib variable names + where applicable. See variable :const:`VARIABLE_MAP`. + url : str, optional + Base URL of the Meteonorm API. The default is + :const:`pvlib.iotools.meteonorm.URL`. + + Raises + ------ + requests.HTTPError + Raises an error when an incorrect request is made. + + Returns + ------- + data : pd.DataFrame + Time series data. The index corresponds to the start (left) of the + interval unless ``interval_index`` is set to True. + meta : dict + Metadata. - * ``'training'`` - historical data with a 7-day delay - * ``'realtime'`` - near-real time (past 7-days) + See Also + -------- + pvlib.iotools.get_meteonorm_observation, + pvlib.iotools.get_meteonorm_tmy + References + ---------- + .. [1] `Meteonorm + `_ + .. [2] `Meteonorm API + `_ + .. [3] `Meteonorm API reference + `_ + """ + endpoint = "forecast/basic" + time_step = None + + data, meta = _get_meteonorm( + latitude, + longitude, + start, + end, + api_key, + parameters, + surface_tilt, + surface_azimuth, + time_step, + horizon, + interval_index, + map_variables, + url, + endpoint, + ) + return data, meta + + +def get_meteonorm_forecast_precision( + latitude, + longitude, + start, + end, + api_key, + parameters="all", + *, + surface_tilt=0, + surface_azimuth=180, + time_step="15min", + horizon="auto", + interval_index=False, + map_variables=True, + url=URL, +): + """ + Retrieve precision forecast data from Meteonorm. + + The Meteonorm data options are described in [1]_ and the API is described + in [2]_. A detailed list of API options can be found in [3]_. + + Parameters + ---------- + latitude : float + In decimal degrees, north is positive (ISO 19115). + longitude: float + In decimal degrees, east is positive (ISO 19115). + start : datetime like + First timestamp of the requested period. If a timezone is not + specified, UTC is assumed. + end : datetime like + Last timestamp of the requested period. If a timezone is not + specified, UTC is assumed. + api_key : str + Meteonorm API key. parameters : list or 'all', default : 'all' List of parameters to request or `'all'` to get all parameters. surface_tilt : float, default : 0 @@ -73,7 +182,8 @@ def get_meteonorm_observation( Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270). time_step : {'1min', '15min', '1h'}, default : '15min' - Frequency of the time series. + Frequency of the time series. The endpoint ``'basic'`` only + supports ``time_step='1h'``. horizon : str or list, default : 'auto' Specification of the horizon line. Can be either 'flat', 'auto', or a list of 360 integer horizon elevation angles. @@ -100,18 +210,9 @@ def get_meteonorm_observation( meta : dict Metadata. - Examples - -------- - >>> # Retrieve historical time series data - >>> df, meta = pvlib.iotools.get_meteonorm_observatrion( # doctest: +SKIP - ... latitude=50, longitude=10, # doctest: +SKIP - ... start='2023-01-01', end='2025-01-01', # doctest: +SKIP - ... api_key='redacted', # doctest: +SKIP - ... endpoint='training') # doctest: +SKIP - See Also -------- - pvlib.iotools.get_meteonorm_tmy + pvlib.iotools.get_meteonorm_forecast_basic References ---------- @@ -122,30 +223,50 @@ def get_meteonorm_observation( .. [3] `Meteonorm API reference `_ """ - endpoint_base = 'observation/' + endpoint = "forecast/precision" data, meta = _get_meteonorm( - latitude, longitude, start, end, api_key, - endpoint_base, endpoint, - parameters, surface_tilt, surface_azimuth, - time_step, horizon, interval_index, - map_variables, url) + latitude, + longitude, + start, + end, + api_key, + parameters, + surface_tilt, + surface_azimuth, + time_step, + horizon, + interval_index, + map_variables, + url, + endpoint, + ) return data, meta -def get_meteonorm_forecast( - latitude, longitude, start, end, api_key, endpoint='precision', - parameters='all', *, surface_tilt=0, surface_azimuth=180, - time_step='15min', horizon='auto', interval_index=False, - map_variables=True, url=URL): +def get_meteonorm_observation_realtime( + latitude, + longitude, + start, + end, + api_key, + parameters="all", + *, + surface_tilt=0, + surface_azimuth=180, + time_step="15min", + horizon="auto", + interval_index=False, + map_variables=True, + url=URL, +): """ - Retrieve historical and near real-time observational data from Meteonorm. + Retrieve near real-time observational data from Meteonorm. The Meteonorm data options are described in [1]_ and the API is described in [2]_. A detailed list of API options can be found in [3]_. - This function supports retrieval of forecasting data, either the - 'training' or the 'basic' endpoints. + Near-real time is supports data access for the past 7-days. Parameters ---------- @@ -161,13 +282,111 @@ def get_meteonorm_forecast( specified, UTC is assumed. api_key : str Meteonorm API key. - endpoint : str, default : precision - API endpoint, see [3]_. Must be one of: + parameters : list or 'all', default : 'all' + List of parameters to request or `'all'` to get all parameters. + surface_tilt : float, default : 0 + Tilt angle from horizontal plane. + surface_azimuth : float, default : 180 + Orientation (azimuth angle) of the (fixed) plane. Clockwise from north + (north=0, east=90, south=180, west=270). + time_step : {'1min', '15min', '1h'}, default : '15min' + Frequency of the time series. + horizon : str or list, default : 'auto' + Specification of the horizon line. Can be either 'flat', 'auto', or + a list of 360 integer horizon elevation angles. + interval_index : bool, default : False + Index is pd.DatetimeIndex when False, and pd.IntervalIndex when True. + This is an experimental feature which may be removed without warning. + map_variables : bool, default : True + When true, renames columns of the Dataframe to pvlib variable names + where applicable. See variable :const:`VARIABLE_MAP`. + url : str, optional + Base URL of the Meteonorm API. The default is + :const:`pvlib.iotools.meteonorm.URL`. + + Raises + ------ + requests.HTTPError + Raises an error when an incorrect request is made. + + Returns + ------- + data : pd.DataFrame + Time series data. The index corresponds to the start (left) of the + interval unless ``interval_index`` is set to True. + meta : dict + Metadata. + + See Also + -------- + pvlib.iotools.get_meteonorm_observation_training + + References + ---------- + .. [1] `Meteonorm + `_ + .. [2] `Meteonorm API + `_ + .. [3] `Meteonorm API reference + `_ + """ + endpoint = "observation/realtime" - * ``'precision'`` - forecast with 1-min, 15-min, or hourly - resolution - * ``'basic'`` - forecast with hourly resolution + data, meta = _get_meteonorm( + latitude, + longitude, + start, + end, + api_key, + parameters, + surface_tilt, + surface_azimuth, + time_step, + horizon, + interval_index, + map_variables, + url, + endpoint, + ) + return data, meta + +def get_meteonorm_observation_training( + latitude, + longitude, + start, + end, + api_key, + parameters="all", + *, + surface_tilt=0, + surface_azimuth=180, + time_step="15min", + horizon="auto", + interval_index=False, + map_variables=True, + url=URL, +): + """ + Retrieve historical observational data from Meteonorm. + + The Meteonorm data options are described in [1]_ and the API is described + in [2]_. A detailed list of API options can be found in [3]_. + + Parameters + ---------- + latitude : float + In decimal degrees, north is positive (ISO 19115). + longitude: float + In decimal degrees, east is positive (ISO 19115). + start : datetime like + First timestamp of the requested period. If a timezone is not + specified, UTC is assumed. + end : datetime like + Last timestamp of the requested period. If a timezone is not + specified, UTC is assumed. + api_key : str + Meteonorm API key. parameters : list or 'all', default : 'all' List of parameters to request or `'all'` to get all parameters. surface_tilt : float, default : 0 @@ -176,8 +395,7 @@ def get_meteonorm_forecast( Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270). time_step : {'1min', '15min', '1h'}, default : '15min' - Frequency of the time series. The endpoint ``'basic'`` only - supports ``time_step='1h'``. + Frequency of the time series. horizon : str or list, default : 'auto' Specification of the horizon line. Can be either 'flat', 'auto', or a list of 360 integer horizon elevation angles. @@ -204,10 +422,18 @@ def get_meteonorm_forecast( meta : dict Metadata. + Examples + -------- + >>> # Retrieve historical time series data + >>> df, meta = pvlib.iotools.get_meteonorm_observation_training( # doctest: +SKIP + ... latitude=50, longitude=10, # doctest: +SKIP + ... start='2023-01-01', end='2025-01-01', # doctest: +SKIP + ... api_key='redacted', # doctest: +SKIP + ... endpoint='training') # doctest: +SKIP + See Also -------- - pvlib.iotools.get_meteonorm_observation, - pvlib.iotools.get_meteonorm_tmy + pvlib.iotools.get_meteonorm_observation_realtime References ---------- @@ -218,40 +444,60 @@ def get_meteonorm_forecast( .. [3] `Meteonorm API reference `_ """ - endpoint_base = 'forecast/' + endpoint = "observation/training" data, meta = _get_meteonorm( - latitude, longitude, start, end, api_key, - endpoint_base, endpoint, - parameters, surface_tilt, surface_azimuth, - time_step, horizon, interval_index, - map_variables, url) + latitude, + longitude, + start, + end, + api_key, + parameters, + surface_tilt, + surface_azimuth, + time_step, + horizon, + interval_index, + map_variables, + url, + endpoint, + ) return data, meta def _get_meteonorm( - latitude, longitude, start, end, api_key, - endpoint_base, endpoint, - parameters, surface_tilt, surface_azimuth, - time_step, horizon, interval_index, - map_variables, url): - + latitude, + longitude, + start, + end, + api_key, + parameters, + surface_tilt, + surface_azimuth, + time_step, + horizon, + interval_index, + map_variables, + url, + endpoint, +): # Relative date strings are not yet supported start = pd.Timestamp(start) end = pd.Timestamp(end) - start = start.tz_localize('UTC') if start.tzinfo is None else start - end = end.tz_localize('UTC') if end.tzinfo is None else end + start = start.tz_localize("UTC") if start.tzinfo is None else start + end = end.tz_localize("UTC") if end.tzinfo is None else end params = { - 'lat': latitude, - 'lon': longitude, - 'start': start.strftime('%Y-%m-%dT%H:%M:%SZ'), - 'end': end.strftime('%Y-%m-%dT%H:%M:%SZ'), - 'parameters': parameters, - 'surface_tilt': surface_tilt, - 'surface_azimuth': surface_azimuth, - 'horizon': horizon, - 'response_format': 'json', + "lat": latitude, + "lon": longitude, + "start": start.strftime("%Y-%m-%dT%H:%M:%SZ"), + "end": end.strftime("%Y-%m-%dT%H:%M:%SZ"), + "parameters": parameters, + "surface_tilt": surface_tilt, + "surface_azimuth": surface_azimuth, + "horizon": horizon, + 'frequency': TIME_STEP_MAP.get(time_step, time_step), + "response_format": "json", } # Allow specifying single parameters as string @@ -262,45 +508,53 @@ def _get_meteonorm( parameter_dict = {v: k for k, v in VARIABLE_MAP.items()} parameters = [parameter_dict.get(p, p) for p in parameters] # convert list to string with values separated by commas - params['parameters'] = ','.join(parameters) + params["parameters"] = ",".join(parameters) if not isinstance(horizon, str): - params['horizon'] = ','.join(map(str, horizon)) - - if 'basic' not in endpoint: - params['frequency'] = TIME_STEP_MAP.get(time_step, time_step) - else: - if time_step not in ['1h', '1_hour']: - raise ValueError("The 'forecast/basic' api endpoint only " - "supports ``time_step='1h'``.") + params["horizon"] = ",".join(map(str, horizon)) headers = {"Authorization": f"Bearer {api_key}"} response = requests.get( - urljoin(url, endpoint_base + endpoint.lstrip('/')), - headers=headers, params=params) + urljoin(url, endpoint), headers=headers, params=params + ) if not response.ok: # response.raise_for_status() does not give a useful error message - raise requests.HTTPError("Meteonorm API returned an error: " - + response.json()['error']['message']) + raise requests.HTTPError( + "Meteonorm API returned an error: " + response.json()["error"]["message"] + ) data, meta = _parse_meteonorm(response, interval_index, map_variables) return data, meta -TMY_ENDPOINT = 'climate/tmy' - - -def get_meteonorm_tmy(latitude, longitude, api_key, - parameters='all', *, surface_tilt=0, - surface_azimuth=180, time_step='1h', horizon='auto', - terrain_situation='open', albedo=None, turbidity='auto', - random_seed=None, clear_sky_radiation_model='esra', - data_version='latest', future_scenario=None, - future_year=None, interval_index=False, - map_variables=True, url=URL): +TMY_ENDPOINT = "climate/tmy" + + +def get_meteonorm_tmy( + latitude, + longitude, + api_key, + parameters="all", + *, + surface_tilt=0, + surface_azimuth=180, + time_step="1h", + horizon="auto", + terrain_situation="open", + albedo=None, + turbidity="auto", + random_seed=None, + clear_sky_radiation_model="esra", + data_version="latest", + future_scenario=None, + future_year=None, + interval_index=False, + map_variables=True, + url=URL, +): """ Retrieve TMY irradiance and weather data from Meteonorm. @@ -389,21 +643,21 @@ def get_meteonorm_tmy(latitude, longitude, api_key, `_ """ params = { - 'lat': latitude, - 'lon': longitude, - 'surface_tilt': surface_tilt, - 'surface_azimuth': surface_azimuth, - 'frequency': TIME_STEP_MAP.get(time_step, time_step), - 'parameters': parameters, - 'horizon': horizon, - 'situation': terrain_situation, - 'turbidity': turbidity, - 'clear_sky_radiation_model': clear_sky_radiation_model, - 'data_version': data_version, - 'random_seed': random_seed, - 'future_scenario': future_scenario, - 'future_year': future_year, - 'response_format': 'json', + "lat": latitude, + "lon": longitude, + "surface_tilt": surface_tilt, + "surface_azimuth": surface_azimuth, + "frequency": TIME_STEP_MAP.get(time_step, time_step), + "parameters": parameters, + "horizon": horizon, + "situation": terrain_situation, + "turbidity": turbidity, + "clear_sky_radiation_model": clear_sky_radiation_model, + "data_version": data_version, + "random_seed": random_seed, + "future_scenario": future_scenario, + "future_year": future_year, + "response_format": "json", } # Allow specifying single parameters as string @@ -414,23 +668,23 @@ def get_meteonorm_tmy(latitude, longitude, api_key, parameter_dict = {v: k for k, v in VARIABLE_MAP.items()} parameters = [parameter_dict.get(p, p) for p in parameters] # convert list to string with values separated by commas - params['parameters'] = ','.join(parameters) + params["parameters"] = ",".join(parameters) if not isinstance(horizon, str): - params['horizon'] = ','.join(map(str, horizon)) + params["horizon"] = ",".join(map(str, horizon)) if not isinstance(turbidity, str): - params['turbidity'] = ','.join(map(str, turbidity)) + params["turbidity"] = ",".join(map(str, turbidity)) headers = {"Authorization": f"Bearer {api_key}"} - response = requests.get( - urljoin(url, TMY_ENDPOINT.lstrip('/')), headers=headers, params=params) + response = requests.get(urljoin(url, TMY_ENDPOINT.lstrip("/")), headers=headers, params=params) if not response.ok: # response.raise_for_status() does not give a useful error message - raise requests.HTTPError("Meteonorm API returned an error: " - + response.json()['error']['message']) + raise requests.HTTPError( + "Meteonorm API returned an error: " + response.json()["error"]["message"] + ) data, meta = _parse_meteonorm(response, interval_index, map_variables) @@ -438,7 +692,7 @@ def get_meteonorm_tmy(latitude, longitude, api_key, def _parse_meteonorm(response, interval_index, map_variables): - data_json = response.json()['values'] + data_json = response.json()["values"] # identify empty columns empty_columns = [k for k, v in data_json.items() if v is None] # remove empty columns @@ -449,18 +703,18 @@ def _parse_meteonorm(response, interval_index, map_variables): # xxx: experimental feature - see parameter description if interval_index: data.index = pd.IntervalIndex.from_arrays( - left=pd.to_datetime(response.json()['start_times']), - right=pd.to_datetime(response.json()['end_times']), - closed='left', + left=pd.to_datetime(response.json()["start_times"]), + right=pd.to_datetime(response.json()["end_times"]), + closed="left", ) else: - data.index = pd.to_datetime(response.json()['start_times']) + data.index = pd.to_datetime(response.json()["start_times"]) - meta = response.json()['meta'] + meta = response.json()["meta"] if map_variables: data = data.rename(columns=VARIABLE_MAP) - meta['latitude'] = meta.pop('lat') - meta['longitude'] = meta.pop('lon') + meta["latitude"] = meta.pop("lat") + meta["longitude"] = meta.pop("lon") return data, meta diff --git a/tests/iotools/test_meteonorm.py b/tests/iotools/test_meteonorm.py index 78b9f39f8c..09a27cf4a6 100644 --- a/tests/iotools/test_meteonorm.py +++ b/tests/iotools/test_meteonorm.py @@ -111,12 +111,11 @@ def expected_columns_all(): def test_get_meteonorm_training( demo_api_key, demo_url, expected_meta, expected_meteonorm_index, expected_metenorm_data): - data, meta = pvlib.iotools.get_meteonorm_observation( + data, meta = pvlib.iotools.get_meteonorm_observation_training( latitude=50, longitude=10, start='2023-01-01', end='2025-01-01', api_key=demo_api_key, parameters=['ghi', 'global_horizontal_irradiance_with_shading'], - endpoint='training', time_step='1h', url=demo_url) @@ -128,14 +127,13 @@ def test_get_meteonorm_training( @pytest.mark.remote_data @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) def test_get_meteonorm_realtime(demo_api_key, demo_url, expected_columns_all): - data, meta = pvlib.iotools.get_meteonorm_observation( + data, meta = pvlib.iotools.get_meteonorm_observation_realtime( latitude=21, longitude=79, start=pd.Timestamp.now(tz='UTC') - pd.Timedelta(hours=5), end=pd.Timestamp.now(tz='UTC') - pd.Timedelta(hours=1), surface_tilt=20, surface_azimuth=10, parameters=['all'], api_key=demo_api_key, - endpoint='realtime', time_step='1min', horizon='flat', map_variables=False, @@ -158,14 +156,12 @@ def test_get_meteonorm_realtime(demo_api_key, demo_url, expected_columns_all): @pytest.mark.remote_data @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) def test_get_meteonorm_forecast_basic(demo_api_key, demo_url): - data, meta = pvlib.iotools.get_meteonorm_forecast( + data, meta = pvlib.iotools.get_meteonorm_forecast_basic( latitude=50, longitude=10, start=pd.Timestamp.now(tz='UTC'), end=pd.Timestamp.now(tz='UTC') + pd.Timedelta(hours=5), - time_step='1h', api_key=demo_api_key, parameters='ghi', - endpoint='basic', url=demo_url) assert data.shape == (6, 1) @@ -177,13 +173,12 @@ def test_get_meteonorm_forecast_basic(demo_api_key, demo_url): @pytest.mark.remote_data @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) def test_get_meteonorm_forecast_precision(demo_api_key, demo_url): - data, meta = pvlib.iotools.get_meteonorm_forecast( + data, meta = pvlib.iotools.get_meteonorm_forecast_precision( latitude=50, longitude=10, start=pd.Timestamp.now(tz='UTC') + pd.Timedelta(hours=5), end=pd.Timestamp.now(tz='UTC') + pd.Timedelta(hours=6), api_key=demo_api_key, parameters='ghi', - endpoint='precision', time_step='15min', url=demo_url) @@ -195,14 +190,12 @@ def test_get_meteonorm_forecast_precision(demo_api_key, demo_url): @pytest.mark.remote_data @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) def test_get_meteonorm_custom_horizon(demo_api_key, demo_url): - data, meta = pvlib.iotools.get_meteonorm_forecast( + data, meta = pvlib.iotools.get_meteonorm_forecast_basic( latitude=50, longitude=10, start=pd.Timestamp.now(tz='UTC'), end=pd.Timestamp.now(tz='UTC') + pd.Timedelta(hours=5), api_key=demo_api_key, parameters='ghi', - time_step='1h', - endpoint='basic', horizon=list(np.ones(360).astype(int)*80), url=demo_url) @@ -212,28 +205,12 @@ def test_get_meteonorm_custom_horizon(demo_api_key, demo_url): def test_get_meteonorm_forecast_HTTPError(demo_api_key, demo_url): with pytest.raises( HTTPError, match="unknown parameter: not_a_real_parameter"): - _ = pvlib.iotools.get_meteonorm_forecast( + _ = pvlib.iotools.get_meteonorm_forecast_basic( latitude=50, longitude=10, start=pd.Timestamp.now(tz='UTC'), end=pd.Timestamp.now(tz='UTC') + pd.Timedelta(hours=5), - time_step='1h', api_key=demo_api_key, parameters='not_a_real_parameter', - endpoint='basic', - url=demo_url) - - -def test_get_meteonorm_basic_forecast_incorrect_time_step( - demo_api_key, demo_url): - with pytest.raises( - ValueError, match="only supports ``time_step='1h'``"): - _ = pvlib.iotools.get_meteonorm_forecast( - latitude=50, longitude=10, - start=pd.Timestamp.now(tz='UTC'), - end=pd.Timestamp.now(tz='UTC') + pd.Timedelta(hours=5), - time_step='15min', # only '1h' is supported for tmy - api_key=demo_api_key, - endpoint='basic', url=demo_url) From 172ea0a8becfeaeaf5ce12413412411af3ccda2d Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Thu, 7 Aug 2025 12:05:47 +0200 Subject: [PATCH 20/23] Fix linter --- docs/sphinx/source/whatsnew/v0.13.1.rst | 3 ++- pvlib/iotools/__init__.py | 6 +++--- pvlib/iotools/meteonorm.py | 11 +++++++---- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/sphinx/source/whatsnew/v0.13.1.rst b/docs/sphinx/source/whatsnew/v0.13.1.rst index 8ceac6af08..f697c0979a 100644 --- a/docs/sphinx/source/whatsnew/v0.13.1.rst +++ b/docs/sphinx/source/whatsnew/v0.13.1.rst @@ -21,7 +21,8 @@ Bug fixes Enhancements ~~~~~~~~~~~~ * Add iotools functions to retrieve irradiance and weather data from Meteonorm: - :py:func:`~pvlib.iotools.get_meteonorm_observation`, :py:func:`~pvlib.iotools.get_meteonorm_forecast`, + :py:func:`~pvlib.iotools.get_meteonorm_forecast_basic`, :py:func:`~pvlib.iotools.get_meteonorm_forecast_precision`, + `~pvlib.iotools.get_meteonorm_observation_realtime`, `~pvlib.iotools.get_meteonorm_observation_training`, and :py:func:`~pvlib.iotools.get_meteonorm_tmy`. (:pull:`2499`) * Add :py:func:`pvlib.iotools.get_nasa_power` to retrieve data from NASA POWER free API. diff --git a/pvlib/iotools/__init__.py b/pvlib/iotools/__init__.py index 2858d5acce..75663507f3 100644 --- a/pvlib/iotools/__init__.py +++ b/pvlib/iotools/__init__.py @@ -40,8 +40,8 @@ from pvlib.iotools.solcast import get_solcast_tmy # noqa: F401 from pvlib.iotools.solargis import get_solargis # noqa: F401 from pvlib.iotools.meteonorm import get_meteonorm_forecast_basic # noqa: F401 -from pvlib.iotools.meteonorm import get_meteonorm_forecast_precision # noqa: F401 -from pvlib.iotools.meteonorm import get_meteonorm_observation_realtime # noqa: F401 -from pvlib.iotools.meteonorm import get_meteonorm_observation_training # noqa: F401 +from pvlib.iotools.meteonorm import get_meteonorm_forecast_precision # noqa: F401, E501 +from pvlib.iotools.meteonorm import get_meteonorm_observation_realtime # noqa: F401, E501 +from pvlib.iotools.meteonorm import get_meteonorm_observation_training # noqa: F401, E501 from pvlib.iotools.meteonorm import get_meteonorm_tmy # noqa: F401 from pvlib.iotools.nasa_power import get_nasa_power # noqa: F401 diff --git a/pvlib/iotools/meteonorm.py b/pvlib/iotools/meteonorm.py index 7c320dda53..991a1321a1 100644 --- a/pvlib/iotools/meteonorm.py +++ b/pvlib/iotools/meteonorm.py @@ -443,7 +443,7 @@ def get_meteonorm_observation_training( `_ .. [3] `Meteonorm API reference `_ - """ + """ # noqa: E501 endpoint = "observation/training" data, meta = _get_meteonorm( @@ -522,7 +522,8 @@ def _get_meteonorm( if not response.ok: # response.raise_for_status() does not give a useful error message raise requests.HTTPError( - "Meteonorm API returned an error: " + response.json()["error"]["message"] + "Meteonorm API returned an error: " + + response.json()["error"]["message"] ) data, meta = _parse_meteonorm(response, interval_index, map_variables) @@ -678,12 +679,14 @@ def get_meteonorm_tmy( headers = {"Authorization": f"Bearer {api_key}"} - response = requests.get(urljoin(url, TMY_ENDPOINT.lstrip("/")), headers=headers, params=params) + response = requests.get(urljoin(url, TMY_ENDPOINT.lstrip("/")), + headers=headers, params=params) if not response.ok: # response.raise_for_status() does not give a useful error message raise requests.HTTPError( - "Meteonorm API returned an error: " + response.json()["error"]["message"] + "Meteonorm API returned an error: " + + response.json()["error"]["message"] ) data, meta = _parse_meteonorm(response, interval_index, map_variables) From 20ec64aa95908ff761862a2ca9c910c4b7320459 Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Thu, 7 Aug 2025 20:57:11 +0200 Subject: [PATCH 21/23] Implement changes from review from kandersolar --- docs/sphinx/source/whatsnew/v0.13.1.rst | 2 +- pvlib/iotools/meteonorm.py | 378 +++++++++--------------- tests/iotools/test_meteonorm.py | 16 +- 3 files changed, 147 insertions(+), 249 deletions(-) diff --git a/docs/sphinx/source/whatsnew/v0.13.1.rst b/docs/sphinx/source/whatsnew/v0.13.1.rst index f697c0979a..3878dffbfb 100644 --- a/docs/sphinx/source/whatsnew/v0.13.1.rst +++ b/docs/sphinx/source/whatsnew/v0.13.1.rst @@ -22,7 +22,7 @@ Enhancements ~~~~~~~~~~~~ * Add iotools functions to retrieve irradiance and weather data from Meteonorm: :py:func:`~pvlib.iotools.get_meteonorm_forecast_basic`, :py:func:`~pvlib.iotools.get_meteonorm_forecast_precision`, - `~pvlib.iotools.get_meteonorm_observation_realtime`, `~pvlib.iotools.get_meteonorm_observation_training`, + :py:func:`~pvlib.iotools.get_meteonorm_observation_realtime`, :py:func:`~pvlib.iotools.get_meteonorm_observation_training`, and :py:func:`~pvlib.iotools.get_meteonorm_tmy`. (:pull:`2499`) * Add :py:func:`pvlib.iotools.get_nasa_power` to retrieve data from NASA POWER free API. diff --git a/pvlib/iotools/meteonorm.py b/pvlib/iotools/meteonorm.py index 991a1321a1..caf4b11f44 100644 --- a/pvlib/iotools/meteonorm.py +++ b/pvlib/iotools/meteonorm.py @@ -3,6 +3,7 @@ import pandas as pd import requests from urllib.parse import urljoin +from pandas._libs.tslibs.parsing import DateParseError URL = "https://api.meteonorm.com/v1/" @@ -32,20 +33,11 @@ def get_meteonorm_forecast_basic( - latitude, - longitude, - start, - end, - api_key, - parameters="all", - *, - surface_tilt=0, - surface_azimuth=180, - horizon="auto", - interval_index=False, - map_variables=True, - url=URL, -): + latitude, longitude, start, end, + api_key, parameters="all", *, + surface_tilt=0, surface_azimuth=180, + horizon="auto", interval_index=False, + map_variables=True, url=URL): """ Retrieve basic forecast data from Meteonorm. @@ -61,12 +53,14 @@ def get_meteonorm_forecast_basic( In decimal degrees, north is positive (ISO 19115). longitude: float In decimal degrees, east is positive (ISO 19115). - start : datetime like + start : datetime like or str First timestamp of the requested period. If a timezone is not - specified, UTC is assumed. - end : datetime like + specified, UTC is assumed. Relative date/time strings are + also allowed, e.g., 'now' or '+3hours'. + end : datetime like or str Last timestamp of the requested period. If a timezone is not - specified, UTC is assumed. + specified, UTC is assumed. Relative date/time strings are + also allowed, e.g., 'now' or '+3hours'. api_key : str Meteonorm API key. parameters : list or 'all', default : 'all' @@ -104,7 +98,9 @@ def get_meteonorm_forecast_basic( See Also -------- - pvlib.iotools.get_meteonorm_observation, + pvlib.iotools.get_meteonorm_forecast_precision, + pvlib.iotools.get_meteonorm_observation_realtime, + pvlib.iotools.get_meteonorm_observation_training, pvlib.iotools.get_meteonorm_tmy References @@ -120,40 +116,19 @@ def get_meteonorm_forecast_basic( time_step = None data, meta = _get_meteonorm( - latitude, - longitude, - start, - end, - api_key, - parameters, - surface_tilt, - surface_azimuth, - time_step, - horizon, - interval_index, - map_variables, - url, - endpoint, - ) + latitude, longitude, start, end, + api_key, parameters, surface_tilt, surface_azimuth, + time_step, horizon, interval_index, map_variables, + url, endpoint) return data, meta def get_meteonorm_forecast_precision( - latitude, - longitude, - start, - end, - api_key, - parameters="all", - *, - surface_tilt=0, - surface_azimuth=180, - time_step="15min", - horizon="auto", - interval_index=False, - map_variables=True, - url=URL, -): + latitude, longitude, start, end, + api_key, parameters="all", *, + surface_tilt=0, surface_azimuth=180, + time_step="15min", horizon="auto", interval_index=False, + map_variables=True, url=URL): """ Retrieve precision forecast data from Meteonorm. @@ -166,12 +141,14 @@ def get_meteonorm_forecast_precision( In decimal degrees, north is positive (ISO 19115). longitude: float In decimal degrees, east is positive (ISO 19115). - start : datetime like + start : datetime like or str First timestamp of the requested period. If a timezone is not - specified, UTC is assumed. - end : datetime like + specified, UTC is assumed. Relative date/time strings are + also allowed, e.g., 'now' or '+3hours'. + end : datetime like or str Last timestamp of the requested period. If a timezone is not - specified, UTC is assumed. + specified, UTC is assumed. Relative date/time strings are + also allowed, e.g., 'now' or '+3hours'. api_key : str Meteonorm API key. parameters : list or 'all', default : 'all' @@ -182,8 +159,7 @@ def get_meteonorm_forecast_precision( Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270). time_step : {'1min', '15min', '1h'}, default : '15min' - Frequency of the time series. The endpoint ``'basic'`` only - supports ``time_step='1h'``. + Frequency of the time series. horizon : str or list, default : 'auto' Specification of the horizon line. Can be either 'flat', 'auto', or a list of 360 integer horizon elevation angles. @@ -212,7 +188,10 @@ def get_meteonorm_forecast_precision( See Also -------- - pvlib.iotools.get_meteonorm_forecast_basic + pvlib.iotools.get_meteonorm_forecast_basic, + pvlib.iotools.get_meteonorm_observation_realtime, + pvlib.iotools.get_meteonorm_observation_training, + pvlib.iotools.get_meteonorm_tmy References ---------- @@ -226,40 +205,19 @@ def get_meteonorm_forecast_precision( endpoint = "forecast/precision" data, meta = _get_meteonorm( - latitude, - longitude, - start, - end, - api_key, - parameters, - surface_tilt, - surface_azimuth, - time_step, - horizon, - interval_index, - map_variables, - url, - endpoint, - ) + latitude, longitude, start, end, + api_key, parameters, surface_tilt, surface_azimuth, + time_step, horizon, interval_index, map_variables, + url, endpoint) return data, meta def get_meteonorm_observation_realtime( - latitude, - longitude, - start, - end, - api_key, - parameters="all", - *, - surface_tilt=0, - surface_azimuth=180, - time_step="15min", - horizon="auto", - interval_index=False, - map_variables=True, - url=URL, -): + latitude, longitude, start, end, + api_key, parameters="all", *, + surface_tilt=0, surface_azimuth=180, + time_step="15min", horizon="auto", interval_index=False, + map_variables=True, url=URL): """ Retrieve near real-time observational data from Meteonorm. @@ -319,7 +277,11 @@ def get_meteonorm_observation_realtime( See Also -------- - pvlib.iotools.get_meteonorm_observation_training + pvlib.iotools.get_meteonorm_forecast_basic, + pvlib.iotools.get_meteonorm_forecast_precision, + pvlib.iotools.get_meteonorm_observation_training, + pvlib.iotools.get_meteonorm_tmy + References ---------- @@ -333,40 +295,19 @@ def get_meteonorm_observation_realtime( endpoint = "observation/realtime" data, meta = _get_meteonorm( - latitude, - longitude, - start, - end, - api_key, - parameters, - surface_tilt, - surface_azimuth, - time_step, - horizon, - interval_index, - map_variables, - url, - endpoint, - ) + latitude, longitude, start, end, + api_key, parameters, surface_tilt, surface_azimuth, + time_step, horizon, interval_index, map_variables, + url, endpoint) return data, meta def get_meteonorm_observation_training( - latitude, - longitude, - start, - end, - api_key, - parameters="all", - *, - surface_tilt=0, - surface_azimuth=180, - time_step="15min", - horizon="auto", - interval_index=False, - map_variables=True, - url=URL, -): + latitude, longitude, start, end, + api_key, parameters="all", *, + surface_tilt=0, surface_azimuth=180, + time_step="15min", horizon="auto", interval_index=False, + map_variables=True, url=URL): """ Retrieve historical observational data from Meteonorm. @@ -428,12 +369,14 @@ def get_meteonorm_observation_training( >>> df, meta = pvlib.iotools.get_meteonorm_observation_training( # doctest: +SKIP ... latitude=50, longitude=10, # doctest: +SKIP ... start='2023-01-01', end='2025-01-01', # doctest: +SKIP - ... api_key='redacted', # doctest: +SKIP - ... endpoint='training') # doctest: +SKIP + ... api_key='redacted') # doctest: +SKIP See Also -------- - pvlib.iotools.get_meteonorm_observation_realtime + pvlib.iotools.get_meteonorm_forecast_basic, + pvlib.iotools.get_meteonorm_forecast_precision, + pvlib.iotools.get_meteonorm_observation_realtime, + pvlib.iotools.get_meteonorm_tmy References ---------- @@ -447,115 +390,21 @@ def get_meteonorm_observation_training( endpoint = "observation/training" data, meta = _get_meteonorm( - latitude, - longitude, - start, - end, - api_key, - parameters, - surface_tilt, - surface_azimuth, - time_step, - horizon, - interval_index, - map_variables, - url, - endpoint, - ) - return data, meta - - -def _get_meteonorm( - latitude, - longitude, - start, - end, - api_key, - parameters, - surface_tilt, - surface_azimuth, - time_step, - horizon, - interval_index, - map_variables, - url, - endpoint, -): - # Relative date strings are not yet supported - start = pd.Timestamp(start) - end = pd.Timestamp(end) - start = start.tz_localize("UTC") if start.tzinfo is None else start - end = end.tz_localize("UTC") if end.tzinfo is None else end - - params = { - "lat": latitude, - "lon": longitude, - "start": start.strftime("%Y-%m-%dT%H:%M:%SZ"), - "end": end.strftime("%Y-%m-%dT%H:%M:%SZ"), - "parameters": parameters, - "surface_tilt": surface_tilt, - "surface_azimuth": surface_azimuth, - "horizon": horizon, - 'frequency': TIME_STEP_MAP.get(time_step, time_step), - "response_format": "json", - } - - # Allow specifying single parameters as string - if isinstance(parameters, str): - parameters = [parameters] - - # allow the use of pvlib parameter names - parameter_dict = {v: k for k, v in VARIABLE_MAP.items()} - parameters = [parameter_dict.get(p, p) for p in parameters] - # convert list to string with values separated by commas - params["parameters"] = ",".join(parameters) - - if not isinstance(horizon, str): - params["horizon"] = ",".join(map(str, horizon)) - - headers = {"Authorization": f"Bearer {api_key}"} - - response = requests.get( - urljoin(url, endpoint), headers=headers, params=params - ) - - if not response.ok: - # response.raise_for_status() does not give a useful error message - raise requests.HTTPError( - "Meteonorm API returned an error: " - + response.json()["error"]["message"] - ) - - data, meta = _parse_meteonorm(response, interval_index, map_variables) - + latitude, longitude, start, end, + api_key, parameters, surface_tilt, surface_azimuth, + time_step, horizon, interval_index, map_variables, + url, endpoint) return data, meta -TMY_ENDPOINT = "climate/tmy" - - def get_meteonorm_tmy( - latitude, - longitude, - api_key, - parameters="all", - *, - surface_tilt=0, - surface_azimuth=180, - time_step="1h", - horizon="auto", - terrain_situation="open", - albedo=None, - turbidity="auto", - random_seed=None, - clear_sky_radiation_model="esra", - data_version="latest", - future_scenario=None, - future_year=None, - interval_index=False, - map_variables=True, - url=URL, -): + latitude, longitude, api_key, parameters="all", *, + surface_tilt=0, surface_azimuth=180, + time_step="1h", horizon="auto", terrain_situation="open", + albedo=None, turbidity="auto", random_seed=None, + clear_sky_radiation_model="esra", data_version="latest", + future_scenario=None, future_year=None, interval_index=False, + map_variables=True, url=URL): """ Retrieve TMY irradiance and weather data from Meteonorm. @@ -631,8 +480,10 @@ def get_meteonorm_tmy( See Also -------- - pvlib.iotools.get_meteonorm_observation, - pvlib.iotools.get_meteonorm_forecast + pvlib.iotools.get_meteonorm_forecast_basic, + pvlib.iotools.get_meteonorm_forecast_precision, + pvlib.iotools.get_meteonorm_observation_realtime, + pvlib.iotools.get_meteonorm_observation_training References ---------- @@ -643,14 +494,7 @@ def get_meteonorm_tmy( .. [3] `Meteonorm API reference `_ """ - params = { - "lat": latitude, - "lon": longitude, - "surface_tilt": surface_tilt, - "surface_azimuth": surface_azimuth, - "frequency": TIME_STEP_MAP.get(time_step, time_step), - "parameters": parameters, - "horizon": horizon, + additional_params = { "situation": terrain_situation, "turbidity": turbidity, "clear_sky_radiation_model": clear_sky_radiation_model, @@ -661,6 +505,62 @@ def get_meteonorm_tmy( "response_format": "json", } + if not isinstance(turbidity, str): + additional_params["turbidity"] = ",".join(map(str, turbidity)) + + endpoint = "climate/tmy" + + start, end = None, None + + data, meta = _get_meteonorm( + latitude, longitude, start, end, + api_key, parameters, + surface_tilt, surface_azimuth, + time_step, horizon, + interval_index, map_variables, + url, endpoint, **additional_params) + return data, meta + + +def _get_meteonorm( + latitude, longitude, start, end, + api_key, parameters, + surface_tilt, surface_azimuth, + time_step, horizon, + interval_index, map_variables, + url, endpoint, **kwargs): + + # Check for None type in case of TMY request + # Check for DateParseError in case of relative times, e.g., '+3hours' + if (start is not None) & (start != 'now'): + try: + start = pd.Timestamp(start) + start = start.tz_localize("UTC") if start.tzinfo is None else start + start = start.strftime("%Y-%m-%dT%H:%M:%SZ") + except DateParseError: + pass + if (end is not None) & (end != 'now'): + try: + end = pd.Timestamp(end) + end = end.tz_localize("UTC") if end.tzinfo is None else end + end = end.strftime("%Y-%m-%dT%H:%M:%SZ") + except DateParseError: + pass + + params = { + "lat": latitude, + "lon": longitude, + 'start': start, + 'end': end, + "parameters": parameters, + "surface_tilt": surface_tilt, + "surface_azimuth": surface_azimuth, + "horizon": horizon, + 'frequency': TIME_STEP_MAP.get(time_step, time_step), + "response_format": "json", + **kwargs + } + # Allow specifying single parameters as string if isinstance(parameters, str): parameters = [parameters] @@ -674,13 +574,11 @@ def get_meteonorm_tmy( if not isinstance(horizon, str): params["horizon"] = ",".join(map(str, horizon)) - if not isinstance(turbidity, str): - params["turbidity"] = ",".join(map(str, turbidity)) - headers = {"Authorization": f"Bearer {api_key}"} - response = requests.get(urljoin(url, TMY_ENDPOINT.lstrip("/")), - headers=headers, params=params) + response = requests.get( + urljoin(url, endpoint), headers=headers, params=params + ) if not response.ok: # response.raise_for_status() does not give a useful error message diff --git a/tests/iotools/test_meteonorm.py b/tests/iotools/test_meteonorm.py index 09a27cf4a6..74679e298b 100644 --- a/tests/iotools/test_meteonorm.py +++ b/tests/iotools/test_meteonorm.py @@ -57,7 +57,7 @@ def expected_meteonorm_index(): @pytest.fixture -def expected_metenorm_data(): +def expected_meteonorm_data(): # The first 12 rows of data columns = ['ghi', 'global_horizontal_irradiance_with_shading'] expected = [ @@ -110,7 +110,7 @@ def expected_columns_all(): @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) def test_get_meteonorm_training( demo_api_key, demo_url, expected_meta, expected_meteonorm_index, - expected_metenorm_data): + expected_meteonorm_data): data, meta = pvlib.iotools.get_meteonorm_observation_training( latitude=50, longitude=10, start='2023-01-01', end='2025-01-01', @@ -121,7 +121,7 @@ def test_get_meteonorm_training( assert meta == expected_meta pd.testing.assert_index_equal(data.index, expected_meteonorm_index) - pd.testing.assert_frame_equal(data.iloc[:12], expected_metenorm_data) + pd.testing.assert_frame_equal(data.iloc[:12], expected_meteonorm_data) @pytest.mark.remote_data @@ -175,8 +175,8 @@ def test_get_meteonorm_forecast_basic(demo_api_key, demo_url): def test_get_meteonorm_forecast_precision(demo_api_key, demo_url): data, meta = pvlib.iotools.get_meteonorm_forecast_precision( latitude=50, longitude=10, - start=pd.Timestamp.now(tz='UTC') + pd.Timedelta(hours=5), - end=pd.Timestamp.now(tz='UTC') + pd.Timedelta(hours=6), + start='now', + end='+3hours', api_key=demo_api_key, parameters='ghi', time_step='15min', @@ -260,7 +260,7 @@ def expected_meteonorm_tmy_interval_index(): @pytest.fixture -def expected_metenorm_tmy_data(): +def expected_meteonorm_tmy_data(): # The first 12 rows of data columns = ['diffuse_horizontal_irradiance'] expected = [ @@ -290,7 +290,7 @@ def expected_metenorm_tmy_data(): @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) def test_get_meteonorm_tmy( demo_api_key, demo_url, expected_meteonorm_tmy_meta, - expected_metenorm_tmy_data, expected_meteonorm_tmy_interval_index): + expected_meteonorm_tmy_data, expected_meteonorm_tmy_interval_index): data, meta = pvlib.iotools.get_meteonorm_tmy( latitude=50, longitude=10, api_key=demo_api_key, @@ -311,6 +311,6 @@ def test_get_meteonorm_tmy( map_variables=False, url=demo_url) assert meta == expected_meteonorm_tmy_meta - pd.testing.assert_frame_equal(data.iloc[:12], expected_metenorm_tmy_data) + pd.testing.assert_frame_equal(data.iloc[:12], expected_meteonorm_tmy_data) pd.testing.assert_index_equal( data.index, expected_meteonorm_tmy_interval_index) From 6e8a3f9a6fe8545cbbddd5abec8e720266e2565c Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Thu, 7 Aug 2025 22:24:19 +0200 Subject: [PATCH 22/23] Set index to be the middle of the period --- pvlib/iotools/meteonorm.py | 38 ++++++++++++++++----------------- tests/iotools/test_meteonorm.py | 21 +++++------------- 2 files changed, 24 insertions(+), 35 deletions(-) diff --git a/pvlib/iotools/meteonorm.py b/pvlib/iotools/meteonorm.py index caf4b11f44..5cc24f071c 100644 --- a/pvlib/iotools/meteonorm.py +++ b/pvlib/iotools/meteonorm.py @@ -91,7 +91,7 @@ def get_meteonorm_forecast_basic( Returns ------- data : pd.DataFrame - Time series data. The index corresponds to the start (left) of the + Time series data. The index corresponds to the middle of the interval unless ``interval_index`` is set to True. meta : dict Metadata. @@ -181,7 +181,7 @@ def get_meteonorm_forecast_precision( Returns ------- data : pd.DataFrame - Time series data. The index corresponds to the start (left) of the + Time series data. The index corresponds to the middle of the interval unless ``interval_index`` is set to True. meta : dict Metadata. @@ -270,7 +270,7 @@ def get_meteonorm_observation_realtime( Returns ------- data : pd.DataFrame - Time series data. The index corresponds to the start (left) of the + Time series data. The index corresponds to the middle of the interval unless ``interval_index`` is set to True. meta : dict Metadata. @@ -358,7 +358,7 @@ def get_meteonorm_observation_training( Returns ------- data : pd.DataFrame - Time series data. The index corresponds to the start (left) of the + Time series data. The index corresponds to the middle of the interval unless ``interval_index`` is set to True. meta : dict Metadata. @@ -473,7 +473,7 @@ def get_meteonorm_tmy( Returns ------- data : pd.DataFrame - Time series data. The index corresponds to the start (left) of the + Time series data. The index corresponds to the middle of the interval unless ``interval_index`` is set to True. meta : dict Metadata. @@ -513,12 +513,12 @@ def get_meteonorm_tmy( start, end = None, None data, meta = _get_meteonorm( - latitude, longitude, start, end, - api_key, parameters, - surface_tilt, surface_azimuth, - time_step, horizon, - interval_index, map_variables, - url, endpoint, **additional_params) + latitude, longitude, start, end, + api_key, parameters, + surface_tilt, surface_azimuth, + time_step, horizon, + interval_index, map_variables, + url, endpoint, **additional_params) return data, meta @@ -602,14 +602,14 @@ def _parse_meteonorm(response, interval_index, map_variables): data = pd.DataFrame(data_json) # xxx: experimental feature - see parameter description - if interval_index: - data.index = pd.IntervalIndex.from_arrays( - left=pd.to_datetime(response.json()["start_times"]), - right=pd.to_datetime(response.json()["end_times"]), - closed="left", - ) - else: - data.index = pd.to_datetime(response.json()["start_times"]) + data.index = pd.IntervalIndex.from_arrays( + left=pd.to_datetime(response.json()["start_times"]), + right=pd.to_datetime(response.json()["end_times"]), + closed="left", + ) + + if not interval_index: + data.index = data.index.mid meta = response.json()["meta"] diff --git a/tests/iotools/test_meteonorm.py b/tests/iotools/test_meteonorm.py index 74679e298b..b19535b692 100644 --- a/tests/iotools/test_meteonorm.py +++ b/tests/iotools/test_meteonorm.py @@ -51,7 +51,8 @@ def expected_meta(): @pytest.fixture def expected_meteonorm_index(): expected_meteonorm_index = \ - pd.date_range('2023-01-01', '2024-12-31 23:59', freq='1h', tz='UTC') + pd.date_range('2023-01-01', '2024-12-31 23:59', freq='1h', tz='UTC') \ + + pd.Timedelta(minutes=30) expected_meteonorm_index.freq = None return expected_meteonorm_index @@ -74,7 +75,7 @@ def expected_meteonorm_data(): [210.75, 210.7458778], [221.0, 220.99278214], ] - index = pd.date_range('2023-01-01', periods=12, freq='1h', tz='UTC') + index = pd.date_range('2023-01-01 00:30', periods=12, freq='1h', tz='UTC') index.freq = None expected = pd.DataFrame(expected, index=index, columns=columns) return expected @@ -183,7 +184,7 @@ def test_get_meteonorm_forecast_precision(demo_api_key, demo_url): url=demo_url) assert data.index[1] - data.index[0] == pd.Timedelta(minutes=15) - assert data.shape == (5, 1) + assert data.shape == (60/15*3+1, 1) assert meta['frequency'] == '15_minutes' @@ -249,16 +250,6 @@ def expected_meteonorm_tmy_meta(): return meta -@pytest.fixture -def expected_meteonorm_tmy_interval_index(): - index = pd.date_range( - '2005-01-01', periods=8760, freq='1h', tz=3600) - index.freq = None - interval_index = pd.IntervalIndex.from_arrays( - index, index + pd.Timedelta(hours=1), closed='left') - return interval_index - - @pytest.fixture def expected_meteonorm_tmy_data(): # The first 12 rows of data @@ -290,7 +281,7 @@ def expected_meteonorm_tmy_data(): @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) def test_get_meteonorm_tmy( demo_api_key, demo_url, expected_meteonorm_tmy_meta, - expected_meteonorm_tmy_data, expected_meteonorm_tmy_interval_index): + expected_meteonorm_tmy_data): data, meta = pvlib.iotools.get_meteonorm_tmy( latitude=50, longitude=10, api_key=demo_api_key, @@ -312,5 +303,3 @@ def test_get_meteonorm_tmy( url=demo_url) assert meta == expected_meteonorm_tmy_meta pd.testing.assert_frame_equal(data.iloc[:12], expected_meteonorm_tmy_data) - pd.testing.assert_index_equal( - data.index, expected_meteonorm_tmy_interval_index) From 0279a924a90cbd2d5840ba778c653a555f6602db Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Thu, 7 Aug 2025 23:16:55 +0200 Subject: [PATCH 23/23] Extend test coverage --- tests/iotools/test_meteonorm.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/iotools/test_meteonorm.py b/tests/iotools/test_meteonorm.py index b19535b692..6c6ae6ef4b 100644 --- a/tests/iotools/test_meteonorm.py +++ b/tests/iotools/test_meteonorm.py @@ -51,7 +51,7 @@ def expected_meta(): @pytest.fixture def expected_meteonorm_index(): expected_meteonorm_index = \ - pd.date_range('2023-01-01', '2024-12-31 23:59', freq='1h', tz='UTC') \ + pd.date_range('2023-01-01', '2023-12-31 23:59', freq='1h', tz='UTC') \ + pd.Timedelta(minutes=30) expected_meteonorm_index.freq = None return expected_meteonorm_index @@ -114,7 +114,7 @@ def test_get_meteonorm_training( expected_meteonorm_data): data, meta = pvlib.iotools.get_meteonorm_observation_training( latitude=50, longitude=10, - start='2023-01-01', end='2025-01-01', + start='2023-01-01', end='2024-01-01', api_key=demo_api_key, parameters=['ghi', 'global_horizontal_irradiance_with_shading'], time_step='1h', @@ -159,8 +159,8 @@ def test_get_meteonorm_realtime(demo_api_key, demo_url, expected_columns_all): def test_get_meteonorm_forecast_basic(demo_api_key, demo_url): data, meta = pvlib.iotools.get_meteonorm_forecast_basic( latitude=50, longitude=10, - start=pd.Timestamp.now(tz='UTC'), - end=pd.Timestamp.now(tz='UTC') + pd.Timedelta(hours=5), + start='+1hours', + end=pd.Timestamp.now(tz='UTC') + pd.Timedelta(hours=6), api_key=demo_api_key, parameters='ghi', url=demo_url)