Skip to content

Commit d2f15e3

Browse files
authored
Add diffuse IAM integration and gallery example (pvlib#984)
* create marion_integrate function * add tests * Update api.rst * add gallery example * whatsnew * add marion_diffuse, update example+tests * change subtitle in example * add zenith bounds to docstrings * changes from review * fix IAM definition * improve azimuth comment; store cosaoi for later
1 parent 05b3209 commit d2f15e3

File tree

5 files changed

+456
-0
lines changed

5 files changed

+456
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""
2+
Diffuse IAM Calculation
3+
=======================
4+
5+
Integrating an IAM model across angles to determine the overall reflection
6+
loss for diffuse irradiance.
7+
"""
8+
9+
# %%
10+
# The fraction of light reflected from the front of a module depends on the
11+
# angle of incidence (AOI) of the light compared to the panel surface. The
12+
# greater the AOI, the larger the reflected fraction is. The incident angle
13+
# modifier (IAM) is defined as the ratio of light transmitted at the given
14+
# AOI to transmitted light at normal incidence.
15+
# Several models exist to calculate the IAM for a given incidence
16+
# angle (e.g. :py:func:`pvlib.iam.ashrae`, :py:func:`pvlib.iam.martin_ruiz`,
17+
# :py:func:`pvlib.iam.sapm`, :py:func:`pvlib.iam.physical`).
18+
# However, evaluating the IAM for diffuse light is
19+
# not as straightforward because it comes from all directions and therefore
20+
# has a range of angles of incidence. Here we show how to integrate the effect
21+
# of AOI reflection across this AOI range using the process described in [1]_.
22+
# In particular, we will recreate Figures 3, 4, and 5 in that paper.
23+
#
24+
# References
25+
# ----------
26+
# .. [1] B. Marion "Numerical method for angle-of-incidence correction
27+
# factors for diffuse radiation incident photovoltaic modules",
28+
# Solar Energy, Volume 147, Pages 344-348. 2017.
29+
# DOI: 10.1016/j.solener.2017.03.027
30+
#
31+
# .. [2] Duffie, John A. & Beckman, William A. (2013). Solar Engineering
32+
# of Thermal Processes. DOI: 10.1002/9781118671603
33+
34+
35+
from pvlib.iam import marion_diffuse, physical
36+
import numpy as np
37+
import matplotlib.pyplot as plt
38+
39+
40+
# %%
41+
# IAM Model
42+
# ---------
43+
#
44+
# The IAM model used to generate the figures in [1]_ uses Snell's, Fresnel's,
45+
# and Beer's laws to determine the fraction of light transmitted through the
46+
# air-glass interface as a function of AOI.
47+
# The function :py:func:`pvlib.iam.physical` implements this model, except it
48+
# also includes an exponential term to model attenuation in the glazing layer.
49+
# To be faithful to Marion's implementation, we will disable this extinction
50+
# term by setting the attenuation coefficient ``K`` parameter to zero.
51+
# For more details on this IAM model, see [2]_.
52+
#
53+
# Marion generated diffuse irradiance modifiers for two cases: a standard
54+
# uncoated glass with index of refraction n=1.526 and a glass with
55+
# anti-reflective (AR) coating with n=1.3.
56+
# Comparing the IAM model across AOI recreates Figure 3 in [1]_:
57+
58+
aoi = np.arange(0, 91)
59+
iam_no_coating = physical(aoi, n=1.526, K=0)
60+
iam_ar_coating = physical(aoi, n=1.3, K=0)
61+
62+
plt.plot(aoi, iam_ar_coating, c='b', label='$F_b$, AR coated, n=1.3')
63+
plt.plot(aoi, iam_no_coating, c='r', label='$F_b$, uncoated, n=1.526')
64+
plt.xlabel(r'Angle-of-Incidence, AOI $(\degree)$')
65+
plt.ylabel('Diffuse Incidence Angle Modifier')
66+
plt.legend()
67+
plt.ylim([0, 1.2])
68+
plt.grid()
69+
70+
# %%
71+
# Diffuse sky, ground, and horizon IAM
72+
# ------------------------------------
73+
#
74+
# Now that we have an AOI model, we use :py:func:`pvlib.iam.marion_diffuse`
75+
# to integrate it across solid angle and determine diffuse irradiance IAM.
76+
# Marion defines three types of diffuse irradiance:
77+
# sky, horizon, and ground-reflected. The diffuse IAM value is evaluated
78+
# independently for each type.
79+
80+
tilts = np.arange(0, 91, 2.5)
81+
82+
# marion_diffuse calculates all three IAM values (sky, horizon, ground)
83+
iam_no_coating = marion_diffuse('physical', tilts, n=1.526, K=0)
84+
iam_ar_coating = marion_diffuse('physical', tilts, n=1.3, K=0)
85+
86+
# %%
87+
# First we recreate Figure 4 in [1]_, showing the dependence of the sky diffuse
88+
# incidence angle modifier on module tilt.
89+
90+
plt.plot(tilts, iam_ar_coating['sky'], c='b', marker='^',
91+
label='$F_{sky}$, AR coated, n=1.3')
92+
plt.plot(tilts, iam_no_coating['sky'], c='r', marker='x',
93+
label='$F_{sky}$, uncoated, n=1.526')
94+
plt.ylim([0.9, 1.0])
95+
plt.xlabel(r'PV Module Tilt, $\beta (\degree)$')
96+
plt.ylabel('Diffuse Incidence Angle Modifier')
97+
plt.grid()
98+
plt.legend()
99+
plt.show()
100+
101+
# %%
102+
# Now we recreate Figure 5 in [1]_, showing the dependence of the diffuse iam
103+
# values for horizon and ground diffuse irradiance on module tilt. Note that
104+
# :py:func:`pvlib.iam.marion_diffuse` defaults to using 1800 points for the
105+
# horizon case (instead of 180 like the others) to match [1]_.
106+
107+
plt.plot(tilts, iam_ar_coating['horizon'], c='b', marker='^',
108+
label='$F_{hor}$, AR coated, n=1.3')
109+
plt.plot(tilts, iam_no_coating['horizon'], c='r', marker='x',
110+
label='$F_{hor}$, uncoated, n=1.526')
111+
plt.plot(tilts, iam_ar_coating['ground'], c='b', marker='s',
112+
label='$F_{grd}$, AR coated, n=1.3')
113+
plt.plot(tilts, iam_no_coating['ground'], c='r', marker='+',
114+
label='$F_{grd}$, uncoated, n=1.526')
115+
plt.xlabel(r'PV Module Tilt, $\beta (\degree)$')
116+
plt.ylabel('Diffuse Incidence Angle Modifier')
117+
plt.grid()
118+
plt.legend()
119+
plt.show()

docs/sphinx/source/api.rst

+2
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,8 @@ Incident angle modifiers
223223
iam.martin_ruiz_diffuse
224224
iam.sapm
225225
iam.interp
226+
iam.marion_diffuse
227+
iam.marion_integrate
226228

227229
PV temperature models
228230
---------------------

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

+5
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ API Changes
1111
Enhancements
1212
~~~~~~~~~~~~
1313
* Update :func:`~pvlib.bifacial.pvfactors_timeseries` to run with ``pvfactors`` v1.4.1 (:issue:`902`)(:pull:`934`)
14+
* Add :py:func:`pvlib.iam.marion_diffuse` and
15+
:py:func:`pvlib.iam.marion_integrate` to calculate IAM values for
16+
diffuse irradiance. (:pull:`984`)
1417

1518
Bug fixes
1619
~~~~~~~~~
@@ -29,6 +32,8 @@ Documentation
2932
* Clarify units for heat loss factors in
3033
:py:func:`pvlib.temperature.pvsyst_cell` and
3134
:py:func:`pvlib.temperature.faiman`. (:pull:`960`)
35+
* Add a gallery example of calculating diffuse IAM using
36+
:py:func:`pvlib.iam.marion_diffuse`. (:pull:`984`)
3237

3338
Requirements
3439
~~~~~~~~~~~~

pvlib/iam.py

+219
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import numpy as np
1212
import pandas as pd
13+
import functools
1314
from pvlib.tools import cosd, sind, tand, asind
1415

1516
# a dict of required parameter names for each IAM model
@@ -527,3 +528,221 @@ def sapm(aoi, module, upper=None):
527528
iam = pd.Series(iam, aoi.index)
528529

529530
return iam
531+
532+
533+
def marion_diffuse(model, surface_tilt, **kwargs):
534+
"""
535+
Determine diffuse irradiance incidence angle modifiers using Marion's
536+
method of integrating over solid angle.
537+
538+
Parameters
539+
----------
540+
model : str
541+
The IAM function to evaluate across solid angle. Must be one of
542+
`'ashrae', 'physical', 'martin_ruiz', 'sapm'`.
543+
544+
surface_tilt : numeric
545+
Surface tilt angles in decimal degrees.
546+
The tilt angle is defined as degrees from horizontal
547+
(e.g. surface facing up = 0, surface facing horizon = 90).
548+
549+
**kwargs
550+
Extra parameters passed to the IAM function.
551+
552+
Returns
553+
-------
554+
iam : dict
555+
IAM values for each type of diffuse irradiance:
556+
557+
* 'sky': radiation from the sky dome (zenith <= 90)
558+
* 'horizon': radiation from the region of the sky near the horizon
559+
(89.5 <= zenith <= 90)
560+
* 'ground': radiation reflected from the ground (zenith >= 90)
561+
562+
See [1]_ for a detailed description of each class.
563+
564+
See Also
565+
--------
566+
pvlib.iam.marion_integrate
567+
568+
References
569+
----------
570+
.. [1] B. Marion "Numerical method for angle-of-incidence correction
571+
factors for diffuse radiation incident photovoltaic modules",
572+
Solar Energy, Volume 147, Pages 344-348. 2017.
573+
DOI: 10.1016/j.solener.2017.03.027
574+
575+
Examples
576+
--------
577+
>>> marion_diffuse('physical', surface_tilt=20)
578+
{'sky': 0.9539178294437575,
579+
'horizon': 0.7652650139134007,
580+
'ground': 0.6387140117795903}
581+
582+
>>> marion_diffuse('ashrae', [20, 30], b=0.04)
583+
{'sky': array([0.96748999, 0.96938408]),
584+
'horizon': array([0.86478428, 0.91825792]),
585+
'ground': array([0.77004435, 0.8522436 ])}
586+
"""
587+
588+
models = {
589+
'physical': physical,
590+
'ashrae': ashrae,
591+
'sapm': sapm,
592+
'martin_ruiz': martin_ruiz,
593+
}
594+
595+
try:
596+
iam_model = models[model]
597+
except KeyError:
598+
raise ValueError('model must be one of: ' + str(list(models.keys())))
599+
600+
iam_function = functools.partial(iam_model, **kwargs)
601+
iam = {}
602+
for region in ['sky', 'horizon', 'ground']:
603+
iam[region] = marion_integrate(iam_function, surface_tilt, region)
604+
605+
return iam
606+
607+
608+
def marion_integrate(function, surface_tilt, region, num=None):
609+
"""
610+
Integrate an incidence angle modifier (IAM) function over solid angle
611+
to determine a diffuse irradiance correction factor using Marion's method.
612+
613+
This lower-level function actually performs the IAM integration for the
614+
specified solid angle region.
615+
616+
Parameters
617+
----------
618+
function : callable(aoi)
619+
The IAM function to evaluate across solid angle. The function must
620+
be vectorized and take only one parameter, the angle of incidence in
621+
degrees.
622+
623+
surface_tilt : numeric
624+
Surface tilt angles in decimal degrees.
625+
The tilt angle is defined as degrees from horizontal
626+
(e.g. surface facing up = 0, surface facing horizon = 90).
627+
628+
region : {'sky', 'horizon', 'ground'}
629+
The region to integrate over. Must be one of:
630+
631+
* 'sky': radiation from the sky dome (zenith <= 90)
632+
* 'horizon': radiation from the region of the sky near the horizon
633+
(89.5 <= zenith <= 90)
634+
* 'ground': radiation reflected from the ground (zenith >= 90)
635+
636+
See [1]_ for a detailed description of each class.
637+
638+
num : int, optional
639+
The number of increments in the zenith integration.
640+
If not specified, N will follow the values used in [1]_:
641+
642+
* 'sky' or 'ground': num = 180
643+
* 'horizon': num = 1800
644+
645+
Returns
646+
-------
647+
iam : numeric
648+
AOI diffuse correction factor for the specified region.
649+
650+
See Also
651+
--------
652+
pvlib.iam.marion_diffuse
653+
654+
References
655+
----------
656+
.. [1] B. Marion "Numerical method for angle-of-incidence correction
657+
factors for diffuse radiation incident photovoltaic modules",
658+
Solar Energy, Volume 147, Pages 344-348. 2017.
659+
DOI: 10.1016/j.solener.2017.03.027
660+
661+
Examples
662+
--------
663+
>>> marion_integrate(pvlib.iam.ashrae, 20, 'sky')
664+
0.9596085829811408
665+
666+
>>> from functools import partial
667+
>>> f = partial(pvlib.iam.physical, n=1.3)
668+
>>> marion_integrate(f, [20, 30], 'sky')
669+
array([0.96225034, 0.9653219 ])
670+
"""
671+
672+
if num is None:
673+
if region in ['sky', 'ground']:
674+
num = 180
675+
elif region == 'horizon':
676+
num = 1800
677+
else:
678+
raise ValueError('Invalid region: {}'.format(region))
679+
680+
beta = np.radians(surface_tilt)
681+
if isinstance(beta, pd.Series):
682+
# convert Series to np array for broadcasting later
683+
beta = beta.values
684+
ai = np.pi/num # angular increment
685+
686+
phi_range = np.linspace(0, np.pi, num, endpoint=False)
687+
psi_range = np.linspace(0, 2*np.pi, 2*num, endpoint=False)
688+
689+
# the pseudocode in [1] do these checks at the end, but it's
690+
# faster to do this criteria check up front instead of later.
691+
if region == 'sky':
692+
mask = phi_range + ai <= np.pi/2
693+
elif region == 'horizon':
694+
lo = 89.5 * np.pi/180
695+
hi = np.pi/2
696+
mask = (lo <= phi_range) & (phi_range + ai <= hi)
697+
elif region == 'ground':
698+
mask = (phi_range >= np.pi/2)
699+
else:
700+
raise ValueError('Invalid region: {}'.format(region))
701+
phi_range = phi_range[mask]
702+
703+
# fast Cartesian product of phi and psi
704+
angles = np.array(np.meshgrid(phi_range, psi_range)).T.reshape(-1, 2)
705+
# index with single-element lists to maintain 2nd dimension so that
706+
# these angle arrays broadcast across the beta array
707+
phi_1 = angles[:, [0]]
708+
psi_1 = angles[:, [1]]
709+
phi_2 = phi_1 + ai
710+
# psi_2 = psi_1 + ai # not needed
711+
phi_avg = phi_1 + 0.5*ai
712+
psi_avg = psi_1 + 0.5*ai
713+
term_1 = np.cos(beta) * np.cos(phi_avg)
714+
# The AOI formula includes a term based on the difference between
715+
# panel azimuth and the photon azimuth, but because we assume each class
716+
# of diffuse irradiance is isotropic and we are integrating over all
717+
# angles, it doesn't matter what panel azimuth we choose (i.e., the
718+
# system is rotationally invariant). So we choose gamma to be zero so
719+
# that we can omit it from the cos(psi_avg) term.
720+
# Marion's paper mentions this in the Section 3 pseudocode:
721+
# "set gamma to pi (or any value between 0 and 2pi)"
722+
term_2 = np.sin(beta) * np.sin(phi_avg) * np.cos(psi_avg)
723+
cosaoi = term_1 + term_2
724+
aoi = np.arccos(cosaoi)
725+
# simplify Eq 8, (psi_2 - psi_1) is always ai
726+
dAs = ai * (np.cos(phi_1) - np.cos(phi_2))
727+
cosaoi_dAs = cosaoi * dAs
728+
# apply the final AOI check, zeroing out non-passing points
729+
mask = aoi < np.pi/2
730+
cosaoi_dAs = np.where(mask, cosaoi_dAs, 0)
731+
numerator = np.sum(function(np.degrees(aoi)) * cosaoi_dAs, axis=0)
732+
denominator = np.sum(cosaoi_dAs, axis=0)
733+
734+
with np.errstate(invalid='ignore'):
735+
# in some cases, no points pass the criteria
736+
# (e.g. region='ground', surface_tilt=0), so we override the division
737+
# by zero to set Fd=0. Also, preserve nans in beta.
738+
Fd = np.where((denominator != 0) | ~np.isfinite(beta),
739+
numerator / denominator,
740+
0)
741+
742+
# preserve input type
743+
if np.isscalar(surface_tilt):
744+
Fd = Fd.item()
745+
elif isinstance(surface_tilt, pd.Series):
746+
Fd = pd.Series(Fd, surface_tilt.index)
747+
748+
return Fd

0 commit comments

Comments
 (0)