diff --git a/docs/sphinx/source/reference/iotools.rst b/docs/sphinx/source/reference/iotools.rst index cbf89c71a7..bcc9f4e2a6 100644 --- a/docs/sphinx/source/reference/iotools.rst +++ b/docs/sphinx/source/reference/iotools.rst @@ -57,6 +57,17 @@ clear-sky irradiance globally. iotools.parse_cams +NASA POWER +********** + +Satellite-derived irradiance and weather data with global coverage. + +.. autosummary:: + :toctree: generated/ + + iotools.get_nasa_power + + NSRDB ***** diff --git a/docs/sphinx/source/whatsnew/v0.13.1.rst b/docs/sphinx/source/whatsnew/v0.13.1.rst index 9c50d00bbb..ad798edc0f 100644 --- a/docs/sphinx/source/whatsnew/v0.13.1.rst +++ b/docs/sphinx/source/whatsnew/v0.13.1.rst @@ -19,7 +19,8 @@ Bug fixes Enhancements ~~~~~~~~~~~~ - +* Added function :py:func:`~pvlib.iotools.get_nasa_power` to retrieve data from NASA POWER. + (:pull:`2500`) Documentation ~~~~~~~~~~~~~ @@ -45,3 +46,5 @@ Maintenance Contributors ~~~~~~~~~~~~ * Elijah Passmore (:ghuser:`eljpsm`) +* Ioannis Sifnaios (:ghuser:`IoannisSifnaios`) + diff --git a/pvlib/iotools/__init__.py b/pvlib/iotools/__init__.py index 352044e5cd..51136ce9bc 100644 --- a/pvlib/iotools/__init__.py +++ b/pvlib/iotools/__init__.py @@ -39,3 +39,4 @@ 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.nasa_power import get_nasa_power # noqa: F401 diff --git a/pvlib/iotools/nasa_power.py b/pvlib/iotools/nasa_power.py new file mode 100644 index 0000000000..6a9b01f10c --- /dev/null +++ b/pvlib/iotools/nasa_power.py @@ -0,0 +1,153 @@ +"""Functions for reading and retrieving data from NASA POWER.""" + +import pandas as pd +import requests +import numpy as np + +URL = 'https://power.larc.nasa.gov/api/temporal/hourly/point' + +DEFAULT_PARAMETERS = [ + 'ALLSKY_SFC_SW_DNI', 'ALLSKY_SFC_SW_DIFF', 'ALLSKY_SFC_SW_DWN', + 'T2M', 'WS10M' +] + +VARIABLE_MAP = { + 'ALLSKY_SFC_SW_DWN': 'ghi', + 'ALLSKY_SFC_SW_DIFF': 'dhi', + 'ALLSKY_SFC_SW_DNI': 'dni', + 'CLRSKY_SFC_SW_DWN': 'ghi_clear', + 'T2M': 'temp_air', + 'WS2M': 'wind_speed_2m', + 'WS10M': 'wind_speed', +} + + +def get_nasa_power(latitude, longitude, start, end, + parameters=DEFAULT_PARAMETERS, community='re', url=URL, + elevation=None, wind_height=None, wind_surface=None, + map_variables=True): + """ + Retrieve irradiance and weather data from NASA POWER. + + A general description of NASA POWER is given in [1]_ and the API is + described in [2]_. A detailed list of the available parameters 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. + end: datetime like + Last timestamp of the requested period. + parameters: str, list + List of parameters. The default parameters are mentioned below; for the + full list see [3]_. Note that the pvlib naming conventions can also be + used. + + * ``ALLSKY_SFC_SW_DWN``: Global Horizontal Irradiance (GHI) [Wm⁻²] + * ``ALLSKY_SFC_SW_DIFF``: Diffuse Horizontal Irradiance (DHI) [Wm⁻²] + * ``ALLSKY_SFC_SW_DNI``: Direct Normal Irradiance (DNI) [Wm⁻²] + * ``T2M``: Air temperature at 2 m [C] + * ``WS10M``: Wind speed at 10 m [m/s] + + community: str + Can be one of the following depending on which parameters are of + interest. The default is ``'re'``. Note that in many cases this choice + might affect the units of the parameter. + + * ``'re'``: renewable energy + * ``'sb'``: sustainable buildings + * ``'ag'``: agroclimatology + + elevation: float, optional + The custom site elevation in meters to produce the corrected + atmospheric pressure adjusted for elevation. + wind_height: float, optional + The custom wind height in meters to produce the wind speed adjusted + for height. Has to be between 10 and 300 m; see [4]_. + wind_surface: str, optional + The definable surface type to adjust the wind speed. For a list of the + surface types see [4]_. If you provide a wind surface alias please + include a site elevation with the request. + map_variables: bool, optional + When true, renames columns of the Dataframe to pvlib variable names + where applicable. See variable :const:`VARIABLE_MAP`. + The default is `True`. + + 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. + + References + ---------- + .. [1] `NASA Prediction Of Worldwide Energy Resources (POWER) + `_ + .. [2] `NASA POWER API + `_ + .. [3] `NASA POWER API parameters + `_ + .. [4] `NASA POWER corrected wind speed parameters + `_ + """ + start = pd.Timestamp(start) + end = pd.Timestamp(end) + + # 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 = { + 'latitude': latitude, + 'longitude': longitude, + 'start': start.strftime('%Y%m%d'), + 'end': end.strftime('%Y%m%d'), + 'community': community, + 'parameters': ','.join(parameters), # make parameters in a string + 'format': 'json', + 'user': None, + 'header': True, + 'time-standard': 'utc', + 'site-elevation': elevation, + 'wind-elevation': wind_height, + 'wind-surface': wind_surface, + } + + response = requests.get(url, params=params) + if not response.ok: + # response.raise_for_status() does not give a useful error message + raise requests.HTTPError(response.json()) + + # Parse the data to dataframe + data = response.json() + hourly_data = data['properties']['parameter'] + df = pd.DataFrame(hourly_data) + df.index = pd.to_datetime(df.index, format='%Y%m%d%H').tz_localize('UTC') + df = df.replace(-999, np.nan) + + # Create metadata dictionary + meta = data['header'] + meta['times'] = data['times'] + meta['parameters'] = data['parameters'] + + meta['longitude'] = data['geometry']['coordinates'][0] + meta['latitude'] = data['geometry']['coordinates'][1] + meta['altitude'] = data['geometry']['coordinates'][2] + + # Rename according to pvlib convention + if map_variables: + df = df.rename(columns=VARIABLE_MAP) + + return df, meta diff --git a/tests/iotools/test_nasa_power.py b/tests/iotools/test_nasa_power.py new file mode 100644 index 0000000000..ef175c7e37 --- /dev/null +++ b/tests/iotools/test_nasa_power.py @@ -0,0 +1,88 @@ +import pandas as pd +import pytest +import pvlib +from requests.exceptions import HTTPError + + +@pytest.fixture +def data_index(): + index = pd.date_range(start='2025-02-02 00:00+00:00', + end='2025-02-02 23:00+00:00', freq='1h') + return index + + +@pytest.fixture +def ghi_series(data_index): + ghi = [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 50.25, 184.2, 281.55, 368.3, 406.48, + 386.45, 316.05, 210.1, 109.05, 12.9, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + ] + return pd.Series(data=ghi, index=data_index, name='ghi') + + +def test_get_nasa_power(data_index, ghi_series): + data, meta = pvlib.iotools.get_nasa_power(latitude=44.76, + longitude=7.64, + start=data_index[0], + end=data_index[-1], + map_variables=False) + # Check that metadata is correct + assert meta['latitude'] == 44.76 + assert meta['longitude'] == 7.64 + assert meta['altitude'] == 705.88 + assert meta['start'] == '20250202' + assert meta['end'] == '20250202' + assert meta['time_standard'] == 'UTC' + assert meta['title'] == 'NASA/POWER Source Native Resolution Hourly Data' + # Assert that the index is parsed correctly + pd.testing.assert_index_equal(data.index, data_index) + # Test one column + pd.testing.assert_series_equal(data['ALLSKY_SFC_SW_DWN'], ghi_series, + check_freq=False, check_names=False) + + +def test_get_nasa_power_pvlib_params_naming(data_index, ghi_series): + data, meta = pvlib.iotools.get_nasa_power(latitude=44.76, + longitude=7.64, + start=data_index[0], + end=data_index[-1], + parameters=['ghi']) + # Assert that the index is parsed correctly + pd.testing.assert_index_equal(data.index, data_index) + # Test one column + pd.testing.assert_series_equal(data['ghi'], ghi_series, + check_freq=False) + + +def test_get_nasa_power_map_variables(data_index): + # Check that variables are mapped by default to pvlib names + data, meta = pvlib.iotools.get_nasa_power(latitude=44.76, + longitude=7.64, + start=data_index[0], + end=data_index[-1]) + mapped_column_names = ['ghi', 'dni', 'dhi', 'temp_air', 'wind_speed'] + for c in mapped_column_names: + assert c in data.columns + assert meta['latitude'] == 44.76 + assert meta['longitude'] == 7.64 + assert meta['altitude'] == 705.88 + + +def test_get_nasa_power_wrong_parameter_name(data_index): + # Test if HTTPError is raised if a wrong parameter name is asked + with pytest.raises(HTTPError, match=r"ALLSKY_SFC_SW_DLN"): + pvlib.iotools.get_nasa_power(latitude=44.76, + longitude=7.64, + start=data_index[0], + end=data_index[-1], + parameters=['ALLSKY_SFC_SW_DLN']) + + +def test_get_nasa_power_duplicate_parameter_name(data_index): + # Test if HTTPError is raised if a duplicate parameter is asked + with pytest.raises(HTTPError, match=r"ALLSKY_SFC_SW_DWN"): + pvlib.iotools.get_nasa_power(latitude=44.76, + longitude=7.64, + start=data_index[0], + end=data_index[-1], + parameters=2*['ALLSKY_SFC_SW_DWN'])