import numpy as np
import pandas as pd
from pvlib.tools import cosd, sind, tand, acosd, asind
from pvlib import irradiance
from pvlib import shading
[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]_ and [2]_ for details about the equations. Backtracking may be
specified, in which case 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 and 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. [degrees]
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 or tuple, 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). If a float is provided, it represents the maximum
rotation angle, and the minimum rotation angle is assumed to be the
opposite of the maximum angle. If a tuple of (min_angle, max_angle) is
provided, it represents both the minimum and maximum rotation angles.
A rotation to ``max_angle`` is a counter-clockwise rotation about the
y-axis of the tracker coordinate system. For example, for a tracker
with ``axis_azimuth`` oriented to the south, a rotation to
``max_angle`` is towards the west, and a rotation toward ``-max_angle``
is in the opposite direction, toward the east. Hence, a ``max_angle``
of 180 degrees (equivalent to max_angle = (-180, 180)) allows the
tracker to achieve its full rotation capability.
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 that
utilizes backtracking; i.e. the ratio between the PV array surface area
to the 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. The 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] Anderson, K., and Mikofski, M., "Slope-Aware Backtracking for
Single-Axis Trackers", Technical Report NREL/TP-5K00-76626, July 2020.
https://www.nrel.gov/docs/fy20osti/76626.pdf
.. [2] Lorenzo, E., Narvarte, L., and Muñoz, J. (2011). Tracking and
back-tracking 19(6), 747–753. :doi:`10.1002/pip.1085`
"""
# 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')
# The ideal tracking angle, omega_ideal, is the rotation to place the sun
# position vector (xp, yp, zp) in the (x, z) plane, which is normal to
# the panel and contains the axis of rotation. omega_ideal=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.
omega_ideal = shading.projected_solar_zenith_angle(
axis_tilt=axis_tilt,
axis_azimuth=axis_azimuth,
solar_zenith=apparent_zenith,
solar_azimuth=apparent_azimuth,
)
# filter for sun above panel horizon
zen_gt_90 = apparent_zenith > 90
omega_ideal[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(omega_ideal - cross_axis_tilt))
# backtrack angle using [1], Eq. 14
with np.errstate(invalid='ignore'):
omega_correction = np.degrees(
-np.sign(omega_ideal)*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 = omega_ideal + np.where(
temp < 1, omega_correction,
0)
else:
tracker_theta = omega_ideal
# NOTE: max_angle defined relative to zero-point rotation, not the
# system-plane normal
# Determine minimum and maximum rotation angles based on max_angle.
# If max_angle is a single value, assume min_angle is the negative.
if np.isscalar(max_angle):
min_angle = -max_angle
else:
min_angle, max_angle = max_angle
# Clip tracker_theta between the minimum and maximum angles.
tracker_theta = np.clip(tracker_theta, min_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)