Repository: pvlib/pvlib-python

Issue:
Consider extracting the surface orientation calculation in pvlib.tracking.singleaxis() to its own function
**Is your feature request related to a problem? Please describe.**
The usual workflow for modeling single-axis tracking in pvlib is to treat tracker rotation (`tracker_theta`) as an unknown to be calculated from solar position and array geometry.  However, sometimes a user might have their own tracker rotations but not have the corresponding `surface_tilt` and `surface_azimuth` values.  Here are a few motivating examples:
- Using measured rotation angles
- Post-processing the output of `tracking.singleaxis()` to include wind stow events or tracker stalls
- Other tracking algorithms that determine rotation differently from the astronomical method

Assuming I have my tracker rotations already in hand, getting the corresponding `surface_tilt` and `surface_azimuth` angles is not as easy as it should be.  For the specific case of horizontal N-S axis the math isn't so bad, but either way it's annoying to have to DIY when pvlib already has code to calculate those angles from tracker rotation.

**Describe the solution you'd like**
A function `pvlib.tracking.rotation_to_orientation` that implements the same math in `pvlib.tracking.singleaxis` to go from `tracker_theta` to `surface_tilt` and `surface_azimuth`.  Basically extract out the second half of `tracking.singleaxis` into a new function.  Suggestions for the function name are welcome.  To be explicit, this is more or less what I'm imagining:

```python
def rotation_to_orientation(tracker_theta, axis_tilt=0, axis_azimuth=0, max_angle=90):
    # insert math from second half of tracking.singleaxis() here
    out = {'tracker_theta': tracker_theta, 'aoi': aoi,
           'surface_tilt': surface_tilt, 'surface_azimuth': surface_azimuth}
    return pandas_if_needed(out)
```

**Describe alternatives you've considered**
Continue suffering

**Additional context**
This is one step towards a broader goal I have for `pvlib.tracking` to house other methods to determine tracker rotation in addition to the current astronomical method, the same way we have multiple temperature and transposition models.  These functions would be responsible for determining tracker rotations, and they'd all use this `rotation_to_orientation` function to convert rotation to module orientation.

Separately, I wonder if the code could be simplified using the tilt and azimuth equations in Bill's technical report (https://www.nrel.gov/docs/fy13osti/58891.pdf) -- seems like what we're doing is overly complicated, although maybe I've just not studied it closely enough.

cc @williamhobbs @spaneja 


Discussion:
I like this. 

This is related to an issue submitted for NREL SAM, https://github.com/NREL/SAM/issues/850, and I think @mjprilliman is looking at something related. 
@kanderso-nrel Nice meeting you at PVSC the other day.  I've been working on this a bit using the tilt and azimuth equations in the technical report by Bill Marion mentioned.  I would like to use this for the custom backtracking schedules Nevados generates for the terrain following trackers.  

I have surface tilt working.  It requires a dataframe `df` of axis tilt angles. I use the dataframe because Nevados has a different axis tilt for each bay in our tracker.  stdf (surface tilt dataframe) starts as a dataframe of rotation angles (theta) indexed by timestep and is transformed into surface tilts.  col.name is used to match the tracker and bay's rotation angle to their corresponding axis tilt.

```
        def calc_surface_tilt(col):
            axis_tilt = df.at[col.name, 'axis_tilt']
            surface_tilt = np.rad2deg(
                np.arccos(
                    np.cos(np.deg2rad(col)) * np.cos(np.deg2rad(axis_tilt))
                )
            )
            return surface_tilt

        stdf = stdf.apply(calc_surface_tilt, axis=0)
```

Unfortunately I can't seem to get surface azimuth working correctly.  sadf (surface angle data frame) is almost equal to surface azimuth as calculated by pvlib, but not quite in the middle of the day.  `ts2` is the output of `pvlib.tracking.singleaxis`


```
    sadf = np.rad2deg(
        np.deg2rad(180) +
        np.arcsin(
            (
                np.sin(np.deg2rad(ts2['tracker_theta'])) /
                np.sin(np.deg2rad(ts2['surface_tilt']))
            ).clip(upper=1, lower=-1)
        )
    )
```
![image](https://user-images.githubusercontent.com/33131958/174192086-124a53c6-78c3-445c-a50d-3a70f6944adb.png)

![image](https://user-images.githubusercontent.com/33131958/174192355-62437cb6-9f23-403f-ab26-21924d79dab7.png)



Thanks @kurt-rhee for this investigation.  Trying some simple examples on my end, things seem to line up.  Here's a complete copy/pasteable example where I get negligible difference between the current pvlib approach and your code.  Note that I did replace the hard-coded axis_azimuth of 180 in the surface_azimuth calculation. 

<details>
  <summary>Click to expand!</summary>

```python
import pvlib
import pandas as pd
import numpy as np

axis_tilt = 20
axis_azimuth = 230
loc = pvlib.location.Location(40, -80)
times = pd.date_range('2019-06-01', '2019-06-02', freq='5T', tz='Etc/GMT+5')
sp = loc.get_solarposition(times)
tr = pvlib.tracking.singleaxis(sp.apparent_zenith, sp.azimuth,
                               axis_tilt=axis_tilt, axis_azimuth=axis_azimuth)


def rotation_to_orientation(tracker_theta, axis_tilt=0, axis_azimuth=0, max_angle=90):
    surface_tilt = np.rad2deg(
        np.arccos(
            np.cos(np.deg2rad(tracker_theta)) * np.cos(np.deg2rad(axis_tilt))
        )
    )
    surface_azimuth = np.rad2deg(
        np.deg2rad(axis_azimuth) +
        np.arcsin(
            (
                np.sin(np.deg2rad(tracker_theta)) /
                np.sin(np.deg2rad(surface_tilt))
            ).clip(upper=1, lower=-1)
        )
    )
    return pd.DataFrame({
        'tracker_theta': tracker_theta,
        'surface_tilt': surface_tilt,
        'surface_azimuth': surface_azimuth,
    })

tr2 = rotation_to_orientation(tr.tracker_theta, axis_tilt=axis_tilt, axis_azimuth=axis_azimuth)
```

</details>

```python
In [53]: (tr[['surface_tilt', 'surface_azimuth']] - tr2[['surface_tilt', 'surface_azimuth']]).describe()
Out[53]: 
       surface_tilt  surface_azimuth
count  1.780000e+02     1.780000e+02
mean  -6.586492e-16     3.193450e-15
std    8.916369e-15     2.864187e-14
min   -2.842171e-14    -5.684342e-14
25%   -7.105427e-15     0.000000e+00
50%    0.000000e+00     0.000000e+00
75%    3.552714e-15     2.842171e-14
max    2.131628e-14     5.684342e-14
```
Not that there was much doubt, but I've convinced myself that Bill's surface orientation equations are mathematically equivalent to the approach pvlib takes.  Here are some notes if anyone is interested: https://gist.github.com/kanderso-nrel/ac3051de41261df317180c794144d6a9

If we do switch to Bill's equations we should be sure to preserve the handling of NaN and edge cases of the current implementation.
I realize that my small error metric is due to a small timeshift that I had in my data that changed my answer when resampling / averaging. 

Cheers

Patch:
diff --git a/pvlib/tools.py b/pvlib/tools.py
--- a/pvlib/tools.py
+++ b/pvlib/tools.py
@@ -85,6 +85,25 @@ def asind(number):
     return res
 
 
+def acosd(number):
+    """
+    Inverse Cosine returning an angle in degrees
+
+    Parameters
+    ----------
+    number : float
+        Input number
+
+    Returns
+    -------
+    result : float
+        arccos result
+    """
+
+    res = np.degrees(np.arccos(number))
+    return res
+
+
 def localize_to_utc(time, location):
     """
     Converts or localizes a time series to UTC.
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, tand
+from pvlib.tools import cosd, sind, tand, acosd, asind
 from pvlib.pvsystem import (
     PVSystem, Array, SingleAxisTrackerMount, _unwrap_single_value
 )
@@ -334,9 +334,9 @@ def singleaxis(apparent_zenith, apparent_azimuth,
     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. [degrees]
+        * `tracker_theta`: The rotation angle of the tracker is a right-handed
+          rotation defined by `axis_azimuth`.
+          tracker_theta = 0 is horizontal. [degrees]
         * `aoi`: The angle-of-incidence of direct irradiance onto the
           rotated panel surface. [degrees]
         * `surface_tilt`: The angle between the panel surface and the earth
@@ -349,6 +349,7 @@ def singleaxis(apparent_zenith, apparent_azimuth,
     --------
     pvlib.tracking.calc_axis_tilt
     pvlib.tracking.calc_cross_axis_tilt
+    pvlib.tracking.calc_surface_orientation
 
     References
     ----------
@@ -396,9 +397,10 @@ def singleaxis(apparent_zenith, apparent_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)
+    # not necessary to calculate y'
+    # 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)
@@ -446,81 +448,18 @@ def singleaxis(apparent_zenith, apparent_azimuth,
     # 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
-    # (clockwise is positive) rather than a trigonometric angle.
-    # NOTE: the *0 is a trick to preserve NaN values.
-    panel_norm = np.array([sind(tracker_theta),
-                           tracker_theta*0,
-                           cosd(tracker_theta)])
-
-    # sun position in vector format in panel-oriented x, y, z coordinates
-    sun_vec = np.array([xp, yp, zp])
-
-    # calculate angle-of-incidence on panel
-    # 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
-    # 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.
-
-    # 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 = np.dot(rot_x, panel_norm).T
-
-    # 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
-
-    # calculate vector magnitudes
-    projected_normal_mag = np.sqrt(np.nansum(projected_normal**2, axis=1))
-
-    # 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
-    surface_azimuth = \
-        np.degrees(np.arctan2(projected_normal[:, 1], projected_normal[:, 0]))
-
-    # Rotate 0 reference from panel's x-axis to its y-axis and then back to
-    # north.
-    surface_azimuth = 90 - surface_azimuth + axis_azimuth
-
-    # 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)
-    # 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))
+    # Calculate auxiliary angles
+    surface = calc_surface_orientation(tracker_theta, axis_tilt, axis_azimuth)
+    surface_tilt = surface['surface_tilt']
+    surface_azimuth = surface['surface_azimuth']
+    aoi = irradiance.aoi(surface_tilt, surface_azimuth,
+                         apparent_zenith, apparent_azimuth)
 
     # Bundle DataFrame for return values and filter for sun below horizon.
     out = {'tracker_theta': tracker_theta, 'aoi': aoi,
-           'surface_tilt': surface_tilt, 'surface_azimuth': surface_azimuth}
+           'surface_azimuth': surface_azimuth, 'surface_tilt': surface_tilt}
     if index is not None:
         out = pd.DataFrame(out, index=index)
-        out = out[['tracker_theta', 'aoi', 'surface_azimuth', 'surface_tilt']]
         out[zen_gt_90] = np.nan
     else:
         out = {k: np.where(zen_gt_90, np.nan, v) for k, v in out.items()}
@@ -528,6 +467,60 @@ def singleaxis(apparent_zenith, apparent_azimuth,
     return out
 
 
+def calc_surface_orientation(tracker_theta, axis_tilt=0, axis_azimuth=0):
+    """
+    Calculate the surface tilt and azimuth angles for a given tracker rotation.
+
+    Parameters
+    ----------
+    tracker_theta : numeric
+        Tracker rotation angle as a right-handed rotation around
+        the axis defined by ``axis_tilt`` and ``axis_azimuth``.  For example,
+        with ``axis_tilt=0`` and ``axis_azimuth=180``, ``tracker_theta > 0``
+        results in ``surface_azimuth`` to the West while ``tracker_theta < 0``
+        results in ``surface_azimuth`` to the East. [degree]
+    axis_tilt : float, default 0
+        The tilt of the axis of rotation with respect to horizontal. [degree]
+    axis_azimuth : float, default 0
+        A value denoting the compass direction along which the axis of
+        rotation lies. Measured east of north. [degree]
+
+    Returns
+    -------
+    dict or DataFrame
+        Contains keys ``'surface_tilt'`` and ``'surface_azimuth'`` representing
+        the module orientation accounting for tracker rotation and axis
+        orientation. [degree]
+
+    References
+    ----------
+    .. [1] William F. Marion and Aron P. Dobos, "Rotation Angle for the Optimum
+       Tracking of One-Axis Trackers", Technical Report NREL/TP-6A20-58891,
+       July 2013. :doi:`10.2172/1089596`
+    """
+    with np.errstate(invalid='ignore', divide='ignore'):
+        surface_tilt = acosd(cosd(tracker_theta) * cosd(axis_tilt))
+
+        # clip(..., -1, +1) to prevent arcsin(1 + epsilon) issues:
+        azimuth_delta = asind(np.clip(sind(tracker_theta) / sind(surface_tilt),
+                                      a_min=-1, a_max=1))
+        # Combine Eqs 2, 3, and 4:
+        azimuth_delta = np.where(abs(tracker_theta) < 90,
+                                 azimuth_delta,
+                                 -azimuth_delta + np.sign(tracker_theta) * 180)
+        # handle surface_tilt=0 case:
+        azimuth_delta = np.where(sind(surface_tilt) != 0, azimuth_delta, 90)
+        surface_azimuth = (axis_azimuth + azimuth_delta) % 360
+
+    out = {
+        'surface_tilt': surface_tilt,
+        'surface_azimuth': surface_azimuth,
+    }
+    if hasattr(tracker_theta, 'index'):
+        out = pd.DataFrame(out)
+    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


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
@@ -517,3 +517,72 @@ def test_singleaxis_aoi_gh1221():
     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)
+
+
+def test_calc_surface_orientation_types():
+    # numpy arrays
+    rotations = np.array([-10, 0, 10])
+    expected_tilts = np.array([10, 0, 10], dtype=float)
+    expected_azimuths = np.array([270, 90, 90], dtype=float)
+    out = tracking.calc_surface_orientation(tracker_theta=rotations)
+    np.testing.assert_allclose(expected_tilts, out['surface_tilt'])
+    np.testing.assert_allclose(expected_azimuths, out['surface_azimuth'])
+
+    # pandas Series
+    rotations = pd.Series(rotations)
+    expected_tilts = pd.Series(expected_tilts).rename('surface_tilt')
+    expected_azimuths = pd.Series(expected_azimuths).rename('surface_azimuth')
+    out = tracking.calc_surface_orientation(tracker_theta=rotations)
+    assert_series_equal(expected_tilts, out['surface_tilt'])
+    assert_series_equal(expected_azimuths, out['surface_azimuth'])
+
+    # float
+    for rotation, expected_tilt, expected_azimuth in zip(
+            rotations, expected_tilts, expected_azimuths):
+        out = tracking.calc_surface_orientation(rotation)
+        assert out['surface_tilt'] == pytest.approx(expected_tilt)
+        assert out['surface_azimuth'] == pytest.approx(expected_azimuth)
+
+
+def test_calc_surface_orientation_kwargs():
+    # non-default axis tilt & azimuth
+    rotations = np.array([-10, 0, 10])
+    expected_tilts = np.array([22.2687445, 20.0, 22.2687445])
+    expected_azimuths = np.array([152.72683041, 180.0, 207.27316959])
+    out = tracking.calc_surface_orientation(rotations,
+                                            axis_tilt=20,
+                                            axis_azimuth=180)
+    np.testing.assert_allclose(out['surface_tilt'], expected_tilts)
+    np.testing.assert_allclose(out['surface_azimuth'], expected_azimuths)
+
+
+def test_calc_surface_orientation_special():
+    # special cases for rotations
+    rotations = np.array([-180, -90, -0, 0, 90, 180])
+    expected_tilts = np.array([180, 90, 0, 0, 90, 180], dtype=float)
+    expected_azimuths = [270, 270, 90, 90, 90, 90]
+    out = tracking.calc_surface_orientation(rotations)
+    np.testing.assert_allclose(out['surface_tilt'], expected_tilts)
+    np.testing.assert_allclose(out['surface_azimuth'], expected_azimuths)
+
+    # special case for axis_tilt
+    rotations = np.array([-10, 0, 10])
+    expected_tilts = np.array([90, 90, 90], dtype=float)
+    expected_azimuths = np.array([350, 0, 10], dtype=float)
+    out = tracking.calc_surface_orientation(rotations, axis_tilt=90)
+    np.testing.assert_allclose(out['surface_tilt'], expected_tilts)
+    np.testing.assert_allclose(out['surface_azimuth'], expected_azimuths)
+
+    # special cases for axis_azimuth
+    rotations = np.array([-10, 0, 10])
+    expected_tilts = np.array([10, 0, 10], dtype=float)
+    expected_azimuth_offsets = np.array([-90, 90, 90], dtype=float)
+    for axis_azimuth in [0, 90, 180, 270, 360]:
+        expected_azimuths = (axis_azimuth + expected_azimuth_offsets) % 360
+        out = tracking.calc_surface_orientation(rotations,
+                                                axis_azimuth=axis_azimuth)
+        np.testing.assert_allclose(out['surface_tilt'], expected_tilts)
+        # the rounding is a bit ugly, but necessary to test approximately equal
+        # in a modulo-360 sense.
+        np.testing.assert_allclose(np.round(out['surface_azimuth'], 4) % 360,
+                                   expected_azimuths, rtol=1e-5, atol=1e-5)