Skip to content

Commit 27872b8

Browse files
authored
Add diffuse self-shading functions (#1017)
* Create shading.py * add tests * add example * api entries * whatsnew * lint * docstring update * comment tests * rename passias_x -> x_passias, add general masking_angle function * diffuse horizontal -> sky diffuse * height -> slant_height, plus other review comments
1 parent 49da031 commit 27872b8

File tree

5 files changed

+359
-0
lines changed

5 files changed

+359
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""
2+
Diffuse Self-Shading
3+
====================
4+
5+
Modeling the reduction in diffuse irradiance caused by row-to-row diffuse
6+
shading.
7+
"""
8+
9+
# %%
10+
# The term "self-shading" usually refers to adjacent rows blocking direct
11+
# irradiance and casting shadows on each other. However, the concept also
12+
# applies to diffuse irradiance because rows block a portion of the sky
13+
# dome even when the sun is high in the sky. The irradiance loss fraction
14+
# depends on how tightly the rows are packed and where on the module the
15+
# loss is evaluated -- a point near the top of edge of a module will see
16+
# more of the sky than a point near the bottom edge.
17+
#
18+
# This example uses the approach presented by Passias and Källbäck in [1]_
19+
# and recreates two figures from that paper using
20+
# :py:func:`pvlib.shading.masking_angle_passias` and
21+
# :py:func:`pvlib.shading.sky_diffuse_passias`.
22+
#
23+
# References
24+
# ----------
25+
# .. [1] D. Passias and B. Källbäck, "Shading effects in rows of solar cell
26+
# panels", Solar Cells, Volume 11, Pages 281-291. 1984.
27+
# DOI: 10.1016/0379-6787(84)90017-6
28+
29+
from pvlib import shading, irradiance
30+
import matplotlib.pyplot as plt
31+
import numpy as np
32+
33+
# %%
34+
# First we'll recreate Figure 4, showing how the average masking angle varies
35+
# with array tilt and array packing. The masking angle of a given point on a
36+
# module is the angle from horizontal to the next row's top edge and represents
37+
# the portion of the sky dome blocked by the next row. Because it changes
38+
# from the bottom to the top of a module, the average across the module is
39+
# calculated. In [1]_, ``k`` refers to the ratio of row pitch to row slant
40+
# height (i.e. 1 / GCR).
41+
42+
surface_tilt = np.arange(0, 90, 0.5)
43+
44+
plt.figure()
45+
for k in [1, 1.5, 2, 2.5, 3, 4, 5, 7, 10]:
46+
gcr = 1/k
47+
psi = shading.masking_angle_passias(surface_tilt, gcr)
48+
plt.plot(surface_tilt, psi, label='k={}'.format(k))
49+
50+
plt.xlabel('Inclination angle [degrees]')
51+
plt.ylabel('Average masking angle [degrees]')
52+
plt.legend()
53+
plt.show()
54+
55+
# %%
56+
# So as the array is packed tighter (decreasing ``k``), the average masking
57+
# angle increases.
58+
#
59+
# Next we'll recreate Figure 5. Note that the y-axis here is the ratio of
60+
# diffuse plane of array irradiance (after accounting for shading) to diffuse
61+
# horizontal irradiance. This means that the deviation from 100% is due to the
62+
# combination of self-shading and the fact that being at a tilt blocks off
63+
# the portion of the sky behind the row. The first effect is modeled with
64+
# :py:func:`pvlib.shading.sky_diffuse_passias` and the second with
65+
# :py:func:`pvlib.irradiance.isotropic`.
66+
67+
plt.figure()
68+
for k in [1, 1.5, 2, 10]:
69+
gcr = 1/k
70+
psi = shading.masking_angle_passias(surface_tilt, gcr)
71+
shading_loss = shading.sky_diffuse_passias(psi)
72+
transposition_ratio = irradiance.isotropic(surface_tilt, dhi=1.0)
73+
relative_diffuse = transposition_ratio * (1-shading_loss) * 100 # %
74+
plt.plot(surface_tilt, relative_diffuse, label='k={}'.format(k))
75+
76+
plt.xlabel('Inclination angle [degrees]')
77+
plt.ylabel('Relative diffuse irradiance [%]')
78+
plt.ylim(0, 105)
79+
plt.legend()
80+
plt.show()
81+
82+
# %%
83+
# As ``k`` decreases, GCR increases, so self-shading loss increases and
84+
# collected diffuse irradiance decreases.

docs/sphinx/source/api.rst

+6
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,12 @@ Effects on PV System Output
354354
soiling.hsu
355355
soiling.kimber
356356

357+
.. autosummary::
358+
:toctree: generated/
359+
360+
shading.masking_angle
361+
shading.masking_angle_passias
362+
shading.sky_diffuse_passias
357363

358364

359365
Tracking

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

+5
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ Enhancements
3838
* Add :py:func:`pvlib.iam.marion_diffuse` and
3939
:py:func:`pvlib.iam.marion_integrate` to calculate IAM values for
4040
diffuse irradiance. (:pull:`984`)
41+
* Add :py:func:`pvlib.shading.sky_diffuse_passias`,
42+
:py:func:`pvlib.shading.masking_angle_passias`, and
43+
:py:func:`pvlib.shading.masking_angle` to model diffuse shading loss.
44+
(:pull:`1017`)
4145
* Add :py:func:`pvlib.inverter.fit_sandia` that fits the Sandia inverter model
4246
to a set of inverter efficiency curves. (:pull:`1011`)
4347

@@ -75,6 +79,7 @@ Documentation
7579
* Add a transposition gain example to the gallery. (:pull:`979`)
7680
* Add a gallery example of calculating diffuse IAM using
7781
:py:func:`pvlib.iam.marion_diffuse`. (:pull:`984`)
82+
* Add a gallery example of modeling diffuse shading loss. (:pull:`1017`)
7883
* Add minigalleries to API reference pages. (:pull:`991`)
7984

8085
Requirements

pvlib/shading.py

+193
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
"""
2+
The ``shading`` module contains functions that model module shading and the
3+
associated effects on PV module output
4+
"""
5+
6+
import numpy as np
7+
import pandas as pd
8+
from pvlib.tools import sind, cosd
9+
10+
11+
def masking_angle(surface_tilt, gcr, slant_height):
12+
"""
13+
The elevation angle below which diffuse irradiance is blocked.
14+
15+
The ``height`` parameter determines how far up the module's surface to
16+
evaluate the masking angle. The lower the point, the steeper the masking
17+
angle [1]_. SAM uses a "worst-case" approach where the masking angle
18+
is calculated for the bottom of the array (i.e. ``slant_height=0``) [2]_.
19+
20+
Parameters
21+
----------
22+
surface_tilt : numeric
23+
Panel tilt from horizontal [degrees].
24+
25+
gcr : float
26+
The ground coverage ratio of the array [unitless].
27+
28+
slant_height : numeric
29+
The distance up the module's slant height to evaluate the masking
30+
angle, as a fraction [0-1] of the module slant height [unitless].
31+
32+
Returns
33+
-------
34+
mask_angle : numeric
35+
Angle from horizontal where diffuse light is blocked by the
36+
preceding row [degrees].
37+
38+
See Also
39+
--------
40+
masking_angle_passias
41+
sky_diffuse_passias
42+
43+
References
44+
----------
45+
.. [1] D. Passias and B. Källbäck, "Shading effects in rows of solar cell
46+
panels", Solar Cells, Volume 11, Pages 281-291. 1984.
47+
DOI: 10.1016/0379-6787(84)90017-6
48+
.. [2] Gilman, P. et al., (2018). "SAM Photovoltaic Model Technical
49+
Reference Update", NREL Technical Report NREL/TP-6A20-67399.
50+
Available at https://www.nrel.gov/docs/fy18osti/67399.pdf
51+
"""
52+
# The original equation (8 in [1]) requires pitch and collector width,
53+
# but it's easy to non-dimensionalize it to make it a function of GCR
54+
# by factoring out B from the argument to arctan.
55+
numerator = (1 - slant_height) * sind(surface_tilt)
56+
denominator = 1/gcr - (1 - slant_height) * cosd(surface_tilt)
57+
phi = np.arctan(numerator / denominator)
58+
return np.degrees(phi)
59+
60+
61+
def masking_angle_passias(surface_tilt, gcr):
62+
r"""
63+
The average masking angle over the slant height of a row.
64+
65+
The masking angle is the angle from horizontal where the sky dome is
66+
blocked by the row in front. The masking angle is larger near the lower
67+
edge of a row than near the upper edge. This function calculates the
68+
average masking angle as described in [1]_.
69+
70+
Parameters
71+
----------
72+
surface_tilt : numeric
73+
Panel tilt from horizontal [degrees].
74+
75+
gcr : float
76+
The ground coverage ratio of the array [unitless].
77+
78+
Returns
79+
----------
80+
mask_angle : numeric
81+
Average angle from horizontal where diffuse light is blocked by the
82+
preceding row [degrees].
83+
84+
See Also
85+
--------
86+
masking_angle
87+
sky_diffuse_passias
88+
89+
Notes
90+
-----
91+
The pvlib-python authors believe that Eqn. 9 in [1]_ is incorrect.
92+
Here we use an independent equation. First, Eqn. 8 is non-dimensionalized
93+
(recasting in terms of GCR):
94+
95+
.. math::
96+
97+
\psi(z') = \arctan \left [
98+
\frac{(1 - z') \sin \beta}
99+
{\mathrm{GCR}^{-1} + (z' - 1) \cos \beta}
100+
\right ]
101+
102+
Where :math:`GCR = B/C` and :math:`z' = z/B`. The average masking angle
103+
:math:`\overline{\psi} = \int_0^1 \psi(z') \mathrm{d}z'` is then
104+
evaluated symbolically using Maxima (using :math:`X = 1/\mathrm{GCR}`):
105+
106+
.. code-block:: none
107+
108+
load(scifac) /* for the gcfac function */
109+
assume(X>0, cos(beta)>0, cos(beta)-X<0); /* X is 1/GCR */
110+
gcfac(integrate(atan((1-z)*sin(beta)/(X+(z-1)*cos(beta))), z, 0, 1))
111+
112+
This yields the equation implemented by this function:
113+
114+
.. math::
115+
116+
\overline{\psi} = \
117+
&-\frac{X}{2} \sin\beta \log | 2 X \cos\beta - (X^2 + 1)| \\
118+
&+ (X \cos\beta - 1) \arctan \frac{X \cos\beta - 1}{X \sin\beta} \\
119+
&+ (1 - X \cos\beta) \arctan \frac{\cos\beta}{\sin\beta} \\
120+
&+ X \log X \sin\beta
121+
122+
The pvlib-python authors have validated this equation against numerical
123+
integration of :math:`\overline{\psi} = \int_0^1 \psi(z') \mathrm{d}z'`.
124+
125+
References
126+
----------
127+
.. [1] D. Passias and B. Källbäck, "Shading effects in rows of solar cell
128+
panels", Solar Cells, Volume 11, Pages 281-291. 1984.
129+
DOI: 10.1016/0379-6787(84)90017-6
130+
"""
131+
# wrap it in an array so that division by zero is handled well
132+
beta = np.radians(np.array(surface_tilt))
133+
sin_b = np.sin(beta)
134+
cos_b = np.cos(beta)
135+
X = 1/gcr
136+
137+
with np.errstate(divide='ignore', invalid='ignore'): # ignore beta=0
138+
term1 = -X * sin_b * np.log(np.abs(2 * X * cos_b - (X**2 + 1))) / 2
139+
term2 = (X * cos_b - 1) * np.arctan((X * cos_b - 1) / (X * sin_b))
140+
term3 = (1 - X * cos_b) * np.arctan(cos_b / sin_b)
141+
term4 = X * np.log(X) * sin_b
142+
143+
psi_avg = term1 + term2 + term3 + term4
144+
# when beta=0, divide by zero makes psi_avg NaN. replace with 0:
145+
psi_avg = np.where(np.isfinite(psi_avg), psi_avg, 0)
146+
147+
if isinstance(surface_tilt, pd.Series):
148+
psi_avg = pd.Series(psi_avg, index=surface_tilt.index)
149+
150+
return np.degrees(psi_avg)
151+
152+
153+
def sky_diffuse_passias(masking_angle):
154+
r"""
155+
The diffuse irradiance loss caused by row-to-row sky diffuse shading.
156+
157+
Even when the sun is high in the sky, a row's view of the sky dome will
158+
be partially blocked by the row in front. This causes a reduction in the
159+
diffuse irradiance incident on the module. The reduction depends on the
160+
masking angle, the elevation angle from a point on the shaded module to
161+
the top of the shading row. In [1]_ the masking angle is calculated as
162+
the average across the module height. SAM assumes the "worst-case" loss
163+
where the masking angle is calculated for the bottom of the array [2]_.
164+
165+
This function, as in [1]_, makes the assumption that sky diffuse
166+
irradiance is isotropic.
167+
168+
Parameters
169+
----------
170+
masking_angle : numeric
171+
The elevation angle below which diffuse irradiance is blocked
172+
[degrees].
173+
174+
Returns
175+
-------
176+
derate : numeric
177+
The fraction [0-1] of blocked sky diffuse irradiance.
178+
179+
See Also
180+
--------
181+
masking_angle
182+
masking_angle_passias
183+
184+
References
185+
----------
186+
.. [1] D. Passias and B. Källbäck, "Shading effects in rows of solar cell
187+
panels", Solar Cells, Volume 11, Pages 281-291. 1984.
188+
DOI: 10.1016/0379-6787(84)90017-6
189+
.. [2] Gilman, P. et al., (2018). "SAM Photovoltaic Model Technical
190+
Reference Update", NREL Technical Report NREL/TP-6A20-67399.
191+
Available at https://www.nrel.gov/docs/fy18osti/67399.pdf
192+
"""
193+
return 1 - cosd(masking_angle/2)**2

pvlib/tests/test_shading.py

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import numpy as np
2+
import pandas as pd
3+
4+
from pandas.testing import assert_series_equal
5+
import pytest
6+
7+
from pvlib import shading
8+
9+
10+
@pytest.fixture
11+
def surface_tilt():
12+
idx = pd.date_range('2019-01-01', freq='h', periods=3)
13+
return pd.Series([0, 20, 90], index=idx)
14+
15+
16+
@pytest.fixture
17+
def masking_angle(surface_tilt):
18+
# masking angles for the surface_tilt fixture,
19+
# assuming GCR=0.5 and height=0.25
20+
return pd.Series([0.0, 11.20223712, 20.55604522], index=surface_tilt.index)
21+
22+
23+
@pytest.fixture
24+
def average_masking_angle(surface_tilt):
25+
# average masking angles for the surface_tilt fixture, assuming GCR=0.5
26+
return pd.Series([0.0, 7.20980655, 13.779867461], index=surface_tilt.index)
27+
28+
29+
@pytest.fixture
30+
def shading_loss(surface_tilt):
31+
# diffuse shading loss values for the average_masking_angle fixture
32+
return pd.Series([0, 0.00395338, 0.01439098], index=surface_tilt.index)
33+
34+
35+
def test_masking_angle_series(surface_tilt, masking_angle):
36+
# series inputs and outputs
37+
masking_angle_actual = shading.masking_angle(surface_tilt, 0.5, 0.25)
38+
assert_series_equal(masking_angle_actual, masking_angle)
39+
40+
41+
def test_masking_angle_scalar(surface_tilt, masking_angle):
42+
# scalar inputs and outputs, including zero
43+
for tilt, angle in zip(surface_tilt, masking_angle):
44+
masking_angle_actual = shading.masking_angle(tilt, 0.5, 0.25)
45+
assert np.isclose(masking_angle_actual, angle)
46+
47+
48+
def test_masking_angle_passias_series(surface_tilt, average_masking_angle):
49+
# pandas series inputs and outputs
50+
masking_angle_actual = shading.masking_angle_passias(surface_tilt, 0.5)
51+
assert_series_equal(masking_angle_actual, average_masking_angle)
52+
53+
54+
def test_masking_angle_passias_scalar(surface_tilt, average_masking_angle):
55+
# scalar inputs and outputs, including zero
56+
for tilt, angle in zip(surface_tilt, average_masking_angle):
57+
masking_angle_actual = shading.masking_angle_passias(tilt, 0.5)
58+
assert np.isclose(masking_angle_actual, angle)
59+
60+
61+
def test_sky_diffuse_passias_series(average_masking_angle, shading_loss):
62+
# pandas series inputs and outputs
63+
actual_loss = shading.sky_diffuse_passias(average_masking_angle)
64+
assert_series_equal(shading_loss, actual_loss)
65+
66+
67+
def test_sky_diffuse_passias_scalar(average_masking_angle, shading_loss):
68+
# scalar inputs and outputs
69+
for angle, loss in zip(average_masking_angle, shading_loss):
70+
actual_loss = shading.sky_diffuse_passias(angle)
71+
assert np.isclose(loss, actual_loss)

0 commit comments

Comments
 (0)