"""
The ``snow`` module contains functions that model module snow cover and the
associated effects on PV module output
"""
import numpy as np
import pandas as pd
from pvlib.tools import (
_pandas_infer_freq_BaseOffset_transition_handler,
cosd,
sind,
tand,
)
def _time_delta_in_hours(times):
delta = times.to_series().diff()
return delta.dt.total_seconds().div(3600)
[docs]
def fully_covered_nrel(snowfall, snow_depth=None, threshold_snowfall=1.,
threshold_depth=1.):
'''
Calculates the timesteps when modules are fully covered by snow.
Parameters
----------
snowfall: Series
Snowfall in each time period. [cm]
snow_depth: Series, optional
Snow depth on the ground at the beginning of each time period.
Must have the same index as ``snowfall``. [cm]
threshold_snowfall: float, default 1.0
Hourly snowfall above which the row is fully covered for that hour.
[cm/hr]
threshold_depth: float, default 1.0
Snow depth on the ground, above which snow can affect the modules. [cm]
Returns
----------
covered: Series
A Series of boolean, True where the snowfall exceeds the defined
threshold to fully cover the panel.
Notes
-----
Implements the model described in [1]_ with minor improvements in [2]_.
``snow_depth`` is used to return `False` (not fully covered) when snow
is less than ``threshold_depth``. This check is described in [2]_ as needed
for systems with low tilt angle.
References
----------
.. [1] Marion, B.; Schaefer, R.; Caine, H.; Sanchez, G. (2013).
"Measured and modeled photovoltaic system energy losses from snow for
Colorado and Wisconsin locations." Solar Energy 97; pp.112-121.
.. [2] Ryberg, D; Freeman, J. "Integration, Validation, and Application
of a PV Snow Coverage Model in SAM" (2017) NREL Technical Report
NREL/TP-6A20-68705
'''
timestep = _time_delta_in_hours(snowfall.index)
hourly_snow_rate = snowfall / timestep
# if we can infer a time frequency, use first snowfall value
# otherwise the first snowfall value is ignored
freq = _pandas_infer_freq_BaseOffset_transition_handler(snowfall.index)
if freq is not None:
timedelta = pd.tseries.frequencies.to_offset(freq) / pd.Timedelta('1h')
hourly_snow_rate.iloc[0] = snowfall.iloc[0] / timedelta
else: # can't infer frequency from index
hourly_snow_rate.iloc[0] = 0 # replaces NaN
covered = (hourly_snow_rate > threshold_snowfall)
# no coverage when no snow on the ground
if snow_depth is not None:
covered = covered & (snow_depth >= threshold_depth)
return covered
[docs]
def coverage_nrel(snowfall, poa_irradiance, temp_air, surface_tilt,
snow_depth=None, initial_coverage=0, threshold_snowfall=1.,
threshold_depth=1., can_slide_coefficient=-80.,
slide_amount_coefficient=0.197):
'''
Calculates the fraction of the slant height of a row of modules that is
covered by snow at every time step.
Implements the model described in [1]_ with minor improvements in [2]_,
with the change that the output is in fraction of the row's slant height
rather than in tenths of the row slant height. As described in [1]_, model
validation focused on fixed tilt systems.
Parameters
----------
snowfall : Series
Snowfall within each time period. [cm]
poa_irradiance : Series
Total in-plane irradiance [W/m^2]
temp_air : Series
Ambient air temperature [C]
surface_tilt : numeric
Tilt of module's from horizontal, e.g. surface facing up = 0,
surface facing horizon = 90. [degrees]
snow_depth : Series, optional
Snow depth on the ground at the beginning of each time period.
Must have the same index as ``snowfall``. [cm]
initial_coverage : float, default 0
Fraction of row's slant height that is covered with snow at the
beginning of the simulation. [unitless]
threshold_snowfall: float, default 1.0
Hourly snowfall above which the row is fully covered for that hour.
[cm/hr]
threshold_depth: float, default 1.0
Snow depth on the ground, above which snow can affect the modules. [cm]
can_slide_coefficient : float, default -80.
Coefficient to determine if snow can slide given irradiance and air
temperature. [W/(m^2 C)]
slide_amount_coefficient : float, default 0.197
Coefficient to determine fraction of snow that slides off in one hour.
[unitless]
Returns
-------
snow_coverage : Series
The fraction of the slant height of a row of modules that is covered
by snow at each time step.
Notes
-----
In [1]_, ``can_slide_coefficient`` is termed `m`, and the value of
``slide_amount_coefficient`` is given in tenths of a module's slant height.
``snow_depth`` is used to set ``snow_coverage`` to 0 (not fully covered)
when snow is less than ``threshold_depth``. This check is described in
[2]_ as needed for systems with low tilt angle.
References
----------
.. [1] Marion, B.; Schaefer, R.; Caine, H.; Sanchez, G. (2013).
"Measured and modeled photovoltaic system energy losses from snow for
Colorado and Wisconsin locations." Solar Energy 97; pp.112-121.
.. [2] Ryberg, D; Freeman, J. (2017). "Integration, Validation, and
Application of a PV Snow Coverage Model in SAM" NREL Technical Report
NREL/TP-6A20-68705
'''
# find times with new snowfall
new_snowfall = fully_covered_nrel(snowfall, snow_depth, threshold_snowfall,
threshold_depth)
# set up output Series
snow_coverage = pd.Series(np.nan, index=poa_irradiance.index)
# determine amount that snow can slide in each timestep
can_slide = temp_air > poa_irradiance / can_slide_coefficient
slide_amt = slide_amount_coefficient * sind(surface_tilt) * \
_time_delta_in_hours(poa_irradiance.index)
slide_amt[~can_slide] = 0.
# don't slide during snow events
slide_amt[new_snowfall] = 0.
# don't slide in the interval preceding the snowfall data
slide_amt.iloc[0] = 0
if snow_depth is not None:
# All slides off if snow on the ground is less than threshold_depth.
# Described in [2] to avoid non-sliding snow for low-tilt systems.
# Default threshold_depth of 1cm is from [2[ and SAM's implementation.
# https://github.com/NatLabRockies/ssc/issues/1265
slide_amt[snow_depth < threshold_depth] = 1.
# build time series of cumulative slide amounts
sliding_period_ID = new_snowfall.cumsum()
cumulative_sliding = slide_amt.groupby(sliding_period_ID).cumsum()
# set up time series of snow coverage without any sliding applied
snow_coverage[new_snowfall] = 1.0
if np.isnan(snow_coverage.iloc[0]):
snow_coverage.iloc[0] = initial_coverage
snow_coverage = snow_coverage.ffill()
snow_coverage -= cumulative_sliding
return snow_coverage.clip(lower=0)
[docs]
def dc_loss_nrel(snow_coverage, num_strings):
'''
Calculates the fraction of DC capacity lost due to snow coverage.
DC capacity loss assumes that if a string is partially covered by snow,
the string's capacity is lost; see [1]_, Eq. 11.8.
Module orientation is accounted for by specifying the number of cell
strings in parallel along the slant height.
For example, a typical 60-cell module has 3 parallel strings, each
comprising 20 cells in series, with the cells arranged in 6 columns of 10
cells each. For a row consisting of single modules, if the module is
mounted in portrait orientation, i.e., the row slant height is along a
column of 10 cells, there is 1 string in parallel along the row slant
height, so `num_strings=1`. In contrast, if the module is mounted in
landscape orientation with the row slant height comprising 6 cells, there
are 3 parallel strings along the row slant height, so `num_strings=3`.
Parameters
----------
snow_coverage : numeric
The fraction of row slant height covered by snow at each time step.
num_strings: int
The number of parallel-connected strings along a row slant height.
Returns
-------
loss : numeric
fraction of DC capacity loss due to snow coverage at each time step.
References
----------
.. [1] Gilman, P. et al., (2018). "SAM Photovoltaic Model Technical
Reference Update", NREL Technical Report NREL/TP-6A20-67399.
Available at https://www.nlr.gov/docs/fy18osti/67399.pdf
'''
return np.ceil(snow_coverage * num_strings) / num_strings
def _townsend_effective_snow(snow_total, snow_events):
'''
Calculates effective snow using the total snowfall received each month and
the number of snowfall events each month.
Parameters
----------
snow_total : array-like
Snow received each month. Referred to as S in [1]_. [cm]
snow_events : array-like
Number of snowfall events each month. Referred to as N in [1]_. [-]
Returns
-------
effective_snowfall : array-like
Effective snowfall as defined in the Townsend model. [cm]
References
----------
.. [1] Townsend, Tim & Powers, Loren. (2011). Photovoltaics and snow: An
update from two winters of measurements in the SIERRA. 37th IEEE
Photovoltaic Specialists Conference, Seattle, WA, USA.
:doi:`10.1109/PVSC.2011.6186627`
'''
snow_events_no_zeros = np.maximum(snow_events, 1)
effective_snow = 0.5 * snow_total * (1 + 1 / snow_events_no_zeros)
return np.where(snow_events > 0, effective_snow, 0)
[docs]
def loss_townsend(snow_total, snow_events, surface_tilt, relative_humidity,
temp_air, poa_global, slant_height, lower_edge_height,
string_factor=1.0, angle_of_repose=40,
front_side_fraction=1.0):
'''
Calculates monthly snow loss based on the Townsend monthly snow loss
model.
This model is described in [1]_.
Parameters
----------
snow_total : array-like
Snow received each month. Referred to as S in [1]_. [cm]
snow_events : array-like
Number of snowfall events each month. Snow events are defined as days
in the month that have snowfall greater than 1 inch. May be int or
float type for the average events in a typical month. Referred to as N
in [1]_.
surface_tilt : float
Tilt angle of the array. [deg]
relative_humidity : array-like
Monthly average relative humidity. [%]
temp_air : array-like
Monthly average ambient temperature. [C]
poa_global : array-like
Monthly plane of array insolation. [Wh/m2]
slant_height : float
Row length in the slanted plane of array dimension. [m]
lower_edge_height : float
Distance from array lower edge to the ground. [m]
string_factor : float, default 1.0
Multiplier applied to monthly loss fraction. Use 1.0 if the DC array
has only one string of modules in the slant direction, use 0.75
otherwise. [-]
angle_of_repose : float, default 40
Piled snow angle, assumed to stabilize at 40°, the midpoint of
25°-55° avalanching slope angles. [deg]
front_side_fraction : numeric or array-like, default 1.0
Fraction of monthly energy from front-side insolation. [unitless]
Multiplies the calculated loss fraction. For example,
use 0.9 when 90% of monthly energy is from the front side
of a bifacial system and 10% is from the rear side.
Returns
-------
loss : array-like
Monthly average DC capacity loss fraction due to snow coverage.
Notes
-----
This model has not been validated for tracking arrays; however, for
tracking arrays [1]_ suggests using the maximum rotation angle in place
of ``surface_tilt``. The author of [1]_ recommends using one-half the
table width for ``slant_height``, i.e., the distance from the tracker
axis to the module edge.
The parameter `string_factor` is an enhancement added to the model after
publication of [1]_, as described in [2]_.
The definition for snow events documented above is based on [3]_.
For bifacial systems, [2]_ recommends including both front-side and
rear-side insolation in ``poa_global``. The resulting loss is
scaled by the front-side energy fraction ``front_side_fraction``.
References
----------
.. [1] Townsend, Tim & Powers, Loren. (2011). Photovoltaics and snow: An
update from two winters of measurements in the SIERRA. 37th IEEE
Photovoltaic Specialists Conference, Seattle, WA, USA.
:doi:`10.1109/PVSC.2011.6186627`
.. [2] Townsend, T. and Previtali, J. (2023). A Fresh Dusting: Current
Uses of the Townsend Snow Model. In "Photovoltaic Reliability
Workshop (PVRW) 2023 Proceedings: Posters.", ed. Silverman,
T. J. Dec. 2023. NREL/CP-5900-87918.
Available at: https://www.osti.gov/biblio/2229734
.. [3] Townsend, T. (2013). Predicting PV Energy Loss Caused by Snow.
Solar Power International, Chicago IL.
:doi:`10.13140/RG.2.2.14299.68647`
'''
# unit conversions from cm and m to in, from C to K, and from % to fraction
# doing this early to facilitate comparison of this code with [1]
snow_total_inches = snow_total / 2.54 # to inches
relative_humidity_fraction = relative_humidity / 100.
poa_global_kWh = poa_global / 1000.
slant_height_inches = slant_height * 39.37
lower_edge_height_inches = lower_edge_height * 39.37
temp_air_kelvin = temp_air + 273.15
C1 = 5.7e04
C2 = 0.51
snow_total_prev = np.roll(snow_total_inches, 1)
snow_events_prev = np.roll(snow_events, 1)
effective_snow = _townsend_effective_snow(snow_total_inches, snow_events)
effective_snow_prev = _townsend_effective_snow(
snow_total_prev,
snow_events_prev
)
effective_snow_weighted = (
1 / 3 * effective_snow_prev
+ 2 / 3 * effective_snow
)
# the lower limit of 0.1 in^2 is per private communication with the model's
# author. CWH 1/30/2023
lower_edge_distance = np.clip(
lower_edge_height_inches**2 - effective_snow_weighted**2, a_min=0.1,
a_max=None)
gamma = (
slant_height_inches
* effective_snow_weighted
* cosd(surface_tilt)
/ lower_edge_distance
* 2
* tand(angle_of_repose)
)
ground_interference_term = 1 - C2 * np.exp(-gamma)
# Calculate Eqn. 3 in the reference.
# Although the reference says Eqn. 3 calculates percentage loss, the y-axis
# of Figure 7 indicates Eqn. 3 calculates fractional loss. Since the slope
# of the line in Figure 7 is the same as C1 in Eqn. 3, it is assumed that
# Eqn. 3 calculates fractional loss.
loss_fraction = (
C1
* effective_snow_weighted
* cosd(surface_tilt)**2
* ground_interference_term
* relative_humidity_fraction
/ temp_air_kelvin**2
/ poa_global_kWh**0.67
* string_factor
)
loss_fraction = loss_fraction * front_side_fraction
return np.clip(loss_fraction, 0, 1)