Skip to content

Commit ca6be44

Browse files
committed
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
1 parent dce21b5 commit ca6be44

3 files changed

Lines changed: 86 additions & 20 deletions

File tree

docs/sphinx/source/whatsnew/v0.15.1.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ Bug fixes
2020

2121
Enhancements
2222
~~~~~~~~~~~~
23+
* Add ``is_backtracking`` and ``is_at_limit`` boolean outputs to
24+
:py:func:`pvlib.tracking.singleaxis`
25+
(:issue:`2672`, :ghuser:`r0hansaxena`)
2326

2427

2528
Documentation

pvlib/tracking.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ def singleaxis(apparent_zenith, solar_azimuth,
103103
* `surface_azimuth`: The azimuth of the rotated panel, determined by
104104
projecting the vector normal to the panel's surface to the earth's
105105
surface. [degrees]
106+
* `is_backtracking`: Boolean indicator of whether the tracker is
107+
in backtracking mode to avoid row-to-row shading.
108+
* `is_at_limit`: Boolean indicator of whether the rotation angle
109+
has been clipped to ``max_angle`` or its opposite.
106110
107111
See also
108112
--------
@@ -172,11 +176,14 @@ def singleaxis(apparent_zenith, solar_azimuth,
172176
# there's no row-to-row shade to avoid, & backtracking is unnecessary
173177
# [1], Eqs. 15-16
174178
with np.errstate(invalid='ignore'):
179+
_backtracking_mask = temp < 1
175180
tracker_theta = omega_ideal + np.where(
176-
temp < 1, omega_correction,
181+
_backtracking_mask, omega_correction,
177182
0)
183+
is_backtracking = _backtracking_mask
178184
else:
179185
tracker_theta = omega_ideal
186+
is_backtracking = np.full_like(omega_ideal, False, dtype=bool)
180187

181188
# NOTE: max_angle defined relative to zero-point rotation, not the
182189
# system-plane normal
@@ -189,7 +196,9 @@ def singleaxis(apparent_zenith, solar_azimuth,
189196
min_angle, max_angle = max_angle
190197

191198
# Clip tracker_theta between the minimum and maximum angles.
199+
tracker_theta_unclipped = tracker_theta.copy()
192200
tracker_theta = np.clip(tracker_theta, min_angle, max_angle)
201+
is_at_limit = tracker_theta_unclipped != tracker_theta
193202

194203
# Calculate auxiliary angles
195204
surface = calc_surface_orientation(tracker_theta, axis_tilt, axis_azimuth)
@@ -200,12 +209,15 @@ def singleaxis(apparent_zenith, solar_azimuth,
200209

201210
# Bundle DataFrame for return values and filter for sun below horizon.
202211
out = {'tracker_theta': tracker_theta, 'aoi': aoi,
203-
'surface_azimuth': surface_azimuth, 'surface_tilt': surface_tilt}
212+
'surface_azimuth': surface_azimuth, 'surface_tilt': surface_tilt,
213+
'is_backtracking': is_backtracking, 'is_at_limit': is_at_limit}
204214
if index is not None:
205215
out = pd.DataFrame(out, index=index)
206-
out[zen_gt_90] = np.nan
216+
out.loc[zen_gt_90, ['tracker_theta', 'aoi',
217+
'surface_azimuth', 'surface_tilt']] = np.nan
207218
else:
208-
out = {k: np.where(zen_gt_90, np.nan, v) for k, v in out.items()}
219+
for k in ['tracker_theta', 'aoi', 'surface_azimuth', 'surface_tilt']:
220+
out[k] = np.where(zen_gt_90, np.nan, out[k])
209221

210222
return out
211223

tests/test_tracking.py

Lines changed: 67 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def test_solar_noon():
2727
index=index, dtype=np.float64)
2828
expect = expect[SINGLEAXIS_COL_ORDER]
2929

30-
assert_frame_equal(expect, tracker_data)
30+
assert_frame_equal(expect, tracker_data[SINGLEAXIS_COL_ORDER])
3131

3232

3333
def test_scalars():
@@ -86,7 +86,7 @@ def test_nans():
8686
[nan, nan, nan, nan],
8787
[nan, nan, nan, nan]]),
8888
columns=['tracker_theta', 'aoi', 'surface_azimuth', 'surface_tilt'])
89-
assert_frame_equal(tracker_data, expect)
89+
assert_frame_equal(tracker_data[SINGLEAXIS_COL_ORDER], expect)
9090

9191

9292
def test_arrays_multi():
@@ -122,7 +122,7 @@ def test_azimuth_north_south():
122122
index=[0], dtype=np.float64)
123123
expect = expect[SINGLEAXIS_COL_ORDER]
124124

125-
assert_frame_equal(expect, tracker_data)
125+
assert_frame_equal(expect, tracker_data[SINGLEAXIS_COL_ORDER])
126126

127127
tracker_data = tracking.singleaxis(apparent_zenith, apparent_azimuth,
128128
axis_tilt=0, axis_azimuth=0,
@@ -131,7 +131,7 @@ def test_azimuth_north_south():
131131

132132
expect['tracker_theta'] *= -1
133133

134-
assert_frame_equal(expect, tracker_data)
134+
assert_frame_equal(expect, tracker_data[SINGLEAXIS_COL_ORDER])
135135

136136

137137
def test_max_angle():
@@ -147,7 +147,7 @@ def test_max_angle():
147147
index=[0], dtype=np.float64)
148148
expect = expect[SINGLEAXIS_COL_ORDER]
149149

150-
assert_frame_equal(expect, tracker_data)
150+
assert_frame_equal(expect, tracker_data[SINGLEAXIS_COL_ORDER])
151151

152152

153153
def test_min_angle():
@@ -163,7 +163,7 @@ def test_min_angle():
163163
index=[0], dtype=np.float64)
164164
expect = expect[SINGLEAXIS_COL_ORDER]
165165

166-
assert_frame_equal(expect, tracker_data)
166+
assert_frame_equal(expect, tracker_data[SINGLEAXIS_COL_ORDER])
167167

168168

169169
def test_backtrack():
@@ -180,7 +180,7 @@ def test_backtrack():
180180
index=[0], dtype=np.float64)
181181
expect = expect[SINGLEAXIS_COL_ORDER]
182182

183-
assert_frame_equal(expect, tracker_data)
183+
assert_frame_equal(expect, tracker_data[SINGLEAXIS_COL_ORDER])
184184

185185
tracker_data = tracking.singleaxis(apparent_zenith, apparent_azimuth,
186186
axis_tilt=0, axis_azimuth=0,
@@ -192,7 +192,7 @@ def test_backtrack():
192192
index=[0], dtype=np.float64)
193193
expect = expect[SINGLEAXIS_COL_ORDER]
194194

195-
assert_frame_equal(expect, tracker_data)
195+
assert_frame_equal(expect, tracker_data[SINGLEAXIS_COL_ORDER])
196196

197197

198198
def test_axis_tilt():
@@ -210,7 +210,7 @@ def test_axis_tilt():
210210
index=[0], dtype=np.float64)
211211
expect = expect[SINGLEAXIS_COL_ORDER]
212212

213-
assert_frame_equal(expect, tracker_data)
213+
assert_frame_equal(expect, tracker_data[SINGLEAXIS_COL_ORDER])
214214

215215
tracker_data = tracking.singleaxis(apparent_zenith, apparent_azimuth,
216216
axis_tilt=30, axis_azimuth=0,
@@ -222,7 +222,7 @@ def test_axis_tilt():
222222
index=[0], dtype=np.float64)
223223
expect = expect[SINGLEAXIS_COL_ORDER]
224224

225-
assert_frame_equal(expect, tracker_data)
225+
assert_frame_equal(expect, tracker_data[SINGLEAXIS_COL_ORDER])
226226

227227

228228
def test_axis_azimuth():
@@ -239,7 +239,7 @@ def test_axis_azimuth():
239239
index=[0], dtype=np.float64)
240240
expect = expect[SINGLEAXIS_COL_ORDER]
241241

242-
assert_frame_equal(expect, tracker_data)
242+
assert_frame_equal(expect, tracker_data[SINGLEAXIS_COL_ORDER])
243243

244244
apparent_zenith = pd.Series([30])
245245
apparent_azimuth = pd.Series([180])
@@ -254,7 +254,7 @@ def test_axis_azimuth():
254254
index=[0], dtype=np.float64)
255255
expect = expect[SINGLEAXIS_COL_ORDER]
256256

257-
assert_frame_equal(expect, tracker_data)
257+
assert_frame_equal(expect, tracker_data[SINGLEAXIS_COL_ORDER])
258258

259259

260260
def test_horizon_flat():
@@ -272,7 +272,7 @@ def test_horizon_flat():
272272
[ 0., 45., 270., 0.],
273273
[ nan, nan, nan, nan]]),
274274
columns=['tracker_theta', 'aoi', 'surface_azimuth', 'surface_tilt'])
275-
assert_frame_equal(out, expected)
275+
assert_frame_equal(out[SINGLEAXIS_COL_ORDER], expected)
276276

277277

278278
def test_horizon_tilted():
@@ -288,7 +288,7 @@ def test_horizon_tilted():
288288
[ 0., 45., 180., 90.],
289289
[ 179., 45., 359., 90.]]),
290290
columns=['tracker_theta', 'aoi', 'surface_azimuth', 'surface_tilt'])
291-
assert_frame_equal(out, expected)
291+
assert_frame_equal(out[SINGLEAXIS_COL_ORDER], expected)
292292

293293

294294
def test_low_sun_angles():
@@ -301,8 +301,8 @@ def test_low_sun_angles():
301301
'aoi': np.array([80.420987]),
302302
'surface_azimuth': np.array([253.897886]),
303303
'surface_tilt': np.array([64.341094])}
304-
for k, v in result.items():
305-
assert_allclose(expected[k], v)
304+
for k, v in expected.items():
305+
assert_allclose(v, result[k])
306306

307307

308308
def test_calc_axis_tilt():
@@ -389,6 +389,57 @@ def test_slope_aware_backtracking():
389389
check_less_precise=True)
390390

391391

392+
def test_singleaxis_backtracking_flag():
393+
"""Test that is_backtracking is True when backtracking is active."""
394+
# low sun angle that triggers backtracking
395+
apparent_zenith = pd.Series([80])
396+
apparent_azimuth = pd.Series([90])
397+
398+
# with backtrack=True and a low sun angle, backtracking should be active
399+
tracker_data = tracking.singleaxis(apparent_zenith, apparent_azimuth,
400+
axis_tilt=0, axis_azimuth=0,
401+
max_angle=90, backtrack=True,
402+
gcr=2.0/7.0)
403+
assert tracker_data['is_backtracking'].iloc[0] is np.bool_(True)
404+
405+
# with backtrack=False, is_backtracking should always be False
406+
tracker_data = tracking.singleaxis(apparent_zenith, apparent_azimuth,
407+
axis_tilt=0, axis_azimuth=0,
408+
max_angle=90, backtrack=False,
409+
gcr=2.0/7.0)
410+
assert tracker_data['is_backtracking'].iloc[0] is np.bool_(False)
411+
412+
# at solar noon (small zenith), backtracking should not be active
413+
index = pd.date_range(start='20180701T1200', freq='1s', periods=1)
414+
apparent_zenith_noon = pd.Series([10], index=index)
415+
apparent_azimuth_noon = pd.Series([180], index=index)
416+
tracker_data = tracking.singleaxis(apparent_zenith_noon,
417+
apparent_azimuth_noon,
418+
axis_tilt=0, axis_azimuth=0,
419+
max_angle=90, backtrack=True,
420+
gcr=2.0/7.0)
421+
assert tracker_data['is_backtracking'].iloc[0] is np.bool_(False)
422+
423+
424+
def test_singleaxis_at_limit_flag():
425+
"""Test that is_at_limit is True when angle is clipped."""
426+
# 60 degree ideal angle clipped to 45 degree max
427+
apparent_zenith = pd.Series([60])
428+
apparent_azimuth = pd.Series([90])
429+
tracker_data = tracking.singleaxis(apparent_zenith, apparent_azimuth,
430+
axis_tilt=0, axis_azimuth=0,
431+
max_angle=45, backtrack=True,
432+
gcr=2.0/7.0)
433+
assert tracker_data['is_at_limit'].iloc[0] is np.bool_(True)
434+
435+
# 60 degree ideal angle within 90 degree max — should not be clipped
436+
tracker_data = tracking.singleaxis(apparent_zenith, apparent_azimuth,
437+
axis_tilt=0, axis_azimuth=0,
438+
max_angle=90, backtrack=True,
439+
gcr=2.0/7.0)
440+
assert tracker_data['is_at_limit'].iloc[0] is np.bool_(False)
441+
442+
392443
def test_singleaxis_aoi_gh1221():
393444
# vertical tracker
394445
loc = pvlib.location.Location(40.1134, -88.3695)

0 commit comments

Comments
 (0)