Repository: pvlib/pvlib-python

Issue:
Incorrect AOI from pvlib.tracking.singleaxis
`pvlib.tracking.singleaxis` produces an incorrect AOI when the sun is above the earth horizon but behind the module plane.

**To Reproduce**
Model a fixed tilt system (90 tilt, 180 azimuth) and compare to a vertical single axis tracker with very small rotation limit.

```

import pandas as pd
import pytz
import pvlib
from matplotlib import pyplot as plt

loc = pvlib.location.Location(40.1134, -88.3695)

dr = pd.date_range(start='02-Jun-1998 00:00:00', end='02-Jun-1998 23:55:00',
                   freq='5T')
tz = pytz.timezone('Etc/GMT+6')
dr = dr.tz_localize(tz)
hr = dr.hour + dr.minute/60

sp = loc.get_solarposition(dr)

cs = loc.get_clearsky(dr)

tr = pvlib.tracking.singleaxis(sp['apparent_zenith'], sp['azimuth'],
                               axis_tilt=90, axis_azimuth=180, max_angle=0.01,
                               backtrack=False)

fixed = pvlib.irradiance.aoi(90, 180, sp['apparent_zenith'], sp['azimuth'])

plt.plot(hr, fixed)
plt.plot(hr, tr['aoi'])
plt.plot(hr, sp[['apparent_elevation']])
plt.show()

plt.legend(['aoi - fixed', 'aoi - tracked', 'apparent_elevation'])
```

**Expected behavior**
The AOI for the fixed tilt system shows values greater than 90 when the sun is behind the module plane. The AOI from `singleaxis` does not.

I think the source of the error is the use of `abs` in [this ](https://github.com/pvlib/pvlib-python/blob/ca61503fa83e76631f84fb4237d9e11ae99f3c77/pvlib/tracking.py#L446)line.

**Screenshots**
![aoi_fixed_vs_tracked](https://user-images.githubusercontent.com/5393711/117505270-01087a80-af41-11eb-9220-10cccf2714e1.png)


**Versions:**
 - ``pvlib.__version__``: 0.8.1

First reported by email from Jim Wilzcak (NOAA) for the PVlib Matlab function [pvl_singleaxis.m](https://github.com/sandialabs/MATLAB_PV_LIB/blob/master/pvl_singleaxis.m)



Discussion:
I agree that `abs` looks like a problem.
This is an ancient line first committed April 3, 2015:
https://github.com/pvlib/pvlib-python/blob/46f69bf9ae2869d75f74664684b4de3b3b3e9bf2/pvlib/tracking.py#L219
Yes, my initial port of the matlab code was as close to 1:1 as I could make it. I don't recall second guessing the `abs` at the time, but I certainly should have.
We (Dan and I) concluded that line is in error, in the matlab code.
Should we just replace that entire line with a call to `irradiance.aoi`?
@kanderso-nrel the shortcoming with using `irradiance.aoi` afaict is that it calls [`irradiance.aoi_projection`](https://github.com/pvlib/pvlib-python/blob/0e6fea6219618c0da944e6ed686c10f5b1e244a2/pvlib/irradiance.py#L153) which is redundant because the single axis tracker already calculates the solar vector and rotates it into the tracker reference frame to use the `x` and `z` components to calculate the tracker rotation. 

COINCIDENTALLY, `irradiance.aoi` is already used in the `SingleAzisTracker` method `get_aoi` which afaict should be a static or class method because it NEVER uses self. I guess that's a separate issue. Anyway, it says this method isn't necessary, b/c `singleaxis` already returns AOI.

ALSO, I was thrown for a bit in `irradiance.aoi_projection` which doesn't have a lot of commentary, because when calculating the dot product of surface normal and solar vector, it shows `z=cos(tilt)*cos(ze)` first and `x=sin(tilt)*sin(ze)*cos(az-surfaz)` last. Whatever

Anyway, back to this, should we consider adjusting `irradiance.aoi` to allow the user to pass in the AOI projection as an alternate parameter? Seems a bit like a hacky workaround, but something like this:

```python
# in irradiance.py
def aoi(surface_tilt, surface_azimuth, solar_zenith, solar_azimuth,
       projection=None):
    if projection is None:
        projection = aoi_projection(
            surface_tilt, surface_azimuth, solar_zenith, solar_azimuth)
    # from here it's the same
    aoi_value = np.rad2deg(np.arccos(projection))
    ...
```

then in `singleaxis` we change it to this:

```python
    # calculate angle-of-incidence on panel
    # aoi = np.degrees(np.arccos(np.abs(np.sum(sun_vec*panel_norm, axis=0))))
    projection = (xp * panel_norm[0]
                  + yp * panel_norm[1]
                  + zp * panel_norm[2])
    # can't use np.dot for 2D matrices
    # expanding arrays is about 1.5x faster than sum
    # can skip sun_vec array formation, but still need panel norm for later
    aoi = irradiance.aoi(None, None, None, None, projection=projection)
```
or maybe just to get this ball roling we use clip for now and just close it with a #TODO

Patch:
diff --git a/pvlib/tracking.py b/pvlib/tracking.py
--- a/pvlib/tracking.py
+++ b/pvlib/tracking.py
@@ -458,7 +458,9 @@ def singleaxis(apparent_zenith, apparent_azimuth,
     sun_vec = np.array([xp, yp, zp])
 
     # calculate angle-of-incidence on panel
-    aoi = np.degrees(np.arccos(np.abs(np.sum(sun_vec*panel_norm, axis=0))))
+    # TODO: use irradiance.aoi
+    projection = np.clip(np.sum(sun_vec*panel_norm, axis=0), -1, 1)
+    aoi = np.degrees(np.arccos(projection))
 
     # Calculate panel tilt and azimuth in a coordinate system where the panel
     # tilt is the angle from horizontal, and the panel azimuth is the compass


Test patch:
diff --git a/pvlib/tests/test_tracking.py b/pvlib/tests/test_tracking.py
--- a/pvlib/tests/test_tracking.py
+++ b/pvlib/tests/test_tracking.py
@@ -502,3 +502,18 @@ def test_slope_aware_backtracking():
     np.testing.assert_allclose(
         truetracking['tracker_theta'], expected_data['TrueTracking'],
         rtol=1e-3, atol=1e-3)
+
+
+def test_singleaxis_aoi_gh1221():
+    # vertical tracker
+    loc = pvlib.location.Location(40.1134, -88.3695)
+    dr = pd.date_range(
+        start='02-Jun-1998 00:00:00', end='02-Jun-1998 23:55:00', freq='5T',
+        tz='Etc/GMT+6')
+    sp = loc.get_solarposition(dr)
+    tr = pvlib.tracking.singleaxis(
+        sp['apparent_zenith'], sp['azimuth'], axis_tilt=90, axis_azimuth=180,
+        max_angle=0.001, backtrack=False)
+    fixed = pvlib.irradiance.aoi(90, 180, sp['apparent_zenith'], sp['azimuth'])
+    fixed[np.isnan(tr['aoi'])] = np.nan
+    assert np.allclose(tr['aoi'], fixed, equal_nan=True)