-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Add diffuse self-shading functions #1017
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 10 commits
924179c
809ce0f
84146bc
50fd328
94917a7
7def4b8
d47effa
601e9c7
d07452d
2cf5cc5
d498e6a
f914092
8eaefae
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
""" | ||
Diffuse Self-Shading | ||
==================== | ||
|
||
Modeling the reduction in diffuse irradiance caused by row-to-row diffuse | ||
shading. | ||
""" | ||
|
||
# %% | ||
# The term "self-shading" usually refers to adjacent rows blocking direct | ||
# irradiance and casting shadows on each other. However, the concept also | ||
# applies to diffuse irradiance because rows block a portion of the sky | ||
# dome even when the sun is high in the sky. The irradiance loss fraction | ||
# depends on how tightly the rows are packed and where on the module the | ||
# loss is evaluated -- a point near the top of edge of a module will see | ||
# more of the sky than a point near the bottom edge. | ||
# | ||
# This example uses the approach presented by Passias and Källbäck in [1]_ | ||
# and recreates two figures from that paper using | ||
# :py:func:`pvlib.shading.masking_angle_passias` and | ||
# :py:func:`pvlib.shading.sky_diffuse_passias`. | ||
# | ||
# References | ||
# ---------- | ||
# .. [1] D. Passias and B. Källbäck, "Shading effects in rows of solar cell | ||
# panels", Solar Cells, Volume 11, Pages 281-291. 1984. | ||
# DOI: 10.1016/0379-6787(84)90017-6 | ||
|
||
from pvlib import shading, irradiance | ||
import matplotlib.pyplot as plt | ||
import numpy as np | ||
|
||
# %% | ||
# First we'll recreate Figure 4, showing how the average masking angle varies | ||
# with array tilt and array packing. The masking angle of a given point on a | ||
# module is the angle from horizontal to the next row's top edge and represents | ||
# the portion of the sky dome blocked by the next row. Because it changes | ||
# from the bottom to the top of a module, the average across the module is | ||
# calculated. In [1]_, ``k`` refers to the ratio of row pitch to row slant | ||
# height (i.e. 1 / GCR). | ||
|
||
surface_tilt = np.arange(0, 90, 0.5) | ||
|
||
plt.figure() | ||
for k in [1, 1.5, 2, 2.5, 3, 4, 5, 7, 10]: | ||
gcr = 1/k | ||
psi = shading.masking_angle_passias(surface_tilt, gcr) | ||
plt.plot(surface_tilt, psi, label='k={}'.format(k)) | ||
|
||
plt.xlabel('Inclination angle [degrees]') | ||
plt.ylabel('Average masking angle [degrees]') | ||
plt.legend() | ||
plt.show() | ||
|
||
# %% | ||
# So as the array is packed tighter (decreasing ``k``), the average masking | ||
# angle increases. | ||
# | ||
# Next we'll recreate Figure 5. Note that the y-axis here is the ratio of | ||
# diffuse plane of array irradiance (after accounting for shading) to diffuse | ||
# horizontal irradiance. This means that the deviation from 100% is due to the | ||
# combination of self-shading and the fact that being at a tilt blocks off | ||
# the portion of the sky behind the row. The first effect is modeled with | ||
# :py:func:`pvlib.shading.sky_diffuse_passias` and the second with | ||
# :py:func:`pvlib.irradiance.isotropic`. | ||
|
||
plt.figure() | ||
for k in [1, 1.5, 2, 10]: | ||
gcr = 1/k | ||
psi = shading.masking_angle_passias(surface_tilt, gcr) | ||
shading_loss = shading.sky_diffuse_passias(psi) | ||
transposition_ratio = irradiance.isotropic(surface_tilt, dhi=1.0) | ||
relative_diffuse = transposition_ratio * (1-shading_loss) * 100 # % | ||
plt.plot(surface_tilt, relative_diffuse, label='k={}'.format(k)) | ||
|
||
plt.xlabel('Inclination angle [degrees]') | ||
plt.ylabel('Relative diffuse irradiance [%]') | ||
plt.ylim(0, 105) | ||
plt.legend() | ||
plt.show() | ||
|
||
# %% | ||
# As ``k`` decreases, GCR increases, so self-shading loss increases and | ||
# collected diffuse irradiance decreases. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,193 @@ | ||
""" | ||
The ``shading`` module contains functions that model module shading and the | ||
associated effects on PV module output | ||
""" | ||
|
||
import numpy as np | ||
import pandas as pd | ||
from pvlib.tools import sind, cosd | ||
|
||
|
||
def masking_angle(surface_tilt, gcr, height): | ||
""" | ||
The elevation angle below which diffuse irradiance is blocked. | ||
|
||
The ``height`` parameter determines how far up the module's surface to | ||
evaluate the masking angle. The lower the point, the steeper the masking | ||
angle [1]_. SAM uses a "worst-case" approach where the masking angle | ||
is calculated for the bottom of the array (i.e. ``height=0``) [2]_. | ||
|
||
Parameters | ||
---------- | ||
surface_tilt : numeric | ||
Panel tilt from horizontal [degrees]. | ||
|
||
gcr : float | ||
The ground coverage ratio of the array [unitless]. | ||
|
||
height : numeric | ||
kandersolar marked this conversation as resolved.
Show resolved
Hide resolved
|
||
The distance up the module's slant height to evaluate the masking | ||
angle, as a fraction [0-1] of the module height [unitless]. | ||
kandersolar marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
Returns | ||
------- | ||
mask_angle : numeric | ||
Angle from horizontal where diffuse light is blocked by the | ||
preceding row [degrees]. | ||
|
||
See Also | ||
-------- | ||
masking_angle_passias | ||
sky_diffuse_passias | ||
|
||
References | ||
---------- | ||
.. [1] D. Passias and B. Källbäck, "Shading effects in rows of solar cell | ||
panels", Solar Cells, Volume 11, Pages 281-291. 1984. | ||
DOI: 10.1016/0379-6787(84)90017-6 | ||
.. [2] Gilman, P. et al., (2018). "SAM Photovoltaic Model Technical | ||
Reference Update", NREL Technical Report NREL/TP-6A20-67399. | ||
Available at https://www.nrel.gov/docs/fy18osti/67399.pdf | ||
""" | ||
# The original equation (8 in [1]) requires pitch and collector width, | ||
# but it's easy to non-dimensionalize it to make it a function of GCR | ||
# by factoring out B from the argument to arctan. | ||
numerator = (1 - height) * sind(surface_tilt) | ||
denominator = 1/gcr - (1 - height) * cosd(surface_tilt) | ||
phi = np.arctan(numerator / denominator) | ||
return np.degrees(phi) | ||
|
||
|
||
def masking_angle_passias(surface_tilt, gcr): | ||
r""" | ||
The average masking angle over the slant height of a row. | ||
|
||
The masking angle is the angle from horizontal where the sky dome is | ||
blocked by the row in front. The masking angle is larger near the lower | ||
edge of a row than near the upper edge. This function calculates the | ||
average masking angle as described in [1]_. | ||
|
||
Parameters | ||
---------- | ||
surface_tilt : numeric | ||
Panel tilt from horizontal [degrees]. | ||
|
||
gcr : float | ||
The ground coverage ratio of the array [unitless]. | ||
|
||
Returns | ||
---------- | ||
mask_angle : numeric | ||
Average angle from horizontal where diffuse light is blocked by the | ||
preceding row [degrees]. | ||
|
||
See Also | ||
-------- | ||
masking_angle | ||
sky_diffuse_passias | ||
|
||
Notes | ||
----- | ||
The pvlib-python authors believe that Eqn. 9 in [1]_ is incorrect. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The equation below isn't equivalent to the published equation? Or, you couldn't reproduce the published equation [9]? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The latter. The published equation [9] also gives different results from evaluating the integral numerically: https://gist.github.com/kanderso-nrel/2c6c3a1853338cdef5b4bbc67092ccc8 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm persuaded. Your solution looks correct to me. I computed a few points on the k=1 and k=2 curves to check. |
||
Here we use an independent equation. First, Eqn. 8 is non-dimensionalized | ||
(recasting in terms of GCR): | ||
|
||
.. math:: | ||
|
||
\psi(z') = \arctan \left [ | ||
\frac{(1 - z') \sin \beta} | ||
{\mathrm{GCR}^{-1} + (z' - 1) \cos \beta} | ||
\right ] | ||
|
||
Where :math:`GCR = B/C` and :math:`z' = z/B`. The average masking angle | ||
:math:`\overline{\psi} = \int_0^1 \psi(z') \mathrm{d}z'` is then | ||
evaluated symbolically using Maxima (using :math:`X = 1/\mathrm{GCR}`): | ||
|
||
.. code-block:: none | ||
|
||
load(scifac) /* for the gcfac function */ | ||
assume(X>0, cos(beta)>0, cos(beta)-X<0); /* X is 1/GCR */ | ||
gcfac(integrate(atan((1-z)*sin(beta)/(X+(z-1)*cos(beta))), z, 0, 1)) | ||
|
||
This yields the equation implemented by this function: | ||
|
||
.. math:: | ||
|
||
\overline{\psi} = \ | ||
&-\frac{X}{2} \sin\beta \log | 2 X \cos\beta - (X^2 + 1)| \\ | ||
&+ (X \cos\beta - 1) \arctan \frac{X \cos\beta - 1}{X \sin\beta} \\ | ||
&+ (1 - X \cos\beta) \arctan \frac{\cos\beta}{\sin\beta} \\ | ||
&+ X \log X \sin\beta | ||
|
||
The pvlib-python authors have validated this equation against numerical | ||
integration of :math:`\overline{\psi} = \int_0^1 \psi(z') \mathrm{d}z'`. | ||
|
||
References | ||
---------- | ||
.. [1] D. Passias and B. Källbäck, "Shading effects in rows of solar cell | ||
panels", Solar Cells, Volume 11, Pages 281-291. 1984. | ||
DOI: 10.1016/0379-6787(84)90017-6 | ||
""" | ||
# wrap it in an array so that division by zero is handled well | ||
beta = np.radians(np.array(surface_tilt)) | ||
sin_b = np.sin(beta) | ||
cos_b = np.cos(beta) | ||
X = 1/gcr | ||
|
||
with np.errstate(divide='ignore', invalid='ignore'): # ignore beta=0 | ||
term1 = -X * sin_b * np.log(np.abs(2 * X * cos_b - (X**2 + 1))) / 2 | ||
term2 = (X * cos_b - 1) * np.arctan((X * cos_b - 1) / (X * sin_b)) | ||
term3 = (1 - X * cos_b) * np.arctan(cos_b / sin_b) | ||
mikofski marked this conversation as resolved.
Show resolved
Hide resolved
|
||
term4 = X * np.log(X) * sin_b | ||
|
||
psi_avg = term1 + term2 + term3 + term4 | ||
# when beta=0, divide by zero makes psi_avg NaN. replace with 0: | ||
psi_avg = np.where(np.isfinite(psi_avg), psi_avg, 0) | ||
|
||
if isinstance(surface_tilt, pd.Series): | ||
psi_avg = pd.Series(psi_avg, index=surface_tilt.index) | ||
|
||
return np.degrees(psi_avg) | ||
|
||
|
||
def sky_diffuse_passias(masking_angle): | ||
r""" | ||
The diffuse irradiance loss caused by row-to-row sky diffuse shading. | ||
|
||
Even when the sun is high in the sky, a row's view of the sky dome will | ||
be partially blocked by the row in front. This causes a reduction in the | ||
diffuse irradiance incident on the module. The reduction depends on the | ||
masking angle, the elevation angle from a point on the shaded module to | ||
the top of the shading row. SAM assumes the "worst-case" loss where the | ||
masking angle is calculated for the bottom of the array [1]_. In [2]_ | ||
the masking angle is calculated as the average across the module height. | ||
|
||
This function, as in [2]_, makes the assumption that sky diffuse | ||
irradiance is isotropic. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Entirely editorial - I'd swap the order of the last and next-to-last sentences, so that the focus stays on the Passias approach. |
||
|
||
Parameters | ||
---------- | ||
masking_angle : numeric | ||
The elevation angle below which diffuse irradiance is blocked | ||
[degrees]. | ||
|
||
Returns | ||
------- | ||
derate : numeric | ||
The fraction [0-1] of blocked diffuse horizontal irradiance. | ||
kandersolar marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
See Also | ||
-------- | ||
masking_angle | ||
masking_angle_passias | ||
|
||
References | ||
---------- | ||
.. [1] Gilman, P. et al., (2018). "SAM Photovoltaic Model Technical | ||
Reference Update", NREL Technical Report NREL/TP-6A20-67399. | ||
Available at https://www.nrel.gov/docs/fy18osti/67399.pdf | ||
.. [2] D. Passias and B. Källbäck, "Shading effects in rows of solar cell | ||
panels", Solar Cells, Volume 11, Pages 281-291. 1984. | ||
DOI: 10.1016/0379-6787(84)90017-6 | ||
""" | ||
return 1 - cosd(masking_angle/2)**2 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
import numpy as np | ||
import pandas as pd | ||
|
||
from pandas.testing import assert_series_equal | ||
import pytest | ||
|
||
from pvlib import shading | ||
|
||
|
||
@pytest.fixture | ||
def surface_tilt(): | ||
idx = pd.date_range('2019-01-01', freq='h', periods=3) | ||
return pd.Series([0, 20, 90], index=idx) | ||
|
||
|
||
@pytest.fixture | ||
def masking_angle(surface_tilt): | ||
# masking angles for the surface_tilt fixture, | ||
# assuming GCR=0.5 and height=0.25 | ||
return pd.Series([0.0, 11.20223712, 20.55604522], index=surface_tilt.index) | ||
|
||
|
||
@pytest.fixture | ||
def average_masking_angle(surface_tilt): | ||
# average masking angles for the surface_tilt fixture, assuming GCR=0.5 | ||
return pd.Series([0.0, 7.20980655, 13.779867461], index=surface_tilt.index) | ||
|
||
|
||
@pytest.fixture | ||
def shading_loss(surface_tilt): | ||
# diffuse shading loss values for the average_masking_angle fixture | ||
return pd.Series([0, 0.00395338, 0.01439098], index=surface_tilt.index) | ||
|
||
|
||
def test_masking_angle_series(surface_tilt, masking_angle): | ||
# series inputs and outputs | ||
masking_angle_actual = shading.masking_angle(surface_tilt, 0.5, 0.25) | ||
assert_series_equal(masking_angle_actual, masking_angle) | ||
|
||
|
||
def test_masking_angle_scalar(surface_tilt, masking_angle): | ||
# scalar inputs and outputs, including zero | ||
for tilt, angle in zip(surface_tilt, masking_angle): | ||
masking_angle_actual = shading.masking_angle(tilt, 0.5, 0.25) | ||
assert np.isclose(masking_angle_actual, angle) | ||
|
||
|
||
def test_masking_angle_passias_series(surface_tilt, average_masking_angle): | ||
# pandas series inputs and outputs | ||
masking_angle_actual = shading.masking_angle_passias(surface_tilt, 0.5) | ||
assert_series_equal(masking_angle_actual, average_masking_angle) | ||
|
||
|
||
def test_masking_angle_passias_scalar(surface_tilt, average_masking_angle): | ||
# scalar inputs and outputs, including zero | ||
for tilt, angle in zip(surface_tilt, average_masking_angle): | ||
masking_angle_actual = shading.masking_angle_passias(tilt, 0.5) | ||
assert np.isclose(masking_angle_actual, angle) | ||
|
||
|
||
def test_sky_diffuse_passias_series(average_masking_angle, shading_loss): | ||
# pandas series inputs and outputs | ||
actual_loss = shading.sky_diffuse_passias(average_masking_angle) | ||
assert_series_equal(shading_loss, actual_loss) | ||
|
||
|
||
def test_sky_diffuse_passias_scalar(average_masking_angle, shading_loss): | ||
# scalar inputs and outputs | ||
for angle, loss in zip(average_masking_angle, shading_loss): | ||
actual_loss = shading.sky_diffuse_passias(angle) | ||
assert np.isclose(loss, actual_loss) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have a notion that
gcr
is sometimes defined asgcr := width * cos(tilt) / pitch
for fixed-tilt arrays. Is that a common usage? If so, this parameter description and the one inmasking_angle_passias
should clarify thatwidth / pitch
is meant.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In my experience the usual definition of ground coverage ratio is the ratio of module area to ground area, where the "ground area" is left somewhat ambiguous. That's the definition used in Pvsyst, Helioscope and
pvlib.tracking.SingleAxisTracker
. I think we should stick withgcr = width / pitch
. I'd be interested to see where the ratio of projected horizontal width to pitch is used.