Skip to main content

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