import numpy as np
import pandas as pd
from pvlib.tools import cosd, sind, tand
from pvlib.pvsystem import (
PVSystem, Array, SingleAxisTrackerMount, _unwrap_single_value
)
from pvlib import irradiance, atmosphere
from pvlib._deprecation import deprecated
[docs]@deprecated('0.9.0', alternative='PVSystem with SingleAxisTrackerMount')
class SingleAxisTracker(PVSystem):
"""
A class for single-axis trackers that inherits the PV modeling methods from
:py:class:`~pvlib.pvsystem.PVSystem`. For details on calculating tracker
rotation see :py:func:`pvlib.tracking.singleaxis`.
Parameters
----------
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, in decimal 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, 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 up to the east. Use
:func:`~pvlib.tracking.calc_cross_axis_tilt` to calculate
`cross_axis_tilt`. [degrees]
**kwargs
Passed to :py:class:`~pvlib.pvsystem.PVSystem`. If the `arrays`
parameter is specified it must have only a single Array. Furthermore
if a :py:class:`~pvlib.pvsystem.Array` is provided it must have
``surface_tilt`` and ``surface_azimuth`` equal to None.
Raises
------
ValueError
If more than one Array is specified.
ValueError
If an Array is provided with a surface tilt or azimuth not None.
See also
--------
pvlib.tracking.singleaxis
pvlib.tracking.calc_axis_tilt
pvlib.tracking.calc_cross_axis_tilt
"""
def __init__(self, axis_tilt=0, axis_azimuth=0, max_angle=90,
backtrack=True, gcr=2.0/7.0, cross_axis_tilt=0.0, **kwargs):
mount_kwargs = {
k: kwargs.pop(k) for k in ['racking_model', 'module_height']
if k in kwargs
}
mount = SingleAxisTrackerMount(axis_tilt, axis_azimuth, max_angle,
backtrack, gcr, cross_axis_tilt,
**mount_kwargs)
array_defaults = {
'albedo': None, 'surface_type': None, 'module': None,
'module_type': None, 'module_parameters': None,
'temperature_model_parameters': None,
'modules_per_string': 1,
}
array_kwargs = {
key: kwargs.get(key, array_defaults[key]) for key in array_defaults
}
# strings/strings_per_inverter is a special case
array_kwargs['strings'] = kwargs.get('strings_per_inverter', 1)
array = Array(mount=mount, **array_kwargs)
pass_through_kwargs = { # other args to pass to PVSystem()
k: v for k, v in kwargs.items() if k not in array_defaults
}
# leave these in case someone is using them
self.axis_tilt = axis_tilt
self.axis_azimuth = axis_azimuth
self.max_angle = max_angle
self.backtrack = backtrack
self.gcr = gcr
self.cross_axis_tilt = cross_axis_tilt
pass_through_kwargs['surface_tilt'] = None
pass_through_kwargs['surface_azimuth'] = None
super().__init__(arrays=[array], **pass_through_kwargs)
def __repr__(self):
attrs = ['axis_tilt', 'axis_azimuth', 'max_angle', 'backtrack', 'gcr',
'cross_axis_tilt']
sat_repr = ('SingleAxisTracker:\n ' + '\n '.join(
f'{attr}: {getattr(self, attr)}' for attr in attrs))
# get the parent PVSystem info
pvsystem_repr = super().__repr__()
# remove the first line (contains 'PVSystem: \n')
pvsystem_repr = '\n'.join(pvsystem_repr.split('\n')[1:])
return sat_repr + '\n' + pvsystem_repr
[docs] def singleaxis(self, apparent_zenith, apparent_azimuth):
"""
Get tracking data. See :py:func:`pvlib.tracking.singleaxis` more
detail.
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.
Returns
-------
tracking data
"""
tracking_data = singleaxis(apparent_zenith, apparent_azimuth,
self.axis_tilt, self.axis_azimuth,
self.max_angle, self.backtrack,
self.gcr, self.cross_axis_tilt)
return tracking_data
[docs] def get_aoi(self, surface_tilt, surface_azimuth, solar_zenith,
solar_azimuth):
"""Get the angle of incidence on the system.
For a given set of solar zenith and azimuth angles, the
surface tilt and azimuth parameters are typically determined
by :py:meth:`~SingleAxisTracker.singleaxis`. The
:py:meth:`~SingleAxisTracker.singleaxis` method also returns
the angle of incidence, so this method is only needed
if using a different tracking algorithm.
Parameters
----------
surface_tilt : numeric
Panel tilt from horizontal.
surface_azimuth : numeric
Panel azimuth from north
solar_zenith : float or Series.
Solar zenith angle.
solar_azimuth : float or Series.
Solar azimuth angle.
Returns
-------
aoi : Series
The angle of incidence in degrees from normal.
"""
aoi = irradiance.aoi(surface_tilt, surface_azimuth,
solar_zenith, solar_azimuth)
return aoi
[docs] @_unwrap_single_value
def get_irradiance(self, surface_tilt, surface_azimuth,
solar_zenith, solar_azimuth, dni, ghi, dhi,
dni_extra=None, airmass=None, model='haydavies',
**kwargs):
"""
Uses the :func:`irradiance.get_total_irradiance` function to
calculate the plane of array irradiance components on a tilted
surface defined by the input data and ``self.albedo``.
For a given set of solar zenith and azimuth angles, the
surface tilt and azimuth parameters are typically determined
by :py:meth:`~SingleAxisTracker.singleaxis`.
Parameters
----------
surface_tilt : numeric
Panel tilt from horizontal.
surface_azimuth : numeric
Panel azimuth from north
solar_zenith : numeric
Solar zenith angle.
solar_azimuth : numeric
Solar azimuth angle.
dni : float or Series
Direct Normal Irradiance
ghi : float or Series
Global horizontal irradiance
dhi : float or Series
Diffuse horizontal irradiance
dni_extra : float or Series, default None
Extraterrestrial direct normal irradiance
airmass : float or Series, default None
Airmass
model : String, default 'haydavies'
Irradiance model.
**kwargs
Passed to :func:`irradiance.get_total_irradiance`.
Returns
-------
poa_irradiance : DataFrame
Column names are: ``total, beam, sky, ground``.
"""
# not needed for all models, but this is easier
if dni_extra is None:
dni_extra = irradiance.get_extra_radiation(solar_zenith.index)
if airmass is None:
airmass = atmosphere.get_relative_airmass(solar_zenith)
# SingleAxisTracker only supports a single Array, but we need the
# validate/iterate machinery so that single length tuple input/output
# is handled the same as PVSystem.get_irradiance. GH 1159
dni = self._validate_per_array(dni, system_wide=True)
ghi = self._validate_per_array(ghi, system_wide=True)
dhi = self._validate_per_array(dhi, system_wide=True)
return tuple(
irradiance.get_total_irradiance(
surface_tilt,
surface_azimuth,
solar_zenith,
solar_azimuth,
dni, ghi, dhi,
dni_extra=dni_extra,
airmass=airmass,
model=model,
albedo=self.arrays[0].albedo,
**kwargs)
for array, dni, ghi, dhi in zip(
self.arrays, dni, ghi, dhi
)
)
[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, in decimal 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, 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 up to the east. 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.
tracker_theta = 0 is horizontal, and positive rotation angles are
clockwise. [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
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
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 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)
surface_tilt = 90 - np.degrees(np.arccos(dotproduct))
# 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}
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
[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.
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 up to the east.
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 [degrees]
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)