Skip to content

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

Merged
merged 13 commits into from
Aug 20, 2020
84 changes: 84 additions & 0 deletions docs/examples/plot_passias_diffuse_shading.py
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.passias_masking_angle` and
# :py:func:`pvlib.shading.passias_sky_diffuse`.
#
# 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.passias_masking_angle(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.passias_sky_diffuse` and the second with
# :py:func:`pvlib.irradiance.isotropic`.

plt.figure()
for k in [1, 1.5, 2, 10]:
gcr = 1/k
psi = shading.passias_masking_angle(surface_tilt, gcr)
shading_loss = shading.passias_sky_diffuse(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.
5 changes: 5 additions & 0 deletions docs/sphinx/source/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,11 @@ Effects on PV System Output
soiling.hsu
soiling.kimber

.. autosummary::
:toctree: generated/

shading.passias_masking_angle
shading.passias_sky_diffuse


Tracking
Expand Down
4 changes: 4 additions & 0 deletions docs/sphinx/source/whatsnew/v0.8.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ Enhancements
* Add :py:func:`pvlib.iam.marion_diffuse` and
:py:func:`pvlib.iam.marion_integrate` to calculate IAM values for
diffuse irradiance. (:pull:`984`)
* Add :py:func:`pvlib.shading.passias_sky_diffuse` and
:py:func:`pvlib.shading.passias_masking_angle` to model diffuse shading loss.
(:pull:`1017`)

Bug fixes
~~~~~~~~~
Expand Down Expand Up @@ -73,6 +76,7 @@ Documentation
* Add a transposition gain example to the gallery. (:pull:`979`)
* Add a gallery example of calculating diffuse IAM using
:py:func:`pvlib.iam.marion_diffuse`. (:pull:`984`)
* Add a gallery example of modeling diffuse shading loss. (:pull:`1017`)
* Add minigalleries to API reference pages. (:pull:`991`)

Requirements
Expand Down
140 changes: 140 additions & 0 deletions pvlib/shading.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""
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


def passias_masking_angle(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
--------
passias_sky_diffuse

Notes
-----
The pvlib-python authors believe that Eqn. 9 in [1]_ is incorrect.
Copy link
Member

Choose a reason for hiding this comment

The 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]?

Copy link
Member Author

Choose a reason for hiding this comment

The 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

Copy link
Member

Choose a reason for hiding this comment

The 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)
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 passias_sky_diffuse(masking_angle):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering if this signature creates space for other approaches to calculate self-shading. What if the arguments are tilt and 'gcr' and passias_masking_angle was private and called within this function? I doubt that the average "masking angle" is general among other models, I could be wrong.

A thought: how is this visible sky dome calculation done in bifacial_vf? Maybe there's some commonality that could inform the interface here, although we wouldn't re-use code from bifacial_vf. If I recall right, that calculation is done with view factors over small increments of angle.

I have a mild preference for sky_diffuse_passias as the function name.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I separated the two functions in anticipation of people wanting to use the "worst-case" angle like SAM does instead of Passias et al.'s average angle. That said, a function that just does 1 - cos(angle/2)^2 does seem strange.

Good idea about bifacial_vf; I haven't had an excuse to get familiar with it before now but am happy to have one.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked at the SAM documentation. I think that the low level functions that calculates the average masking_angle is useful, as would be a function that returns masking_angle at a point along the row's surface. Maybe someone would want to use the masking_angle in combination with other sky diffuse models.

Still chewing on the name passias_sky_diffuse for the effects function.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a masking_angle function that calculates the angle for a given point and renamed passias_sky_diffuse to sky_diffuse_passias, but am happy to rename if you have a better alternative.

Maybe someone would want to use the masking_angle in combination with other sky diffuse models.

It occurs to me that the masking angle is also useful for comparing with solar position to determine if direct shading is relevant.

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.
Copy link
Member

Choose a reason for hiding this comment

The 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.

See Also
--------
passias_masking_angle

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 - np.cos(np.radians(masking_angle)/2)**2
51 changes: 51 additions & 0 deletions pvlib/tests/test_shading.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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 angle values 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 masking_angle fixture
return pd.Series([0, 0.00395338, 0.01439098], index=surface_tilt.index)


def test_passias_masking_angle_series(surface_tilt, masking_angle):
# pandas series inputs and outputs
masking_angle_actual = shading.passias_masking_angle(surface_tilt, 0.5)
assert_series_equal(masking_angle_actual, masking_angle)


def test_passias_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.passias_masking_angle(tilt, 0.5)
assert np.isclose(masking_angle_actual, angle)


def test_passias_sky_diffuse_series(masking_angle, shading_loss):
# pandas series inputs and outputs
actual_loss = shading.passias_sky_diffuse(masking_angle)
assert_series_equal(shading_loss, actual_loss)


def test_passias_sky_diffuse_scalar(masking_angle, shading_loss):
# scalar inputs and outputs
for angle, loss in zip(masking_angle, shading_loss):
actual_loss = shading.passias_sky_diffuse(angle)
assert np.isclose(loss, actual_loss)