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.
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