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)