Skip to content

Move surface orientation calculation from tracking.singleaxis to new function; switch to Marion & Dobos 2013 equations #1480

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Jul 5, 2022
1 change: 1 addition & 0 deletions docs/sphinx/source/reference/tracking.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ Functions
tracking.singleaxis
tracking.calc_axis_tilt
tracking.calc_cross_axis_tilt
tracking.calc_surface_orientation
1 change: 1 addition & 0 deletions docs/sphinx/source/whatsnew.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ What's New

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

.. include:: whatsnew/v0.9.2.rst
.. include:: whatsnew/v0.9.1.rst
.. include:: whatsnew/v0.9.0.rst
.. include:: whatsnew/v0.8.1.rst
Expand Down
3 changes: 3 additions & 0 deletions docs/sphinx/source/whatsnew/v0.9.2.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ Deprecations

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

Bug fixes
~~~~~~~~~
Expand Down
69 changes: 69 additions & 0 deletions pvlib/tests/test_tracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -517,3 +517,72 @@ def test_singleaxis_aoi_gh1221():
fixed = pvlib.irradiance.aoi(90, 180, sp['apparent_zenith'], sp['azimuth'])
fixed[np.isnan(tr['aoi'])] = np.nan
assert np.allclose(tr['aoi'], fixed, equal_nan=True)


def test_calc_surface_orientation_types():
# numpy arrays
rotations = np.array([-10, 0, 10])
expected_tilts = np.array([10, 0, 10], dtype=float)
expected_azimuths = np.array([270, 90, 90], dtype=float)
out = tracking.calc_surface_orientation(tracker_theta=rotations)
np.testing.assert_allclose(expected_tilts, out['surface_tilt'])
np.testing.assert_allclose(expected_azimuths, out['surface_azimuth'])

# pandas Series
rotations = pd.Series(rotations)
expected_tilts = pd.Series(expected_tilts).rename('surface_tilt')
expected_azimuths = pd.Series(expected_azimuths).rename('surface_azimuth')
out = tracking.calc_surface_orientation(tracker_theta=rotations)
assert_series_equal(expected_tilts, out['surface_tilt'])
assert_series_equal(expected_azimuths, out['surface_azimuth'])

# float
for rotation, expected_tilt, expected_azimuth in zip(
rotations, expected_tilts, expected_azimuths):
out = tracking.calc_surface_orientation(rotation)
assert out['surface_tilt'] == pytest.approx(expected_tilt)
assert out['surface_azimuth'] == pytest.approx(expected_azimuth)


def test_calc_surface_orientation_kwargs():
# non-default axis tilt & azimuth
rotations = np.array([-10, 0, 10])
expected_tilts = np.array([22.2687445, 20.0, 22.2687445])
expected_azimuths = np.array([152.72683041, 180.0, 207.27316959])
out = tracking.calc_surface_orientation(rotations,
axis_tilt=20,
axis_azimuth=180)
np.testing.assert_allclose(out['surface_tilt'], expected_tilts)
np.testing.assert_allclose(out['surface_azimuth'], expected_azimuths)


def test_calc_surface_orientation_special():
# special cases for rotations
rotations = np.array([-180, -90, -0, 0, 90, 180])
expected_tilts = np.array([180, 90, 0, 0, 90, 180], dtype=float)
expected_azimuths = [270, 270, 90, 90, 90, 90]
out = tracking.calc_surface_orientation(rotations)
np.testing.assert_allclose(out['surface_tilt'], expected_tilts)
np.testing.assert_allclose(out['surface_azimuth'], expected_azimuths)

# special case for axis_tilt
rotations = np.array([-10, 0, 10])
expected_tilts = np.array([90, 90, 90], dtype=float)
expected_azimuths = np.array([350, 0, 10], dtype=float)
out = tracking.calc_surface_orientation(rotations, axis_tilt=90)
np.testing.assert_allclose(out['surface_tilt'], expected_tilts)
np.testing.assert_allclose(out['surface_azimuth'], expected_azimuths)

# special cases for axis_azimuth
rotations = np.array([-10, 0, 10])
expected_tilts = np.array([10, 0, 10], dtype=float)
expected_azimuth_offsets = np.array([-90, 90, 90], dtype=float)
for axis_azimuth in [0, 90, 180, 270, 360]:
expected_azimuths = (axis_azimuth + expected_azimuth_offsets) % 360
out = tracking.calc_surface_orientation(rotations,
axis_azimuth=axis_azimuth)
np.testing.assert_allclose(out['surface_tilt'], expected_tilts)
# the rounding is a bit ugly, but necessary to test approximately equal
# in a modulo-360 sense.
np.testing.assert_allclose(np.round(out['surface_azimuth'], 4) % 360,
expected_azimuths, rtol=1e-5, atol=1e-5)
19 changes: 19 additions & 0 deletions pvlib/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,25 @@ def asind(number):
return res


def acosd(number):
"""
Inverse Cosine returning an angle in degrees

Parameters
----------
number : float
Input number

Returns
-------
result : float
arccos result
"""

res = np.degrees(np.arccos(number))
return res


def localize_to_utc(time, location):
"""
Converts or localizes a time series to UTC.
Expand Down
147 changes: 70 additions & 77 deletions pvlib/tracking.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import numpy as np
import pandas as pd

from pvlib.tools import cosd, sind, tand
from pvlib.tools import cosd, sind, tand, acosd, asind
from pvlib.pvsystem import (
PVSystem, Array, SingleAxisTrackerMount, _unwrap_single_value
)
Expand Down Expand Up @@ -334,9 +334,9 @@ def singleaxis(apparent_zenith, apparent_azimuth,
Returns
-------
dict or DataFrame with the following columns:
* `tracker_theta`: The rotation angle of the tracker.
tracker_theta = 0 is horizontal, and positive rotation angles are
clockwise. [degrees]
* `tracker_theta`: The rotation angle of the tracker is a right-handed
rotation defined by `axis_azimuth`.
tracker_theta = 0 is horizontal. [degrees]
* `aoi`: The angle-of-incidence of direct irradiance onto the
rotated panel surface. [degrees]
* `surface_tilt`: The angle between the panel surface and the earth
Expand All @@ -349,6 +349,7 @@ def singleaxis(apparent_zenith, apparent_azimuth,
--------
pvlib.tracking.calc_axis_tilt
pvlib.tracking.calc_cross_axis_tilt
pvlib.tracking.calc_surface_orientation

References
----------
Expand Down Expand Up @@ -396,9 +397,10 @@ def singleaxis(apparent_zenith, apparent_azimuth,
cos_axis_tilt = cosd(axis_tilt)
sin_axis_tilt = sind(axis_tilt)
xp = x*cos_axis_azimuth - y*sin_axis_azimuth
yp = (x*cos_axis_tilt*sin_axis_azimuth
+ y*cos_axis_tilt*cos_axis_azimuth
- z*sin_axis_tilt)
# not necessary to calculate y'
# yp = (x*cos_axis_tilt*sin_axis_azimuth
# + y*cos_axis_tilt*cos_axis_azimuth
# - z*sin_axis_tilt)
zp = (x*sin_axis_tilt*sin_axis_azimuth
+ y*sin_axis_tilt*cos_axis_azimuth
+ z*cos_axis_tilt)
Expand Down Expand Up @@ -446,88 +448,79 @@ def singleaxis(apparent_zenith, apparent_azimuth,
# system-plane normal
tracker_theta = np.clip(tracker_theta, -max_angle, max_angle)

# Calculate panel normal vector in panel-oriented x, y, z coordinates.
# y-axis is axis of tracker rotation. tracker_theta is a compass angle
# (clockwise is positive) rather than a trigonometric angle.
# NOTE: the *0 is a trick to preserve NaN values.
panel_norm = np.array([sind(tracker_theta),
tracker_theta*0,
cosd(tracker_theta)])

# sun position in vector format in panel-oriented x, y, z coordinates
sun_vec = np.array([xp, yp, zp])

# calculate angle-of-incidence on panel
# TODO: use irradiance.aoi
projection = np.clip(np.sum(sun_vec*panel_norm, axis=0), -1, 1)
aoi = np.degrees(np.arccos(projection))

# Calculate panel tilt and azimuth in a coordinate system where the panel
# tilt is the angle from horizontal, and the panel azimuth is the compass
# angle (clockwise from north) to the projection of the panel's normal to
# the earth's surface. These outputs are provided for convenience and
# comparison with other PV software which use these angle conventions.

# Project normal vector to earth surface. First rotate about x-axis by
# angle -axis_tilt so that y-axis is also parallel to earth surface, then
# project.

# Calculate standard rotation matrix
rot_x = np.array([[1, 0, 0],
[0, cosd(-axis_tilt), -sind(-axis_tilt)],
[0, sind(-axis_tilt), cosd(-axis_tilt)]])

# panel_norm_earth contains the normal vector expressed in earth-surface
# coordinates (z normal to surface, y aligned with tracker axis parallel to
# earth)
panel_norm_earth = np.dot(rot_x, panel_norm).T

# projection to plane tangent to earth surface, in earth surface
# coordinates
projected_normal = np.array([panel_norm_earth[:, 0],
panel_norm_earth[:, 1],
panel_norm_earth[:, 2]*0]).T

# calculate vector magnitudes
projected_normal_mag = np.sqrt(np.nansum(projected_normal**2, axis=1))

# renormalize the projected vector, avoid creating nan values.
non_zeros = projected_normal_mag != 0
projected_normal[non_zeros] = (projected_normal[non_zeros].T /
projected_normal_mag[non_zeros]).T

# calculation of surface_azimuth
surface_azimuth = \
np.degrees(np.arctan2(projected_normal[:, 1], projected_normal[:, 0]))

# Rotate 0 reference from panel's x-axis to its y-axis and then back to
# north.
surface_azimuth = 90 - surface_azimuth + axis_azimuth

# Map azimuth into [0,360) domain.
with np.errstate(invalid='ignore'):
surface_azimuth = surface_azimuth % 360

# Calculate surface_tilt
dotproduct = (panel_norm_earth * projected_normal).sum(axis=1)
# for edge cases like axis_tilt=90, numpy's SIMD can produce values like
# dotproduct = (1 + 2e-16). Clip off the excess so that arccos works:
dotproduct = np.clip(dotproduct, -1, 1)
surface_tilt = 90 - np.degrees(np.arccos(dotproduct))
# Calculate auxiliary angles
surface = calc_surface_orientation(tracker_theta, axis_tilt, axis_azimuth)
surface_tilt = surface['surface_tilt']
surface_azimuth = surface['surface_azimuth']
aoi = irradiance.aoi(surface_tilt, surface_azimuth,
apparent_zenith, apparent_azimuth)

# Bundle DataFrame for return values and filter for sun below horizon.
out = {'tracker_theta': tracker_theta, 'aoi': aoi,
'surface_tilt': surface_tilt, 'surface_azimuth': surface_azimuth}
'surface_azimuth': surface_azimuth, 'surface_tilt': surface_tilt}
if index is not None:
out = pd.DataFrame(out, index=index)
out = out[['tracker_theta', 'aoi', 'surface_azimuth', 'surface_tilt']]
out[zen_gt_90] = np.nan
else:
out = {k: np.where(zen_gt_90, np.nan, v) for k, v in out.items()}

return out


def calc_surface_orientation(tracker_theta, axis_tilt=0, axis_azimuth=0):
"""
Calculate the surface tilt and azimuth angles for a given tracker rotation.

Parameters
----------
tracker_theta : numeric
Tracker rotation angle as a right-handed rotation around
the axis defined by ``axis_tilt`` and ``axis_azimuth``. For example,
with ``axis_tilt=0`` and ``axis_azimuth=180``, ``tracker_theta > 0``
results in ``surface_azimuth`` to the West while ``tracker_theta < 0``
results in ``surface_azimuth`` to the East. [degree]
axis_tilt : float, default 0
The tilt of the axis of rotation with respect to horizontal. [degree]
axis_azimuth : float, default 0
A value denoting the compass direction along which the axis of
rotation lies. Measured east of north. [degree]

Returns
-------
dict or DataFrame
Contains keys ``'surface_tilt'`` and ``'surface_azimuth'`` representing
the module orientation accounting for tracker rotation and axis
orientation. [degree]

References
----------
.. [1] William F. Marion and Aron P. Dobos, "Rotation Angle for the Optimum
Tracking of One-Axis Trackers", Technical Report NREL/TP-6A20-58891,
July 2013. :doi:`10.2172/1089596`
"""
with np.errstate(invalid='ignore', divide='ignore'):
surface_tilt = acosd(cosd(tracker_theta) * cosd(axis_tilt))

# clip(..., -1, +1) to prevent arcsin(1 + epsilon) issues:
azimuth_delta = asind(np.clip(sind(tracker_theta) / sind(surface_tilt),
a_min=-1, a_max=1))
# Combine Eqs 2, 3, and 4:
azimuth_delta = np.where(abs(tracker_theta) < 90,
azimuth_delta,
-azimuth_delta + np.sign(tracker_theta) * 180)
# handle surface_tilt=0 case:
azimuth_delta = np.where(sind(surface_tilt) != 0, azimuth_delta, 90)
surface_azimuth = (axis_azimuth + azimuth_delta) % 360

out = {
'surface_tilt': surface_tilt,
'surface_azimuth': surface_azimuth,
}
if hasattr(tracker_theta, 'index'):
out = pd.DataFrame(out)
return out


def calc_axis_tilt(slope_azimuth, slope_tilt, axis_azimuth):
"""
Calculate tracker axis tilt in the global reference frame when on a sloped
Expand Down