Repository: pvlib/pvlib-python

Issue:
`NonExistentTimeError` when calling `hour_angle`
**Describe the bug**

Cannot compute `hour_angle` in timezones and on a day where a daylight savings transition happens at midnight.

**To Reproduce**

```python
from datetime import datetime, timedelta
import pytz
import pandas as pd
import pvlib

date = datetime(2014, 9, 7, 0, 0, 0, tzinfo=pytz.UTC)
times = pd.DatetimeIndex(
    [date + timedelta(hours=hour) for hour in range(24)]
).tz_convert("America/Santiago")

day_of_year = date.timetuple().tm_yday

equation_of_time = pvlib.solarposition.equation_of_time_spencer71(day_of_year)
declination = pvlib.solarposition.declination_spencer71(day_of_year)
hour_angle = pvlib.solarposition.hour_angle(
    times, 70.6693, equation_of_time
)
```

The call to `times.normalize()` inside `hour_angle` raises an exception:

```
pytz.exceptions.NonExistentTimeError: 2014-09-07 00:00:00
```

**Expected behavior**

I'm not entirely sure.  Pandas' `tz_localize` includes arguments for handling these sort of situations (https://pandas.pydata.org/docs/reference/api/pandas.Series.dt.tz_localize.html) and I suspect we could normalize the times like this:

```python
tz = times.tz
normalized_times = times.tz_localize(None).normalize().tz_localize(tz, nonexistent="shift_forward")
```

This would normalize the times to 1:00am instead of 12:00am (which didn't exist on that date).  Here's some related discussion from a Pandas issue: https://github.com/pandas-dev/pandas/issues/40517

I suspect we'd need to similarly handle ambiguous times when daylight savings ends.

Does this seem like a reasonable approach?  I can put a small PR together if so.

**Versions:**
 - ``pvlib.__version__``: 0.10.5
 - ``pandas.__version__``: 2.2.2
 - python: 3.11.7


Discussion:
Confirmed the issue with pvlib v0.11.0. 

The discussion around the pandas issue suggests this occurs when DST adjustment occurs in a way that midnight local never "happens".

Short of a worldwide deprecation of daylight savings times, or handling of this case by `pandas.normalize`, I suppose we ought to patch pvlib with liberal comments.  rdtools worked around the problem in the similar way as proposed here https://github.com/NREL/rdtools/pull/373/files

@scttnlsn re-reading my comment, I don't think I was very clear: PR welcome along the lines you propose.

@cwhanse Sounds good - I'll put something together



Patch:
diff --git a/docs/sphinx/source/whatsnew/v0.11.2.rst b/docs/sphinx/source/whatsnew/v0.11.2.rst
index 49a1121c17..8fb16ed0a9 100644
--- a/docs/sphinx/source/whatsnew/v0.11.2.rst
+++ b/docs/sphinx/source/whatsnew/v0.11.2.rst
@@ -17,6 +17,8 @@ Bug Fixes
 ~~~~~~~~~
 * :py:meth:`~pvlib.pvsystem.PVSystem.get_irradiance` accepts float inputs.
   (:issue:`1338`, :pull:`2227`)
+* Handle DST transitions that happen at midnight in :py:func:`pvlib.solarposition.hour_angle`
+  (:issue:`2132` :pull:`2133`)
 
 Bug fixes
 ~~~~~~~~~
@@ -74,3 +76,4 @@ Contributors
 * matsuobasho (:ghuser:`matsuobasho`)
 * Echedey Luis (:ghuser:`echedey-ls`)
 * Kevin Anderson (:ghuser:`kandersolar`)
+* Scott Nelson (:ghuser:`scttnlsn`)
diff --git a/pvlib/solarposition.py b/pvlib/solarposition.py
index 46fa01fbe5..b667ac04e0 100644
--- a/pvlib/solarposition.py
+++ b/pvlib/solarposition.py
@@ -1349,6 +1349,12 @@ def hour_angle(times, longitude, equation_of_time):
     times : :class:`pandas.DatetimeIndex`
         Corresponding timestamps, must be localized to the timezone for the
         ``longitude``.
+
+        A `pytz.exceptions.AmbiguousTimeError` will be raised if any of the
+        given times are on a day when the local daylight savings transition
+        happens at midnight.  If you're working with such a timezone,
+        consider converting to a non-DST timezone (e.g. GMT-4) before
+        calling this function.
     longitude : numeric
         Longitude in degrees
     equation_of_time : numeric
@@ -1421,7 +1427,17 @@ def _times_to_hours_after_local_midnight(times):
     if not times.tz:
         raise ValueError('times must be localized')
 
-    hrs = (times - times.normalize()) / pd.Timedelta('1h')
+    # Some timezones have a DST shift at midnight:
+    #   11:59pm -> 1:00am - results in a nonexistent midnight
+    #   12:59am -> 12:00am - results in an ambiguous midnight
+    # We remove the timezone before normalizing for this reason.
+    naive_normalized_times = times.tz_localize(None).normalize()
+
+    # Use Pandas functionality for shifting nonexistent times forward
+    normalized_times = naive_normalized_times.tz_localize(
+        times.tz, nonexistent='shift_forward', ambiguous='raise')
+
+    hrs = (times - normalized_times) / pd.Timedelta('1h')
 
     # ensure array return instead of a version-dependent pandas <T>Index
     return np.array(hrs)


Test patch:
diff --git a/pvlib/tests/test_solarposition.py b/pvlib/tests/test_solarposition.py
index 9a69673d6c..88093e05f9 100644
--- a/pvlib/tests/test_solarposition.py
+++ b/pvlib/tests/test_solarposition.py
@@ -8,6 +8,7 @@
 from .conftest import assert_frame_equal, assert_series_equal
 from numpy.testing import assert_allclose
 import pytest
+import pytz
 
 from pvlib.location import Location
 from pvlib import solarposition, spa
@@ -711,6 +712,38 @@ def test_hour_angle():
         solarposition._local_times_from_hours_since_midnight(times, hours)
 
 
+def test_hour_angle_with_tricky_timezones():
+    # GH 2132
+    # tests timezones that have a DST shift at midnight
+
+    eot = np.array([-3.935172, -4.117227, -4.026295, -4.026295])
+
+    longitude = 70.6693
+    times = pd.DatetimeIndex([
+        '2014-09-06 23:00:00',
+        '2014-09-07 00:00:00',
+        '2014-09-07 01:00:00',
+        '2014-09-07 02:00:00',
+    ]).tz_localize('America/Santiago', nonexistent='shift_forward')
+
+    with pytest.raises(pytz.exceptions.NonExistentTimeError):
+        times.normalize()
+
+    # should not raise `pytz.exceptions.NonExistentTimeError`
+    solarposition.hour_angle(times, longitude, eot)
+
+    longitude = 82.3666
+    times = pd.DatetimeIndex([
+        '2014-11-01 23:00:00',
+        '2014-11-02 00:00:00',
+        '2014-11-02 01:00:00',
+        '2014-11-02 02:00:00',
+    ]).tz_localize('America/Havana', ambiguous=[True, True, False, False])
+
+    with pytest.raises(pytz.exceptions.AmbiguousTimeError):
+        solarposition.hour_angle(times, longitude, eot)
+
+
 def test_sun_rise_set_transit_geometric(expected_rise_set_spa, golden_mst):
     """Test geometric calculations for sunrise, sunset, and transit times"""
     times = expected_rise_set_spa.index