Repository: pvlib/pvlib-python

Issue:
backtracking for rare case when sun below tracker improvement
**Describe the bug**
- related to #656
- in the rare case when the sun rays are below the tracker, then the top of the next row is shaded
- currently tracker backtracks away from sun, back is facing sun instead of front
- this only happens for tilted trackers and very low sun angles, either early morning or late evening when the sun rays are furthest north or south

**To Reproduce**
Steps to reproduce the behavior:
1. create a tilted tracker
```python
# in Brazil so facing north
axis_azimuth = 0.0 
axis_tilt = 20
max_angle = 75.0
gcr = 0.35
```
2. pick the earliest morning (or latest evening) timestamp
```python
import pvlib
import pandas as pd

# Brazil, timezone is UTC-3[hrs]
starttime = '2017-01-01T00:30:00-0300'
stoptime = '2017-12-31T23:59:59-0300'
lat, lon = -27.597300, -48.549610
times = pd.DatetimeIndex(pd.date_range(
    starttime, stoptime, freq='H'))
solpos = pvlib.solarposition.get_solarposition(
    times, lat, lon)
# get the early times
ts0 = '2017-01-01 05:30:00-03:00'
ts1 = '2017-01-01 12:30:00-03:00'
apparent_zenith = solpos['apparent_zenith'][ts0:ts1]
azimuth = solpos['azimuth'][ts0:ts1]
sat = pvlib.tracking.singleaxis(
    apparent_zenith, azimuth, axis_tilt, axis_azimuth, max_angle, True, gcr)
```
3. notice that the tracker suddenly jumps from one side facing east to west
```
                           tracker_theta        aoi  surface_azimuth  surface_tilt
2017-01-01 05:30:00-03:00     -21.964540  62.721237       310.299287     29.368272
2017-01-01 06:30:00-03:00      16.231156  69.264752        40.403367     25.546154
2017-01-01 07:30:00-03:00      69.073645  20.433849        82.548858     70.389280
2017-01-01 08:30:00-03:00      54.554616  18.683626        76.316479     56.978562
2017-01-01 09:30:00-03:00      40.131687  17.224233        67.917292     44.072837
2017-01-01 10:30:00-03:00      25.769332  16.144347        54.683567     32.194782
2017-01-01 11:30:00-03:00      11.439675  15.509532        30.610665     22.923644
2017-01-01 12:30:00-03:00      -2.877428  15.358209       351.639727     20.197537
```

4. AOI is also wrong

**Expected behavior**
The tracker should avoid shade. It should not jump from one direction to the other. If the sun ray is below the tracker then it will need to track to it's max rotation or backtrack. If there is shading at it's max rotation then it should track backtrack to zero, or perhaps parallel to the sun rays. Perhaps if bifacial, then it could go backwards, 180 from the correct backtrack position to show it's backside to the sun.

proposed algorithm (_updated after [this comment](#issuecomment-559154895)_):
```python
if backtracking:
    # cos(R) = L / Lx, R is rotation, L is surface length,
    # Lx is shadow on ground, tracker shades when Lx > x
    # x is row spacing related to GCR, x = L/GCR
    lrot = np.cos(tr_rot_no_lim)  # tracker rotation not limited by max angle

    # Note: if tr_rot > 90[deg] then lrot < 0 
    # which *can* happen at low angles if axis tilt > 0
    # tracker should never backtrack more than 90[deg], when lrot = 0
    cos_rot = np.minimum(np.abs(lrot) / self.gcr, 1)

    # so if lrot<0 tracker should backtrack forward
    # backtrack_rot = np.sign(lrot) * np.arccos(cos_rot)

    # NOTE: updated after comment from @kevinsa5 at Nov 27, 2019, 8:16 AM PST
    # to remove sign()
    backtrack_rot = np.arccos(cos_rot)
```

also remove abs from aoi calculation

https://github.com/pvlib/pvlib-python/blob/c699575cb6857674f0a96348b77e10c805e741c7/pvlib/tracking.py#L461

**Screenshots**
If applicable, add screenshots to help explain your problem.

**Versions:**
 - ``pvlib.__version__``:  0.6.3
 - ``pandas.__version__``:  0.24
 - python: 3.7

**Additional context**
Add any other context about the problem here.

[STY] remove old comments from single axis tracking
**Describe the bug**
After #823 is merged there may be stale comments in `pvlib.tracking.singleaxis` and commented code that can be removed. This might make the code more readable. It would also resolve some stickler complaints about long lines.

**To Reproduce**
Comments to remove:
1. [L375-L379](../blob/e210b8253458a60c95fc21939e9817271cf51934/pvlib/tracking.py#L375-L379) - the tracking algorithm now follows [1] that uses clockwise rotation around z-axis from north
2. [L393-L395](../blob/e210b8253458a60c95fc21939e9817271cf51934/pvlib/tracking.py#L393-L395) - ditto
3. [L400-L410](../blob/e210b8253458a60c95fc21939e9817271cf51934/pvlib/tracking.py#L400-L410) - ditto
4. [L441-L452](../blob/e210b8253458a60c95fc21939e9817271cf51934/pvlib/tracking.py#L441-L452) - pvlib has been using arctan2(x,z) in `pvlib.tracking.singleaxis` for 6 years since 1fb82cc262e43e1d2b55e4b5510a1a5e7e340667, so I believe these comments are unnecessary now
5. [L471-L472](../blob/e210b8253458a60c95fc21939e9817271cf51934/pvlib/tracking.py#L471-L472) - this commented code was updated in #823, should we leave it or delete it?
3. [L553-L555](../blob/e210b8253458a60c95fc21939e9817271cf51934/pvlib/tracking.py#L553-L555)

etc.

[1] https://www.nrel.gov/docs/fy20osti/76626.pdf

**Expected behavior**
A clear and concise description of what you expected to happen.

**Screenshots**
If applicable, add screenshots to help explain your problem.

**Versions:**
 - ``pvlib.__version__``: 
 - ``pandas.__version__``: 
 - python:

**Additional context**
Add any other context about the problem here.



Discussion:

Patch:
diff --git a/pvlib/tracking.py b/pvlib/tracking.py
--- a/pvlib/tracking.py
+++ b/pvlib/tracking.py
@@ -1,7 +1,7 @@
 import numpy as np
 import pandas as pd
 
-from pvlib.tools import cosd, sind
+from pvlib.tools import cosd, sind, tand
 from pvlib.pvsystem import _combine_localized_attributes
 from pvlib.pvsystem import PVSystem
 from pvlib.location import Location
@@ -10,8 +10,9 @@
 
 class SingleAxisTracker(PVSystem):
     """
-    Inherits the PV modeling methods from :py:class:`~pvlib.pvsystem.PVSystem`.
-
+    A class for single-axis trackers that inherits the PV modeling methods from
+    :py:class:`~pvlib.pvsystem.PVSystem`. For details on calculating tracker
+    rotation see :py:func:`pvlib.tracking.singleaxis`.
 
     Parameters
     ----------
@@ -21,7 +22,7 @@ class SingleAxisTracker(PVSystem):
 
     axis_azimuth : float, default 0
         A value denoting the compass direction along which the axis of
-        rotation lies. Measured in decimal degrees East of North.
+        rotation lies. Measured in decimal degrees east of north.
 
     max_angle : float, default 90
         A value denoting the maximum rotation angle, in decimal degrees,
@@ -43,16 +44,33 @@ class SingleAxisTracker(PVSystem):
         between the tracking axes has a gcr of 2/6=0.333. If gcr is not
         provided, a gcr of 2/7 is default. gcr must be <=1.
 
+    cross_axis_tilt : float, default 0.0
+        The angle, relative to horizontal, of the line formed by the
+        intersection between the slope containing the tracker axes and a plane
+        perpendicular to the tracker axes. Cross-axis tilt should be specified
+        using a right-handed convention. For example, trackers with axis
+        azimuth of 180 degrees (heading south) will have a negative cross-axis
+        tilt if the tracker axes plane slopes down to the east and positive
+        cross-axis tilt if the tracker axes plane slopes up to the east. Use
+        :func:`~pvlib.tracking.calc_cross_axis_tilt` to calculate
+        `cross_axis_tilt`. [degrees]
+
+    See also
+    --------
+    pvlib.tracking.singleaxis
+    pvlib.tracking.calc_axis_tilt
+    pvlib.tracking.calc_cross_axis_tilt
     """
 
-    def __init__(self, axis_tilt=0, axis_azimuth=0,
-                 max_angle=90, backtrack=True, gcr=2.0/7.0, **kwargs):
+    def __init__(self, axis_tilt=0, axis_azimuth=0, max_angle=90,
+                 backtrack=True, gcr=2.0/7.0, cross_axis_tilt=0.0, **kwargs):
 
         self.axis_tilt = axis_tilt
         self.axis_azimuth = axis_azimuth
         self.max_angle = max_angle
         self.backtrack = backtrack
         self.gcr = gcr
+        self.cross_axis_tilt = cross_axis_tilt
 
         kwargs['surface_tilt'] = None
         kwargs['surface_azimuth'] = None
@@ -60,7 +78,8 @@ def __init__(self, axis_tilt=0, axis_azimuth=0,
         super(SingleAxisTracker, self).__init__(**kwargs)
 
     def __repr__(self):
-        attrs = ['axis_tilt', 'axis_azimuth', 'max_angle', 'backtrack', 'gcr']
+        attrs = ['axis_tilt', 'axis_azimuth', 'max_angle', 'backtrack', 'gcr',
+                 'cross_axis_tilt']
         sat_repr = ('SingleAxisTracker:\n  ' + '\n  '.join(
             ('{}: {}'.format(attr, getattr(self, attr)) for attr in attrs)))
         # get the parent PVSystem info
@@ -88,8 +107,8 @@ def singleaxis(self, apparent_zenith, apparent_azimuth):
         """
         tracking_data = singleaxis(apparent_zenith, apparent_azimuth,
                                    self.axis_tilt, self.axis_azimuth,
-                                   self.max_angle,
-                                   self.backtrack, self.gcr)
+                                   self.max_angle, self.backtrack,
+                                   self.gcr, self.cross_axis_tilt)
 
         return tracking_data
 
@@ -215,13 +234,15 @@ def get_irradiance(self, surface_tilt, surface_azimuth,
 
 class LocalizedSingleAxisTracker(SingleAxisTracker, Location):
     """
-    The LocalizedSingleAxisTracker class defines a standard set of
-    installed PV system attributes and modeling functions. This class
-    combines the attributes and methods of the SingleAxisTracker (a
-    subclass of PVSystem) and Location classes.
-
-    The LocalizedSingleAxisTracker may have bugs due to the difficulty
-    of robustly implementing multiple inheritance. See
+    The :py:class:`~pvlib.tracking.LocalizedSingleAxisTracker` class defines a
+    standard set of installed PV system attributes and modeling functions. This
+    class combines the attributes and methods of the
+    :py:class:`~pvlib.tracking.SingleAxisTracker` (a subclass of
+    :py:class:`~pvlib.pvsystem.PVSystem`) and
+    :py:class:`~pvlib.location.Location` classes.
+
+    The :py:class:`~pvlib.tracking.LocalizedSingleAxisTracker` may have bugs
+    due to the difficulty of robustly implementing multiple inheritance. See
     :py:class:`~pvlib.modelchain.ModelChain` for an alternative paradigm
     for modeling PV systems at specific locations.
     """
@@ -247,25 +268,25 @@ def __repr__(self):
 
 def singleaxis(apparent_zenith, apparent_azimuth,
                axis_tilt=0, axis_azimuth=0, max_angle=90,
-               backtrack=True, gcr=2.0/7.0):
+               backtrack=True, gcr=2.0/7.0, cross_axis_tilt=0):
     """
-    Determine the rotation angle of a single axis tracker when given a
-    particular sun zenith and azimuth angle. See [1]_ for details about
-    the equations.
-    Backtracking may be specified, and if so, a ground coverage
-    ratio is required.
-
-    Rotation angle is determined in a panel-oriented coordinate system.
-    The tracker azimuth axis_azimuth defines the positive y-axis; the
-    positive x-axis is 90 degress clockwise from the y-axis and parallel
-    to the earth surface, and the positive z-axis is normal and oriented
-    towards the sun. Rotation angle tracker_theta indicates tracker
-    position relative to horizontal: tracker_theta = 0 is horizontal,
-    and positive tracker_theta is a clockwise rotation around the y axis
-    in the x, y, z coordinate system. For example, if tracker azimuth
-    axis_azimuth is 180 (oriented south), tracker_theta = 30 is a
-    rotation of 30 degrees towards the west, and tracker_theta = -90 is
-    a rotation to the vertical plane facing east.
+    Determine the rotation angle of a single-axis tracker when given particular
+    solar zenith and azimuth angles.
+
+    See [1]_ for details about the equations. Backtracking may be specified,
+    and if so, a ground coverage ratio is required.
+
+    Rotation angle is determined in a right-handed coordinate system. The
+    tracker `axis_azimuth` defines the positive y-axis, the positive x-axis is
+    90 degrees clockwise from the y-axis and parallel to the Earth's surface,
+    and the positive z-axis is normal to both x & y-axes and oriented skyward.
+    Rotation angle `tracker_theta` is a right-handed rotation around the y-axis
+    in the x, y, z coordinate system and indicates tracker position relative to
+    horizontal. For example, if tracker `axis_azimuth` is 180 (oriented south)
+    and `axis_tilt` is zero, then a `tracker_theta` of zero is horizontal, a
+    `tracker_theta` of 30 degrees is a rotation of 30 degrees towards the west,
+    and a `tracker_theta` of -90 degrees is a rotation to the vertical plane
+    facing east.
 
     Parameters
     ----------
@@ -281,7 +302,7 @@ def singleaxis(apparent_zenith, apparent_azimuth,
 
     axis_azimuth : float, default 0
         A value denoting the compass direction along which the axis of
-        rotation lies. Measured in decimal degrees East of North.
+        rotation lies. Measured in decimal degrees east of north.
 
     max_angle : float, default 90
         A value denoting the maximum rotation angle, in decimal degrees,
@@ -303,24 +324,41 @@ def singleaxis(apparent_zenith, apparent_azimuth,
         between the tracking axes has a gcr of 2/6=0.333. If gcr is not
         provided, a gcr of 2/7 is default. gcr must be <=1.
 
+    cross_axis_tilt : float, default 0.0
+        The angle, relative to horizontal, of the line formed by the
+        intersection between the slope containing the tracker axes and a plane
+        perpendicular to the tracker axes. Cross-axis tilt should be specified
+        using a right-handed convention. For example, trackers with axis
+        azimuth of 180 degrees (heading south) will have a negative cross-axis
+        tilt if the tracker axes plane slopes down to the east and positive
+        cross-axis tilt if the tracker axes plane slopes up to the east. Use
+        :func:`~pvlib.tracking.calc_cross_axis_tilt` to calculate
+        `cross_axis_tilt`. [degrees]
+
     Returns
     -------
     dict or DataFrame with the following columns:
         * `tracker_theta`: The rotation angle of the tracker.
           tracker_theta = 0 is horizontal, and positive rotation angles are
-          clockwise.
+          clockwise. [degrees]
         * `aoi`: The angle-of-incidence of direct irradiance onto the
-          rotated panel surface.
+          rotated panel surface. [degrees]
         * `surface_tilt`: The angle between the panel surface and the earth
-          surface, accounting for panel rotation.
+          surface, accounting for panel rotation. [degrees]
         * `surface_azimuth`: The azimuth of the rotated panel, determined by
           projecting the vector normal to the panel's surface to the earth's
-          surface.
+          surface. [degrees]
+
+    See also
+    --------
+    pvlib.tracking.calc_axis_tilt
+    pvlib.tracking.calc_cross_axis_tilt
 
     References
     ----------
-    .. [1] Lorenzo, E et al., 2011, "Tracking and back-tracking", Prog. in
-       Photovoltaics: Research and Applications, v. 19, pp. 747-753.
+    .. [1] Kevin Anderson and Mark Mikofski, "Slope-Aware Backtracking for
+       Single-Axis Trackers", Technical Report NREL/TP-5K00-76626, July 2020.
+       https://www.nrel.gov/docs/fy20osti/76626.pdf
     """
 
     # MATLAB to Python conversion by
@@ -338,118 +376,84 @@ def singleaxis(apparent_zenith, apparent_azimuth,
     if apparent_azimuth.ndim > 1 or apparent_zenith.ndim > 1:
         raise ValueError('Input dimensions must not exceed 1')
 
-    # Calculate sun position x, y, z using coordinate system as in [1], Eq 2.
-
-    # Positive y axis is oriented parallel to earth surface along tracking axis
-    # (for the purpose of illustration, assume y is oriented to the south);
-    # positive x axis is orthogonal, 90 deg clockwise from y-axis, and parallel
-    # to the earth's surface (if y axis is south, x axis is west);
-    # positive z axis is normal to x, y axes, pointed upward.
-
-    # Equations in [1] assume solar azimuth is relative to reference vector
-    # pointed south, with clockwise positive.
-    # Here, the input solar azimuth is degrees East of North,
-    # i.e., relative to a reference vector pointed
-    # north with clockwise positive.
-    # Rotate sun azimuth to coordinate system as in [1]
-    # to calculate sun position.
-
-    az = apparent_azimuth - 180
-    apparent_elevation = 90 - apparent_zenith
-    x = cosd(apparent_elevation) * sind(az)
-    y = cosd(apparent_elevation) * cosd(az)
-    z = sind(apparent_elevation)
-
-    # translate array azimuth from compass bearing to [1] coord system
-    # wholmgren: strange to see axis_azimuth calculated differently from az,
-    # (not that it matters, or at least it shouldn't...).
-    axis_azimuth_south = axis_azimuth - 180
-
-    # translate input array tilt angle axis_tilt to [1] coordinate system.
-
-    # In [1] coordinates, axis_tilt is a rotation about the x-axis.
-    # For a system with array azimuth (y-axis) oriented south,
-    # the x-axis is oriented west, and a positive axis_tilt is a
-    # counterclockwise rotation, i.e, lifting the north edge of the panel.
-    # Thus, in [1] coordinate system, in the northern hemisphere a positive
-    # axis_tilt indicates a rotation toward the equator,
-    # whereas in the southern hemisphere rotation toward the equator is
-    # indicated by axis_tilt<0.  Here, the input axis_tilt is
-    # always positive and is a rotation toward the equator.
-
-    # Calculate sun position (xp, yp, zp) in panel-oriented coordinate system:
-    # positive y-axis is oriented along tracking axis at panel tilt;
-    # positive x-axis is orthogonal, clockwise, parallel to earth surface;
-    # positive z-axis is normal to x-y axes, pointed upward.
-    # Calculate sun position (xp,yp,zp) in panel coordinates using [1] Eq 11
-    # note that equation for yp (y' in Eq. 11 of Lorenzo et al 2011) is
-    # corrected, after conversation with paper's authors.
-
-    xp = x*cosd(axis_azimuth_south) - y*sind(axis_azimuth_south)
-    yp = (x*cosd(axis_tilt)*sind(axis_azimuth_south) +
-          y*cosd(axis_tilt)*cosd(axis_azimuth_south) -
-          z*sind(axis_tilt))
-    zp = (x*sind(axis_tilt)*sind(axis_azimuth_south) +
-          y*sind(axis_tilt)*cosd(axis_azimuth_south) +
-          z*cosd(axis_tilt))
+    # Calculate sun position x, y, z using coordinate system as in [1], Eq 1.
+
+    # NOTE: solar elevation = 90 - solar zenith, then use trig identities:
+    # sin(90-x) = cos(x) & cos(90-x) = sin(x)
+    sin_zenith = sind(apparent_zenith)
+    x = sin_zenith * sind(apparent_azimuth)
+    y = sin_zenith * cosd(apparent_azimuth)
+    z = cosd(apparent_zenith)
+
+    # Assume the tracker reference frame is right-handed. Positive y-axis is
+    # oriented along tracking axis; from north, the y-axis is rotated clockwise
+    # by the axis azimuth and tilted from horizontal by the axis tilt. The
+    # positive x-axis is 90 deg clockwise from the y-axis and parallel to
+    # horizontal (e.g., if the y-axis is south, the x-axis is west); the
+    # positive z-axis is normal to the x and y axes, pointed upward.
+
+    # Calculate sun position (xp, yp, zp) in tracker coordinate system using
+    # [1] Eq 4.
+
+    cos_axis_azimuth = cosd(axis_azimuth)
+    sin_axis_azimuth = sind(axis_azimuth)
+    cos_axis_tilt = cosd(axis_tilt)
+    sin_axis_tilt = sind(axis_tilt)
+    xp = x*cos_axis_azimuth - y*sin_axis_azimuth
+    yp = (x*cos_axis_tilt*sin_axis_azimuth
+          + y*cos_axis_tilt*cos_axis_azimuth
+          - z*sin_axis_tilt)
+    zp = (x*sin_axis_tilt*sin_axis_azimuth
+          + y*sin_axis_tilt*cos_axis_azimuth
+          + z*cos_axis_tilt)
 
     # The ideal tracking angle wid is the rotation to place the sun position
-    # vector (xp, yp, zp) in the (y, z) plane; i.e., normal to the panel and
-    # containing the axis of rotation.  wid = 0 indicates that the panel is
-    # horizontal.  Here, our convention is that a clockwise rotation is
+    # vector (xp, yp, zp) in the (y, z) plane, which is normal to the panel and
+    # contains the axis of rotation.  wid = 0 indicates that the panel is
+    # horizontal. Here, our convention is that a clockwise rotation is
     # positive, to view rotation angles in the same frame of reference as
-    # azimuth.  For example, for a system with tracking axis oriented south,
-    # a rotation toward the east is negative, and a rotation to the west is
-    # positive.
-
-    # Use arctan2 and avoid the tmp corrections.
-
-    # angle from x-y plane to projection of sun vector onto x-z plane
-#     tmp = np.degrees(np.arctan(zp/xp))
-
-    # Obtain wid by translating tmp to convention for rotation angles.
-    # Have to account for which quadrant of the x-z plane in which the sun
-    # vector lies.  Complete solution here but probably not necessary to
-    # consider QIII and QIV.
-#     wid = pd.Series(index=times)
-#     wid[(xp>=0) & (zp>=0)] =  90 - tmp[(xp>=0) & (zp>=0)]  # QI
-#     wid[(xp<0)  & (zp>=0)] = -90 - tmp[(xp<0)  & (zp>=0)]  # QII
-#     wid[(xp<0)  & (zp<0)]  = -90 - tmp[(xp<0)  & (zp<0)]   # QIII
-#     wid[(xp>=0) & (zp<0)]  =  90 - tmp[(xp>=0) & (zp<0)]   # QIV
+    # azimuth. For example, for a system with tracking axis oriented south, a
+    # rotation toward the east is negative, and a rotation to the west is
+    # positive. This is a right-handed rotation around the tracker y-axis.
 
     # Calculate angle from x-y plane to projection of sun vector onto x-z plane
-    # and then obtain wid by translating tmp to convention for rotation angles.
-    wid = 90 - np.degrees(np.arctan2(zp, xp))
+    # using [1] Eq. 5.
+
+    wid = np.degrees(np.arctan2(xp, zp))
 
     # filter for sun above panel horizon
     zen_gt_90 = apparent_zenith > 90
     wid[zen_gt_90] = np.nan
 
-    # Account for backtracking; modified from [1] to account for rotation
-    # angle convention being used here.
+    # Account for backtracking
     if backtrack:
-        axes_distance = 1/gcr
-        # clip needed for low angles. GH 656
-        temp = np.clip(axes_distance*cosd(wid), -1, 1)
+        # distance between rows in terms of rack lengths relative to cross-axis
+        # tilt
+        axes_distance = 1/(gcr * cosd(cross_axis_tilt))
 
-        # backtrack angle
-        # (always positive b/c acosd returns values between 0 and 180)
-        wc = np.degrees(np.arccos(temp))
+        # NOTE: account for rare angles below array, see GH 824
+        temp = np.abs(axes_distance * cosd(wid - cross_axis_tilt))
 
-        # Eq 4 applied when wid in QIV (wid < 0 evalulates True), QI
+        # backtrack angle using [1], Eq. 14
         with np.errstate(invalid='ignore'):
-            # errstate for GH 622
-            tracker_theta = np.where(wid < 0, wid + wc, wid - wc)
+            wc = np.degrees(-np.sign(wid)*np.arccos(temp))
+
+        # NOTE: in the middle of the day, arccos(temp) is out of range because
+        # there's no row-to-row shade to avoid, & backtracking is unnecessary
+        # [1], Eqs. 15-16
+        with np.errstate(invalid='ignore'):
+            tracker_theta = wid + np.where(temp < 1, wc, 0)
     else:
         tracker_theta = wid
 
-    tracker_theta = np.minimum(tracker_theta, max_angle)
-    tracker_theta = np.maximum(tracker_theta, -max_angle)
+    # NOTE: max_angle defined relative to zero-point rotation, not the
+    # system-plane normal
+    tracker_theta = np.clip(tracker_theta, -max_angle, max_angle)
 
-    # calculate panel normal vector in panel-oriented x, y, z coordinates.
-    # y-axis is axis of tracker rotation.  tracker_theta is a compass angle
+    # Calculate panel normal vector in panel-oriented x, y, z coordinates.
+    # y-axis is axis of tracker rotation. tracker_theta is a compass angle
     # (clockwise is positive) rather than a trigonometric angle.
-    # the *0 is a trick to preserve NaN values.
+    # NOTE: the *0 is a trick to preserve NaN values.
     panel_norm = np.array([sind(tracker_theta),
                            tracker_theta*0,
                            cosd(tracker_theta)])
@@ -460,30 +464,28 @@ def singleaxis(apparent_zenith, apparent_azimuth,
     # calculate angle-of-incidence on panel
     aoi = np.degrees(np.arccos(np.abs(np.sum(sun_vec*panel_norm, axis=0))))
 
-    # 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 angle (clockwise from north) to the projection
-    # of the panel's normal to the earth's surface.
-    # These outputs are provided for convenience and comparison
-    # with other PV software which use these angle conventions.
+    # 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
+    # angle (clockwise from north) to the projection of the panel's normal to
+    # the earth's surface. These outputs are provided for convenience and
+    # comparison with other PV software which use these angle conventions.
 
-    # project normal vector to earth surface.
-    # First rotate about x-axis by angle -axis_tilt so that y-axis is
-    # also parallel to earth surface, then project.
+    # Project normal vector to earth surface. First rotate about x-axis by
+    # angle -axis_tilt so that y-axis is also parallel to earth surface, then
+    # project.
 
     # Calculate standard rotation matrix
     rot_x = np.array([[1, 0, 0],
                       [0, cosd(-axis_tilt), -sind(-axis_tilt)],
                       [0, sind(-axis_tilt), cosd(-axis_tilt)]])
 
-    # panel_norm_earth contains the normal vector
-    # expressed in earth-surface coordinates
-    # (z normal to surface, y aligned with tracker axis parallel to earth)
+    # panel_norm_earth contains the normal vector expressed in earth-surface
+    # coordinates (z normal to surface, y aligned with tracker axis parallel to
+    # earth)
     panel_norm_earth = np.dot(rot_x, panel_norm).T
 
-    # projection to plane tangent to earth surface,
-    # in earth surface coordinates
+    # projection to plane tangent to earth surface, in earth surface
+    # coordinates
     projected_normal = np.array([panel_norm_earth[:, 0],
                                  panel_norm_earth[:, 1],
                                  panel_norm_earth[:, 2]*0]).T
@@ -491,69 +493,22 @@ def singleaxis(apparent_zenith, apparent_azimuth,
     # calculate vector magnitudes
     projected_normal_mag = np.sqrt(np.nansum(projected_normal**2, axis=1))
 
-    # renormalize the projected vector
-    # avoid creating nan values.
+    # renormalize the projected vector, avoid creating nan values.
     non_zeros = projected_normal_mag != 0
     projected_normal[non_zeros] = (projected_normal[non_zeros].T /
                                    projected_normal_mag[non_zeros]).T
 
     # calculation of surface_azimuth
-    # 1. Find the angle.
-#     surface_azimuth = pd.Series(
-#         np.degrees(np.arctan(projected_normal[:,1]/projected_normal[:,0])),
-#                                 index=times)
     surface_azimuth = \
         np.degrees(np.arctan2(projected_normal[:, 1], projected_normal[:, 0]))
 
-    # 2. Clean up atan when x-coord or y-coord is zero
-#     surface_azimuth[(projected_normal[:,0]==0) & (projected_normal[:,1]>0)] =  90
-#     surface_azimuth[(projected_normal[:,0]==0) & (projected_normal[:,1]<0)] =  -90
-#     surface_azimuth[(projected_normal[:,1]==0) & (projected_normal[:,0]>0)] =  0
-#     surface_azimuth[(projected_normal[:,1]==0) & (projected_normal[:,0]<0)] = 180
-
-    # 3. Correct atan for QII and QIII
-#     surface_azimuth[(projected_normal[:,0]<0) & (projected_normal[:,1]>0)] += 180 # QII
-#     surface_azimuth[(projected_normal[:,0]<0) & (projected_normal[:,1]<0)] += 180 # QIII
-
-    # 4. Skip to below
-
-    # at this point surface_azimuth contains angles between -90 and +270,
-    # where 0 is along the positive x-axis,
-    # the y-axis is in the direction of the tracker azimuth,
-    # and positive angles are rotations from the positive x axis towards
-    # the positive y-axis.
-    # Adjust to compass angles
-    # (clockwise rotation from 0 along the positive y-axis)
-#    surface_azimuth[surface_azimuth<=90] = 90 - surface_azimuth[surface_azimuth<=90]
-#    surface_azimuth[surface_azimuth>90] = 450 - surface_azimuth[surface_azimuth>90]
-
-    # finally rotate to align y-axis with true north
-    # PVLIB_MATLAB has this latitude correction,
-    # but I don't think it's latitude dependent if you always
-    # specify axis_azimuth with respect to North.
-#     if latitude > 0 or True:
-#         surface_azimuth = surface_azimuth - axis_azimuth
-#     else:
-#         surface_azimuth = surface_azimuth - axis_azimuth - 180
-#     surface_azimuth[surface_azimuth<0] = 360 + surface_azimuth[surface_azimuth<0]
-
-    # the commented code above is mostly part of PVLIB_MATLAB.
-    # My (wholmgren) take is that it can be done more simply.
-    # Say that we're pointing along the postive x axis (likely west).
-    # We just need to rotate 90 degrees to get from the x axis
-    # to the y axis (likely south),
-    # and then add the axis_azimuth to get back to North.
-    # Anything left over is the azimuth that we want,
-    # and we can map it into the [0,360) domain.
-
-    # 4. Rotate 0 reference from panel's x axis to it's y axis and
-    #    then back to North.
+    # Rotate 0 reference from panel's x-axis to its y-axis and then back to
+    # north.
     surface_azimuth = 90 - surface_azimuth + axis_azimuth
 
-    # 5. Map azimuth into [0,360) domain.
-    # surface_azimuth[surface_azimuth < 0] += 360
-    # surface_azimuth[surface_azimuth >= 360] -= 360
-    surface_azimuth = surface_azimuth % 360
+    # Map azimuth into [0,360) domain.
+    with np.errstate(invalid='ignore'):
+        surface_azimuth = surface_azimuth % 360
 
     # Calculate surface_tilt
     dotproduct = (panel_norm_earth * projected_normal).sum(axis=1)
@@ -561,7 +516,7 @@ def singleaxis(apparent_zenith, apparent_azimuth,
 
     # Bundle DataFrame for return values and filter for sun below horizon.
     out = {'tracker_theta': tracker_theta, 'aoi': aoi,
-           'surface_azimuth': surface_azimuth, 'surface_tilt': surface_tilt}
+           'surface_tilt': surface_tilt, 'surface_azimuth': surface_azimuth}
     if index is not None:
         out = pd.DataFrame(out, index=index)
         out = out[['tracker_theta', 'aoi', 'surface_azimuth', 'surface_tilt']]
@@ -570,3 +525,154 @@ def singleaxis(apparent_zenith, apparent_azimuth,
         out = {k: np.where(zen_gt_90, np.nan, v) for k, v in out.items()}
 
     return out
+
+
+def calc_axis_tilt(slope_azimuth, slope_tilt, axis_azimuth):
+    """
+    Calculate tracker axis tilt in the global reference frame when on a sloped
+    plane.
+
+    Parameters
+    ----------
+    slope_azimuth : float
+        direction of normal to slope on horizontal [degrees]
+    slope_tilt : float
+        tilt of normal to slope relative to vertical [degrees]
+    axis_azimuth : float
+        direction of tracker axes on horizontal [degrees]
+
+    Returns
+    -------
+    axis_tilt : float
+        tilt of tracker [degrees]
+
+    See also
+    --------
+    pvlib.tracking.singleaxis
+    pvlib.tracking.calc_cross_axis_tilt
+
+    Notes
+    -----
+    See [1]_ for derivation of equations.
+
+    References
+    ----------
+    .. [1] Kevin Anderson and Mark Mikofski, "Slope-Aware Backtracking for
+       Single-Axis Trackers", Technical Report NREL/TP-5K00-76626, July 2020.
+       https://www.nrel.gov/docs/fy20osti/76626.pdf
+    """
+    delta_gamma = axis_azimuth - slope_azimuth
+    # equations 18-19
+    tan_axis_tilt = cosd(delta_gamma) * tand(slope_tilt)
+    return np.degrees(np.arctan(tan_axis_tilt))
+
+
+def _calc_tracker_norm(ba, bg, dg):
+    """
+    Calculate tracker normal, v, cross product of tracker axis and unit normal,
+    N, to the system slope plane.
+
+    Parameters
+    ----------
+    ba : float
+        axis tilt [degrees]
+    bg : float
+        ground tilt [degrees]
+    dg : float
+        delta gamma, difference between axis and ground azimuths [degrees]
+
+    Returns
+    -------
+    vector : tuple
+        vx, vy, vz
+    """
+    cos_ba = cosd(ba)
+    cos_bg = cosd(bg)
+    sin_bg = sind(bg)
+    sin_dg = sind(dg)
+    vx = sin_dg * cos_ba * cos_bg
+    vy = sind(ba)*sin_bg + cosd(dg)*cos_ba*cos_bg
+    vz = -sin_dg*sin_bg*cos_ba
+    return vx, vy, vz
+
+
+def _calc_beta_c(v, dg, ba):
+    """
+    Calculate the cross-axis tilt angle.
+
+    Parameters
+    ----------
+    v : tuple
+        tracker normal
+    dg : float
+        delta gamma, difference between axis and ground azimuths [degrees]
+    ba : float
+        axis tilt [degrees]
+
+    Returns
+    -------
+    beta_c : float
+        cross-axis tilt angle [radians]
+    """
+    vnorm = np.sqrt(np.dot(v, v))
+    beta_c = np.arcsin(
+        ((v[0]*cosd(dg) - v[1]*sind(dg)) * sind(ba) + v[2]*cosd(ba)) / vnorm)
+    return beta_c
+
+
+def calc_cross_axis_tilt(
+        slope_azimuth, slope_tilt, axis_azimuth, axis_tilt):
+    """
+    Calculate the angle, relative to horizontal, of the line formed by the
+    intersection between the slope containing the tracker axes and a plane
+    perpendicular to the tracker axes.
+
+    Use the cross-axis tilt to avoid row-to-row shade when backtracking on a
+    slope not parallel with the axis azimuth. Cross-axis tilt should be
+    specified using a right-handed convention. For example, trackers with axis
+    azimuth of 180 degrees (heading south) will have a negative cross-axis tilt
+    if the tracker axes plane slopes down to the east and positive cross-axis
+    tilt if the tracker axes plane slopes up to the east.
+
+    Parameters
+    ----------
+    slope_azimuth : float
+        direction of the normal to the slope containing the tracker axes, when
+        projected on the horizontal [degrees]
+    slope_tilt : float
+        angle of the slope containing the tracker axes, relative to horizontal
+        [degrees]
+    axis_azimuth : float
+        direction of tracker axes projected on the horizontal [degrees]
+    axis_tilt : float
+        tilt of trackers relative to horizontal [degrees]
+
+    Returns
+    -------
+    cross_axis_tilt : float
+        angle, relative to horizontal, of the line formed by the intersection
+        between the slope containing the tracker axes and a plane perpendicular
+        to the tracker axes [degrees]
+
+    See also
+    --------
+    pvlib.tracking.singleaxis
+    pvlib.tracking.calc_axis_tilt
+
+    Notes
+    -----
+    See [1]_ for derivation of equations.
+
+    References
+    ----------
+    .. [1] Kevin Anderson and Mark Mikofski, "Slope-Aware Backtracking for
+       Single-Axis Trackers", Technical Report NREL/TP-5K00-76626, July 2020.
+       https://www.nrel.gov/docs/fy20osti/76626.pdf
+    """
+    # delta-gamma, difference between axis and slope azimuths
+    delta_gamma = axis_azimuth - slope_azimuth
+    # equation 22
+    v = _calc_tracker_norm(axis_tilt, slope_tilt, delta_gamma)
+    # equation 26
+    beta_c = _calc_beta_c(v, delta_gamma, axis_tilt)
+    return np.degrees(beta_c)


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
@@ -6,8 +6,10 @@
 from conftest import assert_frame_equal
 from numpy.testing import assert_allclose
 
+import pvlib
 from pvlib.location import Location
 from pvlib import tracking
+from conftest import DATA_DIR
 
 SINGLEAXIS_COL_ORDER = ['tracker_theta', 'aoi',
                         'surface_azimuth', 'surface_tilt']
@@ -41,7 +43,7 @@ def test_scalars():
     expect = {'tracker_theta': 0, 'aoi': 10, 'surface_azimuth': 90,
               'surface_tilt': 0}
     for k, v in expect.items():
-        assert_allclose(tracker_data[k], v)
+        assert np.isclose(tracker_data[k], v)
 
 
 def test_arrays():
@@ -55,7 +57,7 @@ def test_arrays():
     expect = {'tracker_theta': 0, 'aoi': 10, 'surface_azimuth': 90,
               'surface_tilt': 0}
     for k, v in expect.items():
-        assert_allclose(tracker_data[k], v)
+        assert_allclose(tracker_data[k], v, atol=1e-7)
 
 
 def test_nans():
@@ -71,7 +73,7 @@ def test_nans():
               'surface_azimuth': np.array([90, nan, nan]),
               'surface_tilt': np.array([0, nan, nan])}
     for k, v in expect.items():
-        assert_allclose(tracker_data[k], v)
+        assert_allclose(tracker_data[k], v, atol=1e-7)
 
     # repeat with Series because nans can differ
     apparent_zenith = pd.Series(apparent_zenith)
@@ -268,7 +270,7 @@ def test_horizon_tilted():
     out = tracking.singleaxis(solar_zenith, solar_azimuth, axis_tilt=90,
                               axis_azimuth=180, backtrack=False, max_angle=180)
     expected = pd.DataFrame(np.array(
-        [[ 180.,  45.,   0.,  90.],
+        [[-180.,  45.,   0.,  90.],
          [   0.,  45., 180.,  90.],
          [ 179.,  45., 359.,  90.]]),
         columns=['tracker_theta', 'aoi', 'surface_azimuth', 'surface_tilt'])
@@ -276,15 +278,15 @@ def test_horizon_tilted():
 
 
 def test_low_sun_angles():
-    # GH 656
+    # GH 656, 824
     result = tracking.singleaxis(
         apparent_zenith=80, apparent_azimuth=338, axis_tilt=30,
         axis_azimuth=180, max_angle=60, backtrack=True, gcr=0.35)
     expected = {
-        'tracker_theta': np.array([-50.31051385]),
-        'aoi': np.array([61.35300178]),
-        'surface_azimuth': np.array([112.53615425]),
-        'surface_tilt': np.array([56.42233095])}
+        'tracker_theta': np.array([60.0]),
+        'aoi': np.array([80.420987]),
+        'surface_azimuth': np.array([253.897886]),
+        'surface_tilt': np.array([64.341094])}
     for k, v in result.items():
         assert_allclose(expected[k], v)
 
@@ -447,6 +449,7 @@ def test_SingleAxisTracker___repr__():
   max_angle: 45
   backtrack: True
   gcr: 0.25
+  cross_axis_tilt: 0.0
   name: None
   surface_tilt: None
   surface_azimuth: None
@@ -471,6 +474,7 @@ def test_LocalizedSingleAxisTracker___repr__():
   max_angle: 90
   backtrack: True
   gcr: 0.25
+  cross_axis_tilt: 0.0
   name: None
   surface_tilt: None
   surface_azimuth: None
@@ -486,3 +490,87 @@ def test_LocalizedSingleAxisTracker___repr__():
   tz: UTC"""
 
     assert localized_system.__repr__() == expected
+
+
+def test_calc_axis_tilt():
+    # expected values
+    expected_axis_tilt = 2.239  # [degrees]
+    expected_side_slope = 9.86649274360294  # [degrees]
+    expected = DATA_DIR / 'singleaxis_tracker_wslope.csv'
+    expected = pd.read_csv(expected, index_col='timestamp', parse_dates=True)
+    # solar positions
+    starttime = '2017-01-01T00:30:00-0300'
+    stoptime = '2017-12-31T23:59:59-0300'
+    lat, lon = -27.597300, -48.549610
+    times = pd.DatetimeIndex(pd.date_range(starttime, stoptime, freq='H'))
+    solpos = pvlib.solarposition.get_solarposition(times, lat, lon)
+    # singleaxis tracker w/slope data
+    slope_azimuth, slope_tilt = 77.34, 10.1149
+    axis_azimuth = 0.0
+    max_angle = 75.0
+    # Note: GCR is relative to horizontal distance between rows
+    gcr = 0.33292759  # GCR = length / horizontal_pitch = 1.64 / 5 / cos(9.86)
+    # calculate tracker axis zenith
+    axis_tilt = tracking.calc_axis_tilt(
+        slope_azimuth, slope_tilt, axis_azimuth=axis_azimuth)
+    assert np.isclose(axis_tilt, expected_axis_tilt)
+    # calculate cross-axis tilt and relative rotation
+    cross_axis_tilt = tracking.calc_cross_axis_tilt(
+        slope_azimuth, slope_tilt, axis_azimuth, axis_tilt)
+    assert np.isclose(cross_axis_tilt, expected_side_slope)
+    sat = tracking.singleaxis(
+        solpos.apparent_zenith, solpos.azimuth, axis_tilt, axis_azimuth,
+        max_angle, backtrack=True, gcr=gcr, cross_axis_tilt=cross_axis_tilt)
+    np.testing.assert_allclose(
+        sat['tracker_theta'], expected['tracker_theta'], atol=1e-7)
+    np.testing.assert_allclose(sat['aoi'], expected['aoi'], atol=1e-7)
+    np.testing.assert_allclose(
+        sat['surface_azimuth'], expected['surface_azimuth'], atol=1e-7)
+    np.testing.assert_allclose(
+        sat['surface_tilt'], expected['surface_tilt'], atol=1e-7)
+
+
+def test_slope_aware_backtracking():
+    """
+    Test validation data set from https://www.nrel.gov/docs/fy20osti/76626.pdf
+    """
+    expected_data = np.array(
+        [('2019-01-01T08:00-0500',  2.404287, 122.79177, -84.440, -10.899),
+         ('2019-01-01T09:00-0500', 11.263058, 133.288729, -72.604, -25.747),
+         ('2019-01-01T10:00-0500', 18.733558, 145.285552, -59.861, -59.861),
+         ('2019-01-01T11:00-0500', 24.109076, 158.939435, -45.578, -45.578),
+         ('2019-01-01T12:00-0500', 26.810735, 173.931802, -28.764, -28.764),
+         ('2019-01-01T13:00-0500', 26.482495, 189.371536, -8.475, -8.475),
+         ('2019-01-01T14:00-0500', 23.170447, 204.13681, 15.120, 15.120),
+         ('2019-01-01T15:00-0500', 17.296785, 217.446538, 39.562, 39.562),
+         ('2019-01-01T16:00-0500',  9.461862, 229.102218, 61.587, 32.339),
+         ('2019-01-01T17:00-0500',  0.524817, 239.330401, 79.530, 5.490)],
+        dtype=[
+            ('Time', '<M8[h]'), ('ApparentElevation', '<f8'),
+            ('SolarAzimuth', '<f8'), ('TrueTracking', '<f8'),
+            ('Backtracking', '<f8')])
+    expected_axis_tilt = 9.666
+    expected_slope_angle = -2.576
+    slope_azimuth, slope_tilt = 180.0, 10.0
+    axis_azimuth = 195.0
+    axis_tilt = tracking.calc_axis_tilt(
+        slope_azimuth, slope_tilt, axis_azimuth)
+    assert np.isclose(axis_tilt, expected_axis_tilt, rtol=1e-3, atol=1e-3)
+    cross_axis_tilt = tracking.calc_cross_axis_tilt(
+        slope_azimuth, slope_tilt, axis_azimuth, axis_tilt)
+    assert np.isclose(
+        cross_axis_tilt, expected_slope_angle, rtol=1e-3, atol=1e-3)
+    sat = tracking.singleaxis(
+        90.0-expected_data['ApparentElevation'], expected_data['SolarAzimuth'],
+        axis_tilt, axis_azimuth, max_angle=90.0, backtrack=True, gcr=0.5,
+        cross_axis_tilt=cross_axis_tilt)
+    np.testing.assert_allclose(
+        sat['tracker_theta'], expected_data['Backtracking'],
+        rtol=1e-3, atol=1e-3)
+    truetracking = tracking.singleaxis(
+        90.0-expected_data['ApparentElevation'], expected_data['SolarAzimuth'],
+        axis_tilt, axis_azimuth, max_angle=90.0, backtrack=False, gcr=0.5,
+        cross_axis_tilt=cross_axis_tilt)
+    np.testing.assert_allclose(
+        truetracking['tracker_theta'], expected_data['TrueTracking'],
+        rtol=1e-3, atol=1e-3)