From b649f3522bd31e234da688a831f1a102f165115f Mon Sep 17 00:00:00 2001 From: lorenzo riches Date: Fri, 30 Oct 2026 11:28:36 +1100 Subject: [PATCH] explicit parsing of timestamps --- pvlib/iotools/solcast.py | 26 +++++++++++++++- tests/iotools/test_solcast.py | 56 ++++++++++++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/pvlib/iotools/solcast.py b/pvlib/iotools/solcast.py index 96ed677097..eeeb928c9d 100644 --- a/pvlib/iotools/solcast.py +++ b/pvlib/iotools/solcast.py @@ -78,6 +78,8 @@ def get_solcast_tmy( data : pandas.DataFrame containing the values for the parameters requested. The times in the DataFrame index indicate the midpoint of each interval. + The index is timezone-aware, using the UTC offset returned by + the API, or UTC if the returned timestamps have mixed offsets. metadata: dict latitude and longitude of the request. @@ -179,6 +181,8 @@ def get_solcast_historic( data : pandas.DataFrame containing the values for the parameters requested. The times in the DataFrame index indicate the midpoint of each interval. + The index is timezone-aware, using the UTC offset returned by + the API, or UTC if the returned timestamps have mixed offsets. metadata: dict latitude and longitude of the request. @@ -263,6 +267,8 @@ def get_solcast_forecast( data : pandas.DataFrame Contains the values for the parameters requested. The times in the DataFrame index indicate the midpoint of each interval. + The index is timezone-aware, using the UTC offset returned by + the API, or UTC if the returned timestamps have mixed offsets. metadata: dict latitude and longitude of the request. @@ -341,6 +347,8 @@ def get_solcast_live( data : pandas.DataFrame containing the values for the parameters requested. The times in the DataFrame index indicate the midpoint of each interval. + The index is timezone-aware, using the UTC offset returned by + the API, or UTC if the returned timestamps have mixed offsets. metadata: dict latitude and longitude of the request. @@ -400,6 +408,22 @@ def get_solcast_live( return data, {"latitude": latitude, "longitude": longitude} +def _parse_period_end(period_end): + """Parse Solcast ``period_end`` timestamps to a tz-aware DatetimeIndex. + + Supports both the legacy format (``2025-04-16T06:00:00.0000000Z``) and + the ISO 8601 UTC offset format (``2025-04-16T14:00:00+01:30``). + If all timestamps share the same UTC offset, that offset is kept; + otherwise the timestamps are converted to UTC. + """ + period_end = pd.Series(period_end).astype(str) + times = pd.DatetimeIndex(pd.to_datetime(period_end, utc=True)) + offsets = period_end.str.extract(r'(Z|[+-]\d{2}:?\d{2})$')[0] + if len(times) > 0 and offsets.nunique(dropna=False) == 1: + times = times.tz_convert(pd.Timestamp(period_end.iloc[0]).tz) + return times + + def _solcast2pvlib(data): """Format the data from Solcast to pvlib's conventions. @@ -414,7 +438,7 @@ def _solcast2pvlib(data): """ # move from period_end to period_middle as per pvlib convention - data["period_mid"] = pd.to_datetime( + data["period_mid"] = _parse_period_end( data.period_end) - pd.to_timedelta(data.period.values) / 2 data = data.set_index("period_mid").drop(columns=["period_end", "period"]) diff --git a/tests/iotools/test_solcast.py b/tests/iotools/test_solcast.py index d6df24c222..8dbf7d088f 100644 --- a/tests/iotools/test_solcast.py +++ b/tests/iotools/test_solcast.py @@ -185,11 +185,65 @@ def test_get_solcast_tmy( ['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) ) - ) + ), + ( + # new standard API format: float air_temp, ISO 8601 UTC offset + pd.DataFrame( + [[942, 843, 1017.4, 30.2, 7.8, 316, 1010, -2, 4.6, 16.4, + '2023-09-20T12:00:00+10:00', 'PT30M', 90], + [936, 832, 1017.9, 29.8, 7.9, 316, 996, -14, 4.5, 16.3, + '2023-09-20T11:30:00+10:00', '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.0200e+01, 7.8000e+00, + 3.1600e+02, 1.0100e+03, 2.0000e+00, 4.6000e+00, 1.6400e+00, 90], + [9.3600e+02, 8.3200e+02, 1.0179e+05, 2.9800e+01, 7.9000e+00, + 3.1600e+02, 9.9600e+02, 1.4000e+01, 4.5000e+00, 1.6300e+00, 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 11:45:00+10:00', '2023-09-20 11:15:00+10:00'], + 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)) + assert df.index.tz is not None + + +@pytest.mark.parametrize("period_end,expected", [ + # legacy format, 7 fractional-second digits and Z suffix + (['2025-04-16T06:00:00.0000000Z', '2025-04-16T06:30:00.0000000Z'], + ['2025-04-16T06:00:00+00:00', '2025-04-16T06:30:00+00:00']), + # new standard format, explicit UTC offset + (['2025-04-16T14:00:00+01:30', '2025-04-16T14:30:00+01:30'], + ['2025-04-16T14:00:00+01:30', '2025-04-16T14:30:00+01:30']), + (['2025-04-16T06:00:00+00:00'], ['2025-04-16T06:00:00+00:00']), +]) +def test_parse_period_end(period_end, expected): + out = pvlib.iotools.solcast._parse_period_end(pd.Series(period_end)) + expected = pd.DatetimeIndex(pd.to_datetime(expected)) + assert out.equals(expected) + assert str(out.tz) == str(expected.tz) + + +def test_parse_period_end_mixed_offsets(): + # e.g. a local time series spanning a daylight saving transition + out = pvlib.iotools.solcast._parse_period_end(pd.Series( + ['2025-04-06T01:00:00+11:00', '2025-04-06T03:00:00+10:00'])) + expected = pd.DatetimeIndex( + ['2025-04-05T14:00:00', '2025-04-05T17:00:00'], tz='UTC') + assert str(out.tz) == 'UTC' + assert out.equals(expected) @pytest.mark.parametrize("map_variables", [True, False])