diff --git a/docs/sphinx/source/whatsnew/v0.16.2.rst b/docs/sphinx/source/whatsnew/v0.16.2.rst index 5893d8bf46..1b347ea24c 100644 --- a/docs/sphinx/source/whatsnew/v0.16.2.rst +++ b/docs/sphinx/source/whatsnew/v0.16.2.rst @@ -21,6 +21,9 @@ Bug fixes is scalar (:issue:`2881`, :pull:`2882`) * :py:func:`pvlib.scaling.wvm` no longer returns all NaNs when ``positions`` contains a single site (:issue:`2895`, :pull:`2896`) +* Fix :py:func:`pvlib.clearsky.detect_clearsky` and :py:func:`pvlib.temperature.prilliman` + failing on Daylight Saving Time (DST) transition days, and :py:func:`pvlib.soiling.kimber` + failing on timezone-naive data. (:issue:`2935`) Enhancements ~~~~~~~~~~~~ @@ -80,3 +83,4 @@ Contributors * :ghuser:`bfs2021` * :ghuser:`xiajiahang` * Jakub Krasuski (:ghuser:`Blizzeq`) +* S B Pranay (:ghuser:`pranaysb`) diff --git a/pvlib/soiling.py b/pvlib/soiling.py index 6fdad506e6..1fc159ea72 100644 --- a/pvlib/soiling.py +++ b/pvlib/soiling.py @@ -192,11 +192,9 @@ def kimber(rainfall, cleaning_threshold=6, soiling_loss_rate=0.0015, # convert grace_period to timedelta grace_period = datetime.timedelta(days=grace_period) - # get indices as np.datetime64 (UTC), calculate timestep as np.timedelta64, - # and convert timestep to fraction of days - rain_index_vals = rainfall.index.tz_convert(None).to_numpy() - timestep_interval = (rain_index_vals[1] - rain_index_vals[0]) - day_fraction = timestep_interval / np.timedelta64(24, 'h') + # calculate timestep as timedelta and convert to fraction of days + timestep_interval = rainfall.index[1] - rainfall.index[0] + day_fraction = timestep_interval / pd.Timedelta(days=1) # accumulate rainfall accumulated_rainfall = rainfall.rolling( diff --git a/pvlib/tools.py b/pvlib/tools.py index a81306b60c..f9aa161565 100644 --- a/pvlib/tools.py +++ b/pvlib/tools.py @@ -429,18 +429,17 @@ def _get_sample_intervals(times, win_length): """ Calculates time interval and samples per window for Reno-style clear sky detection functions """ - deltas = ( - np.diff(times.tz_localize(None).to_numpy()) # tz-naive np.datetime64 - / np.timedelta64(1, '60s') - ) + if not isinstance(times, pd.DatetimeIndex): + times = pd.DatetimeIndex(times) + + deltas = (times[1:] - times[:-1]) / pd.Timedelta('60s') # determine if we can proceed if ( # infer wrapper is None if no freq is determined _pandas_infer_freq_BaseOffset_transition_handler(times) and len(np.unique(deltas)) == 1 ): - sample_interval = times[1] - times[0] - sample_interval = sample_interval.seconds / 60 # in minutes + sample_interval = (times[1] - times[0]).total_seconds() / 60 samples_per_window = int(win_length / sample_interval) return sample_interval, samples_per_window else: diff --git a/tests/test_clearsky.py b/tests/test_clearsky.py index 20d88679ff..ebb97afac8 100644 --- a/tests/test_clearsky.py +++ b/tests/test_clearsky.py @@ -710,6 +710,17 @@ def test_detect_clearsky_optimizer_not_failed( assert isinstance(clear_samples, pd.Series) +def test_detect_clearsky_dst(): + # GH 2935 + loc = Location(49.28, -123.12, tz='America/Vancouver') + times = pd.date_range('2026-03-07', '2026-03-10', freq='1min', + tz=loc.tz, inclusive='left') + ghi = loc.get_clearsky(times)['ghi'] + result = clearsky.detect_clearsky(ghi, ghi) + assert isinstance(result, pd.Series) + assert len(result) == len(times) + + @pytest.fixture def detect_clearsky_helper_data(): samples_per_window = 3 diff --git a/tests/test_soiling.py b/tests/test_soiling.py index 53acc1c723..6ce4d1498b 100644 --- a/tests/test_soiling.py +++ b/tests/test_soiling.py @@ -170,6 +170,14 @@ def test_kimber_nowash(greensboro_rain, expected_kimber_nowash): assert np.allclose(nowash.values, expected_kimber_nowash['soiling'].values) +def test_kimber_tz_naive(greensboro_rain, expected_kimber_nowash): + # GH 2935 + rain_naive = greensboro_rain.copy() + rain_naive.index = rain_naive.index.tz_localize(None) + actual = kimber(rain_naive) + assert np.allclose(actual.values, expected_kimber_nowash['soiling'].values) + + @pytest.fixture def expected_kimber_manwash(): return pd.read_csv( diff --git a/tests/test_temperature.py b/tests/test_temperature.py index f7cc242477..b6e18acde7 100644 --- a/tests/test_temperature.py +++ b/tests/test_temperature.py @@ -425,6 +425,16 @@ def test_prilliman_nans(): assert_series_equal(actual, expected) +def test_prilliman_dst(): + # GH 2935 + times = pd.date_range('2026-03-07', '2026-03-10', freq='1min', + tz='America/Vancouver', inclusive='left') + cell_temperature = pd.Series(25.0, index=times) + wind_speed = pd.Series(1.0, index=times) + actual = temperature.prilliman(cell_temperature, wind_speed) + assert_series_equal(cell_temperature, actual) + + def test_glm_conversions(): # it is easiest and sufficient to test conversion from & to the same model glm = temperature.GenericLinearModel(module_efficiency=0.1, diff --git a/tests/test_tools.py b/tests/test_tools.py index 4b733ad711..e68b9e21ff 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -283,3 +283,27 @@ def test__file_context_manager(): buffer = StringIO("test content") with tools._file_context_manager(buffer) as obj: assert obj.read() == "test content" + + +def test_get_sample_intervals_dst(): + # GH 2935 + times = pd.date_range('2026-03-07', '2026-03-10', freq='1min', + tz='America/Vancouver') + interval, count = tools._get_sample_intervals(times, 20) + assert interval == 1.0 + assert count == 20 + + +def test_get_sample_intervals_naive(): + # GH 2935 + times = pd.date_range('2026-01-01', periods=60, freq='1min') + interval, count = tools._get_sample_intervals(times, 20) + assert interval == 1.0 + assert count == 20 + + +def test_get_sample_intervals_unequal(): + times = pd.DatetimeIndex(['2026-01-01 00:00', '2026-01-01 00:01', + '2026-01-01 00:05']) + with pytest.raises(NotImplementedError, match='unequal time intervals'): + tools._get_sample_intervals(times, 20)