Skip to main content

pvlib/
tracking.rs

1/// Calculate single-axis tracker positions with backtracking support.
2/// 
3/// # Arguments
4/// * `solar_zenith` - Apparent solar zenith angle in degrees.
5/// * `solar_azimuth` - Apparent solar azimuth angle in degrees.
6/// * `axis_tilt` - Tilt of the tracker axis from horizontal in degrees.
7/// * `axis_azimuth` - Azimuth of the tracker axis in degrees (North=0, East=90).
8/// * `max_angle` - Maximum rotation angle of the tracker from horizontal (e.g. 45.0 or 60.0 degrees).
9/// * `backtrack` - Enable backtracking (True).
10/// * `gcr` - Ground Coverage Ratio (module width / row pitch).
11/// 
12/// # Returns
13/// A tuple containing `(surface_tilt, surface_azimuth, aoi)`. All in degrees.
14pub fn singleaxis(
15    solar_zenith: f64,
16    solar_azimuth: f64,
17    axis_tilt: f64,
18    axis_azimuth: f64,
19    max_angle: f64,
20    backtrack: bool,
21    gcr: f64,
22) -> (f64, f64, f64) {
23    if solar_zenith >= 90.0 { return (0.0, axis_azimuth, 90.0); }
24
25    let sz_rad = solar_zenith.to_radians();
26    let sa_rad = solar_azimuth.to_radians();
27    let aa_rad = axis_azimuth.to_radians();
28    let _at_rad = axis_tilt.to_radians(); // Assuming HSAT for MVP
29
30    // Ideal Rotational angle (un-limited)
31    let rot_angle_rad = (sz_rad.tan() * (sa_rad - aa_rad).sin()).atan();
32    let mut rot_deg = rot_angle_rad.to_degrees();
33
34    // Backtracking logic (standard simplified geometric implementation)
35    if backtrack && gcr > 0.0 {
36        let temp = (rot_angle_rad.cos() * gcr).clamp(-1.0, 1.0);
37        let shade_angle = temp.acos();
38        
39        if rot_angle_rad.abs() > shade_angle {
40            let bt_angle_rad = rot_angle_rad.signum() * (rot_angle_rad.abs() - shade_angle);
41            rot_deg -= bt_angle_rad.to_degrees();
42        }
43    }
44
45    // Apply hardware limits
46    rot_deg = rot_deg.clamp(-max_angle, max_angle);
47
48    let surface_tilt = rot_deg.abs();
49    let surface_azimuth = if rot_deg >= 0.0 {
50        (axis_azimuth - 90.0).rem_euclid(360.0)
51    } else {
52        (axis_azimuth + 90.0).rem_euclid(360.0)
53    };
54
55    let cos_aoi = sz_rad.cos() * surface_tilt.to_radians().cos()
56        + sz_rad.sin() * surface_tilt.to_radians().sin() * (sa_rad - surface_azimuth.to_radians()).cos();
57    
58    let aoi = cos_aoi.clamp(-1.0, 1.0).acos().to_degrees();
59
60    (surface_tilt, surface_azimuth, aoi)
61}
62
63/// Calculate tracking axis tilt from slope tilt and azimuths.
64pub fn calc_axis_tilt(slope_azimuth: f64, slope_tilt: f64, axis_azimuth: f64) -> f64 {
65    let sa_rad = slope_azimuth.to_radians();
66    let st_rad = slope_tilt.to_radians();
67    let aa_rad = axis_azimuth.to_radians();
68    
69    let axis_tilt_rad = (st_rad.tan() * (aa_rad - sa_rad).cos()).atan();
70    axis_tilt_rad.to_degrees()
71}
72
73/// Calculate the surface tilt and azimuth angles for a given tracker rotation.
74///
75/// # Arguments
76/// * `tracker_theta` - Tracker rotation angle (degrees). Right-handed rotation
77///   around the axis defined by `axis_tilt` and `axis_azimuth`.
78/// * `axis_tilt` - Tilt of the axis of rotation with respect to horizontal (degrees).
79/// * `axis_azimuth` - Compass direction along which the axis of rotation lies (degrees).
80///
81/// # Returns
82/// A tuple `(surface_tilt, surface_azimuth)` in degrees.
83///
84/// # References
85/// Marion, W.F. and Dobos, A.P., 2013, "Rotation Angle for the Optimum Tracking
86/// of One-Axis Trackers", NREL/TP-6A20-58891.
87pub fn calc_surface_orientation(tracker_theta: f64, axis_tilt: f64, axis_azimuth: f64) -> (f64, f64) {
88    let tt_rad = tracker_theta.to_radians();
89    let at_rad = axis_tilt.to_radians();
90
91    // Surface tilt: acos(cos(tracker_theta) * cos(axis_tilt))
92    let surface_tilt_rad = (tt_rad.cos() * at_rad.cos()).clamp(-1.0, 1.0).acos();
93    let surface_tilt = surface_tilt_rad.to_degrees();
94
95    // Surface azimuth: axis_azimuth + azimuth_delta
96    let sin_st = surface_tilt_rad.sin();
97
98    let azimuth_delta = if sin_st.abs() < 1e-10 {
99        // surface_tilt ~= 0, azimuth is arbitrary; use 90 per pvlib convention
100        90.0
101    } else {
102        // azimuth_delta = asin(sin(tracker_theta) / sin(surface_tilt))
103        let raw = (tt_rad.sin() / sin_st).clamp(-1.0, 1.0).asin().to_degrees();
104
105        if tracker_theta.abs() < 90.0 {
106            raw
107        } else {
108            -raw + tracker_theta.signum() * 180.0
109        }
110    };
111
112    let surface_azimuth = (axis_azimuth + azimuth_delta).rem_euclid(360.0);
113
114    (surface_tilt, surface_azimuth)
115}
116
117/// Calculate cross-axis tilt.
118pub fn calc_cross_axis_tilt(slope_azimuth: f64, slope_tilt: f64, axis_azimuth: f64, axis_tilt: f64) -> f64 {
119    let sa_rad = slope_azimuth.to_radians();
120    let st_rad = slope_tilt.to_radians();
121    let aa_rad = axis_azimuth.to_radians();
122    let at_rad = axis_tilt.to_radians();
123    
124    let cross_axis_tilt_rad = (st_rad.tan() * (aa_rad - sa_rad).sin() * at_rad.cos()).atan();
125    cross_axis_tilt_rad.to_degrees()
126}
127