Repository: pvlib/pvlib-python (python)

Issue:
Remove Python 2 vestiges
With the merging of #728, we can now consider dropping Python2-specific code and workarounds.

Some candidates are:
- https://github.com/pvlib/pvlib-python/blob/master/pvlib/atmosphere.py#L214
- Uses of `from __future__ import ...`
- Other places where Python 2 vs. 3 considerations may have "[complected](https://www.youtube.com/watch?v=rI8tNMsozo0)" the code.

Pull request description:
remove python 2 lines
 - [X] Closes #729
 - [X] I am familiar with the [contributing guidelines](http://pvlib-python.readthedocs.io/en/latest/contributing.html).
 - [x] Fully tested. Added and/or modified tests to ensure correct behavior for all reasonable inputs. Tests (usually) must pass on the TravisCI and Appveyor testing services.
 - [x] Updates entries to `docs/sphinx/source/api.rst` for API changes.
 - [x] Adds description and name entries in the appropriate `docs/sphinx/source/whatsnew` file for all changes.
 - [x] Code quality and style is sufficient. Passes LGTM and SticklerCI checks.
 - [x] New code is fully documented. Includes sphinx/numpydoc compliant docstrings and comments in the code where necessary.
 - [x] Pull request is nearly complete and ready for detailed review.

Brief description of the problem and proposed solution (if not already fully described in the issue linked to above):


Patch:
diff --git a/docs/sphinx/source/whatsnew/v0.7.0.rst b/docs/sphinx/source/whatsnew/v0.7.0.rst
index a5251d9..505bd00 100644
--- a/docs/sphinx/source/whatsnew/v0.7.0.rst
+++ b/docs/sphinx/source/whatsnew/v0.7.0.rst
@@ -10,10 +10,16 @@ compatibility notes.
 **Python 2.7 support ended on June 1, 2019.** (:issue:`501`)
 **Minimum numpy version is now 1.10.4. Minimum pandas version is now 0.18.1.**
 
+Enhancements
+~~~~~~~~~~~~
+* Created two new incidence angle modifier functions: :py:func:`pvlib.pvsystem.iam_martin_ruiz`
+  and :py:func:`pvlib.pvsystem.iam_interp`. (:issue:`751`)
+
 Bug fixes
 ~~~~~~~~~
 * Fix handling of keyword arguments in `forecasts.get_processed_data`.
   (:issue:`745`)
+* Fix output as Series feature in :py:func:`pvlib.pvsystem.ashraeiam`.
 
 Testing
 ~~~~~~~
@@ -26,4 +32,5 @@ Contributors
 * Mark Campanellli (:ghuser:`markcampanelli`)
 * Will Holmgren (:ghuser:`wholmgren`)
 * Oscar Dowson (:ghuser:`odow`)
+* Anton Driesse (:ghuser:`adriesse`)
 * Alexander Morgan (:ghuser:`alexandermorgan`)
diff --git a/pvlib/atmosphere.py b/pvlib/atmosphere.py
index ab4446a..743010e 100644
--- a/pvlib/atmosphere.py
+++ b/pvlib/atmosphere.py
@@ -3,8 +3,6 @@ The ``atmosphere`` module contains methods to calculate relative and
 absolute airmass and to determine pressure from altitude or vice versa.
 """
 
-from __future__ import division
-
 from warnings import warn
 
 import numpy as np
@@ -211,8 +209,7 @@ def get_relative_airmass(zenith, model='kastenyoung1989'):
     Sandia Report, (2012).
     '''
 
-    # need to filter first because python 2.7 does not support raising a
-    # negative number to a negative power.
+    # set zenith values greater than 90 to nans
     z = np.where(zenith > 90, np.nan, zenith)
     zenith_rad = np.radians(z)
 
diff --git a/pvlib/clearsky.py b/pvlib/clearsky.py
index 6543244..0aa8012 100644
--- a/pvlib/clearsky.py
+++ b/pvlib/clearsky.py
@@ -3,8 +3,6 @@ The ``clearsky`` module contains several methods
 to calculate clear sky GHI, DNI, and DHI.
 """
 
-from __future__ import division
-
 import os
 from collections import OrderedDict
 import calendar
diff --git a/pvlib/iotools/ecmwf_macc.py b/pvlib/iotools/ecmwf_macc.py
index ef0ee22..4fa8ebd 100644
--- a/pvlib/iotools/ecmwf_macc.py
+++ b/pvlib/iotools/ecmwf_macc.py
@@ -2,7 +2,6 @@
 Read data from ECMWF MACC Reanalysis.
 """
 
-from __future__ import division
 import threading
 import pandas as pd
 
diff --git a/pvlib/iotools/epw.py b/pvlib/iotools/epw.py
index 6b6c5dd..4a714d0 100644
--- a/pvlib/iotools/epw.py
+++ b/pvlib/iotools/epw.py
@@ -3,13 +3,7 @@ Import functions for EPW data files.
 """
 
 import io
-
-try:
-    # python 2 compatibility
-    from urllib2 import urlopen, Request
-except ImportError:
-    from urllib.request import urlopen, Request
-
+from urllib.request import urlopen, Request
 import pandas as pd
 
 
diff --git a/pvlib/iotools/psm3.py b/pvlib/iotools/psm3.py
index 560a259..caa7ee9 100644
--- a/pvlib/iotools/psm3.py
+++ b/pvlib/iotools/psm3.py
@@ -7,11 +7,7 @@ see https://developer.nrel.gov/docs/solar/nsrdb/psm3_data_download/
 import io
 import requests
 import pandas as pd
-# Python-2 compatible JSONDecodeError
-try:
-    from json import JSONDecodeError
-except ImportError:
-    JSONDecodeError = ValueError
+from json import JSONDecodeError
 
 URL = "http://developer.nrel.gov/api/solar/nsrdb_psm3_download.csv"
 
diff --git a/pvlib/iotools/surfrad.py b/pvlib/iotools/surfrad.py
index 503b9da..22e451c 100644
--- a/pvlib/iotools/surfrad.py
+++ b/pvlib/iotools/surfrad.py
@@ -2,13 +2,7 @@
 Import functions for NOAA SURFRAD Data.
 """
 import io
-
-try:
-    # python 2 compatibility
-    from urllib2 import urlopen, Request
-except ImportError:
-    from urllib.request import urlopen, Request
-
+from urllib.request import urlopen, Request
 import pandas as pd
 import numpy as np
 
diff --git a/pvlib/iotools/tmy.py b/pvlib/iotools/tmy.py
index 9c5ba9e..a3860c3 100644
--- a/pvlib/iotools/tmy.py
+++ b/pvlib/iotools/tmy.py
@@ -5,13 +5,7 @@ Import functions for TMY2 and TMY3 data files.
 import datetime
 import io
 import re
-
-try:
-    # python 2 compatibility
-    from urllib2 import urlopen, Request
-except ImportError:
-    from urllib.request import urlopen, Request
-
+from urllib.request import urlopen, Request
 import dateutil
 import pandas as pd
 
@@ -160,7 +154,7 @@ def read_tmy3(filename=None, coerce_year=None, recolumn=True):
         try:
             filename = _interactive_load()
         except ImportError:
-            raise ImportError('Interactive load failed. Tkinter not supported '
+            raise ImportError('Interactive load failed. tkinter not supported '
                               'on this system. Try installing X-Quartz and '
                               'reloading')
 
@@ -210,9 +204,9 @@ def read_tmy3(filename=None, coerce_year=None, recolumn=True):
 
 
 def _interactive_load():
-    import Tkinter
-    from tkFileDialog import askopenfilename
-    Tkinter.Tk().withdraw()  # Start interactive file input
+    import tkinter
+    from tkinter.filedialog import askopenfilename
+    tkinter.Tk().withdraw()  # Start interactive file input
     return askopenfilename()
 
 
@@ -406,7 +400,7 @@ def read_tmy2(filename):
         try:
             filename = _interactive_load()
         except ImportError:
-            raise ImportError('Interactive load failed. Tkinter not supported '
+            raise ImportError('Interactive load failed. tkinter not supported '
                               'on this system. Try installing X-Quartz and '
                               'reloading')
 
diff --git a/pvlib/irradiance.py b/pvlib/irradiance.py
index 000cf32..494d13b 100644
--- a/pvlib/irradiance.py
+++ b/pvlib/irradiance.py
@@ -4,8 +4,6 @@ horizontal irradiance, direct normal irradiance, diffuse horizontal
 irradiance, and total irradiance under various conditions.
 """
 
-from __future__ import division
-
 import datetime
 from collections import OrderedDict
 from functools import partial
diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py
index 35701bd..f040b5f 100644
--- a/pvlib/pvsystem.py
+++ b/pvlib/pvsystem.py
@@ -3,21 +3,15 @@ The ``pvsystem`` module contains functions for modeling the output and
 performance of PV modules and inverters.
 """
 
-from __future__ import division
-
 from collections import OrderedDict
 import io
 import os
-try:
-    from urllib2 import urlopen
-except ImportError:
-    from urllib.request import urlopen
-
+from urllib.request import urlopen
 import numpy as np
 import pandas as pd
 
 from pvlib import atmosphere, irradiance, tools, singlediode as _singlediode
-from pvlib.tools import _build_kwargs
+from pvlib.tools import _build_kwargs, cosd
 from pvlib.location import Location
 
 
@@ -946,7 +940,7 @@ def ashraeiam(aoi, b=0.05):
     iam = np.where(aoi_gte_90, 0, iam)
     iam = np.maximum(0, iam)
 
-    if isinstance(iam, pd.Series):
+    if isinstance(aoi, pd.Series):
         iam = pd.Series(iam, index=aoi.index)
 
     return iam
@@ -1064,6 +1058,161 @@ def physicaliam(aoi, n=1.526, K=4., L=0.002):
     return iam
 
 
+def iam_martin_ruiz(aoi, a_r=0.16):
+    '''
+    Determine the incidence angle modifier (iam) using the Martin
+    and Ruiz incident angle model.
+
+    Parameters
+    ----------
+    aoi : numeric, degrees
+        The angle of incidence between the module normal vector and the
+        sun-beam vector in degrees. Theta must be a numeric scalar or vector.
+        iam is 0 where |aoi| > 90.
+
+    a_r : numeric
+        The angular losses coefficient described in equation 3 of [1].
+        This is an empirical dimensionless parameter. Values of a_r are
+        generally on the order of 0.08 to 0.25 for flat-plate PV modules.
+        a_r must be a positive numeric scalar or vector (same length as aoi).
+
+    Returns
+    -------
+    iam : numeric
+        The incident angle modifier(s)
+
+    Notes
+    -----
+    iam_martin_ruiz calculates the incidence angle modifier (iam)
+    as described by Martin and Ruiz in [1]. The information
+    required is the incident angle (aoi) and the angular losses
+    coefficient (a_r). Please note that [1] has a corrigendum which makes
+    the document much simpler to understand.
+
+    The incident angle modifier is defined as
+    [1-exp(-cos(aoi/ar))] / [1-exp(-1/ar)], which is
+    presented as AL(alpha) = 1 - IAM in equation 4 of [1]. Thus IAM is
+    equal to 1 at aoi = 0, and equal to 0 at aoi = 90.  This equation is only
+    valid for -90 <= aoi <= 90, therefore iam must be constrained to 0.0
+    beyond this range.
+
+    References
+    ----------
+    [1] N. Martin and J. M. Ruiz, "Calculation of the PV modules angular
+    losses under field conditions by means of an analytical model", Solar
+    Energy Materials & Solar Cells, vol. 70, pp. 25-38, 2001.
+
+    [2] N. Martin and J. M. Ruiz, "Corrigendum to 'Calculation of the PV
+    modules angular losses under field conditions by means of an
+    analytical model'", Solar Energy Materials & Solar Cells, vol. 110,
+    pp. 154, 2013.
+
+    See Also
+    --------
+    physicaliam
+    ashraeiam
+    iam_interp
+    '''
+    # Contributed by Anton Driesse (@adriesse), PV Performance Labs. July, 2019
+
+    aoi_input = aoi
+
+    aoi = np.asanyarray(aoi)
+    a_r = np.asanyarray(a_r)
+
+    if np.any(np.less_equal(a_r, 0)):
+        raise RuntimeError("The parameter 'a_r' cannot be zero or negative.")
+
+    with np.errstate(invalid='ignore'):
+        iam = (1 - np.exp(-cosd(aoi) / a_r)) / (1 - np.exp(-1 / a_r))
+        iam = np.where(np.abs(aoi) >= 90.0, 0.0, iam)
+
+    if isinstance(aoi_input, pd.Series):
+        iam = pd.Series(iam, index=aoi_input.index)
+
+    return iam
+
+
+def iam_interp(aoi, theta_ref, iam_ref, method='linear', normalize=True):
+    '''
+    Determine the incidence angle modifier (iam) by interpolating a set of
+    reference values, which are usually measured values.
+
+    Parameters
+    ----------
+    aoi : numeric, degrees
+        The angle of incidence between the module normal vector and the
+        sun-beam vector in degrees.
+
+    theta_ref : numeric, degrees
+        Vector of angles at which the iam is known.
+
+    iam_ref : numeric, unitless
+        iam values for each angle in theta_ref.
+
+    method : str, default 'linear'
+        Specifies the interpolation method.
+        Useful options are: 'linear', 'quadratic','cubic'.
+        See scipy.interpolate.interp1d for more options.
+
+    normalize : boolean
+        When true, the interpolated values are divided by the interpolated
+        value at zero degrees.  This ensures that the iam at normal
+        incidence is equal to 1.0.
+
+    Returns
+    -------
+    iam : numeric
+        The incident angle modifier(s)
+
+    Notes:
+    ------
+    theta_ref must have two or more points and may span any range of angles.
+    Typically there will be a dozen or more points in the range 0-90 degrees.
+    iam beyond the range of theta_ref are extrapolated, but constrained to be
+    non-negative.
+
+    The sign of aoi is ignored; only the magnitude is used.
+
+    See Also
+    --------
+    physicaliam
+    ashraeiam
+    iam_martin_ruiz
+    '''
+    # Contributed by Anton Driesse (@adriesse), PV Performance Labs. July, 2019
+
+    from scipy.interpolate import interp1d
+
+    # Scipy doesn't give the clearest feedback, so check number of points here.
+    MIN_REF_VALS = {'linear': 2, 'quadratic': 3, 'cubic': 4, 1: 2, 2: 3, 3: 4}
+
+    if len(theta_ref) < MIN_REF_VALS.get(method, 2):
+        raise ValueError("Too few reference points defined "
+                         "for interpolation method '%s'." % method)
+
+    if np.any(np.less(iam_ref, 0)):
+        raise ValueError("Negative value(s) found in 'iam_ref'. "
+                         "This is not physically possible.")
+
+    interpolator = interp1d(theta_ref, iam_ref, kind=method,
+                            fill_value='extrapolate')
+    aoi_input = aoi
+
+    aoi = np.asanyarray(aoi)
+    aoi = np.abs(aoi)
+    iam = interpolator(aoi)
+    iam = np.clip(iam, 0, None)
+
+    if normalize:
+        iam /= interpolator(0)
+
+    if isinstance(aoi_input, pd.Series):
+        iam = pd.Series(iam, index=aoi_input.index)
+
+    return iam
+
+
 def calcparams_desoto(effective_irradiance, temp_cell,
                       alpha_sc, a_ref, I_L_ref, I_o_ref, R_sh_ref, R_s,
                       EgRef=1.121, dEgdT=-0.0002677,
@@ -1632,14 +1781,8 @@ def retrieve_sam(name=None, path=None):
         else:
             csvdata = path
     elif name is None and path is None:
-        try:
-            # python 2
-            import Tkinter as tkinter
-            from tkFileDialog import askopenfilename
-        except ImportError:
-            # python 3
-            import tkinter
-            from tkinter.filedialog import askopenfilename
+        import tkinter
+        from tkinter.filedialog import askopenfilename
 
         tkinter.Tk().withdraw()
         csvdata = askopenfilename()
diff --git a/pvlib/solarposition.py b/pvlib/solarposition.py
index d557370..be7f33a 100644
--- a/pvlib/solarposition.py
+++ b/pvlib/solarposition.py
@@ -8,7 +8,6 @@ Calculate the solar position using a variety of methods/packages.
 # Tony Lorenzo (@alorenzo175), University of Arizona, 2015
 # Cliff hansen (@cwhanse), Sandia National Laboratories, 2018
 
-from __future__ import division
 import os
 import datetime as dt
 try:
diff --git a/pvlib/spa.py b/pvlib/spa.py
index 3a44a2e..6c99a03 100644
--- a/pvlib/spa.py
+++ b/pvlib/spa.py
@@ -6,7 +6,6 @@ numpy arrays or compiling the code to machine language with numba.
 # Contributors:
 # Created by Tony Lorenzo (@alorenzo175), Univ. of Arizona, 2015
 
-from __future__ import division
 import os
 import threading
 import warnings
diff --git a/pvlib/tracking.py b/pvlib/tracking.py
index f81b0e8..9859022 100644
--- a/pvlib/tracking.py
+++ b/pvlib/tracking.py
@@ -1,5 +1,3 @@
-from __future__ import division
-
 import numpy as np
 import pandas as pd
 


Test patch:
diff --git a/pvlib/test/conftest.py b/pvlib/test/conftest.py
index e91adb8..d26ff00 100644
--- a/pvlib/test/conftest.py
+++ b/pvlib/test/conftest.py
@@ -41,8 +41,6 @@ test_dir = os.path.dirname(
 data_dir = os.path.join(test_dir, os.pardir, 'data')
 
 
-has_python2 = parse_version(platform.python_version()) < parse_version('3')
-
 platform_is_windows = platform.system() == 'Windows'
 skip_windows = pytest.mark.skipif(platform_is_windows,
                                   reason='does not run on windows')
diff --git a/pvlib/test/test_conftest.py b/pvlib/test/test_conftest.py
index e2a8365..14cc7e5 100644
--- a/pvlib/test/test_conftest.py
+++ b/pvlib/test/test_conftest.py
@@ -1,10 +1,9 @@
 import pytest
 
-from conftest import fail_on_pvlib_version, platform_is_windows, has_python2
+from conftest import fail_on_pvlib_version
 
 
-# allow xpass for python 2 on windows
-@pytest.mark.xfail(strict=(not (platform_is_windows and has_python2)),
+@pytest.mark.xfail(strict=True,
                    reason='fail_on_pvlib_version should cause test to fail')
 @fail_on_pvlib_version('0.0')
 def test_fail_on_pvlib_version():
@@ -16,8 +15,7 @@ def test_fail_on_pvlib_version_pass():
     pass
 
 
-@pytest.mark.xfail(strict=(not (platform_is_windows and has_python2)),
-                   reason='ensure that the test is called')
+@pytest.mark.xfail(strict=True, reason='ensure that the test is called')
 @fail_on_pvlib_version('100000.0')
 def test_fail_on_pvlib_version_fail_in_test():
     raise Exception
diff --git a/pvlib/test/test_ecmwf_macc.py b/pvlib/test/test_ecmwf_macc.py
index de0e389..8823b78 100644
--- a/pvlib/test/test_ecmwf_macc.py
+++ b/pvlib/test/test_ecmwf_macc.py
@@ -2,7 +2,6 @@
 tests for :mod:`pvlib.iotools.ecmwf_macc`
 """
 
-from __future__ import division
 import os
 import datetime
 import numpy as np
diff --git a/pvlib/test/test_pvsystem.py b/pvlib/test/test_pvsystem.py
index 17f70e9..7de8930 100644
--- a/pvlib/test/test_pvsystem.py
+++ b/pvlib/test/test_pvsystem.py
@@ -148,6 +148,88 @@ def test_PVSystem_physicaliam(mocker):
     assert iam < 1.
 
 
+def test_iam_martin_ruiz():
+
+    aoi = 45.
+    a_r = 0.16
+    expected = 0.98986965
+
+    # will fail of default values change
+    iam = pvsystem.iam_martin_ruiz(aoi)
+    assert_allclose(iam, expected)
+    # will fail of parameter names change
+    iam = pvsystem.iam_martin_ruiz(aoi=aoi, a_r=a_r)
+    assert_allclose(iam, expected)
+
+    a_r = 0.18
+    aoi = [-100, -60, 0, 60, 100, np.nan, np.inf]
+    expected = [0.0, 0.9414631, 1.0, 0.9414631, 0.0, np.nan, 0.0]
+
+    # check out of range of inputs as list
+    iam = pvsystem.iam_martin_ruiz(aoi, a_r)
+    assert_allclose(iam, expected, equal_nan=True)
+
+    # check out of range of inputs as array
+    iam = pvsystem.iam_martin_ruiz(np.array(aoi), a_r)
+    assert_allclose(iam, expected, equal_nan=True)
+
+    # check out of range of inputs as Series
+    aoi = pd.Series(aoi)
+    expected = pd.Series(expected)
+    iam = pvsystem.iam_martin_ruiz(aoi, a_r)
+    assert_series_equal(iam, expected)
+
+    # check exception clause
+    with pytest.raises(RuntimeError):
+        pvsystem.iam_martin_ruiz(0.0, a_r=0.0)
+
+
+@requires_scipy
+def test_iam_interp():
+
+    aoi_meas = [0.0, 45.0, 65.0, 75.0]
+    iam_meas = [1.0,  0.9,  0.8,  0.6]
+
+    # simple default linear method
+    aoi = 55.0
+    expected = 0.85
+    iam = pvsystem.iam_interp(aoi, aoi_meas, iam_meas)
+    assert_allclose(iam, expected)
+
+    # simple non-default method
+    aoi = 55.0
+    expected = 0.8878062
+    iam = pvsystem.iam_interp(aoi, aoi_meas, iam_meas, method='cubic')
+    assert_allclose(iam, expected)
+
+    # check with all reference values
+    aoi = aoi_meas
+    expected = iam_meas
+    iam = pvsystem.iam_interp(aoi, aoi_meas, iam_meas)
+    assert_allclose(iam, expected)
+
+    # check normalization and Series
+    aoi = pd.Series(aoi)
+    expected = pd.Series(expected)
+    iam_mult = np.multiply(0.9, iam_meas)
+    iam = pvsystem.iam_interp(aoi, aoi_meas, iam_mult, normalize=True)
+    assert_series_equal(iam, expected)
+
+    # check beyond reference values
+    aoi = [-45, 0, 45, 85, 90, 95, 100, 105, 110]
+    expected = [0.9, 1.0, 0.9, 0.4, 0.3, 0.2, 0.1, 0.0, 0.0]
+    iam = pvsystem.iam_interp(aoi, aoi_meas, iam_meas)
+    assert_allclose(iam, expected)
+
+    # check exception clause
+    with pytest.raises(ValueError):
+        pvsystem.iam_interp(0.0, [0], [1])
+
+    # check exception clause
+    with pytest.raises(ValueError):
+        pvsystem.iam_interp(0.0, [0, 90], [1, -1])
+
+
 # if this completes successfully we'll be able to do more tests below.
 @pytest.fixture(scope="session")
 def sam_data():