From ca6be4472b6db99bb02c25542a8f7cecbc474862 Mon Sep 17 00:00:00 2001 From: r0hansaxena Date: Wed, 4 Mar 2026 18:02:48 +0530 Subject: [PATCH] Add is_backtracking and is_at_limit outputs to singleaxis Add two boolean output columns to pvlib.tracking.singleaxis: - is_backtracking: True when tracker is in backtracking mode - is_at_limit: True when rotation angle is clipped to max/min Closes #2672 --- docs/sphinx/source/whatsnew/v0.15.1.rst | 3 + pvlib/tracking.py | 20 ++++-- tests/test_tracking.py | 83 ++++++++++++++++++++----- 3 files changed, 86 insertions(+), 20 deletions(-) diff --git a/docs/sphinx/source/whatsnew/v0.15.1.rst b/docs/sphinx/source/whatsnew/v0.15.1.rst index 83b13d01f0..70d55c91e1 100644 --- a/docs/sphinx/source/whatsnew/v0.15.1.rst +++ b/docs/sphinx/source/whatsnew/v0.15.1.rst @@ -20,6 +20,9 @@ Bug fixes Enhancements ~~~~~~~~~~~~ +* Add ``is_backtracking`` and ``is_at_limit`` boolean outputs to + :py:func:`pvlib.tracking.singleaxis` + (:issue:`2672`, :ghuser:`r0hansaxena`) Documentation diff --git a/pvlib/tracking.py b/pvlib/tracking.py index ae50a5bc3f..b73913994b 100644 --- a/pvlib/tracking.py +++ b/pvlib/tracking.py @@ -103,6 +103,10 @@ def singleaxis(apparent_zenith, solar_azimuth, * `surface_azimuth`: The azimuth of the rotated panel, determined by projecting the vector normal to the panel's surface to the earth's surface. [degrees] + * `is_backtracking`: Boolean indicator of whether the tracker is + in backtracking mode to avoid row-to-row shading. + * `is_at_limit`: Boolean indicator of whether the rotation angle + has been clipped to ``max_angle`` or its opposite. See also -------- @@ -172,11 +176,14 @@ def singleaxis(apparent_zenith, solar_azimuth, # there's no row-to-row shade to avoid, & backtracking is unnecessary # [1], Eqs. 15-16 with np.errstate(invalid='ignore'): + _backtracking_mask = temp < 1 tracker_theta = omega_ideal + np.where( - temp < 1, omega_correction, + _backtracking_mask, omega_correction, 0) + is_backtracking = _backtracking_mask else: tracker_theta = omega_ideal + is_backtracking = np.full_like(omega_ideal, False, dtype=bool) # NOTE: max_angle defined relative to zero-point rotation, not the # system-plane normal @@ -189,7 +196,9 @@ def singleaxis(apparent_zenith, solar_azimuth, min_angle, max_angle = max_angle # Clip tracker_theta between the minimum and maximum angles. + tracker_theta_unclipped = tracker_theta.copy() tracker_theta = np.clip(tracker_theta, min_angle, max_angle) + is_at_limit = tracker_theta_unclipped != tracker_theta # Calculate auxiliary angles surface = calc_surface_orientation(tracker_theta, axis_tilt, axis_azimuth) @@ -200,12 +209,15 @@ def singleaxis(apparent_zenith, solar_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_azimuth': surface_azimuth, 'surface_tilt': surface_tilt, + 'is_backtracking': is_backtracking, 'is_at_limit': is_at_limit} if index is not None: out = pd.DataFrame(out, index=index) - out[zen_gt_90] = np.nan + out.loc[zen_gt_90, ['tracker_theta', 'aoi', + 'surface_azimuth', 'surface_tilt']] = np.nan else: - out = {k: np.where(zen_gt_90, np.nan, v) for k, v in out.items()} + for k in ['tracker_theta', 'aoi', 'surface_azimuth', 'surface_tilt']: + out[k] = np.where(zen_gt_90, np.nan, out[k]) return out diff --git a/tests/test_tracking.py b/tests/test_tracking.py index 9a7ee98bef..beced7bfd4 100644 --- a/tests/test_tracking.py +++ b/tests/test_tracking.py @@ -27,7 +27,7 @@ def test_solar_noon(): index=index, dtype=np.float64) expect = expect[SINGLEAXIS_COL_ORDER] - assert_frame_equal(expect, tracker_data) + assert_frame_equal(expect, tracker_data[SINGLEAXIS_COL_ORDER]) def test_scalars(): @@ -86,7 +86,7 @@ def test_nans(): [nan, nan, nan, nan], [nan, nan, nan, nan]]), columns=['tracker_theta', 'aoi', 'surface_azimuth', 'surface_tilt']) - assert_frame_equal(tracker_data, expect) + assert_frame_equal(tracker_data[SINGLEAXIS_COL_ORDER], expect) def test_arrays_multi(): @@ -122,7 +122,7 @@ def test_azimuth_north_south(): index=[0], dtype=np.float64) expect = expect[SINGLEAXIS_COL_ORDER] - assert_frame_equal(expect, tracker_data) + assert_frame_equal(expect, tracker_data[SINGLEAXIS_COL_ORDER]) tracker_data = tracking.singleaxis(apparent_zenith, apparent_azimuth, axis_tilt=0, axis_azimuth=0, @@ -131,7 +131,7 @@ def test_azimuth_north_south(): expect['tracker_theta'] *= -1 - assert_frame_equal(expect, tracker_data) + assert_frame_equal(expect, tracker_data[SINGLEAXIS_COL_ORDER]) def test_max_angle(): @@ -147,7 +147,7 @@ def test_max_angle(): index=[0], dtype=np.float64) expect = expect[SINGLEAXIS_COL_ORDER] - assert_frame_equal(expect, tracker_data) + assert_frame_equal(expect, tracker_data[SINGLEAXIS_COL_ORDER]) def test_min_angle(): @@ -163,7 +163,7 @@ def test_min_angle(): index=[0], dtype=np.float64) expect = expect[SINGLEAXIS_COL_ORDER] - assert_frame_equal(expect, tracker_data) + assert_frame_equal(expect, tracker_data[SINGLEAXIS_COL_ORDER]) def test_backtrack(): @@ -180,7 +180,7 @@ def test_backtrack(): index=[0], dtype=np.float64) expect = expect[SINGLEAXIS_COL_ORDER] - assert_frame_equal(expect, tracker_data) + assert_frame_equal(expect, tracker_data[SINGLEAXIS_COL_ORDER]) tracker_data = tracking.singleaxis(apparent_zenith, apparent_azimuth, axis_tilt=0, axis_azimuth=0, @@ -192,7 +192,7 @@ def test_backtrack(): index=[0], dtype=np.float64) expect = expect[SINGLEAXIS_COL_ORDER] - assert_frame_equal(expect, tracker_data) + assert_frame_equal(expect, tracker_data[SINGLEAXIS_COL_ORDER]) def test_axis_tilt(): @@ -210,7 +210,7 @@ def test_axis_tilt(): index=[0], dtype=np.float64) expect = expect[SINGLEAXIS_COL_ORDER] - assert_frame_equal(expect, tracker_data) + assert_frame_equal(expect, tracker_data[SINGLEAXIS_COL_ORDER]) tracker_data = tracking.singleaxis(apparent_zenith, apparent_azimuth, axis_tilt=30, axis_azimuth=0, @@ -222,7 +222,7 @@ def test_axis_tilt(): index=[0], dtype=np.float64) expect = expect[SINGLEAXIS_COL_ORDER] - assert_frame_equal(expect, tracker_data) + assert_frame_equal(expect, tracker_data[SINGLEAXIS_COL_ORDER]) def test_axis_azimuth(): @@ -239,7 +239,7 @@ def test_axis_azimuth(): index=[0], dtype=np.float64) expect = expect[SINGLEAXIS_COL_ORDER] - assert_frame_equal(expect, tracker_data) + assert_frame_equal(expect, tracker_data[SINGLEAXIS_COL_ORDER]) apparent_zenith = pd.Series([30]) apparent_azimuth = pd.Series([180]) @@ -254,7 +254,7 @@ def test_axis_azimuth(): index=[0], dtype=np.float64) expect = expect[SINGLEAXIS_COL_ORDER] - assert_frame_equal(expect, tracker_data) + assert_frame_equal(expect, tracker_data[SINGLEAXIS_COL_ORDER]) def test_horizon_flat(): @@ -272,7 +272,7 @@ def test_horizon_flat(): [ 0., 45., 270., 0.], [ nan, nan, nan, nan]]), columns=['tracker_theta', 'aoi', 'surface_azimuth', 'surface_tilt']) - assert_frame_equal(out, expected) + assert_frame_equal(out[SINGLEAXIS_COL_ORDER], expected) def test_horizon_tilted(): @@ -288,7 +288,7 @@ def test_horizon_tilted(): [ 0., 45., 180., 90.], [ 179., 45., 359., 90.]]), columns=['tracker_theta', 'aoi', 'surface_azimuth', 'surface_tilt']) - assert_frame_equal(out, expected) + assert_frame_equal(out[SINGLEAXIS_COL_ORDER], expected) def test_low_sun_angles(): @@ -301,8 +301,8 @@ def test_low_sun_angles(): '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) + for k, v in expected.items(): + assert_allclose(v, result[k]) def test_calc_axis_tilt(): @@ -389,6 +389,57 @@ def test_slope_aware_backtracking(): check_less_precise=True) +def test_singleaxis_backtracking_flag(): + """Test that is_backtracking is True when backtracking is active.""" + # low sun angle that triggers backtracking + apparent_zenith = pd.Series([80]) + apparent_azimuth = pd.Series([90]) + + # with backtrack=True and a low sun angle, backtracking should be active + tracker_data = tracking.singleaxis(apparent_zenith, apparent_azimuth, + axis_tilt=0, axis_azimuth=0, + max_angle=90, backtrack=True, + gcr=2.0/7.0) + assert tracker_data['is_backtracking'].iloc[0] is np.bool_(True) + + # with backtrack=False, is_backtracking should always be False + tracker_data = tracking.singleaxis(apparent_zenith, apparent_azimuth, + axis_tilt=0, axis_azimuth=0, + max_angle=90, backtrack=False, + gcr=2.0/7.0) + assert tracker_data['is_backtracking'].iloc[0] is np.bool_(False) + + # at solar noon (small zenith), backtracking should not be active + index = pd.date_range(start='20180701T1200', freq='1s', periods=1) + apparent_zenith_noon = pd.Series([10], index=index) + apparent_azimuth_noon = pd.Series([180], index=index) + tracker_data = tracking.singleaxis(apparent_zenith_noon, + apparent_azimuth_noon, + axis_tilt=0, axis_azimuth=0, + max_angle=90, backtrack=True, + gcr=2.0/7.0) + assert tracker_data['is_backtracking'].iloc[0] is np.bool_(False) + + +def test_singleaxis_at_limit_flag(): + """Test that is_at_limit is True when angle is clipped.""" + # 60 degree ideal angle clipped to 45 degree max + apparent_zenith = pd.Series([60]) + apparent_azimuth = pd.Series([90]) + tracker_data = tracking.singleaxis(apparent_zenith, apparent_azimuth, + axis_tilt=0, axis_azimuth=0, + max_angle=45, backtrack=True, + gcr=2.0/7.0) + assert tracker_data['is_at_limit'].iloc[0] is np.bool_(True) + + # 60 degree ideal angle within 90 degree max — should not be clipped + tracker_data = tracking.singleaxis(apparent_zenith, apparent_azimuth, + axis_tilt=0, axis_azimuth=0, + max_angle=90, backtrack=True, + gcr=2.0/7.0) + assert tracker_data['is_at_limit'].iloc[0] is np.bool_(False) + + def test_singleaxis_aoi_gh1221(): # vertical tracker loc = pvlib.location.Location(40.1134, -88.3695)