Skip to content

Commit 3de8c0c

Browse files
Leonard013claude
andcommitted
Fix sun_rise_set_transit_spa day-of-interest to local calendar day
sun_rise_set_transit_spa normalized timestamps to midnight *after* converting to UTC (introduced in v0.11.1 by #2055). An evening-local timestamp is the next calendar day in UTC, so its day of interest was shifted forward and the function returned the following day's sunrise, sunset, and transit. Use the input's local calendar date as the day of interest, restoring the pre-v0.11.1 behavior. The SPA routine requires 00:00 UTC input, so the local date is re-labeled (not converted) to UTC. The timezone label is stripped before normalize() so truncation runs on a naive index: normalizing a tz-aware index would build local midnight, which does not exist (spring-forward) or is ambiguous (fall-back) on DST-transition days and would raise. This yields unixtimes byte-for-byte identical to the v0.11.0 implementation for all valid inputs. Adds an inline comment explaining the timezone handling (requested in the issue) and a regression test covering negative and positive offsets and a midnight-DST zone. Fixes #2238 This change was drafted with AI assistance (Claude); all AI-generated material has been vetted for accuracy and is license-compliant. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9a0ad55 commit 3de8c0c

3 files changed

Lines changed: 74 additions & 2 deletions

File tree

docs/sphinx/source/whatsnew/v0.15.3.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ Deprecations
1414

1515
Bug fixes
1616
~~~~~~~~~
17+
* :py:func:`~pvlib.solarposition.sun_rise_set_transit_spa` now returns
18+
sunrise, sunset, and transit for the *local* calendar day of each input
19+
timestamp, restoring the behavior from before v0.11.1. Evening-local
20+
timestamps, which fall on the next calendar day in UTC, previously
21+
returned the following day's values. (:issue:`2238`)
1722

1823

1924
Enhancements
@@ -54,3 +59,4 @@ Contributors
5459
* Mathias Aschwanden (:ghuser:`maschwanden`)
5560
* Yonry Zhu (:ghuser:`yonryzhu`)
5661
* Darshan Gowda (:ghuser:`dgowdaan-cmyk`)
62+
* Leonardo Scappatura (:ghuser:`Leonard013`)

pvlib/solarposition.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -446,9 +446,22 @@ def sun_rise_set_transit_spa(times, latitude, longitude, how='numpy',
446446
else:
447447
raise ValueError('times must be localized')
448448

449-
# must convert to midnight UTC on day of interest
450449
times_utc = times.tz_convert('UTC')
451-
unixtime = _datetime_to_unixtime(times_utc.normalize())
450+
451+
# The SPA sunrise/sunset routine (spa.transit_sunrise_sunset) requires
452+
# its input to be midnight UTC (00:00+00:00) on the day of interest,
453+
# where the "day of interest" is the *local* calendar day of each input
454+
# timestamp. Drop the timezone label *first* so ``normalize`` truncates
455+
# a naive index to the local date: normalizing a tz-aware index would
456+
# instead construct local midnight, which does not exist (spring-forward)
457+
# or is ambiguous (fall-back) on DST-transition days and would raise.
458+
# Then re-label that midnight as UTC -- a relabel, not ``tz_convert``,
459+
# because the SPA routine requires 00:00 UTC. Note we deliberately do
460+
# not normalize after converting to UTC: an evening-local timestamp
461+
# falls on the following calendar day in UTC, so that would return the
462+
# next day's sunrise/sunset (the v0.11.1 regression, see GH #2238).
463+
unixtime = _datetime_to_unixtime(
464+
times.tz_localize(None).normalize().tz_localize('UTC'))
452465

453466
spa = _spa_python_import(how)
454467

tests/test_solarposition.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,59 @@ def test_sun_rise_set_transit_spa(expected_rise_set_spa, golden, delta_t):
183183
)
184184

185185

186+
def test_sun_rise_set_transit_spa_local_day():
187+
# GH #2238: the "day of interest" is the *local* calendar day of each
188+
# input timestamp. Evening-local timestamps fall on the next day in
189+
# UTC; in v0.11.1 they incorrectly returned the following day's
190+
# sunrise/sunset/transit. All timestamps on the same local day must
191+
# give identical results, matching pvlib <= v0.11.0.
192+
times = pd.to_datetime(
193+
['2000-01-01 18:00', '2000-01-01 19:00']).tz_localize('Etc/GMT+5')
194+
result = solarposition.sun_rise_set_transit_spa(times, 40, -80)
195+
196+
# every result falls on the local calendar day of the input (2000-01-01)
197+
for col in ['sunrise', 'sunset', 'transit']:
198+
assert (result[col].dt.date == datetime.date(2000, 1, 1)).all()
199+
200+
# both inputs share the same local day, so their results are identical
201+
for col in ['sunrise', 'sunset', 'transit']:
202+
assert result[col].iloc[0] == result[col].iloc[1]
203+
204+
# known-good values from pvlib v0.11.0 (NLR SPA), rounded to the second
205+
expected = pd.DataFrame(
206+
{'sunrise': pd.DatetimeIndex(['2000-01-01 07:41:51'] * 2),
207+
'sunset': pd.DatetimeIndex(['2000-01-01 17:05:04'] * 2),
208+
'transit': pd.DatetimeIndex(['2000-01-01 12:23:23'] * 2)},
209+
index=times,
210+
)
211+
for col in expected.columns:
212+
expected[col] = expected[col].dt.tz_localize('Etc/GMT+5')
213+
214+
result_rounded = pd.DataFrame(index=result.index)
215+
for col, data in result.items():
216+
result_rounded[col] = data.dt.round('1s')
217+
218+
assert_frame_equal(result_rounded, expected, check_dtype=False)
219+
220+
# positive-offset zone: early-morning-local timestamps are the *previous*
221+
# calendar day in UTC, the mirror of the case above
222+
times = pd.to_datetime(
223+
['2000-07-01 05:00', '2000-07-01 06:00']).tz_localize('Etc/GMT-9')
224+
result = solarposition.sun_rise_set_transit_spa(times, 35, 139)
225+
for col in ['sunrise', 'sunset', 'transit']:
226+
assert (result[col].dt.date == datetime.date(2000, 7, 1)).all()
227+
assert result[col].iloc[0] == result[col].iloc[1]
228+
229+
# midnight-DST zone: local midnight does not exist in America/Santiago on
230+
# its 2019-09-08 spring-forward day, so normalizing a tz-aware index would
231+
# raise. The day of interest must still be the local day and not error.
232+
times = pd.DatetimeIndex(
233+
['2019-09-08 20:00']).tz_localize('America/Santiago')
234+
result = solarposition.sun_rise_set_transit_spa(times, -33.45, -70.66)
235+
for col in ['sunrise', 'sunset', 'transit']:
236+
assert (result[col].dt.date == datetime.date(2019, 9, 8)).all()
237+
238+
186239
@requires_ephem
187240
def test_sun_rise_set_transit_ephem(expected_rise_set_ephem, golden):
188241
# test for Golden, CO compare to USNO, using local midnight

0 commit comments

Comments
 (0)