import numpy as np
import pandas as pd
from pvlib.tools import cosd, sind, tand, acosd, asind
from pvlib import irradiance
[docs]def singleaxis(apparent_zenith, apparent_azimuth,
axis_tilt=0, axis_azimuth=0, max_angle=90,
backtrack=True, gcr=2.0/7.0, cross_axis_tilt=0):
"""
Determine the rotation angle of a single-axis tracker when given particular
solar zenith and azimuth angles.
See [1]_ for details about the equations. Backtracking may be specified,
and if so, a ground coverage ratio is required.
Rotation angle is determined in a right-handed coordinate system. The
tracker `axis_azimuth` defines the positive y-axis, the positive x-axis is
90 degrees clockwise from the y-axis and parallel to the Earth's surface,
and the positive z-axis is normal to both x & y-axes and oriented skyward.
Rotation angle `tracker_theta` is a right-handed rotation around the y-axis
in the x, y, z coordinate system and indicates tracker position relative to
horizontal. For example, if tracker `axis_azimuth` is 180 (oriented south)
and `axis_tilt` is zero, then a `tracker_theta` of zero is horizontal, a
`tracker_theta` of 30 degrees is a rotation of 30 degrees towards the west,
and a `tracker_theta` of -90 degrees is a rotation to the vertical plane
facing east.
Parameters
----------
apparent_zenith : float, 1d array, or Series
Solar apparent zenith angles in decimal degrees.
apparent_azimuth : float, 1d array, or Series
Solar apparent azimuth angles in decimal degrees.
axis_tilt : float, default 0
The tilt of the axis of rotation (i.e, the y-axis defined by
``axis_azimuth``) with respect to horizontal.
``axis_tilt`` must be >= 0 and <= 90. [degree]
axis_azimuth : float, default 0
A value denoting the compass direction along which the axis of
rotation lies. Measured in decimal degrees east of north.
max_angle : float, default 90
A value denoting the maximum rotation angle, in decimal degrees,
of the one-axis tracker from its horizontal position (horizontal
if axis_tilt = 0). A max_angle of 90 degrees allows the tracker
to rotate to a vertical position to point the panel towards a
horizon. max_angle of 180 degrees allows for full rotation.
backtrack : bool, default True
Controls whether the tracker has the capability to "backtrack"
to avoid row-to-row shading. False denotes no backtrack
capability. True denotes backtrack capability.
gcr : float, default 2.0/7.0
A value denoting the ground coverage ratio of a tracker system
which utilizes backtracking; i.e. the ratio between the PV array
surface area to total ground area. A tracker system with modules
2 meters wide, centered on the tracking axis, with 6 meters
between the tracking axes has a gcr of 2/6=0.333. If gcr is not
provided, a gcr of 2/7 is default. gcr must be <=1.
cross_axis_tilt : float, default 0.0
The angle, relative to horizontal, of the line formed by the
intersection between the slope containing the tracker axes and a plane
perpendicular to the tracker axes. Cross-axis tilt should be specified
using a right-handed convention. For example, trackers with axis
azimuth of 180 degrees (heading south) will have a negative cross-axis
tilt if the tracker axes plane slopes down to the east and positive
cross-axis tilt if the tracker axes plane slopes down to the west. Use
:func:`~pvlib.tracking.calc_cross_axis_tilt` to calculate
`cross_axis_tilt`. [degrees]
Returns
-------
dict or DataFrame with the following columns:
* `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
surface, accounting for panel rotation. [degrees]
* `surface_azimuth`: The azimuth of the rotated panel, determined by
projecting the vector normal to the panel's surface to the earth's
surface. [degrees]
See also
--------
pvlib.tracking.calc_axis_tilt
pvlib.tracking.calc_cross_axis_tilt
pvlib.tracking.calc_surface_orientation
References
----------
.. [1] Kevin Anderson and Mark Mikofski, "Slope-Aware Backtracking for
Single-Axis Trackers", Technical Report NREL/TP-5K00-76626, July 2020.
https://www.nrel.gov/docs/fy20osti/76626.pdf
"""
# MATLAB to Python conversion by
# Will Holmgren (@wholmgren), U. Arizona. March, 2015.
if isinstance(apparent_zenith, pd.Series):
index = apparent_zenith.index
else:
index = None
# convert scalars to arrays
apparent_azimuth = np.atleast_1d(apparent_azimuth)
apparent_zenith = np.atleast_1d(apparent_zenith)
if apparent_azimuth.ndim > 1 or apparent_zenith.ndim > 1:
raise ValueError('Input dimensions must not exceed 1')
# Calculate sun position x, y, z using coordinate system as in [1], Eq 1.
# NOTE: solar elevation = 90 - solar zenith, then use trig identities:
# sin(90-x) = cos(x) & cos(90-x) = sin(x)
sin_zenith = sind(apparent_zenith)
x = sin_zenith * sind(apparent_azimuth)
y = sin_zenith * cosd(apparent_azimuth)
z = cosd(apparent_zenith)
# Assume the tracker reference frame is right-handed. Positive y-axis is
# oriented along tracking axis; from north, the y-axis is rotated clockwise
# by the axis azimuth and tilted from horizontal by the axis tilt. The
# positive x-axis is 90 deg clockwise from the y-axis and parallel to
# horizontal (e.g., if the y-axis is south, the x-axis is west); the
# positive z-axis is normal to the x and y axes, pointed upward.
# Calculate sun position (xp, yp, zp) in tracker coordinate system using
# [1] Eq 4.
cos_axis_azimuth = cosd(axis_azimuth)
sin_axis_azimuth = sind(axis_azimuth)
cos_axis_tilt = cosd(axis_tilt)
sin_axis_tilt = sind(axis_tilt)
xp = x*cos_axis_azimuth - y*sin_axis_azimuth
# 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)
# The ideal tracking angle wid is the rotation to place the sun position
# vector (xp, yp, zp) in the (y, z) plane, which is normal to the panel and
# contains the axis of rotation. wid = 0 indicates that the panel is
# horizontal. Here, our convention is that a clockwise rotation is
# positive, to view rotation angles in the same frame of reference as
# azimuth. For example, for a system with tracking axis oriented south, a
# rotation toward the east is negative, and a rotation to the west is
# positive. This is a right-handed rotation around the tracker y-axis.
# Calculate angle from x-y plane to projection of sun vector onto x-z plane
# using [1] Eq. 5.
wid = np.degrees(np.arctan2(xp, zp))
# filter for sun above panel horizon
zen_gt_90 = apparent_zenith > 90
wid[zen_gt_90] = np.nan
# Account for backtracking
if backtrack:
# distance between rows in terms of rack lengths relative to cross-axis
# tilt
axes_distance = 1/(gcr * cosd(cross_axis_tilt))
# NOTE: account for rare angles below array, see GH 824
temp = np.abs(axes_distance * cosd(wid - cross_axis_tilt))
# backtrack angle using [1], Eq. 14
with np.errstate(invalid='ignore'):
wc = np.degrees(-np.sign(wid)*np.arccos(temp))
# NOTE: in the middle of the day, arccos(temp) is out of range because
# there's no row-to-row shade to avoid, & backtracking is unnecessary
# [1], Eqs. 15-16
with np.errstate(invalid='ignore'):
tracker_theta = wid + np.where(temp < 1, wc, 0)
else:
tracker_theta = wid
# NOTE: max_angle defined relative to zero-point rotation, not the
# system-plane normal
tracker_theta = np.clip(tracker_theta, -max_angle, max_angle)
# 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_azimuth': surface_azimuth, 'surface_tilt': surface_tilt}
if index is not None:
out = pd.DataFrame(out, index=index)
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
[docs]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.
``axis_tilt`` must be >= 0 and <= 90. [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
[docs]def calc_axis_tilt(slope_azimuth, slope_tilt, axis_azimuth):
"""
Calculate tracker axis tilt in the global reference frame when on a sloped
plane. Axis tilt is the inclination of the tracker rotation axis with
respect to horizontal, ranging from 0 degrees (horizontal axis) to 90
degrees (vertical axis).
Parameters
----------
slope_azimuth : float
direction of normal to slope on horizontal [degrees]
slope_tilt : float
tilt of normal to slope relative to vertical [degrees]
axis_azimuth : float
direction of tracker axes on horizontal [degrees]
Returns
-------
axis_tilt : float
tilt of tracker [degrees]
See also
--------
pvlib.tracking.singleaxis
pvlib.tracking.calc_cross_axis_tilt
Notes
-----
See [1]_ for derivation of equations.
References
----------
.. [1] Kevin Anderson and Mark Mikofski, "Slope-Aware Backtracking for
Single-Axis Trackers", Technical Report NREL/TP-5K00-76626, July 2020.
https://www.nrel.gov/docs/fy20osti/76626.pdf
"""
delta_gamma = axis_azimuth - slope_azimuth
# equations 18-19
tan_axis_tilt = cosd(delta_gamma) * tand(slope_tilt)
return np.degrees(np.arctan(tan_axis_tilt))
def _calc_tracker_norm(ba, bg, dg):
"""
Calculate tracker normal, v, cross product of tracker axis and unit normal,
N, to the system slope plane.
Parameters
----------
ba : float
axis tilt [degrees]
bg : float
ground tilt [degrees]
dg : float
delta gamma, difference between axis and ground azimuths [degrees]
Returns
-------
vector : tuple
vx, vy, vz
"""
cos_ba = cosd(ba)
cos_bg = cosd(bg)
sin_bg = sind(bg)
sin_dg = sind(dg)
vx = sin_dg * cos_ba * cos_bg
vy = sind(ba)*sin_bg + cosd(dg)*cos_ba*cos_bg
vz = -sin_dg*sin_bg*cos_ba
return vx, vy, vz
def _calc_beta_c(v, dg, ba):
"""
Calculate the cross-axis tilt angle.
Parameters
----------
v : tuple
tracker normal
dg : float
delta gamma, difference between axis and ground azimuths [degrees]
ba : float
axis tilt [degrees]
Returns
-------
beta_c : float
cross-axis tilt angle [radians]
"""
vnorm = np.sqrt(np.dot(v, v))
beta_c = np.arcsin(
((v[0]*cosd(dg) - v[1]*sind(dg)) * sind(ba) + v[2]*cosd(ba)) / vnorm)
return beta_c
[docs]def calc_cross_axis_tilt(
slope_azimuth, slope_tilt, axis_azimuth, axis_tilt):
"""
Calculate the angle, relative to horizontal, of the line formed by the
intersection between the slope containing the tracker axes and a plane
perpendicular to the tracker axes.
Use the cross-axis tilt to avoid row-to-row shade when backtracking on a
slope not parallel with the axis azimuth. Cross-axis tilt should be
specified using a right-handed convention. For example, trackers with axis
azimuth of 180 degrees (heading south) will have a negative cross-axis tilt
if the tracker axes plane slopes down to the east and positive cross-axis
tilt if the tracker axes plane slopes down to the west.
Parameters
----------
slope_azimuth : float
direction of the normal to the slope containing the tracker axes, when
projected on the horizontal [degrees]
slope_tilt : float
angle of the slope containing the tracker axes, relative to horizontal
[degrees]
axis_azimuth : float
direction of tracker axes projected on the horizontal [degrees]
axis_tilt : float
tilt of trackers relative to horizontal. ``axis_tilt`` must be >= 0
and <= 90. [degree]
Returns
-------
cross_axis_tilt : float
angle, relative to horizontal, of the line formed by the intersection
between the slope containing the tracker axes and a plane perpendicular
to the tracker axes [degrees]
See also
--------
pvlib.tracking.singleaxis
pvlib.tracking.calc_axis_tilt
Notes
-----
See [1]_ for derivation of equations.
References
----------
.. [1] Kevin Anderson and Mark Mikofski, "Slope-Aware Backtracking for
Single-Axis Trackers", Technical Report NREL/TP-5K00-76626, July 2020.
https://www.nrel.gov/docs/fy20osti/76626.pdf
"""
# delta-gamma, difference between axis and slope azimuths
delta_gamma = axis_azimuth - slope_azimuth
# equation 22
v = _calc_tracker_norm(axis_tilt, slope_tilt, delta_gamma)
# equation 26
beta_c = _calc_beta_c(v, delta_gamma, axis_tilt)
return np.degrees(beta_c)