Skip to main content

pvlib/
shading.rs

1/// Angle from horizontal of the line from a point on the row slant length
2/// to the bottom of the facing row.
3///
4/// # Arguments
5/// * `surface_tilt` - Surface tilt angle in degrees from horizontal.
6/// * `gcr` - Ground coverage ratio (row slant length / row spacing).
7/// * `slant_height` - Fraction [0-1] of the module slant height.
8///
9/// # Returns
10/// Ground angle (psi) in degrees.
11pub fn ground_angle(surface_tilt: f64, gcr: f64, slant_height: f64) -> f64 {
12    let x1 = gcr * slant_height * surface_tilt.to_radians().sin();
13    let x2 = gcr * slant_height * surface_tilt.to_radians().cos() + 1.0;
14    x1.atan2(x2).to_degrees()
15}
16
17/// Calculates the masking angle for rows in a standard PV array.
18///
19/// The masking angle is the elevation angle below which diffuse irradiance
20/// is blocked by the preceding row.
21///
22/// # Arguments
23/// * `surface_tilt` - Tilt of the PV modules in degrees.
24/// * `gcr` - Ground Coverage Ratio (module length / row pitch).
25/// * `slant_height` - Fraction [0-1] of the module slant height to evaluate.
26///
27/// # Returns
28/// Masking angle in degrees.
29pub fn masking_angle(surface_tilt: f64, gcr: f64, slant_height: f64) -> f64 {
30    if gcr <= 0.0 {
31        return 0.0;
32    }
33    let numerator = gcr * (1.0 - slant_height) * surface_tilt.to_radians().sin();
34    let denominator = 1.0 - gcr * (1.0 - slant_height) * surface_tilt.to_radians().cos();
35    (numerator / denominator).atan().to_degrees()
36}
37
38/// Average masking angle over the slant height of a row (Passias 1984).
39///
40/// # Arguments
41/// * `surface_tilt` - Panel tilt from horizontal in degrees.
42/// * `gcr` - Ground coverage ratio.
43///
44/// # Returns
45/// Average masking angle in degrees.
46pub fn masking_angle_passias(surface_tilt: f64, gcr: f64) -> f64 {
47    let beta = surface_tilt.to_radians();
48    let sin_b = beta.sin();
49    let cos_b = beta.cos();
50
51    if sin_b.abs() < 1e-10 || gcr <= 0.0 {
52        return 0.0;
53    }
54
55    let x = 1.0 / gcr;
56
57    let term1 = -x * sin_b * (2.0 * x * cos_b - (x * x + 1.0)).abs().ln() / 2.0;
58    let term2 = (x * cos_b - 1.0) * ((x * cos_b - 1.0) / (x * sin_b)).atan();
59    let term3 = (1.0 - x * cos_b) * (cos_b / sin_b).atan();
60    let term4 = x * x.ln() * sin_b;
61
62    let psi_avg = term1 + term2 + term3 + term4;
63
64    if psi_avg.is_finite() {
65        psi_avg.to_degrees()
66    } else {
67        0.0
68    }
69}
70
71/// Projected solar zenith angle onto the plane perpendicular to a tracker axis.
72///
73/// # Arguments
74/// * `solar_zenith` - Sun's apparent zenith in degrees.
75/// * `solar_azimuth` - Sun's azimuth in degrees.
76/// * `axis_tilt` - Axis tilt angle from horizontal in degrees.
77/// * `axis_azimuth` - Axis azimuth angle in degrees (N=0, E=90, S=180, W=270).
78///
79/// # Returns
80/// Projected solar zenith angle in degrees.
81pub fn projected_solar_zenith_angle(
82    solar_zenith: f64,
83    solar_azimuth: f64,
84    axis_tilt: f64,
85    axis_azimuth: f64,
86) -> f64 {
87    let sin_sz = solar_zenith.to_radians().sin();
88    let cos_aa = axis_azimuth.to_radians().cos();
89    let sin_aa = axis_azimuth.to_radians().sin();
90    let sin_at = axis_tilt.to_radians().sin();
91
92    // Sun's x, y, z coordinates
93    let sx = sin_sz * solar_azimuth.to_radians().sin();
94    let sy = sin_sz * solar_azimuth.to_radians().cos();
95    let sz = solar_zenith.to_radians().cos();
96
97    // Project onto surface: Eq. (4) from Anderson & Mikofski (2020)
98    let sx_prime = sx * cos_aa - sy * sin_aa;
99    let sz_prime = sx * sin_aa * sin_at + sy * sin_at * cos_aa + sz * axis_tilt.to_radians().cos();
100
101    // Eq. (5)
102    sx_prime.atan2(sz_prime).to_degrees()
103}
104
105/// 1D shaded fraction for rows (e.g. single-axis trackers).
106///
107/// Based on Anderson & Jensen (2024).
108///
109/// # Arguments
110/// * `solar_zenith` - Solar zenith angle in degrees.
111/// * `solar_azimuth` - Solar azimuth angle in degrees.
112/// * `axis_azimuth` - Axis azimuth in degrees.
113/// * `shaded_row_rotation` - Rotation of the shaded row in degrees.
114/// * `collector_width` - Vertical length of a tilted row.
115/// * `pitch` - Axis-to-axis horizontal spacing.
116/// * `axis_tilt` - Tilt of the rows axis from horizontal in degrees.
117/// * `surface_to_axis_offset` - Distance between rotating axis and collector surface.
118/// * `cross_axis_slope` - Angle of the plane containing row axes from horizontal in degrees.
119///
120/// # Returns
121/// Shaded fraction [0-1].
122#[allow(clippy::too_many_arguments)]
123pub fn shaded_fraction1d(
124    solar_zenith: f64,
125    solar_azimuth: f64,
126    axis_azimuth: f64,
127    shaded_row_rotation: f64,
128    collector_width: f64,
129    pitch: f64,
130    axis_tilt: f64,
131    surface_to_axis_offset: f64,
132    cross_axis_slope: f64,
133) -> f64 {
134    // Use same rotation for shading row
135    let shading_row_rotation = shaded_row_rotation;
136
137    let psza = projected_solar_zenith_angle(solar_zenith, solar_azimuth, axis_tilt, axis_azimuth);
138
139    let thetas_1_s_diff = shading_row_rotation - psza;
140    let thetas_2_s_diff = shaded_row_rotation - psza;
141    let theta_s_rotation_diff = psza - cross_axis_slope;
142
143    let cos_theta_2_s_diff_abs = thetas_2_s_diff.to_radians().cos().abs().max(1e-6);
144    let collector_width_safe = collector_width.max(1e-6);
145    let cross_axis_cos = cross_axis_slope.to_radians().cos().max(1e-6);
146
147    // Eq. (12) from Anderson & Jensen (2024)
148    let t_asterisk = 0.5
149        + thetas_1_s_diff.to_radians().cos().abs() / cos_theta_2_s_diff_abs / 2.0
150        + (psza.signum()
151            * surface_to_axis_offset
152            / collector_width_safe
153            / cos_theta_2_s_diff_abs
154            * (thetas_2_s_diff.to_radians().sin() - thetas_1_s_diff.to_radians().sin()))
155        - (pitch / collector_width_safe
156            * theta_s_rotation_diff.to_radians().cos()
157            / cos_theta_2_s_diff_abs
158            / cross_axis_cos);
159
160    t_asterisk.clamp(0.0, 1.0)
161}
162
163/// Computes the fraction of sky diffuse irradiance that passes the masking angle.
164pub fn sky_diffuse_pass_equation(masking_angle: f64) -> f64 {
165    (1.0 + masking_angle.to_radians().cos()) / 2.0
166}
167
168/// The diffuse irradiance loss caused by row-to-row sky diffuse shading.
169///
170/// Uses the Passias model assuming isotropic sky diffuse irradiance.
171///
172/// # Arguments
173/// * `masking_angle` - Elevation angle below which diffuse irradiance is blocked in degrees.
174///
175/// # Returns
176/// Fraction [0-1] of blocked sky diffuse irradiance.
177pub fn sky_diffuse_passias(masking_angle: f64) -> f64 {
178    1.0 - (masking_angle / 2.0).to_radians().cos().powi(2)
179}