From da4110f1ba6d2e35aca43a2efd9f3c7ab37c7c4e Mon Sep 17 00:00:00 2001 From: lorenzo-solcast <142359869+lorenzo-solcast@users.noreply.github.com> Date: Wed, 20 Dec 2023 12:04:59 +1100 Subject: [PATCH] add functionality to load Solcast API data to `iotools` (#1875) * prototype (#1) * first iteration * dynamic period * docstring * feedback * linting * Update pvlib/iotools/solcast.py Co-authored-by: Cliff Hansen * Update pvlib/iotools/solcast.py Co-authored-by: Cliff Hansen * Update pvlib/iotools/solcast.py Co-authored-by: Cliff Hansen * midpoint docstring * flak8 formatting * PR 1875 (#2) * kandersolar feedback (#3) * addressing feedback from Kandersolar * Review (#4) added hack for ISO periods in Pandas 0.25 and clearsky parameter maps * comment on pandas version * Update pvlib/iotools/solcast.py Co-authored-by: Adam R. Jensen <39184289+AdamRJensen@users.noreply.github.com> * Update pvlib/iotools/solcast.py Co-authored-by: Adam R. Jensen <39184289+AdamRJensen@users.noreply.github.com> * Update pvlib/iotools/solcast.py Co-authored-by: Adam R. Jensen <39184289+AdamRJensen@users.noreply.github.com> * Update pvlib/iotools/solcast.py Co-authored-by: Adam R. Jensen <39184289+AdamRJensen@users.noreply.github.com> * Update pvlib/iotools/solcast.py Co-authored-by: Adam R. Jensen <39184289+AdamRJensen@users.noreply.github.com> * Update pvlib/iotools/solcast.py Co-authored-by: Adam R. Jensen <39184289+AdamRJensen@users.noreply.github.com> * Update pvlib/iotools/solcast.py Co-authored-by: Adam R. Jensen <39184289+AdamRJensen@users.noreply.github.com> * Update pvlib/iotools/solcast.py Co-authored-by: Adam R. Jensen <39184289+AdamRJensen@users.noreply.github.com> * Update pvlib/iotools/solcast.py Co-authored-by: Adam R. Jensen <39184289+AdamRJensen@users.noreply.github.com> * Adams's feedback * Update pvlib/iotools/solcast.py Co-authored-by: Adam R. Jensen <39184289+AdamRJensen@users.noreply.github.com> * Adams's feedback * Last minor changes * added test for _get_solcast * feat: add additional test coverage (#5) Co-authored-by: Hugh Cutcher * linting --------- Co-authored-by: Cliff Hansen Co-authored-by: Adam R. Jensen <39184289+AdamRJensen@users.noreply.github.com> Co-authored-by: hugh-solcast <143680553+hugh-solcast@users.noreply.github.com> Co-authored-by: Hugh Cutcher --- docs/sphinx/source/reference/iotools.rst | 4 + docs/sphinx/source/whatsnew/v0.10.3.rst | 4 + pvlib/iotools/__init__.py | 4 + pvlib/iotools/solcast.py | 488 +++++++++++++++++++++++ pvlib/tests/iotools/test_solcast.py | 324 +++++++++++++++ 5 files changed, 824 insertions(+) create mode 100644 pvlib/iotools/solcast.py create mode 100644 pvlib/tests/iotools/test_solcast.py diff --git a/docs/sphinx/source/reference/iotools.rst b/docs/sphinx/source/reference/iotools.rst index b41a30b203..05100e9d17 100644 --- a/docs/sphinx/source/reference/iotools.rst +++ b/docs/sphinx/source/reference/iotools.rst @@ -46,6 +46,10 @@ of sources and file formats relevant to solar energy modeling. iotools.get_acis_station_data iotools.get_acis_available_stations iotools.read_panond + iotools.get_solcast_tmy + iotools.get_solcast_historic + iotools.get_solcast_forecast + iotools.get_solcast_live A :py:class:`~pvlib.location.Location` object may be created from metadata diff --git a/docs/sphinx/source/whatsnew/v0.10.3.rst b/docs/sphinx/source/whatsnew/v0.10.3.rst index 30fd46648f..29aa59a543 100644 --- a/docs/sphinx/source/whatsnew/v0.10.3.rst +++ b/docs/sphinx/source/whatsnew/v0.10.3.rst @@ -15,6 +15,9 @@ Enhancements * :py:func:`pvlib.bifacial.infinite_sheds.get_irradiance` and :py:func:`pvlib.bifacial.infinite_sheds.get_irradiance_poa` now include shaded fraction in returned variables. (:pull:`1871`) +* Added :py:func:`~pvlib.iotools.get_solcast_tmy`, :py:func:`~pvlib.iotools.get_solcast_historic`, + :py:func:`~pvlib.iotools.get_solcast_forecast` and :py:func:`~pvlib.iotools.get_solcast_live` to + read data from the Solcast API. (:issue:`1313`, :pull:`1875`) * Added :py:func:`~pvlib.iam.convert` and :py:func:`~pvlib.iam.fit` that convert between IAM models, and that fit an IAM model to data. (:issue:`1824`, :pull:`1827`) @@ -59,4 +62,5 @@ Contributors * :ghuser:`matsuobasho` * Harry Jack (:ghuser:`harry-solcast`) * Kevin Anderson (:ghuser:`kandersolar`) +* Lorenzo Riches (:ghuser:`lorenzo-solcast`) * Cliff Hansen (:ghuser:`cwhanse`) diff --git a/pvlib/iotools/__init__.py b/pvlib/iotools/__init__.py index 9935719b29..6a4991ee7b 100644 --- a/pvlib/iotools/__init__.py +++ b/pvlib/iotools/__init__.py @@ -27,3 +27,7 @@ from pvlib.iotools.acis import get_acis_mpe # noqa: F401 from pvlib.iotools.acis import get_acis_station_data # noqa: F401 from pvlib.iotools.acis import get_acis_available_stations # noqa: F401 +from pvlib.iotools.solcast import get_solcast_forecast # noqa: F401 +from pvlib.iotools.solcast import get_solcast_live # noqa: F401 +from pvlib.iotools.solcast import get_solcast_historic # noqa: F401 +from pvlib.iotools.solcast import get_solcast_tmy # noqa: F401 diff --git a/pvlib/iotools/solcast.py b/pvlib/iotools/solcast.py new file mode 100644 index 0000000000..4fcee40050 --- /dev/null +++ b/pvlib/iotools/solcast.py @@ -0,0 +1,488 @@ +""" Functions to access data from the Solcast API. +""" + +import requests +import pandas as pd +from dataclasses import dataclass + + +BASE_URL = "https://api.solcast.com.au/data" + + +@dataclass +class ParameterMap: + solcast_name: str + pvlib_name: str + conversion: callable = lambda x: x + + +# define the conventions between Solcast and pvlib nomenclature and units +VARIABLE_MAP = [ + # air_temp -> temp_air (deg C) + ParameterMap("air_temp", "temp_air"), + # surface_pressure (hPa) -> pressure (Pa) + ParameterMap("surface_pressure", "pressure", lambda x: x*100), + # dewpoint_temp -> temp_dew (deg C) + ParameterMap("dewpoint_temp", "temp_dew"), + # gti (W/m^2) -> poa_global (W/m^2) + ParameterMap("gti", "poa_global"), + # wind_speed_10m (m/s) -> wind_speed (m/s) + ParameterMap("wind_speed_10m", "wind_speed"), + # wind_direction_10m (deg) -> wind_direction (deg) + ParameterMap("wind_direction_10m", "wind_direction"), + # azimuth -> solar_azimuth (degrees) (different convention) + ParameterMap( + "azimuth", "solar_azimuth", lambda x: -x % 360 + ), + # precipitable_water (kg/m2) -> precipitable_water (cm) + ParameterMap("precipitable_water", "precipitable_water", lambda x: x*10), + # zenith -> solar_zenith + ParameterMap("zenith", "solar_zenith"), + # clearsky + ParameterMap("clearsky_dhi", "dhi_clear"), + ParameterMap("clearsky_dni", "dni_clear"), + ParameterMap("clearsky_ghi", "ghi_clear"), + ParameterMap("clearsky_gti", "poa_global_clear") +] + + +def get_solcast_tmy( + latitude, longitude, api_key, map_variables=True, **kwargs +): + """Get irradiance and weather for a + Typical Meteorological Year (TMY) at a requested location. + + Data is derived from a multi-year time series selected to present the + unique weather phenomena with annual averages that are consistent with + long term averages. See [1]_ for details on the calculation. + + Parameters + ---------- + latitude : float + in decimal degrees, between -90 and 90, north is positive + longitude : float + in decimal degrees, between -180 and 180, east is positive + api_key : str + To access Solcast data you will need an API key [2]_. + map_variables: bool, default: True + When true, renames columns of the DataFrame to pvlib variable names + where applicable. See variable :const:`VARIABLE_MAP`. + Time is the index shifted to the midpoint of each interval + from Solcast's "period end" convention. + kwargs: + Optional parameters passed to the API. + See [3]_ for full list of parameters. + + Returns + ------- + data : pandas.DataFrame + containing the values for the parameters requested. The times + in the DataFrame index indicate the midpoint of each interval. + metadata: dict + latitude and longitude of the request. + + Examples + -------- + >>> df, meta = pvlib.iotools.solcast.get_solcast_tmy( + >>> latitude=-33.856784, + >>> longitude=151.215297, + >>> api_key="your-key" + >>> ) + + you can pass any of the parameters listed in the API docs, + like ``time_zone``. Here we set the value of 10 for + "10 hours ahead of UTC": + + >>> df, meta = pvlib.iotools.solcast.get_solcast_tmy( + >>> latitude=-33.856784, + >>> longitude=151.215297, + >>> time_zone=10, + >>> api_key="your-key" + >>> ) + + References + ---------- + .. [1] `Solcast TMY Docs `_ + .. [2] `Get an API Key `_ + .. [3] `Solcast API Docs `_ + + See Also + -------- + pvlib.iotools.get_solcast_historic, pvlib.iotools.get_solcast_forecast, + pvlib.iotools.get_solcast_live + """ + + params = dict( + latitude=latitude, + longitude=longitude, + format="json", + **kwargs + ) + + data = _get_solcast( + endpoint="tmy/radiation_and_weather", + params=params, + api_key=api_key, + map_variables=map_variables + ) + + return data, {"latitude": latitude, "longitude": longitude} + + +def get_solcast_historic( + latitude, + longitude, + start, + api_key, + end=None, + duration=None, + map_variables=True, + **kwargs +): + """Get historical irradiance and weather estimates. + + for up to 31 days of data at a time for a requested location, + derived from satellite (clouds and irradiance + over non-polar continental areas) and + numerical weather models (other data). + Data is available from 2007-01-01T00:00Z up to real time estimated actuals. + + Parameters + ---------- + latitude : float + in decimal degrees, between -90 and 90, north is positive + longitude : float + in decimal degrees, between -180 and 180, east is positive + start : datetime-like + First day of the requested period + end : optional, datetime-like + Last day of the requested period. + Must include one of ``end`` or ``duration``. + duration : optional, ISO 8601 compliant duration + Must include either ``end`` or ``duration``. + ISO 8601 compliant duration for the historic data, + like "P1D" for one day of data. + Must be within 31 days of ``start``. + map_variables: bool, default: True + When true, renames columns of the DataFrame to pvlib variable names + where applicable. See variable :const:`VARIABLE_MAP`. + Time is the index shifted to the midpoint of each interval + from Solcast's "period end" convention. + api_key : str + To access Solcast data you will need an API key [1]_. + kwargs: + Optional parameters passed to the API. + See [2]_ for full list of parameters. + + Returns + ------- + data : pandas.DataFrame + containing the values for the parameters requested. The times + in the DataFrame index indicate the midpoint of each interval. + metadata: dict + latitude and longitude of the request. + + Examples + -------- + >>> df, meta = pvlib.iotools.solcast.get_solcast_historic( + >>> latitude=-33.856784, + >>> longitude=151.215297, + >>> start='2007-01-01T00:00Z', + >>> duration='P1D', + >>> api_key="your-key" + >>> ) + + you can pass any of the parameters listed in the API docs, + for example using the ``end`` parameter instead + + >>> df, meta = pvlib.iotools.solcast.get_solcast_historic( + >>> latitude=-33.856784, + >>> longitude=151.215297, + >>> start='2007-01-01T00:00Z', + >>> end='2007-01-02T00:00Z', + >>> api_key="your-key" + >>> ) + + References + ---------- + .. [1] `Get an API Key `_ + .. [2] `Solcast API Docs `_ + + See Also + -------- + pvlib.iotools.get_solcast_tmy, pvlib.iotools.get_solcast_forecast, + pvlib.iotools.get_solcast_live + """ + + params = dict( + latitude=latitude, + longitude=longitude, + start=start, + end=end, + duration=duration, + api_key=api_key, + format="json", + **kwargs + ) + + data = _get_solcast( + endpoint="historic/radiation_and_weather", + params=params, + api_key=api_key, + map_variables=map_variables + ) + + return data, {"latitude": latitude, "longitude": longitude} + + +def get_solcast_forecast( + latitude, longitude, api_key, map_variables=True, **kwargs +): + """Get irradiance and weather forecasts from the present time + up to 14 days ahead. + + Parameters + ---------- + latitude : float + in decimal degrees, between -90 and 90, north is positive + longitude : float + in decimal degrees, between -180 and 180, east is positive + api_key : str + To access Solcast data you will need an API key [1]_. + map_variables: bool, default: True + When true, renames columns of the DataFrame to pvlib variable names + where applicable. See variable :const:`VARIABLE_MAP`. + Time is the index shifted to the midpoint of each interval + from Solcast's "period end" convention. + kwargs: + Optional parameters passed to the API. + See [2]_ for full list of parameters. + + Returns + ------- + data : pandas.DataFrame + Contains the values for the parameters requested. The times + in the DataFrame index indicate the midpoint of each interval. + metadata: dict + latitude and longitude of the request. + + Examples + -------- + >>> df, meta = pvlib.iotools.solcast.get_solcast_forecast( + >>> latitude=-33.856784, + >>> longitude=151.215297, + >>> api_key="your-key" + >>> ) + + you can pass any of the parameters listed in the API docs, + like asking for specific variables for a specific time horizon: + + >>> df, meta = pvlib.iotools.solcast.get_solcast_forecast( + >>> latitude=-33.856784, + >>> longitude=151.215297, + >>> output_parameters=['dni', 'clearsky_dni', 'snow_soiling_rooftop'], + >>> hours=24, + >>> api_key="your-key" + >>> ) + + References + ---------- + .. [1] `Get an API Key `_ + .. [2] `Solcast API Docs `_ + + See Also + -------- + pvlib.iotools.get_solcast_tmy, pvlib.iotools.get_solcast_historic, + pvlib.iotools.get_solcast_live + """ + + params = dict( + latitude=latitude, + longitude=longitude, + format="json", + **kwargs + ) + + data = _get_solcast( + endpoint="forecast/radiation_and_weather", + params=params, + api_key=api_key, + map_variables=map_variables + ) + + return data, {"latitude": latitude, "longitude": longitude} + + +def get_solcast_live( + latitude, longitude, api_key, map_variables=True, **kwargs +): + """Get irradiance and weather estimated actuals for near real-time + and past 7 days. + + Parameters + ---------- + latitude : float + in decimal degrees, between -90 and 90, north is positive + longitude : float + in decimal degrees, between -180 and 180, east is positive + api_key : str + To access Solcast data you will need an API key [1]_. + map_variables: bool, default: True + When true, renames columns of the DataFrame to pvlib variable names + where applicable. See variable :const:`VARIABLE_MAP`. + Time is the index shifted to the midpoint of each interval + from Solcast's "period end" convention. + kwargs: + Optional parameters passed to the API. + See [2]_ for full list of parameters. + + Returns + ------- + data : pandas.DataFrame + containing the values for the parameters requested. The times + in the DataFrame index indicate the midpoint of each interval. + metadata: dict + latitude and longitude of the request. + + Examples + -------- + >>> df, meta = pvlib.iotools.solcast.get_solcast_live( + >>> latitude=-33.856784, + >>> longitude=151.215297, + >>> api_key="your-key" + >>> ) + + you can pass any of the parameters listed in the API docs, like + + >>> df, meta = pvlib.iotools.solcast.get_solcast_live( + >>> latitude=-33.856784, + >>> longitude=151.215297, + >>> terrain_shading=True, + >>> output_parameters=['ghi', 'clearsky_ghi', 'snow_soiling_rooftop'], + >>> api_key="your-key" + >>> ) + + use ``map_variables=False`` to avoid converting the data + to PVLib's conventions. + + >>> df, meta = pvlib.iotools.solcast.get_solcast_live( + >>> latitude=-33.856784, + >>> longitude=151.215297, + >>> map_variables=False, + >>> api_key="your-key" + >>> ) + + References + ---------- + .. [1] `Get an API Key `_ + .. [2] `Solcast API Docs `_ + + See Also + -------- + pvlib.iotools.get_solcast_tmy, pvlib.iotools.get_solcast_historic, + pvlib.iotools.get_solcast_forecast + """ + + params = dict( + latitude=latitude, + longitude=longitude, + format="json", + **kwargs + ) + + data = _get_solcast( + endpoint="live/radiation_and_weather", + params=params, + api_key=api_key, + map_variables=map_variables + ) + + return data, {"latitude": latitude, "longitude": longitude} + + +def _solcast2pvlib(data): + """Format the data from Solcast to pvlib's conventions. + + Parameters + ---------- + data : pandas.DataFrame + contains the data returned from the Solcast API + + Returns + ------- + a pandas.DataFrame with the data cast to pvlib's conventions + """ + # move from period_end to period_middle as per pvlib convention + # to support Pandas 0.25 we cast PTXX to XX as ISO8601 + # durations without days aren't supported: + # https://github.com/pandas-dev/pandas/pull/37159\ + # Can remove once minimum supported Pandas version is >=1.2 + periods = data.period.str.replace("PT", "").str.replace("M", "m") + + data["period_mid"] = pd.to_datetime( + data.period_end) - pd.to_timedelta(periods) / 2 + data = data.set_index("period_mid").drop(columns=["period_end", "period"]) + + # rename and convert variables + for variable in VARIABLE_MAP: + if variable.solcast_name in data.columns: + data.rename( + columns={variable.solcast_name: variable.pvlib_name}, + inplace=True + ) + data[variable.pvlib_name] = data[ + variable.pvlib_name].apply(variable.conversion) + return data + + +def _get_solcast( + endpoint, + params, + api_key, + map_variables +): + """Retrieve weather, irradiance and power data from the Solcast API. + + Parameters + ---------- + endpoint : str + one of Solcast API endpoint: + - live/radiation_and_weather + - forecast/radiation_and_weather + - historic/radiation_and_weather + - tmy/radiation_and_weather + params : dict + parameters to be passed to the API + api_key : str + To access Solcast data you will need an API key [1]_. + map_variables: bool, default: True + When true, renames columns of the DataFrame to pvlib's variable names + where applicable. See variable :const:`VARIABLE_MAP`. + Time is the index shifted to the midpoint of each interval + from Solcast's "period end" convention. + + Returns + ------- + A pandas.DataFrame with the data if the request is successful, + an error message otherwise + + References + ---------- + .. [1] `register ` + """ + + response = requests.get( + url='/'.join([BASE_URL, endpoint]), + params=params, + headers={"Authorization": f"Bearer {api_key}"} + ) + + if response.status_code == 200: + j = response.json() + df = pd.DataFrame.from_dict(j[list(j.keys())[0]]) + if map_variables: + return _solcast2pvlib(df) + else: + return df + else: + raise Exception(response.json()) diff --git a/pvlib/tests/iotools/test_solcast.py b/pvlib/tests/iotools/test_solcast.py new file mode 100644 index 0000000000..19b00b8611 --- /dev/null +++ b/pvlib/tests/iotools/test_solcast.py @@ -0,0 +1,324 @@ +from unittest.mock import patch +import pandas as pd +import pvlib +import pytest + + +@pytest.mark.parametrize("endpoint,params,api_key,json_response", [ + ( + "live/radiation_and_weather", + dict( + latitude=-33.856784, + longitude=151.215297, + output_parameters='dni,ghi' + ), + "1234", + {'estimated_actuals': + [{'dni': 836, 'ghi': 561, + 'period_end': '2023-09-18T05:00:00.0000000Z', 'period': 'PT30M'}, + {'dni': 866, 'ghi': 643, + 'period_end': '2023-09-18T04:30:00.0000000Z', 'period': 'PT30M'}, + {'dni': 890, 'ghi': 713, + 'period_end': '2023-09-18T04:00:00.0000000Z', 'period': 'PT30M'}, + {'dni': 909, 'ghi': 768, + 'period_end': '2023-09-18T03:30:00.0000000Z', 'period': 'PT30M'}] + } + ), +]) +def test__get_solcast(requests_mock, endpoint, params, api_key, json_response): + mock_url = f"https://api.solcast.com.au/data/{endpoint}?" \ + f"latitude={params['latitude']}&" \ + f"longitude={params['longitude']}&" \ + f"output_parameters={params['output_parameters']}" + + requests_mock.get(mock_url, json=json_response) + + # with variables remapping + pd.testing.assert_frame_equal( + pvlib.iotools.solcast._get_solcast( + endpoint, params, api_key, True + ), + pvlib.iotools.solcast._solcast2pvlib( + pd.DataFrame.from_dict( + json_response[list(json_response.keys())[0]]) + ) + ) + + # no remapping of variables + pd.testing.assert_frame_equal( + pvlib.iotools.solcast._get_solcast( + endpoint, params, api_key, False + ), + pd.DataFrame.from_dict( + json_response[list(json_response.keys())[0]]) + ) + + +@pytest.mark.parametrize("map_variables", [True, False]) +@pytest.mark.parametrize("endpoint,function,params,json_response", [ + ( + "live/radiation_and_weather", + pvlib.iotools.get_solcast_live, + dict( + api_key="1234", + latitude=-33.856784, + longitude=151.215297, + output_parameters='dni,ghi' + ), + {'estimated_actuals': + [{'dni': 836, 'ghi': 561, + 'period_end': '2023-09-18T05:00:00.0000000Z', 'period': 'PT30M'}, + {'dni': 866, 'ghi': 643, + 'period_end': '2023-09-18T04:30:00.0000000Z', 'period': 'PT30M'}, + {'dni': 890, 'ghi': 713, + 'period_end': '2023-09-18T04:00:00.0000000Z', 'period': 'PT30M'}, + {'dni': 909, 'ghi': 768, + 'period_end': '2023-09-18T03:30:00.0000000Z', 'period': 'PT30M'}] + } + ), +]) +def test_get_solcast_live( + requests_mock, endpoint, function, params, json_response, map_variables +): + mock_url = ( + f"https://api.solcast.com.au/data/{endpoint}?" + f"&latitude={params['latitude']}&" + f"longitude={params['longitude']}&" + f"output_parameters={params['output_parameters']}&format=json" + ) + + requests_mock.get(mock_url, json=json_response) + + if map_variables: + pd.testing.assert_frame_equal( + function(**params, map_variables=map_variables)[0], + pvlib.iotools.solcast._solcast2pvlib( + pd.DataFrame.from_dict( + json_response[list(json_response.keys())[0]] + ) + ), + ) + else: + pd.testing.assert_frame_equal( + function(**params, map_variables=map_variables)[0], + pd.DataFrame.from_dict( + json_response[list(json_response.keys())[0]] + ), + ) + + +@pytest.mark.parametrize("map_variables", [True, False]) +@pytest.mark.parametrize("endpoint,function,params,json_response", [ + ( + "tmy/radiation_and_weather", + pvlib.iotools.get_solcast_tmy, + dict( + api_key="1234", + latitude=-33.856784, + longitude=51.215297 + ), + {'estimated_actuals': [ + {'dni': 151, 'ghi': 609, + 'period_end': '2059-01-01T01:00:00.0000000Z', 'period': 'PT60M'}, + {'dni': 0, 'ghi': 404, + 'period_end': '2059-01-01T02:00:00.0000000Z', 'period': 'PT60M'}, + {'dni': 0, 'ghi': 304, + 'period_end': '2059-01-01T03:00:00.0000000Z', 'period': 'PT60M'}, + {'dni': 0, 'ghi': 174, + 'period_end': '2059-01-01T04:00:00.0000000Z', 'period': 'PT60M'}, + {'dni': 0, 'ghi': 111, + 'period_end': '2059-01-01T05:00:00.0000000Z', 'period': 'PT60M'}] + } + ), +]) +def test_get_solcast_tmy( + requests_mock, endpoint, function, params, json_response, map_variables +): + + mock_url = f"https://api.solcast.com.au/data/{endpoint}?" \ + f"&latitude={params['latitude']}&" \ + f"longitude={params['longitude']}&format=json" + + requests_mock.get(mock_url, json=json_response) + + if map_variables: + pd.testing.assert_frame_equal( + function(**params, map_variables=map_variables)[0], + pvlib.iotools.solcast._solcast2pvlib( + pd.DataFrame.from_dict( + json_response[list(json_response.keys())[0]] + ) + ), + ) + else: + pd.testing.assert_frame_equal( + function(**params, map_variables=map_variables)[0], + pd.DataFrame.from_dict( + json_response[list(json_response.keys())[0]] + ), + ) + + +@pytest.mark.parametrize("in_df,out_df", [ + ( + pd.DataFrame( + [[942, 843, 1017.4, 30, 7.8, 316, 1010, -2, 4.6, 16.4, + '2023-09-20T02:00:00.0000000Z', 'PT30M', 90], + [936, 832, 1017.9, 30, 7.9, 316, 996, -14, 4.5, 16.3, + '2023-09-20T01:30:00.0000000Z', 'PT30M', 0]], + columns=[ + 'dni', 'ghi', 'surface_pressure', 'air_temp', 'wind_speed_10m', + 'wind_direction_10m', 'gti', 'azimuth', 'dewpoint_temp', + 'precipitable_water', 'period_end', 'period', 'zenith'], + index=pd.RangeIndex(start=0, stop=2, step=1) + ), + pd.DataFrame( + [[9.4200e+02, 8.4300e+02, 1.0174e+05, 3.0000e+01, 7.8000e+00, + 3.1600e+02, 1.0100e+03, 2.0000e+00, 4.6000e+00, 1.6400e+02, 90], + [9.3600e+02, 8.3200e+02, 1.0179e+05, 3.0000e+01, 7.9000e+00, + 3.1600e+02, 9.9600e+02, 1.4000e+01, 4.5000e+00, 1.6300e+02, 0]], + columns=[ + 'dni', 'ghi', 'pressure', 'temp_air', 'wind_speed', + 'wind_direction', 'poa_global', 'solar_azimuth', + 'temp_dew', 'precipitable_water', 'solar_zenith'], + index=pd.DatetimeIndex( + ['2023-09-20 01:45:00+00:00', '2023-09-20 01:15:00+00:00'], + dtype='datetime64[ns, UTC]', name='period_mid', freq=None) + ) + ) +]) +def test_solcast2pvlib(in_df, out_df): + df = pvlib.iotools.solcast._solcast2pvlib(in_df) + pd.testing.assert_frame_equal(df.astype(float), out_df.astype(float)) + + +@pytest.mark.parametrize("map_variables", [True, False]) +@pytest.mark.parametrize("endpoint,function,params,json_response", [ + ( + "historic/radiation_and_weather", + pvlib.iotools.get_solcast_historic, + dict( + api_key="1234", + latitude=-33.856784, + longitude=51.215297, + start="2023-01-01T08:00", + duration="P1D", + period="PT1H", + output_parameters='dni' + ), {'estimated_actuals': [ + {'dni': 822, 'period_end': '2023-01-01T09:00:00.0000000Z', + 'period': 'PT60M'}, + {'dni': 918, 'period_end': '2023-01-01T10:00:00.0000000Z', + 'period': 'PT60M'}, + {'dni': 772, 'period_end': '2023-01-01T11:00:00.0000000Z', + 'period': 'PT60M'}, + {'dni': 574, 'period_end': '2023-01-01T12:00:00.0000000Z', + 'period': 'PT60M'}, + {'dni': 494, 'period_end': '2023-01-01T13:00:00.0000000Z', + 'period': 'PT60M'} + ]} + ), +]) +def test_get_solcast_historic( + requests_mock, endpoint, function, params, json_response, map_variables +): + mock_url = f"https://api.solcast.com.au/data/{endpoint}?" \ + f"&latitude={params['latitude']}&" \ + f"longitude={params['longitude']}&format=json" + + requests_mock.get(mock_url, json=json_response) + + if map_variables: + pd.testing.assert_frame_equal( + function(**params, map_variables=map_variables)[0], + pvlib.iotools.solcast._solcast2pvlib( + pd.DataFrame.from_dict( + json_response[list(json_response.keys())[0]] + ) + ), + ) + else: + pd.testing.assert_frame_equal( + function(**params, map_variables=map_variables)[0], + pd.DataFrame.from_dict( + json_response[list(json_response.keys())[0]] + ), + ) + + +@pytest.mark.parametrize("map_variables", [True, False]) +@pytest.mark.parametrize("endpoint,function,params,json_response", [ + ( + "forecast/radiation_and_weather", + pvlib.iotools.get_solcast_forecast, + dict( + api_key="1234", + latitude=-33.856784, + longitude=51.215297, + hours="5", + period="PT1H", + output_parameters='dni' + ), { + 'forecast': [ + {'dni': 0, 'period_end': '2023-12-13T01:00:00.0000000Z', + 'period': 'PT1H'}, + {'dni': 1, 'period_end': '2023-12-13T02:00:00.0000000Z', + 'period': 'PT1H'}, + {'dni': 2, 'period_end': '2023-12-13T03:00:00.0000000Z', + 'period': 'PT1H'}, + {'dni': 3, 'period_end': '2023-12-13T04:00:00.0000000Z', + 'period': 'PT1H'}, + {'dni': 4, 'period_end': '2023-12-13T05:00:00.0000000Z', + 'period': 'PT1H'}, + {'dni': 5, 'period_end': '2023-12-13T06:00:00.0000000Z', + 'period': 'PT1H'} + ]} + ), +]) +def test_get_solcast_forecast( + requests_mock, endpoint, function, params, json_response, map_variables +): + mock_url = f"https://api.solcast.com.au/data/{endpoint}?" \ + f"&latitude={params['latitude']}&" \ + f"longitude={params['longitude']}&format=json" + + requests_mock.get(mock_url, json=json_response) + + if map_variables: + pd.testing.assert_frame_equal( + function(**params, map_variables=map_variables)[0], + pvlib.iotools.solcast._solcast2pvlib( + pd.DataFrame.from_dict( + json_response[list(json_response.keys())[0]] + ) + ), + ) + else: + pd.testing.assert_frame_equal( + function(**params, map_variables=map_variables)[0], + pd.DataFrame.from_dict( + json_response[list(json_response.keys())[0]] + ), + ) + + +@pytest.mark.parametrize( + "function", + [ + pvlib.iotools.get_solcast_forecast, + pvlib.iotools.get_solcast_live, + pvlib.iotools.get_solcast_tmy, + pvlib.iotools.get_solcast_historic, + ], +) +@patch("requests.api.request") +def test_raises_exception(mock_response, function): + dummy_args = { + "latitude": 0, + "longitude": 0, + "api_key": "", + } + with patch.object(mock_response, "status_code", return_value=404): + with pytest.raises(Exception): + function(**dummy_args) + mock_response.json.assert_called_once()