Repository: pvlib/pvlib-python

Issue:
Add `min_angle` argument to `tracking.singleaxis`
In `tracking.singleaxis` the minimum angle of the tracker is assumed to be opposite of the maximum angle, although in some cases the minimum angle could be different. NREL SAM doesn't support that but PVsyst does.

In order to support non symmetrical limiting angles, `tracking.singleaxis` should have another, optional, input, `min_angle`. By default, if not supplied (i.e. value is `None`), the current behavior (`min_angle = -max_angle`) would apply.

Can I propose a PR for this, with modifications to `tracking.singleaxis`, `tracking.SingleAxisTracker` and to `pvsystem.SingleAxisTrackerMount` + corresponding tests?


Discussion:
I'm in favor of pvlib being able to handle asymmetrical rotation limits in principle, but I'm curious what situation has that asymmetry in practice.  @MichalArieli do you have a particular real-world application in mind?

Rather than separate `min_` and `max_` parameters, I think I'd favor a single parameter that accepts a tuple as @cwhanse suggested here: https://github.com/pvlib/pvlib-python/pull/823#issuecomment-561399605.  I'm not sure about renaming `max_angle` to something else though.  `singleaxis(..., max_angle=(-40, 45))` seems okay to me.  And since symmetrical limits is by far the more common case, I think the parameter should continue accepting a single value (in which case symmetry is assumed) in addition to a tuple.

`tracking.SingleAxisTracker` is being removed anyway (#1771), so no point in making any additions there.  Whatever changes we decide on here should only be made to `tracking.singleaxis` and `pvsystem.SingleAxisTrackerMount` (and tests, of course).
@kandersolar  Thanks for the quick response! 

Regarding handling asymmetry in rotation limits, let's take the example of a tracker placed at a 90-degree axis azimuth, tracking south-north. If the sun is at azimuth 80 degrees during sunrise, the algorithm will guide the tracker to briefly turn north. To prevent that we can implement a maximum angle limit for northward movement to ensure smooth and continuous motion and taking into account the time needed for such a large angular change.

I agree its better to have a single parameter that accepts a tuple/ single value. Would you like me to apply these changes and send for a PR? 

> Would you like me to apply these changes and send for a PR?

Please do!

Patch:
diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py
--- a/pvlib/pvsystem.py
+++ b/pvlib/pvsystem.py
@@ -15,7 +15,7 @@
 import pandas as pd
 from dataclasses import dataclass
 from abc import ABC, abstractmethod
-from typing import Optional
+from typing import Optional, Union
 
 from pvlib._deprecation import deprecated, warn_deprecated
 
@@ -1411,12 +1411,21 @@ class SingleAxisTrackerMount(AbstractMount):
         A value denoting the compass direction along which the axis of
         rotation lies, measured east of north. [degrees]
 
-    max_angle : float, default 90
-        A value denoting the maximum rotation angle
+    max_angle : float or tuple, default 90
+        A value denoting the maximum rotation angle, in decimal degrees,
         of the one-axis tracker from its horizontal position (horizontal
-        if axis_tilt = 0). A max_angle of 90 degrees allows the tracker
-        to rotate to a vertical position to point the panel towards a
-        horizon. max_angle of 180 degrees allows for full rotation. [degrees]
+        if axis_tilt = 0). If a float is provided, it represents the maximum
+        rotation angle, and the minimum rotation angle is assumed to be the
+        opposite of the maximum angle. If a tuple of (min_angle, max_angle) is
+        provided, it represents both the minimum and maximum rotation angles.
+
+        A rotation to 'max_angle' is a counter-clockwise rotation about the
+        y-axis of the tracker coordinate system. For example, for a tracker
+        with 'axis_azimuth' oriented to the south, a rotation to 'max_angle'
+        is towards the west, and a rotation toward 'min_angle' is in the
+        opposite direction, toward the east. Hence a max_angle of 180 degrees
+        (equivalent to max_angle = (-180, 180)) allows the tracker to achieve
+        its full rotation capability.
 
     backtrack : bool, default True
         Controls whether the tracker has the capability to "backtrack"
@@ -1452,7 +1461,7 @@ class SingleAxisTrackerMount(AbstractMount):
     """
     axis_tilt: float = 0.0
     axis_azimuth: float = 0.0
-    max_angle: float = 90.0
+    max_angle: Union[float, tuple] = 90.0
     backtrack: bool = True
     gcr: float = 2.0/7.0
     cross_axis_tilt: float = 0.0
diff --git a/pvlib/tracking.py b/pvlib/tracking.py
--- a/pvlib/tracking.py
+++ b/pvlib/tracking.py
@@ -44,12 +44,21 @@ def singleaxis(apparent_zenith, apparent_azimuth,
         A value denoting the compass direction along which the axis of
         rotation lies. Measured in decimal degrees east of north.
 
-    max_angle : float, default 90
+    max_angle : float or tuple, default 90
         A value denoting the maximum rotation angle, in decimal degrees,
         of the one-axis tracker from its horizontal position (horizontal
-        if axis_tilt = 0). A max_angle of 90 degrees allows the tracker
-        to rotate to a vertical position to point the panel towards a
-        horizon. max_angle of 180 degrees allows for full rotation.
+        if axis_tilt = 0). If a float is provided, it represents the maximum
+        rotation angle, and the minimum rotation angle is assumed to be the
+        opposite of the maximum angle. If a tuple of (min_angle, max_angle) is
+        provided, it represents both the minimum and maximum rotation angles.
+
+        A rotation to 'max_angle' is a counter-clockwise rotation about the
+        y-axis of the tracker coordinate system. For example, for a tracker
+        with 'axis_azimuth' oriented to the south, a rotation to 'max_angle'
+        is towards the west, and a rotation toward 'min_angle' is in the
+        opposite direction, toward the east. Hence a max_angle of 180 degrees
+        (equivalent to max_angle = (-180, 180)) allows the tracker to achieve
+        its full rotation capability.
 
     backtrack : bool, default True
         Controls whether the tracker has the capability to "backtrack"
@@ -190,7 +199,16 @@ def singleaxis(apparent_zenith, apparent_azimuth,
 
     # NOTE: max_angle defined relative to zero-point rotation, not the
     # system-plane normal
-    tracker_theta = np.clip(tracker_theta, -max_angle, max_angle)
+
+    # Determine minimum and maximum rotation angles based on max_angle.
+    # If max_angle is a single value, assume min_angle is the negative.
+    if np.isscalar(max_angle):
+        min_angle = -max_angle
+    else:
+        min_angle, max_angle = max_angle
+
+    # Clip tracker_theta between the minimum and maximum angles.
+    tracker_theta = np.clip(tracker_theta, min_angle, max_angle)
 
     # Calculate auxiliary angles
     surface = calc_surface_orientation(tracker_theta, axis_tilt, axis_azimuth)


Test patch:
diff --git a/pvlib/tests/test_pvsystem.py b/pvlib/tests/test_pvsystem.py
--- a/pvlib/tests/test_pvsystem.py
+++ b/pvlib/tests/test_pvsystem.py
@@ -2422,6 +2422,15 @@ def test_SingleAxisTrackerMount_get_orientation(single_axis_tracker_mount):
         assert actual[key] == pytest.approx(expected_value), err_msg
 
 
+def test_SingleAxisTrackerMount_get_orientation_asymmetric_max():
+    mount = pvsystem.SingleAxisTrackerMount(max_angle=(-30, 45))
+    expected = {'surface_tilt': [45, 30], 'surface_azimuth': [90, 270]}
+    actual = mount.get_orientation([60, 60], [90, 270])
+    for key, expected_value in expected.items():
+        err_msg = f"{key} value incorrect"
+        assert actual[key] == pytest.approx(expected_value), err_msg
+
+
 def test_dc_ohms_from_percent():
     expected = .1425
     out = pvsystem.dc_ohms_from_percent(38, 8, 3, 1, 1)
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
@@ -151,6 +151,22 @@ def test_max_angle():
     assert_frame_equal(expect, tracker_data)
 
 
+def test_min_angle():
+    apparent_zenith = pd.Series([60])
+    apparent_azimuth = pd.Series([270])
+    tracker_data = tracking.singleaxis(apparent_zenith, apparent_azimuth,
+                                       axis_tilt=0, axis_azimuth=0,
+                                       max_angle=(-45, 50), backtrack=True,
+                                       gcr=2.0/7.0)
+
+    expect = pd.DataFrame({'aoi': 15, 'surface_azimuth': 270,
+                           'surface_tilt': 45, 'tracker_theta': -45},
+                          index=[0], dtype=np.float64)
+    expect = expect[SINGLEAXIS_COL_ORDER]
+
+    assert_frame_equal(expect, tracker_data)
+
+
 def test_backtrack():
     apparent_zenith = pd.Series([80])
     apparent_azimuth = pd.Series([90])