Repository: pvlib/pvlib-python

Issue:
Output of solarposition.sun_rise_set_transit_ephem depends on installed ephem version
**Describe the bug**
`pvlib.solarposition.sun_rise_set_transit_ephem` returns a different answer depending on what version of `ephem` is installed. I think the problem is that our wrapper assumes that ephem doesn't pay attention to timezone localization, so it converts the timestamp components to UTC but doesn't bother to change the timestamp's tzinfo:

https://github.com/pvlib/pvlib-python/blob/8d0f863da92739669e01ac4da4145e4547638b50/pvlib/solarposition.py#L577-L579

However, starting in `ephem==4.1.1` the timezone is no longer ignored ([ref](https://rhodesmill.org/pyephem/CHANGELOG.html#version-4-1-1-2021-november-27)), so the UTC offset is applied twice.  This can shift the timestamp into the next solar period and return the rise/set/transit for the wrong day. 


**To Reproduce**
See how the returned sunrise differs by ~24 hours (2019-01-01 vs 2019-01-02) here:

```python
import pandas as pd
import pvlib
times = pd.date_range('2019-01-01', freq='h', periods=1, tz='Etc/GMT+8')
out = pvlib.solarposition.sun_rise_set_transit_ephem(times, 40, -120)
print(out.T)
```

#### `ephem==4.0.0.1`:
```

               2019-01-01 00:00:00-08:00
sunrise 2019-01-01 07:21:28.793036-08:00
sunset  2019-01-01 16:45:50.959086-08:00
transit 2019-01-01 12:03:35.730674-08:00
```

#### `ephem==4.1.2`:
```
               2019-01-01 00:00:00-08:00
sunrise 2019-01-02 07:21:35.237404-08:00
sunset  2019-01-01 16:45:50.947472-08:00
transit 2019-01-01 12:03:35.728413-08:00
```

**Expected behavior**
pvlib should give ephem timestamps consistent with its input requirements.  Replacing the above manual utc offset subtraction (which leaves the original tzinfo in place) with `thetime.astimezone(pytz.UTC)` may be suitable for both old and new versions of ephem.  I don't ever use pytz and python datetimes so maybe there's a better alternative.

**Versions:**
 - ``pvlib.__version__``: 0.9.1
 - python: 3.7

**Additional context**
This difference would have popped up back in November when ephem 4.1.1 was released had it not been for #1447.  Here's an example failure: https://dev.azure.com/solararbiter/pvlib%20python/_build/results?buildId=6027&view=logs&j=e1592cb8-2816-5754-b393-3839a187d454&t=377c4fd6-97bd-5996-bc02-4d072a8786ea&l=2267



Discussion:

Patch:
diff --git a/pvlib/iotools/crn.py b/pvlib/iotools/crn.py
--- a/pvlib/iotools/crn.py
+++ b/pvlib/iotools/crn.py
@@ -2,6 +2,7 @@
 """
 
 import pandas as pd
+import numpy as np
 
 
 HEADERS = [
@@ -107,13 +108,24 @@ def read_crn(filename, map_variables=True):
     """
 
     # read in data
+    # TODO: instead of parsing as strings and then post-processing, switch to
+    # pd.read_fwf(..., dtype=dict(zip(HEADERS, DTYPES)), skip_blank_lines=True)
+    # when our minimum pandas >= 1.2.0 (skip_blank_lines bug for <1.2.0).
+    # As a workaround, parse all values as strings, then drop NaN, then cast
+    # to the appropriate dtypes, and mask "sentinal" NaN (e.g. -9999.0)
     data = pd.read_fwf(filename, header=None, names=HEADERS, widths=WIDTHS,
-                       na_values=NAN_DICT)
-    # Remove rows with all nans
+                       dtype=str)
+
+    # drop empty (bad) lines
     data = data.dropna(axis=0, how='all')
-    # set dtypes here because dtype kwarg not supported in read_fwf until 0.20
+
+    # can't set dtypes in read_fwf because int cols can't contain NaN, so
+    # do it here instead
     data = data.astype(dict(zip(HEADERS, DTYPES)))
 
+    # finally, replace -999 values with NaN
+    data = data.replace(NAN_DICT, value=np.nan)
+
     # set index
     # UTC_TIME does not have leading 0s, so must zfill(4) to comply
     # with %H%M format
diff --git a/pvlib/solarposition.py b/pvlib/solarposition.py
--- a/pvlib/solarposition.py
+++ b/pvlib/solarposition.py
@@ -22,6 +22,7 @@
 import pandas as pd
 import scipy.optimize as so
 import warnings
+import datetime
 
 from pvlib import atmosphere
 from pvlib.tools import datetime_to_djd, djd_to_datetime
@@ -574,9 +575,10 @@ def sun_rise_set_transit_ephem(times, latitude, longitude,
     trans = []
     for thetime in times:
         thetime = thetime.to_pydatetime()
-        # pyephem drops timezone when converting to its internal datetime
-        # format, so handle timezone explicitly here
-        obs.date = ephem.Date(thetime - thetime.utcoffset())
+        # older versions of pyephem ignore timezone when converting to its
+        # internal datetime format, so convert to UTC here to support
+        # all versions.  GH #1449
+        obs.date = ephem.Date(thetime.astimezone(datetime.timezone.utc))
         sunrise.append(_ephem_to_timezone(rising(sun), tzinfo))
         sunset.append(_ephem_to_timezone(setting(sun), tzinfo))
         trans.append(_ephem_to_timezone(transit(sun), tzinfo))
diff --git a/pvlib/tracking.py b/pvlib/tracking.py
--- a/pvlib/tracking.py
+++ b/pvlib/tracking.py
@@ -510,6 +510,9 @@ def singleaxis(apparent_zenith, apparent_azimuth,
 
     # Calculate surface_tilt
     dotproduct = (panel_norm_earth * projected_normal).sum(axis=1)
+    # for edge cases like axis_tilt=90, numpy's SIMD can produce values like
+    # dotproduct = (1 + 2e-16). Clip off the excess so that arccos works:
+    dotproduct = np.clip(dotproduct, -1, 1)
     surface_tilt = 90 - np.degrees(np.arccos(dotproduct))
 
     # Bundle DataFrame for return values and filter for sun below horizon.
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -39,7 +39,7 @@
 URL = 'https://github.com/pvlib/pvlib-python'
 
 INSTALL_REQUIRES = ['numpy >= 1.16.0',
-                    'pandas >= 0.22.0',
+                    'pandas >= 0.25.0',
                     'pytz',
                     'requests',
                     'scipy >= 1.2.0',


Test patch:
diff --git a/pvlib/tests/iotools/test_crn.py b/pvlib/tests/iotools/test_crn.py
--- a/pvlib/tests/iotools/test_crn.py
+++ b/pvlib/tests/iotools/test_crn.py
@@ -83,7 +83,7 @@ def test_read_crn_problems(testfile_problems, columns_mapped, dtypes):
                               '2020-07-06 13:10:00'],
                              freq=None).tz_localize('UTC')
     values = np.array([
-        [92821, 20200706, 1200, 20200706, 700, '3.0', -80.69, 28.62, 24.9,
+        [92821, 20200706, 1200, 20200706, 700, '3', -80.69, 28.62, 24.9,
          0.0, np.nan, 0, 25.5, 'C', 0, 93.0, 0, nan, nan, 990, 0, 1.57, 0],
         [92821, 20200706, 1310, 20200706, 810, '2.623', -80.69, 28.62,
          26.9, 0.0, 430.0, 0, 30.2, 'C', 0, 87.0, 0, nan, nan, 989, 0,
diff --git a/pvlib/tests/iotools/test_psm3.py b/pvlib/tests/iotools/test_psm3.py
--- a/pvlib/tests/iotools/test_psm3.py
+++ b/pvlib/tests/iotools/test_psm3.py
@@ -170,7 +170,7 @@ def test_read_psm3_map_variables():
     data, metadata = psm3.read_psm3(MANUAL_TEST_DATA, map_variables=True)
     columns_mapped = ['Year', 'Month', 'Day', 'Hour', 'Minute', 'dhi', 'dni',
                       'ghi', 'dhi_clear', 'dni_clear', 'ghi_clear',
-                      'Cloud Type', 'Dew Point', 'apparent_zenith',
+                      'Cloud Type', 'Dew Point', 'solar_zenith',
                       'Fill Flag', 'albedo', 'wind_speed',
                       'precipitable_water', 'wind_direction',
                       'relative_humidity', 'temp_air', 'pressure']
diff --git a/pvlib/tests/test_conftest.py b/pvlib/tests/test_conftest.py
--- a/pvlib/tests/test_conftest.py
+++ b/pvlib/tests/test_conftest.py
@@ -52,22 +52,21 @@ def test_use_fixture_with_decorator(some_data):
                                            'assert_frame_equal'])
 @pytest.mark.parametrize('pd_version', ['1.0.0', '1.1.0'])
 @pytest.mark.parametrize('check_less_precise', [True, False])
-def test__check_pandas_assert_kwargs(mocker, monkeypatch,
-                                     function_name, pd_version,
+def test__check_pandas_assert_kwargs(mocker, function_name, pd_version,
                                      check_less_precise):
     # test that conftest._check_pandas_assert_kwargs returns appropriate
     # kwargs for the assert_x_equal functions
 
-    # patch the pandas assert; not interested in actually calling them:
-    def patched_assert(*args, **kwargs):
-        pass
+    # NOTE: be careful about mixing mocker.patch and pytest.MonkeyPatch!
+    # they do not coordinate their cleanups, so it is safest to only
+    # use one or the other.  GH #1447
 
-    monkeypatch.setattr(pandas.testing, function_name, patched_assert)
-    # then attach a spy to it so we can see what args it is called with:
-    mocked_function = mocker.spy(pandas.testing, function_name)
+    # patch the pandas assert; not interested in actually calling them,
+    # plus we want to spy on how they get called.
+    spy = mocker.patch('pandas.testing.' + function_name)
     # patch pd.__version__ to exercise the two branches in
     # conftest._check_pandas_assert_kwargs
-    monkeypatch.setattr(pandas, '__version__', pd_version)
+    mocker.patch('pandas.__version__', new=pd_version)
 
     # finally, run the function and check what args got passed to pandas:
     assert_function = getattr(conftest, function_name)
@@ -79,4 +78,4 @@ def patched_assert(*args, **kwargs):
     else:
         expected_kwargs = {'check_less_precise': check_less_precise}
 
-    mocked_function.assert_called_with(*args, **expected_kwargs)
+    spy.assert_called_once_with(*args, **expected_kwargs)