Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/sphinx/source/whatsnew/v0.15.1.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 16 additions & 4 deletions pvlib/tracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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

Expand Down
83 changes: 67 additions & 16 deletions tests/test_tracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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,
Expand All @@ -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():
Expand All @@ -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():
Expand All @@ -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():
Expand All @@ -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,
Expand All @@ -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():
Expand All @@ -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,
Expand All @@ -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():
Expand All @@ -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])
Expand All @@ -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():
Expand All @@ -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():
Expand All @@ -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():
Expand All @@ -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():
Expand Down Expand Up @@ -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)
Expand Down