Repository: pvlib/pvlib-python

Issue:
IAM that supports AR coating like Fresnel
# Problem
Currently pvlib supports the DeSoto physical model (similar to normal glass), ASHRAE, Martin & Ruiz, and SAPM polynomial, but it doesn't have a pure Fresnel model that allows additional interfaces like an AR coating. 

* DeSoto physical model is most similar to the Fresnel for normal glass but only has one interface, so is limited to IAM curves below it only, while an AR coating would have a greater &rho; 
* Martin & Ruiz could be used to approximate an AR coated glass if the correct `a_r` were known. The default of `a_r=0.16` is slightly above the normal glass Fresnel IAM, but an `a_r=0.14` seems to match an AR coating with index of refraction of 1.2 most closely.

![pvlib_iam](https://user-images.githubusercontent.com/1385621/180581071-0ff411f1-144a-40b6-a6a9-189ef55f019f.png)


# Proposal
a new method in `pvl.iam.fresnel_ar(aoi, n_ar=1.2, n_air=1.0, n_glass=1.56)` that implements the [Fresnel equation](https://en.wikipedia.org/wiki/Fresnel_equations)

# Alternative
Suggest readers to use Martin & Ruiz with `a_r=0.14` instead of default.

# additional content
PVsyst has switched to Fresnel equations. We can duplicate [their methods](https://www.pvsyst.com/help/iam_loss.htm) ignoring additional reflections and the encapsulant layer:
![Fresnel-v-ASHRAE](https://user-images.githubusercontent.com/1385621/180581112-67f3ed9d-5bd3-4dfe-8180-8b5d173fcdd2.png)

<details>

```python
import numpy as np
import matplotlib.pyplot as plt
plt.ion()


# constants
n_glass = 1.56
n_air = 1.0
theta_inc = np.linspace(0, 88, 100)


def snell(theta_1, n1, n2):
    """Snell's equation"""
    sintheta_2 = n1/n2 * np.sin(np.radians(theta_1))
    return sintheta_2, np.degrees(np.arcsin(sintheta_2))


def refl_s(theta_1, theta_2, n1, n2):
    """Fresnel's equation"""
    n1_costheta_1 = n1*np.cos(np.radians(theta_1))
    n2_costheta_2 = n2*np.cos(np.radians(theta_2))
    return np.abs((n1_costheta_1 - n2_costheta_2)/(n1_costheta_1 + n2_costheta_2))**2


def refl_p(theta_1, theta_2, n1, n2):
    """Fresnel's equation"""
    n1_costheta_2 = n1*np.cos(np.radians(theta_2))
    n2_costheta_1 = n2*np.cos(np.radians(theta_1))
    return np.abs((n1_costheta_2 - n2_costheta_1)/(n1_costheta_2 + n2_costheta_1))**2


def refl_eff(rs, rp):
    """effective reflectivity"""
    return (rs+rp)/2


def trans(refl):
    """transmissivity"""
    return 1-refl


def refl0(n1, n2):
    """reflectivity at normal incidence"""
    return np.abs((n1-n2)/(n1+n2))**2


def fresnel(theta_inc, n1=n_air, n2=n_glass):
    """calculate IAM using Fresnel's Law"""
    _, theta_tr = snell(theta_inc, n1, n2)
    rs = refl_s(theta_inc, theta_tr, n1, n2)
    rp = refl_p(theta_inc, theta_tr, n1, n2)
    reff = refl_eff(rs, rp)
    r0 = refl0(n1, n2)
    return trans(reff)/trans(r0)


def ashrae(theta_inc, b0=0.05):
    """ASHRAE equation"""
    return 1 - b0*(1/np.cos(np.radians(theta_inc)) - 1)


def fresnel_ar(theta_inc, n_ar, n1=n_air, n2=n_glass):
    """calculate IAM using Fresnel's law with AR"""
    # use fresnel() for n2=n_ar
    _, theta_ar = snell(theta_inc, n1, n_ar)
    rs_ar1 = refl_s(theta_inc, theta_ar, n1, n_ar)
    rp_ar1 = refl_p(theta_inc, theta_ar, n1, n_ar)
    r0_ar1 = refl0(n1, n_ar)
    # repeat with fresnel() with n1=n_ar
    _, theta_tr = snell(theta_ar, n_ar, n2)
    rs = refl_s(theta_ar, theta_tr, n_ar, n2)
    rp = refl_p(theta_ar, theta_tr, n_ar, n2)
    # note that combined reflectivity is product of transmissivity!
    # so... rho12 = 1 - (1-rho1)(1-rho2) 
    reff = refl_eff(1-(1-rs_ar1)*(1-rs), 1-(1-rp_ar1)*(1-rp))
    r0 = 1-(1-refl0(n_ar, n2))*(1-r0_ar1)
    return trans(reff)/trans(r0)


# plot Fresnel for normal glass and ASHRAE
plt.plot(theta_inc, fresnel(theta_inc))
plt.plot(theta_inc, ashrae(theta_inc))

# calculate IAM for AR with n=1.1 and plot
iam_ar11 = fresnel_ar(theta_inc, n_ar=1.1)
plt.plot(theta_inc, iam_ar11)

# repeat for AR with n=1.2
iam_ar12 = fresnel_ar(theta_inc, n_ar=1.2)
plt.plot(theta_inc, iam_ar12)

# make plot pretty
plt.legend(['Fresnel, normal glass', 'ASHRAE, $b_0=0.05$', 'Fresnel $n_{AR}=1.1$', 'Fresnel $n_{AR}=1.2$'])
plt.title("IAM correction, Fresnel vs. ASHRAE, using basic eqn's")
plt.ylabel('IAM')
plt.xlabel(r'incidence angle $\theta_{inc} [\degree]$')
plt.grid()
plt.ylim([0.55,1.05])
```
</details>




Discussion:
+1. This [reference](https://www.sciencedirect.com/science/article/abs/pii/S0038092X96001375) might be relevant.
I seem to recall from somewhere that PVsyst actually interpolates from a fixed set of pre-calculated values when simulating.
PVsyst allows a user specified custom IAM v AOI lookup table in the module PAN file, but that presupposes there exist qualified IAM measurements either from a lab or the manufacturer. Otherwise they use Fresnel as of v6.67. See https://www.pvsyst.com/help/iam_loss.htm
Yes, what I meant is that they use the Fresnel equations to populate the table for interpolation. At least this is my recollection.

Patch:
diff --git a/pvlib/iam.py b/pvlib/iam.py
--- a/pvlib/iam.py
+++ b/pvlib/iam.py
@@ -11,7 +11,7 @@
 import numpy as np
 import pandas as pd
 import functools
-from pvlib.tools import cosd, sind, tand, asind
+from pvlib.tools import cosd, sind
 
 # a dict of required parameter names for each IAM model
 # keys are the function names for the IAM models
@@ -91,21 +91,22 @@ def ashrae(aoi, b=0.05):
     return iam
 
 
-def physical(aoi, n=1.526, K=4., L=0.002):
+def physical(aoi, n=1.526, K=4.0, L=0.002, *, n_ar=None):
     r"""
     Determine the incidence angle modifier using refractive index ``n``,
-    extinction coefficient ``K``, and glazing thickness ``L``.
+    extinction coefficient ``K``, glazing thickness ``L`` and refractive
+    index ``n_ar`` of an optional anti-reflective coating.
 
     ``iam.physical`` calculates the incidence angle modifier as described in
-    [1]_, Section 3. The calculation is based on a physical model of absorbtion
+    [1]_, Section 3, with additional support of an anti-reflective coating.
+    The calculation is based on a physical model of reflections, absorption,
     and transmission through a transparent cover.
 
     Parameters
     ----------
     aoi : numeric
         The angle of incidence between the module normal vector and the
-        sun-beam vector in degrees. Angles of 0 are replaced with 1e-06
-        to ensure non-nan results. Angles of nan will result in nan.
+        sun-beam vector in degrees. Angles of nan will result in nan.
 
     n : numeric, default 1.526
         The effective index of refraction (unitless). Reference [1]_
@@ -121,6 +122,11 @@ def physical(aoi, n=1.526, K=4., L=0.002):
         indicates that 0.002 meters (2 mm) is reasonable for most
         glass-covered PV panels.
 
+    n_ar : numeric, optional
+        The effective index of refraction of the anti-reflective (AR) coating
+        (unitless). If n_ar is None (default), no AR coating is applied.
+        A typical value for the effective index of an AR coating is 1.29.
+
     Returns
     -------
     iam : numeric
@@ -149,48 +155,65 @@ def physical(aoi, n=1.526, K=4., L=0.002):
     pvlib.iam.interp
     pvlib.iam.sapm
     """
-    zeroang = 1e-06
-
-    # hold a new reference to the input aoi object since we're going to
-    # overwrite the aoi reference below, but we'll need it for the
-    # series check at the end of the function
-    aoi_input = aoi
-
-    aoi = np.where(aoi == 0, zeroang, aoi)
-
-    # angle of reflection
-    thetar_deg = asind(1.0 / n * (sind(aoi)))
-
-    # reflectance and transmittance for normal incidence light
-    rho_zero = ((1-n) / (1+n)) ** 2
-    tau_zero = np.exp(-K*L)
-
-    # reflectance for parallel and perpendicular polarized light
-    rho_para = (tand(thetar_deg - aoi) / tand(thetar_deg + aoi)) ** 2
-    rho_perp = (sind(thetar_deg - aoi) / sind(thetar_deg + aoi)) ** 2
-
-    # transmittance for non-normal light
-    tau = np.exp(-K * L / cosd(thetar_deg))
-
-    # iam is ratio of non-normal to normal incidence transmitted light
-    # after deducting the reflected portion of each
-    iam = ((1 - (rho_para + rho_perp) / 2) / (1 - rho_zero) * tau / tau_zero)
-
-    with np.errstate(invalid='ignore'):
-        # angles near zero produce nan, but iam is defined as one
-        small_angle = 1e-06
-        iam = np.where(np.abs(aoi) < small_angle, 1.0, iam)
-
-        # angles at 90 degrees can produce tiny negative values,
-        # which should be zero. this is a result of calculation precision
-        # rather than the physical model
-        iam = np.where(iam < 0, 0, iam)
-
-        # for light coming from behind the plane, none can enter the module
-        iam = np.where(aoi > 90, 0, iam)
-
-    if isinstance(aoi_input, pd.Series):
-        iam = pd.Series(iam, index=aoi_input.index)
+    n1, n3 = 1, n
+    if n_ar is None or np.allclose(n_ar, n1):
+        # no AR coating
+        n2 = n
+    else:
+        n2 = n_ar
+
+    # incidence angle
+    costheta = np.maximum(0, cosd(aoi))  # always >= 0
+    sintheta = np.sqrt(1 - costheta**2)  # always >= 0
+    n1costheta1 = n1 * costheta
+    n2costheta1 = n2 * costheta
+
+    # refraction angle of first interface
+    sintheta = n1 / n2 * sintheta
+    costheta = np.sqrt(1 - sintheta**2)
+    n1costheta2 = n1 * costheta
+    n2costheta2 = n2 * costheta
+
+    # reflectance of s-, p-polarized, and normal light by the first interface
+    rho12_s = ((n1costheta1 - n2costheta2) / (n1costheta1 + n2costheta2)) ** 2
+    rho12_p = ((n1costheta2 - n2costheta1) / (n1costheta2 + n2costheta1)) ** 2
+    rho12_0 = ((n1 - n2) / (n1 + n2)) ** 2
+
+    # transmittance through the first interface
+    tau_s = 1 - rho12_s
+    tau_p = 1 - rho12_p
+    tau_0 = 1 - rho12_0
+
+    if not np.allclose(n3, n2):  # AR coated glass
+        n3costheta2 = n3 * costheta
+        # refraction angle of second interface
+        sintheta = n2 / n3 * sintheta
+        costheta = np.sqrt(1 - sintheta**2)
+        n2costheta3 = n2 * costheta
+        n3costheta3 = n3 * costheta
+
+        # reflectance by the second interface
+        rho23_s = (
+            (n2costheta2 - n3costheta3) / (n2costheta2 + n3costheta3)
+        ) ** 2
+        rho23_p = (
+            (n2costheta3 - n3costheta2) / (n2costheta3 + n3costheta2)
+        ) ** 2
+        rho23_0 = ((n2 - n3) / (n2 + n3)) ** 2
+
+        # transmittance through the coating, including internal reflections
+        # 1 + rho23*rho12 + (rho23*rho12)^2 + ... = 1/(1 - rho23*rho12)
+        tau_s *= (1 - rho23_s) / (1 - rho23_s * rho12_s)
+        tau_p *= (1 - rho23_p) / (1 - rho23_p * rho12_p)
+        tau_0 *= (1 - rho23_0) / (1 - rho23_0 * rho12_0)
+
+    # transmittance after absorption in the glass
+    tau_s *= np.exp(-K * L / costheta)
+    tau_p *= np.exp(-K * L / costheta)
+    tau_0 *= np.exp(-K * L)
+
+    # incidence angle modifier
+    iam = (tau_s + tau_p) / 2 / tau_0
 
     return iam
 


Test patch:
diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py
--- a/pvlib/tests/test_iam.py
+++ b/pvlib/tests/test_iam.py
@@ -42,7 +42,7 @@ def test_physical():
     expected = np.array([0, 0.8893998, 0.98797788, 0.99926198, 1, 0.99926198,
                          0.98797788, 0.8893998, 0, np.nan])
     iam = _iam.physical(aoi, 1.526, 0.002, 4)
-    assert_allclose(iam, expected, equal_nan=True)
+    assert_allclose(iam, expected, atol=1e-7, equal_nan=True)
 
     # GitHub issue 397
     aoi = pd.Series(aoi)
@@ -51,6 +51,22 @@ def test_physical():
     assert_series_equal(iam, expected)
 
 
+def test_physical_ar():
+    aoi = np.array([0, 22.5, 45, 67.5, 90, 100, np.nan])
+    expected = np.array([1, 0.99944171, 0.9917463, 0.91506158, 0, 0, np.nan])
+    iam = _iam.physical(aoi, n_ar=1.29)
+    assert_allclose(iam, expected, atol=1e-7, equal_nan=True)
+
+
+def test_physical_noar():
+    aoi = np.array([0, 22.5, 45, 67.5, 90, 100, np.nan])
+    expected = _iam.physical(aoi)
+    iam0 = _iam.physical(aoi, n_ar=1)
+    iam1 = _iam.physical(aoi, n_ar=1.526)
+    assert_allclose(iam0, expected, equal_nan=True)
+    assert_allclose(iam1, expected, equal_nan=True)
+
+
 def test_physical_scalar():
     aoi = -45.
     iam = _iam.physical(aoi, 1.526, 0.002, 4)