pvlib/tracking.rs
1/// Calculate single-axis tracker positions with backtracking support.
2///
3/// Determines the rotation angle of a single-axis tracker using the
4/// projected solar zenith angle approach from Anderson & Mikofski (2020).
5/// Backtracking uses the slope-aware method (Eq. 14 from the same reference).
6///
7/// # Arguments
8/// * `solar_zenith` - Apparent solar zenith angle in degrees.
9/// * `solar_azimuth` - Apparent solar azimuth angle in degrees.
10/// * `axis_tilt` - Tilt of the tracker axis from horizontal in degrees.
11/// * `axis_azimuth` - Azimuth of the tracker axis in degrees (North=0, East=90).
12/// * `max_angle` - Maximum rotation angle of the tracker from horizontal (e.g. 45.0 or 60.0 degrees).
13/// * `backtrack` - Enable backtracking (True).
14/// * `gcr` - Ground Coverage Ratio (module width / row pitch).
15/// * `cross_axis_tilt` - Cross-axis tilt angle in degrees (typically from `calc_cross_axis_tilt`).
16/// Use 0.0 for flat terrain.
17///
18/// # Returns
19/// A tuple containing `(surface_tilt, surface_azimuth, aoi)`. All in degrees.
20///
21/// # References
22/// Anderson, K., and Mikofski, M., 2020, "Slope-Aware Backtracking for
23/// Single-Axis Trackers", Technical Report NREL/TP-5K00-76626.
24#[allow(clippy::too_many_arguments)]
25pub fn singleaxis(
26 solar_zenith: f64,
27 solar_azimuth: f64,
28 axis_tilt: f64,
29 axis_azimuth: f64,
30 max_angle: f64,
31 backtrack: bool,
32 gcr: f64,
33 cross_axis_tilt: f64,
34) -> (f64, f64, f64) {
35 use crate::shading::projected_solar_zenith_angle;
36 use crate::irradiance::aoi as irr_aoi;
37
38 // Sun below horizon -- return NaN (matching pvlib-python)
39 if solar_zenith >= 90.0 {
40 return (f64::NAN, f64::NAN, f64::NAN);
41 }
42
43 // Ideal rotation angle via projected solar zenith angle
44 // (Anderson & Mikofski 2020, handles arbitrary axis_tilt)
45 let mut tracker_theta = projected_solar_zenith_angle(
46 solar_zenith,
47 solar_azimuth,
48 axis_tilt,
49 axis_azimuth,
50 );
51
52 // Backtracking — slope-aware method (Anderson & Mikofski 2020, Eq. 14)
53 if backtrack && gcr > 0.0 {
54 let axes_distance = 1.0 / (gcr * cross_axis_tilt.to_radians().cos());
55
56 // temp = |axes_distance * cos(tracker_theta - cross_axis_tilt)|
57 let temp = (axes_distance * (tracker_theta - cross_axis_tilt).to_radians().cos()).abs();
58
59 if temp < 1.0 {
60 // Backtracking correction needed
61 let omega_correction = -tracker_theta.signum() * temp.acos().to_degrees();
62 tracker_theta += omega_correction;
63 }
64 // else: no row-to-row shade, no correction needed (Eqs. 15-16)
65 }
66
67 // Apply hardware limits
68 tracker_theta = tracker_theta.clamp(-max_angle, max_angle);
69
70 // Calculate surface tilt & azimuth using full orientation model
71 let (surface_tilt, surface_azimuth) =
72 calc_surface_orientation(tracker_theta, axis_tilt, axis_azimuth);
73
74 // Angle of incidence
75 let aoi = irr_aoi(surface_tilt, surface_azimuth, solar_zenith, solar_azimuth);
76
77 (surface_tilt, surface_azimuth, aoi)
78}
79
80/// Calculate tracking axis tilt from slope tilt and azimuths.
81pub fn calc_axis_tilt(slope_azimuth: f64, slope_tilt: f64, axis_azimuth: f64) -> f64 {
82 let sa_rad = slope_azimuth.to_radians();
83 let st_rad = slope_tilt.to_radians();
84 let aa_rad = axis_azimuth.to_radians();
85
86 let axis_tilt_rad = (st_rad.tan() * (aa_rad - sa_rad).cos()).atan();
87 axis_tilt_rad.to_degrees()
88}
89
90/// Calculate the surface tilt and azimuth angles for a given tracker rotation.
91///
92/// # Arguments
93/// * `tracker_theta` - Tracker rotation angle (degrees). Right-handed rotation
94/// around the axis defined by `axis_tilt` and `axis_azimuth`.
95/// * `axis_tilt` - Tilt of the axis of rotation with respect to horizontal (degrees).
96/// * `axis_azimuth` - Compass direction along which the axis of rotation lies (degrees).
97///
98/// # Returns
99/// A tuple `(surface_tilt, surface_azimuth)` in degrees.
100///
101/// # References
102/// Marion, W.F. and Dobos, A.P., 2013, "Rotation Angle for the Optimum Tracking
103/// of One-Axis Trackers", NREL/TP-6A20-58891.
104pub fn calc_surface_orientation(tracker_theta: f64, axis_tilt: f64, axis_azimuth: f64) -> (f64, f64) {
105 let tt_rad = tracker_theta.to_radians();
106 let at_rad = axis_tilt.to_radians();
107
108 // Surface tilt: acos(cos(tracker_theta) * cos(axis_tilt))
109 let surface_tilt_rad = (tt_rad.cos() * at_rad.cos()).clamp(-1.0, 1.0).acos();
110 let surface_tilt = surface_tilt_rad.to_degrees();
111
112 // Surface azimuth: axis_azimuth + azimuth_delta
113 let sin_st = surface_tilt_rad.sin();
114
115 let azimuth_delta = if sin_st.abs() < 1e-10 {
116 // surface_tilt ~= 0, azimuth is arbitrary; use 90 per pvlib convention
117 90.0
118 } else {
119 // azimuth_delta = asin(sin(tracker_theta) / sin(surface_tilt))
120 let raw = (tt_rad.sin() / sin_st).clamp(-1.0, 1.0).asin().to_degrees();
121
122 if tracker_theta.abs() < 90.0 {
123 raw
124 } else {
125 -raw + tracker_theta.signum() * 180.0
126 }
127 };
128
129 let surface_azimuth = (axis_azimuth + azimuth_delta).rem_euclid(360.0);
130
131 (surface_tilt, surface_azimuth)
132}
133
134/// Calculate cross-axis tilt.
135pub fn calc_cross_axis_tilt(slope_azimuth: f64, slope_tilt: f64, axis_azimuth: f64, axis_tilt: f64) -> f64 {
136 let sa_rad = slope_azimuth.to_radians();
137 let st_rad = slope_tilt.to_radians();
138 let aa_rad = axis_azimuth.to_radians();
139 let at_rad = axis_tilt.to_radians();
140
141 let cross_axis_tilt_rad = (st_rad.tan() * (aa_rad - sa_rad).sin() * at_rad.cos()).atan();
142 cross_axis_tilt_rad.to_degrees()
143}
144