Skip to content

Commit e2a6f35

Browse files
committed
calculate surface angles using unit normal
1 parent 86a367b commit e2a6f35

2 files changed

Lines changed: 145 additions & 34 deletions

File tree

pvlib/tracking.py

Lines changed: 69 additions & 24 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
@@ -210,6 +210,50 @@ def singleaxis(apparent_zenith, solar_azimuth,
210210
return out
211211

212212

213+
def _unit_normal(axis_azimuth, axis_tilt, theta):
214+
"""
215+
Unit normal to rotated tracker surface, in global E-N-Up coordinates,
216+
given by R*(0, 0, 1)^T, where:
217+
218+
R = Rz(-axis_azimuth) Rx(-axis_tilt) Ry(theta) *
219+
220+
Rz is a rotation by -axis_azimuth about the z-axis (axis_azimuth
221+
is negated to convert from an azimuth angle to a rotation angle). Rx is a
222+
rotation by -axis_tilt about the x-axis, where axis_tilt is negated
223+
because pvlib's convention is that the positive y-axis is tilted
224+
downwards. Ry is a rotation by theta
225+
about the y-axis. theta is negated so that a negative.
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 (3,) if theta scalar
237+
Shape (N,3) if theta has length N
238+
"""
239+
240+
theta = np.asarray(theta)
241+
242+
cA, sA = cosd(-axis_azimuth), sind(-axis_azimuth)
243+
cT, sT = cosd(-axis_tilt), sind(-axis_tilt)
244+
245+
cTh = cosd(theta)
246+
sTh = sind(theta)
247+
248+
x = sA * sT * cTh + cA * sTh
249+
y = sA * sTh - cA * sT * cTh
250+
z = cT * cTh
251+
252+
result = np.column_stack((x, y, z))
253+
254+
return result
255+
256+
213257
def calc_surface_orientation(tracker_theta, axis_tilt=0, axis_azimuth=0):
214258
"""
215259
Calculate the surface tilt and azimuth angles for a given tracker rotation.
@@ -223,8 +267,7 @@ def calc_surface_orientation(tracker_theta, axis_tilt=0, axis_azimuth=0):
223267
results in ``surface_azimuth`` to the West while ``tracker_theta < 0``
224268
results in ``surface_azimuth`` to the East. [degree]
225269
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]
270+
The tilt of the axis of rotation with respect to horizontal. [degree]
228271
axis_azimuth : float, default 0
229272
A value denoting the compass direction along which the axis of
230273
rotation lies. Measured east of north. [degree]
@@ -234,7 +277,9 @@ def calc_surface_orientation(tracker_theta, axis_tilt=0, axis_azimuth=0):
234277
dict or DataFrame
235278
Contains keys ``'surface_tilt'`` and ``'surface_azimuth'`` representing
236279
the module orientation accounting for tracker rotation and axis
237-
orientation. [degree]
280+
orientation (degree).
281+
Where ``surface_tilt``=0, ``surface_azimuth`` is set equal to
282+
``axis_azimuth``.
238283
239284
References
240285
----------
@@ -245,16 +290,17 @@ def calc_surface_orientation(tracker_theta, axis_tilt=0, axis_azimuth=0):
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+
# unit normal to rotated tracker surface
294+
unit_normal = _unit_normal(axis_azimuth, axis_tilt, tracker_theta)
295+
296+
# deviate from [1] to allow for negative tilt.
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+
# constrain angles to [0, 360)
301+
surface_azimuth = np.mod(surface_azimuth, 360.0)
302+
303+
surface_azimuth = np.where(surface_tilt==0., axis_azimuth, surface_azimuth)
258304

259305
out = {
260306
'surface_tilt': surface_tilt,
@@ -378,15 +424,14 @@ def calc_cross_axis_tilt(
378424
----------
379425
slope_azimuth : float
380426
direction of the normal to the slope containing the tracker axes, when
381-
projected on the horizontal [degrees]
427+
projected on the horizontal. [degrees]
382428
slope_tilt : float
383-
angle of the slope containing the tracker axes, relative to horizontal
429+
angle of the slope containing the tracker axes, relative to horizontal.
384430
[degrees]
385431
axis_azimuth : float
386-
direction of tracker axes projected on the horizontal [degrees]
432+
direction of tracker axes projected on the horizontal. [degrees]
387433
axis_tilt : float
388-
tilt of trackers relative to horizontal. ``axis_tilt`` must be >= 0
389-
and <= 90. [degree]
434+
tilt of trackers relative to horizontal. [degree]
390435
391436
Returns
392437
-------

tests/test_tracking.py

Lines changed: 76 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': 0, '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': 0,
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': 0,
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([0, 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., 0., 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+
np.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': 90,
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., 180., 0.],
273339
[ nan, nan, nan, nan]]),
274340
columns=['tracker_theta', 'aoi', 'surface_azimuth', 'surface_tilt'])
275341
assert_frame_equal(out, expected)
@@ -408,7 +474,7 @@ def test_calc_surface_orientation_types():
408474
# numpy arrays
409475
rotations = np.array([-10, 0, 10])
410476
expected_tilts = np.array([10, 0, 10], dtype=float)
411-
expected_azimuths = np.array([270, 90, 90], dtype=float)
477+
expected_azimuths = np.array([270, 0, 90], dtype=float)
412478
out = tracking.calc_surface_orientation(tracker_theta=rotations)
413479
np.testing.assert_allclose(expected_tilts, out['surface_tilt'])
414480
np.testing.assert_allclose(expected_azimuths, out['surface_azimuth'])
@@ -445,7 +511,7 @@ def test_calc_surface_orientation_special():
445511
# special cases for rotations
446512
rotations = np.array([-180, -90, -0, 0, 90, 180])
447513
expected_tilts = np.array([180, 90, 0, 0, 90, 180], dtype=float)
448-
expected_azimuths = [270, 270, 90, 90, 90, 90]
514+
expected_azimuths = [270, 270, 0, 0, 90, 90]
449515
out = tracking.calc_surface_orientation(rotations)
450516
np.testing.assert_allclose(out['surface_tilt'], expected_tilts)
451517
np.testing.assert_allclose(out['surface_azimuth'], expected_azimuths)
@@ -461,7 +527,7 @@ def test_calc_surface_orientation_special():
461527
# special cases for axis_azimuth
462528
rotations = np.array([-10, 0, 10])
463529
expected_tilts = np.array([10, 0, 10], dtype=float)
464-
expected_azimuth_offsets = np.array([-90, 90, 90], dtype=float)
530+
expected_azimuth_offsets = np.array([-90, 0, 90], dtype=float)
465531
for axis_azimuth in [0, 90, 180, 270, 360]:
466532
expected_azimuths = (axis_azimuth + expected_azimuth_offsets) % 360
467533
out = tracking.calc_surface_orientation(rotations,

0 commit comments

Comments
 (0)