Skip to content

Commit c0daa5d

Browse files
kandersolarcwhanse
andauthored
Move surface orientation calculation from tracking.singleaxis to new function (#1480)
* pvlib.tools.acosd * add calc_surface_orientation * prune singleaxis(), call calc_surface_orientation * tests * whatsnew * add calc_surface_orientation to sphinx api list * pr number * a bit of docstring cleanup * reference doi * include 0.9.2 on whatsnew page * Apply suggestions from code review Co-authored-by: Cliff Hansen <[email protected]> * mention right-handed rotation requirement * fix sphinx rendering issue * Apply suggestions from code review Co-authored-by: Cliff Hansen <[email protected]> Co-authored-by: Cliff Hansen <[email protected]>
1 parent c8b04a5 commit c0daa5d

File tree

6 files changed

+163
-77
lines changed

6 files changed

+163
-77
lines changed

docs/sphinx/source/reference/tracking.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ Functions
2525
tracking.singleaxis
2626
tracking.calc_axis_tilt
2727
tracking.calc_cross_axis_tilt
28+
tracking.calc_surface_orientation

docs/sphinx/source/whatsnew.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ What's New
66

77
These are new features and improvements of note in each release.
88

9+
.. include:: whatsnew/v0.9.2.rst
910
.. include:: whatsnew/v0.9.1.rst
1011
.. include:: whatsnew/v0.9.0.rst
1112
.. include:: whatsnew/v0.8.1.rst

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ Deprecations
88

99
Enhancements
1010
~~~~~~~~~~~~
11+
* Add :py:func:`pvlib.tracking.calc_surface_orientation` for calculating
12+
single-axis tracker ``surface_tilt`` and ``surface_azimuth`` from
13+
rotation angles. (:issue:`1471`, :pull:`1480`)
1114

1215
Bug fixes
1316
~~~~~~~~~

pvlib/tests/test_tracking.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,3 +517,72 @@ def test_singleaxis_aoi_gh1221():
517517
fixed = pvlib.irradiance.aoi(90, 180, sp['apparent_zenith'], sp['azimuth'])
518518
fixed[np.isnan(tr['aoi'])] = np.nan
519519
assert np.allclose(tr['aoi'], fixed, equal_nan=True)
520+
521+
522+
def test_calc_surface_orientation_types():
523+
# numpy arrays
524+
rotations = np.array([-10, 0, 10])
525+
expected_tilts = np.array([10, 0, 10], dtype=float)
526+
expected_azimuths = np.array([270, 90, 90], dtype=float)
527+
out = tracking.calc_surface_orientation(tracker_theta=rotations)
528+
np.testing.assert_allclose(expected_tilts, out['surface_tilt'])
529+
np.testing.assert_allclose(expected_azimuths, out['surface_azimuth'])
530+
531+
# pandas Series
532+
rotations = pd.Series(rotations)
533+
expected_tilts = pd.Series(expected_tilts).rename('surface_tilt')
534+
expected_azimuths = pd.Series(expected_azimuths).rename('surface_azimuth')
535+
out = tracking.calc_surface_orientation(tracker_theta=rotations)
536+
assert_series_equal(expected_tilts, out['surface_tilt'])
537+
assert_series_equal(expected_azimuths, out['surface_azimuth'])
538+
539+
# float
540+
for rotation, expected_tilt, expected_azimuth in zip(
541+
rotations, expected_tilts, expected_azimuths):
542+
out = tracking.calc_surface_orientation(rotation)
543+
assert out['surface_tilt'] == pytest.approx(expected_tilt)
544+
assert out['surface_azimuth'] == pytest.approx(expected_azimuth)
545+
546+
547+
def test_calc_surface_orientation_kwargs():
548+
# non-default axis tilt & azimuth
549+
rotations = np.array([-10, 0, 10])
550+
expected_tilts = np.array([22.2687445, 20.0, 22.2687445])
551+
expected_azimuths = np.array([152.72683041, 180.0, 207.27316959])
552+
out = tracking.calc_surface_orientation(rotations,
553+
axis_tilt=20,
554+
axis_azimuth=180)
555+
np.testing.assert_allclose(out['surface_tilt'], expected_tilts)
556+
np.testing.assert_allclose(out['surface_azimuth'], expected_azimuths)
557+
558+
559+
def test_calc_surface_orientation_special():
560+
# special cases for rotations
561+
rotations = np.array([-180, -90, -0, 0, 90, 180])
562+
expected_tilts = np.array([180, 90, 0, 0, 90, 180], dtype=float)
563+
expected_azimuths = [270, 270, 90, 90, 90, 90]
564+
out = tracking.calc_surface_orientation(rotations)
565+
np.testing.assert_allclose(out['surface_tilt'], expected_tilts)
566+
np.testing.assert_allclose(out['surface_azimuth'], expected_azimuths)
567+
568+
# special case for axis_tilt
569+
rotations = np.array([-10, 0, 10])
570+
expected_tilts = np.array([90, 90, 90], dtype=float)
571+
expected_azimuths = np.array([350, 0, 10], dtype=float)
572+
out = tracking.calc_surface_orientation(rotations, axis_tilt=90)
573+
np.testing.assert_allclose(out['surface_tilt'], expected_tilts)
574+
np.testing.assert_allclose(out['surface_azimuth'], expected_azimuths)
575+
576+
# special cases for axis_azimuth
577+
rotations = np.array([-10, 0, 10])
578+
expected_tilts = np.array([10, 0, 10], dtype=float)
579+
expected_azimuth_offsets = np.array([-90, 90, 90], dtype=float)
580+
for axis_azimuth in [0, 90, 180, 270, 360]:
581+
expected_azimuths = (axis_azimuth + expected_azimuth_offsets) % 360
582+
out = tracking.calc_surface_orientation(rotations,
583+
axis_azimuth=axis_azimuth)
584+
np.testing.assert_allclose(out['surface_tilt'], expected_tilts)
585+
# the rounding is a bit ugly, but necessary to test approximately equal
586+
# in a modulo-360 sense.
587+
np.testing.assert_allclose(np.round(out['surface_azimuth'], 4) % 360,
588+
expected_azimuths, rtol=1e-5, atol=1e-5)

pvlib/tools.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,25 @@ def asind(number):
8585
return res
8686

8787

88+
def acosd(number):
89+
"""
90+
Inverse Cosine returning an angle in degrees
91+
92+
Parameters
93+
----------
94+
number : float
95+
Input number
96+
97+
Returns
98+
-------
99+
result : float
100+
arccos result
101+
"""
102+
103+
res = np.degrees(np.arccos(number))
104+
return res
105+
106+
88107
def localize_to_utc(time, location):
89108
"""
90109
Converts or localizes a time series to UTC.

pvlib/tracking.py

Lines changed: 70 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import numpy as np
22
import pandas as pd
33

4-
from pvlib.tools import cosd, sind, tand
4+
from pvlib.tools import cosd, sind, tand, acosd, asind
55
from pvlib.pvsystem import (
66
PVSystem, Array, SingleAxisTrackerMount, _unwrap_single_value
77
)
@@ -334,9 +334,9 @@ def singleaxis(apparent_zenith, apparent_azimuth,
334334
Returns
335335
-------
336336
dict or DataFrame with the following columns:
337-
* `tracker_theta`: The rotation angle of the tracker.
338-
tracker_theta = 0 is horizontal, and positive rotation angles are
339-
clockwise. [degrees]
337+
* `tracker_theta`: The rotation angle of the tracker is a right-handed
338+
rotation defined by `axis_azimuth`.
339+
tracker_theta = 0 is horizontal. [degrees]
340340
* `aoi`: The angle-of-incidence of direct irradiance onto the
341341
rotated panel surface. [degrees]
342342
* `surface_tilt`: The angle between the panel surface and the earth
@@ -349,6 +349,7 @@ def singleaxis(apparent_zenith, apparent_azimuth,
349349
--------
350350
pvlib.tracking.calc_axis_tilt
351351
pvlib.tracking.calc_cross_axis_tilt
352+
pvlib.tracking.calc_surface_orientation
352353
353354
References
354355
----------
@@ -396,9 +397,10 @@ def singleaxis(apparent_zenith, apparent_azimuth,
396397
cos_axis_tilt = cosd(axis_tilt)
397398
sin_axis_tilt = sind(axis_tilt)
398399
xp = x*cos_axis_azimuth - y*sin_axis_azimuth
399-
yp = (x*cos_axis_tilt*sin_axis_azimuth
400-
+ y*cos_axis_tilt*cos_axis_azimuth
401-
- z*sin_axis_tilt)
400+
# not necessary to calculate y'
401+
# yp = (x*cos_axis_tilt*sin_axis_azimuth
402+
# + y*cos_axis_tilt*cos_axis_azimuth
403+
# - z*sin_axis_tilt)
402404
zp = (x*sin_axis_tilt*sin_axis_azimuth
403405
+ y*sin_axis_tilt*cos_axis_azimuth
404406
+ z*cos_axis_tilt)
@@ -446,88 +448,79 @@ def singleaxis(apparent_zenith, apparent_azimuth,
446448
# system-plane normal
447449
tracker_theta = np.clip(tracker_theta, -max_angle, max_angle)
448450

449-
# Calculate panel normal vector in panel-oriented x, y, z coordinates.
450-
# y-axis is axis of tracker rotation. tracker_theta is a compass angle
451-
# (clockwise is positive) rather than a trigonometric angle.
452-
# NOTE: the *0 is a trick to preserve NaN values.
453-
panel_norm = np.array([sind(tracker_theta),
454-
tracker_theta*0,
455-
cosd(tracker_theta)])
456-
457-
# sun position in vector format in panel-oriented x, y, z coordinates
458-
sun_vec = np.array([xp, yp, zp])
459-
460-
# calculate angle-of-incidence on panel
461-
# TODO: use irradiance.aoi
462-
projection = np.clip(np.sum(sun_vec*panel_norm, axis=0), -1, 1)
463-
aoi = np.degrees(np.arccos(projection))
464-
465-
# Calculate panel tilt and azimuth in a coordinate system where the panel
466-
# tilt is the angle from horizontal, and the panel azimuth is the compass
467-
# angle (clockwise from north) to the projection of the panel's normal to
468-
# the earth's surface. These outputs are provided for convenience and
469-
# comparison with other PV software which use these angle conventions.
470-
471-
# Project normal vector to earth surface. First rotate about x-axis by
472-
# angle -axis_tilt so that y-axis is also parallel to earth surface, then
473-
# project.
474-
475-
# Calculate standard rotation matrix
476-
rot_x = np.array([[1, 0, 0],
477-
[0, cosd(-axis_tilt), -sind(-axis_tilt)],
478-
[0, sind(-axis_tilt), cosd(-axis_tilt)]])
479-
480-
# panel_norm_earth contains the normal vector expressed in earth-surface
481-
# coordinates (z normal to surface, y aligned with tracker axis parallel to
482-
# earth)
483-
panel_norm_earth = np.dot(rot_x, panel_norm).T
484-
485-
# projection to plane tangent to earth surface, in earth surface
486-
# coordinates
487-
projected_normal = np.array([panel_norm_earth[:, 0],
488-
panel_norm_earth[:, 1],
489-
panel_norm_earth[:, 2]*0]).T
490-
491-
# calculate vector magnitudes
492-
projected_normal_mag = np.sqrt(np.nansum(projected_normal**2, axis=1))
493-
494-
# renormalize the projected vector, avoid creating nan values.
495-
non_zeros = projected_normal_mag != 0
496-
projected_normal[non_zeros] = (projected_normal[non_zeros].T /
497-
projected_normal_mag[non_zeros]).T
498-
499-
# calculation of surface_azimuth
500-
surface_azimuth = \
501-
np.degrees(np.arctan2(projected_normal[:, 1], projected_normal[:, 0]))
502-
503-
# Rotate 0 reference from panel's x-axis to its y-axis and then back to
504-
# north.
505-
surface_azimuth = 90 - surface_azimuth + axis_azimuth
506-
507-
# Map azimuth into [0,360) domain.
508-
with np.errstate(invalid='ignore'):
509-
surface_azimuth = surface_azimuth % 360
510-
511-
# Calculate surface_tilt
512-
dotproduct = (panel_norm_earth * projected_normal).sum(axis=1)
513-
# for edge cases like axis_tilt=90, numpy's SIMD can produce values like
514-
# dotproduct = (1 + 2e-16). Clip off the excess so that arccos works:
515-
dotproduct = np.clip(dotproduct, -1, 1)
516-
surface_tilt = 90 - np.degrees(np.arccos(dotproduct))
451+
# Calculate auxiliary angles
452+
surface = calc_surface_orientation(tracker_theta, axis_tilt, axis_azimuth)
453+
surface_tilt = surface['surface_tilt']
454+
surface_azimuth = surface['surface_azimuth']
455+
aoi = irradiance.aoi(surface_tilt, surface_azimuth,
456+
apparent_zenith, apparent_azimuth)
517457

518458
# Bundle DataFrame for return values and filter for sun below horizon.
519459
out = {'tracker_theta': tracker_theta, 'aoi': aoi,
520-
'surface_tilt': surface_tilt, 'surface_azimuth': surface_azimuth}
460+
'surface_azimuth': surface_azimuth, 'surface_tilt': surface_tilt}
521461
if index is not None:
522462
out = pd.DataFrame(out, index=index)
523-
out = out[['tracker_theta', 'aoi', 'surface_azimuth', 'surface_tilt']]
524463
out[zen_gt_90] = np.nan
525464
else:
526465
out = {k: np.where(zen_gt_90, np.nan, v) for k, v in out.items()}
527466

528467
return out
529468

530469

470+
def calc_surface_orientation(tracker_theta, axis_tilt=0, axis_azimuth=0):
471+
"""
472+
Calculate the surface tilt and azimuth angles for a given tracker rotation.
473+
474+
Parameters
475+
----------
476+
tracker_theta : numeric
477+
Tracker rotation angle as a right-handed rotation around
478+
the axis defined by ``axis_tilt`` and ``axis_azimuth``. For example,
479+
with ``axis_tilt=0`` and ``axis_azimuth=180``, ``tracker_theta > 0``
480+
results in ``surface_azimuth`` to the West while ``tracker_theta < 0``
481+
results in ``surface_azimuth`` to the East. [degree]
482+
axis_tilt : float, default 0
483+
The tilt of the axis of rotation with respect to horizontal. [degree]
484+
axis_azimuth : float, default 0
485+
A value denoting the compass direction along which the axis of
486+
rotation lies. Measured east of north. [degree]
487+
488+
Returns
489+
-------
490+
dict or DataFrame
491+
Contains keys ``'surface_tilt'`` and ``'surface_azimuth'`` representing
492+
the module orientation accounting for tracker rotation and axis
493+
orientation. [degree]
494+
495+
References
496+
----------
497+
.. [1] William F. Marion and Aron P. Dobos, "Rotation Angle for the Optimum
498+
Tracking of One-Axis Trackers", Technical Report NREL/TP-6A20-58891,
499+
July 2013. :doi:`10.2172/1089596`
500+
"""
501+
with np.errstate(invalid='ignore', divide='ignore'):
502+
surface_tilt = acosd(cosd(tracker_theta) * cosd(axis_tilt))
503+
504+
# clip(..., -1, +1) to prevent arcsin(1 + epsilon) issues:
505+
azimuth_delta = asind(np.clip(sind(tracker_theta) / sind(surface_tilt),
506+
a_min=-1, a_max=1))
507+
# Combine Eqs 2, 3, and 4:
508+
azimuth_delta = np.where(abs(tracker_theta) < 90,
509+
azimuth_delta,
510+
-azimuth_delta + np.sign(tracker_theta) * 180)
511+
# handle surface_tilt=0 case:
512+
azimuth_delta = np.where(sind(surface_tilt) != 0, azimuth_delta, 90)
513+
surface_azimuth = (axis_azimuth + azimuth_delta) % 360
514+
515+
out = {
516+
'surface_tilt': surface_tilt,
517+
'surface_azimuth': surface_azimuth,
518+
}
519+
if hasattr(tracker_theta, 'index'):
520+
out = pd.DataFrame(out)
521+
return out
522+
523+
531524
def calc_axis_tilt(slope_azimuth, slope_tilt, axis_azimuth):
532525
"""
533526
Calculate tracker axis tilt in the global reference frame when on a sloped

0 commit comments

Comments
 (0)