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