diff --git a/docs/sphinx/source/reference/iotools.rst b/docs/sphinx/source/reference/iotools.rst index bcc9f4e2a6..12db7d6818 100644 --- a/docs/sphinx/source/reference/iotools.rst +++ b/docs/sphinx/source/reference/iotools.rst @@ -92,6 +92,19 @@ Commercial datasets Accessing these APIs typically requires payment. Datasets provide near-global coverage. +Meteonorm +********* + +.. autosummary:: + :toctree: generated/ + + iotools.get_meteonorm_forecast_basic + iotools.get_meteonorm_forecast_precision + iotools.get_meteonorm_observation_training + iotools.get_meteonorm_observation_realtime + 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 d83667b302..3878dffbfb 100644 --- a/docs/sphinx/source/whatsnew/v0.13.1.rst +++ b/docs/sphinx/source/whatsnew/v0.13.1.rst @@ -20,6 +20,11 @@ Bug fixes 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`, + :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. (:pull:`2500`) * :py:func:`pvlib.spectrum.spectral_factor_firstsolar` no longer emits warnings diff --git a/pvlib/iotools/__init__.py b/pvlib/iotools/__init__.py index 51136ce9bc..75663507f3 100644 --- a/pvlib/iotools/__init__.py +++ b/pvlib/iotools/__init__.py @@ -39,4 +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_forecast_basic # 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 new file mode 100644 index 0000000000..5cc24f071c --- /dev/null +++ b/pvlib/iotools/meteonorm.py @@ -0,0 +1,621 @@ +"""Functions for retrieving data from Meteonorm.""" + +import pandas as pd +import requests +from urllib.parse import urljoin +from pandas._libs.tslibs.parsing import DateParseError + +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", +} + +TIME_STEP_MAP = { + "1h": "1_hour", + "h": "1_hour", + "15min": "15_minutes", + "1min": "1_minute", + "min": "1_minute", +} + + +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 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]_. + + + Parameters + ---------- + latitude : float + In decimal degrees, north is positive (ISO 19115). + longitude: float + In decimal degrees, east is positive (ISO 19115). + start : datetime like or str + First timestamp of the requested period. If a timezone is not + 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. Relative date/time strings are + also allowed, e.g., 'now' or '+3hours'. + 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 + 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 middle of the + interval unless ``interval_index`` is set to True. + meta : dict + Metadata. + + See Also + -------- + pvlib.iotools.get_meteonorm_forecast_precision, + pvlib.iotools.get_meteonorm_observation_realtime, + pvlib.iotools.get_meteonorm_observation_training, + 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 or str + First timestamp of the requested period. If a timezone is not + 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. Relative date/time strings are + also allowed, e.g., 'now' or '+3hours'. + 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 + 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 middle of the + interval unless ``interval_index`` is set to True. + meta : dict + Metadata. + + See Also + -------- + pvlib.iotools.get_meteonorm_forecast_basic, + pvlib.iotools.get_meteonorm_observation_realtime, + pvlib.iotools.get_meteonorm_observation_training, + pvlib.iotools.get_meteonorm_tmy + + References + ---------- + .. [1] `Meteonorm + `_ + .. [2] `Meteonorm API + `_ + .. [3] `Meteonorm API reference + `_ + """ + 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) + 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): + """ + 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]_. + + Near-real time is supports data access for the past 7-days. + + 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 + 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 middle of the + interval unless ``interval_index`` is set to True. + meta : dict + Metadata. + + See Also + -------- + pvlib.iotools.get_meteonorm_forecast_basic, + pvlib.iotools.get_meteonorm_forecast_precision, + pvlib.iotools.get_meteonorm_observation_training, + pvlib.iotools.get_meteonorm_tmy + + + References + ---------- + .. [1] `Meteonorm + `_ + .. [2] `Meteonorm API + `_ + .. [3] `Meteonorm API reference + `_ + """ + 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) + 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 + 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 middle of the + interval unless ``interval_index`` is set to True. + 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 + + See Also + -------- + pvlib.iotools.get_meteonorm_forecast_basic, + pvlib.iotools.get_meteonorm_forecast_precision, + pvlib.iotools.get_meteonorm_observation_realtime, + pvlib.iotools.get_meteonorm_tmy + + References + ---------- + .. [1] `Meteonorm + `_ + .. [2] `Meteonorm API + `_ + .. [3] `Meteonorm API reference + `_ + """ # noqa: E501 + 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_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. + + 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). + 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 + 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' + 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 integer horizon elevation angles. + 'auto'. + 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']. + albedo : float, optional + Constant ground albedo. If no value is specified a baseline albedo of + 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 + 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 : 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, 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. `'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 middle of the + interval unless ``interval_index`` is set to True. + meta : dict + Metadata. + + See Also + -------- + 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 + ---------- + .. [1] `Meteonorm + `_ + .. [2] `Meteonorm API + `_ + .. [3] `Meteonorm API reference + `_ + """ + additional_params = { + "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", + } + + 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] + + # 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) + + 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] + # remove empty columns + _ = [data_json.pop(k) for k in empty_columns] + + data = pd.DataFrame(data_json) + + # xxx: experimental feature - see parameter description + 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"] + + if map_variables: + data = data.rename(columns=VARIABLE_MAP) + 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 new file mode 100644 index 0000000000..6c6ae6ef4b --- /dev/null +++ b/tests/iotools/test_meteonorm.py @@ -0,0 +1,305 @@ +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 +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': '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', # noqa: E501 + '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, + 'latitude': 50, + 'longitude': 10, + } + return meta + + +@pytest.fixture +def expected_meteonorm_index(): + expected_meteonorm_index = \ + 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 + + +@pytest.fixture +def expected_meteonorm_data(): + # The first 12 rows of data + 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, 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 00:30', periods=12, freq='1h', tz='UTC') + index.freq = None + expected = pd.DataFrame(expected, index=index, columns=columns) + 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( + demo_api_key, demo_url, expected_meta, expected_meteonorm_index, + expected_meteonorm_data): + data, meta = pvlib.iotools.get_meteonorm_observation_training( + latitude=50, longitude=10, + start='2023-01-01', end='2024-01-01', + api_key=demo_api_key, + parameters=['ghi', 'global_horizontal_irradiance_with_shading'], + 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_meteonorm_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_columns_all): + 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, + 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 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_forecast_basic( + latitude=50, longitude=10, + start='+1hours', + end=pd.Timestamp.now(tz='UTC') + pd.Timedelta(hours=6), + api_key=demo_api_key, + parameters='ghi', + 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_forecast_precision( + latitude=50, longitude=10, + start='now', + end='+3hours', + api_key=demo_api_key, + parameters='ghi', + time_step='15min', + url=demo_url) + + assert data.index[1] - data.index[0] == pd.Timedelta(minutes=15) + assert data.shape == (60/15*3+1, 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_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', + 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_forecast_HTTPError(demo_api_key, demo_url): + with pytest.raises( + HTTPError, match="unknown parameter: not_a_real_parameter"): + _ = 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='not_a_real_parameter', + 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_tilt + surface_azimuth=400, + 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, + 'lat': 50, + 'lon': 10, + } + return meta + + +@pytest.fixture +def expected_meteonorm_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 + 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 + + +@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_meteonorm_tmy_data): + 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_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, + 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_meteonorm_tmy_data)