Skip to content

Commit 36f2076

Browse files
cwhansekandersolar
andauthored
calculate surface angles using unit normal (#2702)
* calculate surface angles using unit normal * formatting * more formatting * comments from review * change axis_azimuth default when surface_tilt=0 * subtract 90, not add 90 * clarify returned shape, fix a missing assert * whatsnew * line length * Update docs/sphinx/source/whatsnew/v0.15.1.rst Co-authored-by: Kevin Anderson <kevin.anderso@gmail.com> --------- Co-authored-by: Kevin Anderson <kevin.anderso@gmail.com>
1 parent 44cb79f commit 36f2076

3 files changed

Lines changed: 172 additions & 37 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
@@ -38,6 +38,9 @@ Enhancements
3838
* Accelerate the internals of :py:func:`~pvlib.solarpostion.ephemeris`. (:pull:`2626`)
3939
* Accelerate the intervals of :py:func:`~pvlib.pvsystem.singlediode` when
4040
`method='lambertw'`. (:pull:`2732`, :pull:`2723`)
41+
* :py:func:`~pvlib.tracking.singleaxis` accepts negative values for
42+
parameter `axis_tilt`. (:pull:`2702`, :issue:`1976`)
43+
4144

4245
Documentation
4346
~~~~~~~~~~~~~

pvlib/tracking.py

Lines changed: 73 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,10 @@ def singleaxis(apparent_zenith, solar_azimuth,
4343
4444
axis_tilt : float, default 0
4545
The tilt of the axis of rotation (i.e, the y-axis defined by
46-
``axis_azimuth``) with respect to horizontal.
47-
``axis_tilt`` must be >= 0 and <= 90. [degrees]
46+
``axis_azimuth``) with respect to horizontal (degrees). Positive
47+
``axis_tilt`` is *downward* in the direction of ``axis_azimuth``. For
48+
example, for a tracker with ``axis_azimuth``=180 and ``axis_tilt``=10,
49+
the north end is higher than the south end of the axis.
4850
4951
axis_azimuth : float, default 0
5052
A value denoting the compass direction along which the axis of
@@ -62,9 +64,7 @@ def singleaxis(apparent_zenith, solar_azimuth,
6264
y-axis of the tracker coordinate system. For example, for a tracker
6365
with ``axis_azimuth`` oriented to the south, a rotation to
6466
``max_angle`` is towards the west, and a rotation toward ``-max_angle``
65-
is in the opposite direction, toward the east. Hence, a ``max_angle``
66-
of 180 degrees (equivalent to max_angle = (-180, 180)) allows the
67-
tracker to achieve its full rotation capability.
67+
is in the opposite direction, toward the east.
6868
6969
backtrack : bool, default True
7070
Controls whether the tracker has the capability to "backtrack"
@@ -84,7 +84,7 @@ def singleaxis(apparent_zenith, solar_azimuth,
8484
intersection between the slope containing the tracker axes and a plane
8585
perpendicular to the tracker axes. The cross-axis tilt should be
8686
specified using a right-handed convention. For example, trackers with
87-
axis azimuth of 180 degrees (heading south) will have a negative
87+
``axis_azimuth`` of 180 degrees (heading south) will have a negative
8888
cross-axis tilt if the tracker axes plane slopes down to the east and
8989
positive cross-axis tilt if the tracker axes plane slopes down to the
9090
west. Use :func:`~pvlib.tracking.calc_cross_axis_tilt` to calculate
@@ -100,9 +100,10 @@ def singleaxis(apparent_zenith, solar_azimuth,
100100
rotated panel surface. [degrees]
101101
* `surface_tilt`: The angle between the panel surface and the earth
102102
surface, accounting for panel rotation. [degrees]
103-
* `surface_azimuth`: The azimuth of the rotated panel, determined by
104-
projecting the vector normal to the panel's surface to the earth's
105-
surface. [degrees]
103+
* `surface_azimuth`: The azimuth of the rotated panel (degrees),
104+
determined by projecting the vector normal to the panel's surface to
105+
the earth's surface. Where ``surface_tilt``=0, ``surface_azimuth``
106+
is set equal to ``axis_azimuth`` - 90.
106107
107108
See also
108109
--------
@@ -210,6 +211,48 @@ def singleaxis(apparent_zenith, solar_azimuth,
210211
return out
211212

212213

214+
def _unit_normal(axis_azimuth, axis_tilt, theta):
215+
"""
216+
Unit normal to rotated tracker surface, in global E-N-Up coordinates,
217+
given by R*(0, 0, 1).T, where:
218+
219+
R = Rz(-axis_azimuth) Rx(-axis_tilt) Ry(theta)
220+
221+
Rz is a rotation by -axis_azimuth about the z-axis (axis_azimuth
222+
is negated to convert from an azimuth angle to a rotation angle). Rx is a
223+
rotation by -axis_tilt about the x-axis, where axis_tilt is negated
224+
because pvlib's convention is that the positive y-axis is tilted
225+
downwards. Ry is a rotation by theta about the y-axis.
226+
227+
Parameters
228+
----------
229+
axis_azimuth : scalar
230+
axis_tilt : scalar
231+
theta : scalar or array-like
232+
233+
Returns
234+
-------
235+
ndarray
236+
Shape (N,3) where theta has length N
237+
"""
238+
239+
theta = np.asarray(theta)
240+
241+
cA, sA = cosd(-axis_azimuth), sind(-axis_azimuth)
242+
cT, sT = cosd(-axis_tilt), sind(-axis_tilt)
243+
244+
cTh = cosd(theta)
245+
sTh = sind(theta)
246+
247+
x = sA * sT * cTh + cA * sTh
248+
y = sA * sTh - cA * sT * cTh
249+
z = cT * cTh
250+
251+
result = np.column_stack((x, y, z))
252+
253+
return result
254+
255+
213256
def calc_surface_orientation(tracker_theta, axis_tilt=0, axis_azimuth=0):
214257
"""
215258
Calculate the surface tilt and azimuth angles for a given tracker rotation.
@@ -223,8 +266,7 @@ def calc_surface_orientation(tracker_theta, axis_tilt=0, axis_azimuth=0):
223266
results in ``surface_azimuth`` to the West while ``tracker_theta < 0``
224267
results in ``surface_azimuth`` to the East. [degree]
225268
axis_tilt : float, default 0
226-
The tilt of the axis of rotation with respect to horizontal.
227-
``axis_tilt`` must be >= 0 and <= 90. [degree]
269+
The tilt of the axis of rotation with respect to horizontal. [degree]
228270
axis_azimuth : float, default 0
229271
A value denoting the compass direction along which the axis of
230272
rotation lies. Measured east of north. [degree]
@@ -234,27 +276,32 @@ def calc_surface_orientation(tracker_theta, axis_tilt=0, axis_azimuth=0):
234276
dict or DataFrame
235277
Contains keys ``'surface_tilt'`` and ``'surface_azimuth'`` representing
236278
the module orientation accounting for tracker rotation and axis
237-
orientation. [degree]
279+
orientation (degree).
280+
Where ``surface_tilt``=0, ``surface_azimuth`` is set equal to
281+
``axis_azimuth`` - 90.
238282
239283
References
240284
----------
241285
.. [1] William F. Marion and Aron P. Dobos, "Rotation Angle for the Optimum
242286
Tracking of One-Axis Trackers", Technical Report NREL/TP-6A20-58891,
243287
July 2013. :doi:`10.2172/1089596`
244288
"""
289+
# from [1], Eq. 1
245290
with np.errstate(invalid='ignore', divide='ignore'):
246291
surface_tilt = acosd(cosd(tracker_theta) * cosd(axis_tilt))
247292

248-
# clip(..., -1, +1) to prevent arcsin(1 + epsilon) issues:
249-
azimuth_delta = asind(np.clip(sind(tracker_theta) / sind(surface_tilt),
250-
a_min=-1, a_max=1))
251-
# Combine Eqs 2, 3, and 4:
252-
azimuth_delta = np.where(abs(tracker_theta) < 90,
253-
azimuth_delta,
254-
-azimuth_delta + np.sign(tracker_theta) * 180)
255-
# handle surface_tilt=0 case:
256-
azimuth_delta = np.where(sind(surface_tilt) != 0, azimuth_delta, 90)
257-
surface_azimuth = (axis_azimuth + azimuth_delta) % 360
293+
# for surface azimuth deviate from [1] to allow for negative tilt.
294+
# unit normal to rotated tracker surface
295+
unit_normal = _unit_normal(axis_azimuth, axis_tilt, tracker_theta)
296+
297+
# project unit_normal to x-y plane to calculate azimuth
298+
surface_azimuth = np.degrees(
299+
np.arctan2(unit_normal[:, 0], unit_normal[:, 1]))
300+
301+
surface_azimuth = np.where(surface_tilt == 0., axis_azimuth - 90.,
302+
surface_azimuth)
303+
# constrain angles to [0, 360)
304+
surface_azimuth = np.mod(surface_azimuth, 360.0)
258305

259306
out = {
260307
'surface_tilt': surface_tilt,
@@ -378,15 +425,14 @@ def calc_cross_axis_tilt(
378425
----------
379426
slope_azimuth : float
380427
direction of the normal to the slope containing the tracker axes, when
381-
projected on the horizontal [degrees]
428+
projected on the horizontal. [degrees]
382429
slope_tilt : float
383-
angle of the slope containing the tracker axes, relative to horizontal
430+
angle of the slope containing the tracker axes, relative to horizontal.
384431
[degrees]
385432
axis_azimuth : float
386-
direction of tracker axes projected on the horizontal [degrees]
433+
direction of tracker axes projected on the horizontal. [degrees]
387434
axis_tilt : float
388-
tilt of trackers relative to horizontal. ``axis_tilt`` must be >= 0
389-
and <= 90. [degree]
435+
tilt of trackers relative to horizontal. [degree]
390436
391437
Returns
392438
-------

tests/test_tracking.py

Lines changed: 96 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def test_solar_noon():
2323
gcr=2.0/7.0)
2424

2525
expect = pd.DataFrame({'tracker_theta': 0, 'aoi': 10,
26-
'surface_azimuth': 90, 'surface_tilt': 0},
26+
'surface_azimuth': 270, 'surface_tilt': 0},
2727
index=index, dtype=np.float64)
2828
expect = expect[SINGLEAXIS_COL_ORDER]
2929

@@ -38,7 +38,7 @@ def test_scalars():
3838
max_angle=90, backtrack=True,
3939
gcr=2.0/7.0)
4040
assert isinstance(tracker_data, dict)
41-
expect = {'tracker_theta': 0, 'aoi': 10, 'surface_azimuth': 90,
41+
expect = {'tracker_theta': 0, 'aoi': 10, 'surface_azimuth': 270,
4242
'surface_tilt': 0}
4343
for k, v in expect.items():
4444
assert np.isclose(tracker_data[k], v)
@@ -52,7 +52,7 @@ def test_arrays():
5252
max_angle=90, backtrack=True,
5353
gcr=2.0/7.0)
5454
assert isinstance(tracker_data, dict)
55-
expect = {'tracker_theta': 0, 'aoi': 10, 'surface_azimuth': 90,
55+
expect = {'tracker_theta': 0, 'aoi': 10, 'surface_azimuth': 270,
5656
'surface_tilt': 0}
5757
for k, v in expect.items():
5858
assert_allclose(tracker_data[k], v, atol=1e-7)
@@ -68,7 +68,7 @@ def test_nans():
6868
gcr=2.0/7.0)
6969
expect = {'tracker_theta': np.array([0, nan, nan]),
7070
'aoi': np.array([10, nan, nan]),
71-
'surface_azimuth': np.array([90, nan, nan]),
71+
'surface_azimuth': np.array([270, nan, nan]),
7272
'surface_tilt': np.array([0, nan, nan])}
7373
for k, v in expect.items():
7474
assert_allclose(tracker_data[k], v, atol=1e-7)
@@ -82,7 +82,7 @@ def test_nans():
8282
max_angle=90, backtrack=True,
8383
gcr=2.0/7.0)
8484
expect = pd.DataFrame(np.array(
85-
[[ 0., 10., 90., 0.],
85+
[[ 0., 10., 270., 0.],
8686
[nan, nan, nan, nan],
8787
[nan, nan, nan, nan]]),
8888
columns=['tracker_theta', 'aoi', 'surface_azimuth', 'surface_tilt'])
@@ -195,6 +195,54 @@ def test_backtrack():
195195
assert_frame_equal(expect, tracker_data)
196196

197197

198+
def test__unit_normal():
199+
# with scalar input
200+
unorm = tracking._unit_normal(180., 45., 45.)
201+
assert_allclose(unorm, np.array([[-np.sqrt(2)/2, -0.5, 0.5]]))
202+
# with vector input
203+
az = np.array([0., 90., 180., 270.,
204+
0., 90., 180., 270.,
205+
180., 180., 180, 180.,
206+
180., 180., 180., 180,
207+
0., 90., 180., 270.,
208+
])
209+
tilt = np.array([30., 30., 30., 30.,
210+
0., 0., 0., 0.,
211+
-30., -90., 90., 180.,
212+
0., 0., 0., 0.,
213+
30., 30., 30., 30,
214+
])
215+
theta = np.array([0., 0., 0., 0.,
216+
0., 0., 0., 0.,
217+
0., 0., 0., 0.,
218+
-30., 30., -90., 90.,
219+
30., 30., 30., 30.,
220+
])
221+
expected = np.array(
222+
[[ 0., 0.5, 0.8660254],
223+
[ 0.5, 0., 0.8660254],
224+
[ 0., -0.5, 0.8660254],
225+
[-0.5, -0., 0.8660254],
226+
[ 0., 0., 1.],
227+
[ 0., 0., 1.],
228+
[ 0., -0., 1.],
229+
[-0., 0., 1.],
230+
[-0., 0.5, 0.8660254],
231+
[-0., 1., 0.],
232+
[ 0., -1., 0.],
233+
[ 0., -0., -1.],
234+
[ 0.5, 0., 0.8660254],
235+
[-0.5, -0., 0.8660254],
236+
[ 1., 0., 0.],
237+
[-1., -0., 0.],
238+
[ 0.5, 0.4330127, 0.75],
239+
[ 0.4330127, -0.5, 0.75],
240+
[-0.5, -0.4330127, 0.75],
241+
[-0.4330127, 0.5, 0.75]])
242+
unorms = tracking._unit_normal(az, tilt, theta)
243+
assert np.allclose(unorms, expected)
244+
245+
198246
def test_axis_tilt():
199247
apparent_zenith = pd.Series([30])
200248
apparent_azimuth = pd.Series([135])
@@ -226,6 +274,7 @@ def test_axis_tilt():
226274

227275

228276
def test_axis_azimuth():
277+
# sun to the east, horizontal east-oriented tracker
229278
apparent_zenith = pd.Series([30])
230279
apparent_azimuth = pd.Series([90])
231280

@@ -234,13 +283,30 @@ def test_axis_azimuth():
234283
max_angle=90, backtrack=True,
235284
gcr=2.0/7.0)
236285

237-
expect = pd.DataFrame({'aoi': 30, 'surface_azimuth': 180,
286+
expect = pd.DataFrame({'aoi': 30, 'surface_azimuth': 0,
238287
'surface_tilt': 0, 'tracker_theta': 0},
239288
index=[0], dtype=np.float64)
240289
expect = expect[SINGLEAXIS_COL_ORDER]
241290

242291
assert_frame_equal(expect, tracker_data)
243292

293+
# sun to the east, horizontal south-oriented tracker
294+
apparent_zenith = pd.Series([30])
295+
apparent_azimuth = pd.Series([90])
296+
297+
tracker_data = tracking.singleaxis(apparent_zenith, apparent_azimuth,
298+
axis_tilt=0, axis_azimuth=180,
299+
max_angle=90, backtrack=True,
300+
gcr=2.0/7.0)
301+
302+
expect = pd.DataFrame({'aoi': 0, 'surface_azimuth': 90,
303+
'surface_tilt': 30, 'tracker_theta': -30},
304+
index=[0], dtype=np.float64)
305+
expect = expect[SINGLEAXIS_COL_ORDER]
306+
307+
assert_frame_equal(expect, tracker_data)
308+
309+
# sun to the south, horizontal east-oriented tracker
244310
apparent_zenith = pd.Series([30])
245311
apparent_azimuth = pd.Series([180])
246312

@@ -269,7 +335,7 @@ def test_horizon_flat():
269335
axis_azimuth=180, backtrack=False, max_angle=180)
270336
expected = pd.DataFrame(np.array(
271337
[[ nan, nan, nan, nan],
272-
[ 0., 45., 270., 0.],
338+
[ 0., 45., 90., 0.],
273339
[ nan, nan, nan, nan]]),
274340
columns=['tracker_theta', 'aoi', 'surface_azimuth', 'surface_tilt'])
275341
assert_frame_equal(out, expected)
@@ -389,6 +455,23 @@ def test_slope_aware_backtracking():
389455
check_less_precise=True)
390456

391457

458+
def test_singleaxis_neg_axis_tilt():
459+
''' Check equivalence of (negative tilt, axis azimuth) and
460+
(positive tilt, axis azimuth + 180)
461+
'''
462+
params = dict(apparent_zenith=45, solar_azimuth=270)
463+
464+
tr_pos = pvlib.tracking.singleaxis(axis_tilt=10, axis_azimuth=0,
465+
**params)
466+
tr_neg = pvlib.tracking.singleaxis(axis_tilt=-10, axis_azimuth=180,
467+
**params)
468+
469+
tr_neg['tracker_theta'] *= -1 # expect tracker_theta to be negated
470+
471+
for key in tr_pos:
472+
assert_allclose(tr_pos[key], tr_neg[key])
473+
474+
392475
def test_singleaxis_aoi_gh1221():
393476
# vertical tracker
394477
loc = pvlib.location.Location(40.1134, -88.3695)
@@ -408,7 +491,8 @@ def test_calc_surface_orientation_types():
408491
# numpy arrays
409492
rotations = np.array([-10, 0, 10])
410493
expected_tilts = np.array([10, 0, 10], dtype=float)
411-
expected_azimuths = np.array([270, 90, 90], dtype=float)
494+
expected_azimuths = np.array([270, 270, 90], dtype=float)
495+
# defaults to axis_azimuth=0
412496
out = tracking.calc_surface_orientation(tracker_theta=rotations)
413497
np.testing.assert_allclose(expected_tilts, out['surface_tilt'])
414498
np.testing.assert_allclose(expected_azimuths, out['surface_azimuth'])
@@ -445,7 +529,8 @@ def test_calc_surface_orientation_special():
445529
# special cases for rotations
446530
rotations = np.array([-180, -90, -0, 0, 90, 180])
447531
expected_tilts = np.array([180, 90, 0, 0, 90, 180], dtype=float)
448-
expected_azimuths = [270, 270, 90, 90, 90, 90]
532+
expected_azimuths = [270, 270, 270, 270, 90, 90]
533+
# defaults to axis_azimuth=0
449534
out = tracking.calc_surface_orientation(rotations)
450535
np.testing.assert_allclose(out['surface_tilt'], expected_tilts)
451536
np.testing.assert_allclose(out['surface_azimuth'], expected_azimuths)
@@ -454,14 +539,15 @@ def test_calc_surface_orientation_special():
454539
rotations = np.array([-10, 0, 10])
455540
expected_tilts = np.array([90, 90, 90], dtype=float)
456541
expected_azimuths = np.array([350, 0, 10], dtype=float)
542+
# defaults to axis_azimuth=0
457543
out = tracking.calc_surface_orientation(rotations, axis_tilt=90)
458544
np.testing.assert_allclose(out['surface_tilt'], expected_tilts)
459545
np.testing.assert_allclose(out['surface_azimuth'], expected_azimuths)
460546

461547
# special cases for axis_azimuth
462548
rotations = np.array([-10, 0, 10])
463549
expected_tilts = np.array([10, 0, 10], dtype=float)
464-
expected_azimuth_offsets = np.array([-90, 90, 90], dtype=float)
550+
expected_azimuth_offsets = np.array([-90, -90, 90], dtype=float)
465551
for axis_azimuth in [0, 90, 180, 270, 360]:
466552
expected_azimuths = (axis_azimuth + expected_azimuth_offsets) % 360
467553
out = tracking.calc_surface_orientation(rotations,

0 commit comments

Comments
 (0)