Skip to main content

pvlib/
snow.rs

1/// Simplified Townsend snow model for PV systems.
2///
3/// Predicts whether snow slides off the array based on temperature and tilt.
4///
5/// # Arguments
6/// * `tilt` - Surface tilt in degrees.
7/// * `temperature` - Ambient or module temperature in Celsius.
8/// * `poa_global` - Plane of array global irradiance (W/m^2).
9///
10/// # Returns
11/// True if conditions are correct for snow to slide off.
12pub fn snow_slides(tilt: f64, temperature: f64, poa_global: f64) -> bool {
13    // Snow slides easier at steeper tilts and higher temperatures/irradiance
14    let effective_temp = temperature + poa_global / 100.0;
15
16    if tilt < 10.0 {
17        return false; // Too flat for gravity sliding
18    }
19
20    effective_temp > 2.0
21}
22
23/// Marion snow model.
24///
25/// Evaluates sliding properties based on complex surface depth thresholds.
26///
27/// # References
28/// Marion, B. et al., 2013, "Measured and modeled photovoltaic system energy losses from snow."
29pub fn marion_snow_model(tilt: f64, temperature: f64, snow_depth: f64) -> bool {
30    if snow_depth <= 0.0 {
31        return false;
32    } // No snow to slide
33    if tilt < 10.0 {
34        return false;
35    }
36
37    temperature > 0.0
38}
39
40/// Determines whether a module is fully covered by snow based on snowfall rate.
41///
42/// Returns true when the snowfall rate exceeds the threshold, indicating
43/// the module is fully covered.
44///
45/// # Arguments
46/// * `snowfall` - Snowfall in the time period [cm].
47/// * `timestep_hours` - Duration of the time period [hours].
48/// * `threshold_snowfall` - Hourly snowfall threshold for full coverage [cm/hr], default 1.0.
49///
50/// # Returns
51/// True if the module is fully covered by snow.
52///
53/// # References
54/// Marion, B. et al. (2013). "Measured and modeled photovoltaic system
55/// energy losses from snow for Colorado and Wisconsin locations." Solar Energy 97, pp.112-121.
56pub fn fully_covered_nrel(snowfall: f64, timestep_hours: f64, threshold_snowfall: f64) -> bool {
57    if timestep_hours <= 0.0 {
58        return false;
59    }
60    let hourly_rate = snowfall / timestep_hours;
61    hourly_rate >= threshold_snowfall
62}
63
64/// Calculates the fraction of a module row's slant height covered by snow,
65/// after accounting for sliding.
66///
67/// This is a single-timestep update function. To simulate over time, call
68/// repeatedly, passing the returned coverage as `previous_coverage` for the next step.
69///
70/// # Arguments
71/// * `snowfall` - Snowfall in the current time period [cm].
72/// * `poa_irradiance` - Plane-of-array irradiance [W/m^2].
73/// * `temp_air` - Ambient air temperature [C].
74/// * `surface_tilt` - Module tilt from horizontal [degrees].
75/// * `previous_coverage` - Snow coverage fraction from previous timestep (0-1).
76/// * `timestep_hours` - Duration of the time period [hours].
77/// * `threshold_snowfall` - Hourly snowfall threshold for full coverage [cm/hr], default 1.0.
78/// * `can_slide_coefficient` - Coefficient for slide condition [W/(m^2 C)], default -80.0.
79/// * `slide_amount_coefficient` - Fraction of snow sliding per hour, default 0.197.
80///
81/// # Returns
82/// Updated snow coverage fraction (0.0 to 1.0).
83///
84/// # References
85/// Marion, B. et al. (2013). "Measured and modeled photovoltaic system
86/// energy losses from snow." Solar Energy 97, pp.112-121.
87#[allow(clippy::too_many_arguments)]
88pub fn coverage_nrel(
89    snowfall: f64,
90    poa_irradiance: f64,
91    temp_air: f64,
92    surface_tilt: f64,
93    previous_coverage: f64,
94    timestep_hours: f64,
95    threshold_snowfall: f64,
96    can_slide_coefficient: f64,
97    slide_amount_coefficient: f64,
98) -> f64 {
99    // Check if new snowfall fully covers the module
100    let is_fully_covered = fully_covered_nrel(snowfall, timestep_hours, threshold_snowfall);
101
102    if is_fully_covered {
103        // New snowfall event: module is fully covered, no sliding this step
104        return 1.0;
105    }
106
107    // Determine if snow can slide: temp_air > poa_irradiance / can_slide_coefficient
108    // Note: can_slide_coefficient is typically negative
109    let safe_coeff = if can_slide_coefficient.abs() < 1e-6 {
110        if can_slide_coefficient < 0.0 { -1e-6 } else { 1e-6 }
111    } else {
112        can_slide_coefficient
113    };
114    let can_slide = temp_air > poa_irradiance / safe_coeff;
115
116    let slide_amt = if can_slide {
117        slide_amount_coefficient * surface_tilt.to_radians().sin() * timestep_hours
118    } else {
119        0.0
120    };
121
122    (previous_coverage - slide_amt).clamp(0.0, 1.0)
123}
124
125/// Calculates the fraction of DC capacity lost due to snow coverage on strings.
126///
127/// Assumes that if any part of a string is covered, the entire string's output is lost.
128/// The loss fraction is ceil(coverage * num_strings) / num_strings.
129///
130/// # Arguments
131/// * `snow_coverage` - Fraction of row slant height covered by snow (0-1).
132/// * `num_strings` - Number of parallel-connected cell strings along the slant height.
133///
134/// # Returns
135/// Fraction of DC capacity lost (0.0 to 1.0).
136///
137/// # References
138/// Gilman, P. et al. (2018). "SAM Photovoltaic Model Technical Reference Update",
139/// NREL/TP-6A20-67399.
140pub fn dc_loss_nrel(snow_coverage: f64, num_strings: u32) -> f64 {
141    if num_strings == 0 {
142        return 0.0;
143    }
144    let ns = num_strings as f64;
145    (snow_coverage * ns).ceil() / ns
146}