Skip to content

Commit 7e88d21

Browse files
authored
Improvements and correction to snow.loss_townsend (#1653)
* add limit to gamma term, string_factor, tests * adjust existing test, whatsnew * spacing * remove extra line * review * trailing space
1 parent faf27fe commit 7e88d21

File tree

3 files changed

+129
-16
lines changed

3 files changed

+129
-16
lines changed

docs/sphinx/source/whatsnew/v0.9.5.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ Deprecations
1616

1717
Enhancements
1818
~~~~~~~~~~~~
19+
* Added the optional `string_factor` parameter to
20+
:py:func:`pvlib.snow.loss_townsend` (:issue:`1636`, :pull:`1653`)
21+
22+
Bug fixes
23+
~~~~~~~~~
24+
* Added a limit to :py:func:`pvlib.snow.loss_townsend` to guard against
25+
incorrect loss results for systems that are near the ground (:issue:`1636`,
26+
:pull:`1653`)
1927

2028
* Added optional ``n_ar`` parameter to :py:func:`pvlib.iam.physical` to
2129
support an anti-reflective coating. (:issue:`1501`, :pull:`1616`)
@@ -53,6 +61,7 @@ Contributors
5361
~~~~~~~~~~~~
5462
* Kevin Anderson (:ghuser:`kanderso-nrel`)
5563
* Will Holmgren (:ghuser:`wholmgren`)
64+
* Cliff Hansen (:ghuser:`cwhanse`)
5665
* Adam R. Jensen (:ghuser:`adamrjensen`)
5766
* Pratham Chauhan (:ghuser:`ooprathamm`)
5867
* Karel De Brabandere (:ghuser:`kdebrab`)

pvlib/snow.py

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ def _townsend_effective_snow(snow_total, snow_events):
219219

220220
def loss_townsend(snow_total, snow_events, surface_tilt, relative_humidity,
221221
temp_air, poa_global, slant_height, lower_edge_height,
222-
angle_of_repose=40):
222+
string_factor=1.0, angle_of_repose=40):
223223
'''
224224
Calculates monthly snow loss based on the Townsend monthly snow loss
225225
model [1]_.
@@ -230,7 +230,8 @@ def loss_townsend(snow_total, snow_events, surface_tilt, relative_humidity,
230230
Snow received each month. Referred to as S in [1]_. [cm]
231231
232232
snow_events : array-like
233-
Number of snowfall events each month. Referred to as N in [1]_. [-]
233+
Number of snowfall events each month. May be int or float type for
234+
the average events in a typical month. Referred to as N in [1]_.
234235
235236
surface_tilt : float
236237
Tilt angle of the array. [deg]
@@ -250,6 +251,11 @@ def loss_townsend(snow_total, snow_events, surface_tilt, relative_humidity,
250251
lower_edge_height : float
251252
Distance from array lower edge to the ground. [m]
252253
254+
string_factor : float, default 1.0
255+
Multiplier applied to monthly loss fraction. Use 1.0 if the DC array
256+
has only one string of modules in the slant direction, use 0.75
257+
otherwise. [-]
258+
253259
angle_of_repose : float, default 40
254260
Piled snow angle, assumed to stabilize at 40°, the midpoint of
255261
25°-55° avalanching slope angles. [deg]
@@ -263,7 +269,12 @@ def loss_townsend(snow_total, snow_events, surface_tilt, relative_humidity,
263269
-----
264270
This model has not been validated for tracking arrays; however, for
265271
tracking arrays [1]_ suggests using the maximum rotation angle in place
266-
of ``surface_tilt``.
272+
of ``surface_tilt``. The author of [1]_ recommends using one-half the
273+
table width for ``slant_height``, i.e., the distance from the tracker
274+
axis to the module edge.
275+
276+
The parameter `string_factor` is an enhancement added to the model after
277+
publication of [1]_ per private communication with the model's author.
267278
268279
References
269280
----------
@@ -273,13 +284,22 @@ def loss_townsend(snow_total, snow_events, surface_tilt, relative_humidity,
273284
:doi:`10.1109/PVSC.2011.6186627`
274285
'''
275286

287+
# unit conversions from cm and m to in, from C to K, and from % to fraction
288+
# doing this early to facilitate comparison of this code with [1]
289+
snow_total_inches = snow_total / 2.54 # to inches
290+
relative_humidity_fraction = relative_humidity / 100.
291+
poa_global_kWh = poa_global / 1000.
292+
slant_height_inches = slant_height * 39.37
293+
lower_edge_height_inches = lower_edge_height * 39.37
294+
temp_air_kelvin = temp_air + 273.15
295+
276296
C1 = 5.7e04
277297
C2 = 0.51
278298

279-
snow_total_prev = np.roll(snow_total, 1)
299+
snow_total_prev = np.roll(snow_total_inches, 1)
280300
snow_events_prev = np.roll(snow_events, 1)
281301

282-
effective_snow = _townsend_effective_snow(snow_total, snow_events)
302+
effective_snow = _townsend_effective_snow(snow_total_inches, snow_events)
283303
effective_snow_prev = _townsend_effective_snow(
284304
snow_total_prev,
285305
snow_events_prev
@@ -288,37 +308,38 @@ def loss_townsend(snow_total, snow_events, surface_tilt, relative_humidity,
288308
1 / 3 * effective_snow_prev
289309
+ 2 / 3 * effective_snow
290310
)
291-
effective_snow_weighted_m = effective_snow_weighted / 100
292311

293-
lower_edge_height_clipped = np.maximum(lower_edge_height, 0.01)
312+
# the lower limit of 0.1 in^2 is per private communication with the model's
313+
# author. CWH 1/30/2023
314+
lower_edge_distance = np.clip(
315+
lower_edge_height_inches**2 - effective_snow_weighted**2, a_min=0.1,
316+
a_max=None)
294317
gamma = (
295-
slant_height
296-
* effective_snow_weighted_m
318+
slant_height_inches
319+
* effective_snow_weighted
297320
* cosd(surface_tilt)
298-
/ (lower_edge_height_clipped**2 - effective_snow_weighted_m**2)
321+
/ lower_edge_distance
299322
* 2
300323
* tand(angle_of_repose)
301324
)
302325

303326
ground_interference_term = 1 - C2 * np.exp(-gamma)
304-
relative_humidity_fraction = relative_humidity / 100
305-
temp_air_kelvin = temp_air + 273.15
306-
effective_snow_weighted_in = effective_snow_weighted / 2.54
307-
poa_global_kWh = poa_global / 1000
308327

309328
# Calculate Eqn. 3 in the reference.
310329
# Although the reference says Eqn. 3 calculates percentage loss, the y-axis
311330
# of Figure 7 indicates Eqn. 3 calculates fractional loss. Since the slope
312331
# of the line in Figure 7 is the same as C1 in Eqn. 3, it is assumed that
313332
# Eqn. 3 calculates fractional loss.
333+
314334
loss_fraction = (
315335
C1
316-
* effective_snow_weighted_in
336+
* effective_snow_weighted
317337
* cosd(surface_tilt)**2
318338
* ground_interference_term
319339
* relative_humidity_fraction
320340
/ temp_air_kelvin**2
321341
/ poa_global_kWh**0.67
342+
* string_factor
322343
)
323344

324345
return np.clip(loss_fraction, 0, 1)

pvlib/tests/test_snow.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from pvlib import snow
77
from pvlib.tools import sind
88

9+
import pytest
10+
911

1012
def test_fully_covered_nrel():
1113
dt = pd.date_range(start="2019-1-1 12:00:00", end="2019-1-1 18:00:00",
@@ -108,6 +110,7 @@ def test__townsend_effective_snow():
108110

109111

110112
def test_loss_townsend():
113+
# hand-calculated solution
111114
snow_total = np.array([25.4, 25.4, 12.7, 2.54, 0, 0, 0, 0, 0, 0, 12.7,
112115
25.4])
113116
snow_events = np.array([2, 2, 1, 0, 0, 0, 0, 0, 0, 0, 2, 3])
@@ -118,12 +121,92 @@ def test_loss_townsend():
118121
poa_global = np.array([350000, 350000, 350000, 350000, 350000, 350000,
119122
350000, 350000, 350000, 350000, 350000, 350000])
120123
angle_of_repose = 40
124+
string_factor = 1.0
121125
slant_height = 2.54
122126
lower_edge_height = 0.254
123127
expected = np.array([0.07696253, 0.07992262, 0.06216201, 0.01715392, 0, 0,
124128
0, 0, 0, 0, 0.02643821, 0.06068194])
125129
actual = snow.loss_townsend(snow_total, snow_events, surface_tilt,
126130
relative_humidity, temp_air,
127131
poa_global, slant_height,
128-
lower_edge_height, angle_of_repose)
132+
lower_edge_height, string_factor,
133+
angle_of_repose)
129134
np.testing.assert_allclose(expected, actual, rtol=1e-05)
135+
136+
137+
@pytest.mark.parametrize(
138+
'poa_global,surface_tilt,slant_height,lower_edge_height,string_factor,expected', # noQA: E501
139+
[
140+
(np.asarray(
141+
[60., 80., 100., 125., 175., 225., 225., 210., 175., 125., 90.,
142+
60.], dtype=float) * 1000.,
143+
2.,
144+
79. / 39.37,
145+
3. / 39.37,
146+
1.0,
147+
np.asarray(
148+
[44, 34, 20, 9, 3, 1, 0, 0, 0, 2, 6, 25], dtype=float)
149+
),
150+
(np.asarray(
151+
[60., 80., 100., 125., 175., 225., 225., 210., 175., 125., 90.,
152+
60.], dtype=float) * 1000.,
153+
5.,
154+
316 / 39.37,
155+
120. / 39.37,
156+
0.75,
157+
np.asarray(
158+
[22, 16, 9, 4, 1, 0, 0, 0, 0, 1, 2, 12], dtype=float)
159+
),
160+
(np.asarray(
161+
[60., 80., 100., 125., 175., 225., 225., 210., 175., 125., 90.,
162+
60.], dtype=float) * 1000.,
163+
23.,
164+
158 / 39.27,
165+
12 / 39.37,
166+
0.75,
167+
np.asarray(
168+
[28, 21, 13, 6, 2, 0, 0, 0, 0, 1, 4, 16], dtype=float)
169+
),
170+
(np.asarray(
171+
[80., 100., 125., 150., 225., 300., 300., 275., 225., 150., 115.,
172+
80.], dtype=float) * 1000.,
173+
52.,
174+
39.5 / 39.37,
175+
34. / 39.37,
176+
0.75,
177+
np.asarray(
178+
[7, 5, 3, 1, 0, 0, 0, 0, 0, 0, 1, 4], dtype=float)
179+
),
180+
(np.asarray(
181+
[80., 100., 125., 150., 225., 300., 300., 275., 225., 150., 115.,
182+
80.], dtype=float) * 1000.,
183+
60.,
184+
39.5 / 39.37,
185+
25. / 39.37,
186+
1.,
187+
np.asarray(
188+
[7, 5, 3, 1, 0, 0, 0, 0, 0, 0, 1, 3], dtype=float)
189+
)
190+
]
191+
)
192+
def test_loss_townsend_cases(poa_global, surface_tilt, slant_height,
193+
lower_edge_height, string_factor, expected):
194+
# test cases from Townsend, 1/27/2023, addeed by cwh
195+
# snow_total in inches, convert to cm for pvlib
196+
snow_total = np.asarray(
197+
[20, 15, 10, 4, 1.5, 0, 0, 0, 0, 1.5, 4, 15], dtype=float) * 2.54
198+
# snow events are an average for each month
199+
snow_events = np.asarray(
200+
[5, 4.2, 2.8, 1.3, 0.8, 0, 0, 0, 0, 0.5, 1.5, 4.5], dtype=float)
201+
# air temperature in C
202+
temp_air = np.asarray(
203+
[-6., -2., 1., 4., 7., 10., 13., 16., 14., 12., 7., -3.], dtype=float)
204+
# relative humidity in %
205+
relative_humidity = np.asarray(
206+
[78., 80., 75., 65., 60., 55., 55., 55., 50., 55., 60., 70.],
207+
dtype=float)
208+
actual = snow.loss_townsend(
209+
snow_total, snow_events, surface_tilt, relative_humidity, temp_air,
210+
poa_global, slant_height, lower_edge_height, string_factor)
211+
actual = np.around(actual * 100)
212+
assert np.allclose(expected, actual)

0 commit comments

Comments
 (0)