From 2a50a6316e20e74a58152b0475dbd114ffaf2937 Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Sat, 11 May 2024 22:36:13 -0600 Subject: [PATCH 01/34] Add iam_pchip function and callable model for marion_diffuse --- pvlib/iam.py | 71 ++++++++++++++++++++++++++-------- pvlib/tests/test_iam.py | 85 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 136 insertions(+), 20 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index 46be45e7b2..d6ac9061ba 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -11,6 +11,7 @@ import numpy as np import pandas as pd import functools +import scipy.interpolate from scipy.optimize import minimize from pvlib.tools import cosd, sind, acosd @@ -470,8 +471,6 @@ def interp(aoi, theta_ref, iam_ref, method='linear', normalize=True): ''' # 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} @@ -483,8 +482,9 @@ def interp(aoi, theta_ref, iam_ref, method='linear', normalize=True): 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') + interpolator = scipy.interpolate.interp1d( + theta_ref, iam_ref, kind=method, fill_value='extrapolate' + ) aoi_input = aoi aoi = np.asanyarray(aoi) @@ -501,6 +501,39 @@ def interp(aoi, theta_ref, iam_ref, method='linear', normalize=True): return iam +def iam_pchip(aoi_data, iam_data): + """ + Generate a piecewise-cubic hermite interpolating polynomial for incident-angle + modifier (IAM) data. Assumes aoi_data in [0, 90], in degrees, with normalized + iam_data in [0, 1], unitless. Typically, (0, 1) and (90, 0) are included in the + interpolation data. + + The resulting function requires aoi to be in [0, 180], in degrees. + + This function can work well for IAM measured according to IEC 61853-2, as it + preserves monotonicity between data points. + + Note that scipy.interpolate.PchipInterpolator requires aoi_data be 1D monotonic + increasing and without duplicates. + """ + + iam_pchip_ = scipy.interpolate.PchipInterpolator( + aoi_data, iam_data, extrapolate=False + ) + + def iam(aoi): + """Compute unitless incident-angle modifier as a function of aoi, in degrees.""" + if np.any(np.logical_or(aoi < 0, aoi > 180)): + raise ValueError("aoi not between 0 and 180, inclusive") + + iam_ = iam_pchip_(aoi) + iam_[90 < aoi] = 0.0 + + return iam_ + + return iam + + def sapm(aoi, module, upper=None): r""" Determine the incidence angle modifier (IAM) using the SAPM model. @@ -575,9 +608,9 @@ def marion_diffuse(model, surface_tilt, **kwargs): Parameters ---------- - model : str - The IAM function to evaluate across solid angle. Must be one of - `'ashrae', 'physical', 'martin_ruiz', 'sapm', 'schlick'`. + model : str or callable + The IAM function to evaluate across solid angle. If not a callable, then must be + one of `'ashrae', 'martin_ruiz', 'physical', 'sapm', 'schlick'`. surface_tilt : numeric Surface tilt angles in decimal degrees. @@ -621,27 +654,33 @@ def marion_diffuse(model, surface_tilt, **kwargs): {'sky': array([0.96748999, 0.96938408]), 'horizon': array([0.86478428, 0.91825792]), 'ground': array([0.77004435, 0.8522436 ])} + + # FIXME Add IEC 61853-2 IAM example. """ models = { - 'physical': physical, 'ashrae': ashrae, - 'sapm': sapm, 'martin_ruiz': martin_ruiz, + 'physical': physical, + 'sapm': sapm, 'schlick': schlick, } - try: + if model in models: iam_model = models[model] - except KeyError: - raise ValueError('model must be one of: ' + str(list(models.keys()))) + elif callable(model): + iam_model = model + else: + raise ValueError( + f"model must be one of: {list(models.keys())} or a callable function" + ) iam_function = functools.partial(iam_model, **kwargs) - iam = {} - for region in ['sky', 'horizon', 'ground']: - iam[region] = marion_integrate(iam_function, surface_tilt, region) - return iam + return { + region: marion_integrate(iam_function, surface_tilt, region) + for region in ['sky', 'horizon', 'ground'] + } def marion_integrate(function, surface_tilt, region, num=None): diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index f5ca231bd4..3baf13de57 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -9,7 +9,7 @@ import pytest from .conftest import assert_series_equal -from numpy.testing import assert_allclose +from numpy.testing import assert_allclose, assert_equal from pvlib import iam as _iam @@ -214,6 +214,50 @@ def test_iam_interp(): _iam.interp(0.0, [0, 90], [1, -1]) +# Custom IAM function without kwargs, using IEC 61853-2 measurement data. +# Notice that the measured IAM data here are not stricly monotonic decreasing. +AOI_DATA = np.array([0, 10, 20, 30, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90]) +IAM_DATA = np.array([ + 1.0000, 0.9989, 1.0014, 1.0002, 0.9984, 0.9941, 0.9911, 0.9815, 0.9631, 0.9352, + 0.8922, 0.8134, 0.6778, 0.4541, 0.0000, +]) + + +def test_iam_pchip(): + """Test generating interpolating function for incident-angle modifier (IAM) data.""" + + iam_pchip = _iam.iam_pchip(AOI_DATA, IAM_DATA) + + # Data points should be interpolated. + assert_allclose(iam_pchip(AOI_DATA), IAM_DATA, rtol=0, atol=1e-16) + + # Degrees beyond 90 should be zero. + assert_equal(iam_pchip(np.array([135, 180])), np.array([0.0, 0.0])) + + # Verify interpolated IAM curve is monotonic decreasing. + middle_aoi = (AOI_DATA[:-1] + AOI_DATA[1:]) / 2 + middle_iam = iam_pchip(middle_aoi) + + for idx in range(middle_aoi.size): + if IAM_DATA[idx] < IAM_DATA[idx+1]: + # Increasing IAM on interval. + assert IAM_DATA[idx] < middle_iam[idx] < IAM_DATA[idx+1] + elif IAM_DATA[idx] > IAM_DATA[idx+1]: + # Decreasing IAM on interval. + assert IAM_DATA[idx] > middle_iam[idx] > IAM_DATA[idx+1] + else: + # Constant IAM on interval. + assert IAM_DATA[idx] == middle_iam[idx] == IAM_DATA[idx+1] + + # Test out of bounds scalar. + with pytest.raises(ValueError): + iam_pchip(-45) + + # Test vector with out of bounds element. + with pytest.raises(ValueError): + iam_pchip(np.array([45, 270])) + + @pytest.mark.parametrize('aoi,expected', [ (45, 0.9975036250000002), (np.array([[-30, 30, 100, np.nan]]), @@ -265,10 +309,10 @@ def test_marion_diffuse_model(mocker): assert physical_spy.call_count == 3 for k, v in ashrae_expected.items(): - assert_allclose(ashrae_actual[k], v) + assert_allclose(ashrae_actual[k], v, err_msg=f"ashrae component {k}") for k, v in physical_expected.items(): - assert_allclose(physical_actual[k], v) + assert_allclose(physical_actual[k], v, err_msg=f"physical component {k}") def test_marion_diffuse_kwargs(): @@ -281,7 +325,40 @@ def test_marion_diffuse_kwargs(): actual = _iam.marion_diffuse('ashrae', 20, b=0.04) for k, v in expected.items(): - assert_allclose(actual[k], v) + assert_allclose(actual[k], v, err_msg=f"component {k}") + + +def test_marion_diffuse_iam_function_without_kwargs(): + """ + Test PCHIP-interpolated custom IAM function from an IEC 61853-2 IAM measurement, + without any kwargs and with array input. + """ + expected = { + 'sky': np.array([0.95664428, 0.96958797, 0.95665529, 0.88137573]), + 'horizon': np.array([0.03718587, 0.94953826, 0.976997 , 0.94862772]), + 'ground': np.array([0., 0.88137573, 0.95665529, 0.96958797]), + } + actual = _iam.marion_diffuse( + _iam.iam_pchip(AOI_DATA, IAM_DATA), np.array([0.0, 45, 90, 135]) + ) + + for k, v in expected.items(): + assert_allclose(actual[k], v, rtol=2e-7, err_msg=f"component {k}") + + +def test_marion_diffuse_iam_with_kwargs(): + """Test custom IAM function with kwargs and scalar input.""" + expected = { + 'sky': 0.9687461532452274, + 'horizon': 0.94824614710175, + 'ground': 0.878266931831978, + } + actual = _iam.marion_diffuse( + _iam.interp, 45.0, theta_ref=AOI_DATA, iam_ref=IAM_DATA, normalize=False + ) + + for k, v in expected.items(): + assert_allclose(actual[k], v, err_msg=f"component {k}") def test_marion_diffuse_invalid(): From 398a8ffc945f92a23ceaa56510f00faec639618d Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Sat, 11 May 2024 23:09:33 -0600 Subject: [PATCH 02/34] Tidy up --- pvlib/iam.py | 10 +++------- pvlib/tests/test_iam.py | 14 +++++++------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index d6ac9061ba..eb37099278 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -501,7 +501,7 @@ def interp(aoi, theta_ref, iam_ref, method='linear', normalize=True): return iam -def iam_pchip(aoi_data, iam_data): +def pchip(aoi_data, iam_data): """ Generate a piecewise-cubic hermite interpolating polynomial for incident-angle modifier (IAM) data. Assumes aoi_data in [0, 90], in degrees, with normalized @@ -609,7 +609,7 @@ def marion_diffuse(model, surface_tilt, **kwargs): Parameters ---------- model : str or callable - The IAM function to evaluate across solid angle. If not a callable, then must be + The IAM function to evaluate across solid angle. If not callable, then must be one of `'ashrae', 'martin_ruiz', 'physical', 'sapm', 'schlick'`. surface_tilt : numeric @@ -654,8 +654,6 @@ def marion_diffuse(model, surface_tilt, **kwargs): {'sky': array([0.96748999, 0.96938408]), 'horizon': array([0.86478428, 0.91825792]), 'ground': array([0.77004435, 0.8522436 ])} - - # FIXME Add IEC 61853-2 IAM example. """ models = { @@ -671,9 +669,7 @@ def marion_diffuse(model, surface_tilt, **kwargs): elif callable(model): iam_model = model else: - raise ValueError( - f"model must be one of: {list(models.keys())} or a callable function" - ) + raise ValueError(f"model must be one of: {list(models.keys())} or callable") iam_function = functools.partial(iam_model, **kwargs) diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index 3baf13de57..a517a18fb2 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -215,7 +215,8 @@ def test_iam_interp(): # Custom IAM function without kwargs, using IEC 61853-2 measurement data. -# Notice that the measured IAM data here are not stricly monotonic decreasing. +# Notice that the measured IAM data here are not stricly monotonic decreasing, but a +# PCHIP interpolant should go no higher than the highest data point. AOI_DATA = np.array([0, 10, 20, 30, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90]) IAM_DATA = np.array([ 1.0000, 0.9989, 1.0014, 1.0002, 0.9984, 0.9941, 0.9911, 0.9815, 0.9631, 0.9352, @@ -223,15 +224,14 @@ def test_iam_interp(): ]) -def test_iam_pchip(): +def test_pchip(): """Test generating interpolating function for incident-angle modifier (IAM) data.""" - - iam_pchip = _iam.iam_pchip(AOI_DATA, IAM_DATA) + iam_pchip = _iam.pchip(AOI_DATA, IAM_DATA) # Data points should be interpolated. assert_allclose(iam_pchip(AOI_DATA), IAM_DATA, rtol=0, atol=1e-16) - # Degrees beyond 90 should be zero. + # Degrees >90 and <=180 should be exactly zero. assert_equal(iam_pchip(np.array([135, 180])), np.array([0.0, 0.0])) # Verify interpolated IAM curve is monotonic decreasing. @@ -339,7 +339,7 @@ def test_marion_diffuse_iam_function_without_kwargs(): 'ground': np.array([0., 0.88137573, 0.95665529, 0.96958797]), } actual = _iam.marion_diffuse( - _iam.iam_pchip(AOI_DATA, IAM_DATA), np.array([0.0, 45, 90, 135]) + _iam.pchip(AOI_DATA, IAM_DATA), np.array([0.0, 45, 90, 135]) ) for k, v in expected.items(): @@ -347,7 +347,7 @@ def test_marion_diffuse_iam_function_without_kwargs(): def test_marion_diffuse_iam_with_kwargs(): - """Test custom IAM function with kwargs and scalar input.""" + """Test custom IAM function (iam.interp) with kwargs and scalar input.""" expected = { 'sky': 0.9687461532452274, 'horizon': 0.94824614710175, From ed79db84318eda1cfa0face11d023d06910b79a3 Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Sat, 11 May 2024 23:40:42 -0600 Subject: [PATCH 03/34] Appease flake8 --- pvlib/iam.py | 34 ++++++++++++++++++++-------------- pvlib/tests/test_iam.py | 37 +++++++++++++++++++++++++------------ pyproject.toml | 1 + 3 files changed, 46 insertions(+), 26 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index eb37099278..faafb97c16 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -503,18 +503,18 @@ def interp(aoi, theta_ref, iam_ref, method='linear', normalize=True): def pchip(aoi_data, iam_data): """ - Generate a piecewise-cubic hermite interpolating polynomial for incident-angle - modifier (IAM) data. Assumes aoi_data in [0, 90], in degrees, with normalized - iam_data in [0, 1], unitless. Typically, (0, 1) and (90, 0) are included in the - interpolation data. + Generate a piecewise-cubic hermite interpolating polynomial for + incident-angle modifier (IAM) data. Assumes aoi_data in [0, 90], in + degrees, with normalized iam_data in [0, 1], unitless. Typically, (0, 1) + and (90, 0) are included in the interpolation data. The resulting function requires aoi to be in [0, 180], in degrees. - This function can work well for IAM measured according to IEC 61853-2, as it - preserves monotonicity between data points. + This function can work well for IAM measured according to IEC 61853-2, as + it preserves monotonicity between data points. - Note that scipy.interpolate.PchipInterpolator requires aoi_data be 1D monotonic - increasing and without duplicates. + Note that scipy.interpolate.PchipInterpolator requires aoi_data be 1D + monotonic increasing and without duplicates. """ iam_pchip_ = scipy.interpolate.PchipInterpolator( @@ -522,7 +522,10 @@ def pchip(aoi_data, iam_data): ) def iam(aoi): - """Compute unitless incident-angle modifier as a function of aoi, in degrees.""" + """ + Compute unitless incident-angle modifier as a function of aoi, in + degrees. + """ if np.any(np.logical_or(aoi < 0, aoi > 180)): raise ValueError("aoi not between 0 and 180, inclusive") @@ -609,8 +612,9 @@ def marion_diffuse(model, surface_tilt, **kwargs): Parameters ---------- model : str or callable - The IAM function to evaluate across solid angle. If not callable, then must be - one of `'ashrae', 'martin_ruiz', 'physical', 'sapm', 'schlick'`. + The IAM function to evaluate across solid angle. If not callable, then + must be one of `'ashrae', 'martin_ruiz', 'physical', 'sapm', + 'schlick'`. surface_tilt : numeric Surface tilt angles in decimal degrees. @@ -669,7 +673,9 @@ def marion_diffuse(model, surface_tilt, **kwargs): elif callable(model): iam_model = model else: - raise ValueError(f"model must be one of: {list(models.keys())} or callable") + raise ValueError( + f"model must be one of: {list(models.keys())} or callable" + ) iam_function = functools.partial(iam_model, **kwargs) @@ -926,8 +932,8 @@ def schlick_diffuse(surface_tilt): implements only the integrated Schlick approximation. Note also that the output of this function (which is an exact integration) - can be compared with the output of :py:func:`marion_diffuse` which numerically - integrates the Schlick approximation: + can be compared with the output of :py:func:`marion_diffuse` which + numerically integrates the Schlick approximation: .. code:: diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index a517a18fb2..5768ee39d7 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -215,17 +215,22 @@ def test_iam_interp(): # Custom IAM function without kwargs, using IEC 61853-2 measurement data. -# Notice that the measured IAM data here are not stricly monotonic decreasing, but a -# PCHIP interpolant should go no higher than the highest data point. -AOI_DATA = np.array([0, 10, 20, 30, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90]) +# Notice that the measured IAM data here are not stricly monotonic decreasing, +# but a PCHIP interpolant should go no higher than the highest data point. +AOI_DATA = np.array([ + 0, 10, 20, 30, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, +]) IAM_DATA = np.array([ - 1.0000, 0.9989, 1.0014, 1.0002, 0.9984, 0.9941, 0.9911, 0.9815, 0.9631, 0.9352, - 0.8922, 0.8134, 0.6778, 0.4541, 0.0000, + 1.0000, 0.9989, 1.0014, 1.0002, 0.9984, 0.9941, 0.9911, 0.9815, 0.9631, + 0.9352, 0.8922, 0.8134, 0.6778, 0.4541, 0.0000, ]) def test_pchip(): - """Test generating interpolating function for incident-angle modifier (IAM) data.""" + """ + Test generating interpolating function for incident-angle modifier (IAM) + data. + """ iam_pchip = _iam.pchip(AOI_DATA, IAM_DATA) # Data points should be interpolated. @@ -309,10 +314,14 @@ def test_marion_diffuse_model(mocker): assert physical_spy.call_count == 3 for k, v in ashrae_expected.items(): - assert_allclose(ashrae_actual[k], v, err_msg=f"ashrae component {k}") + assert_allclose( + ashrae_actual[k], v, err_msg=f"ashrae component {k}" + ) for k, v in physical_expected.items(): - assert_allclose(physical_actual[k], v, err_msg=f"physical component {k}") + assert_allclose( + physical_actual[k], v, err_msg=f"physical component {k}" + ) def test_marion_diffuse_kwargs(): @@ -330,12 +339,12 @@ def test_marion_diffuse_kwargs(): def test_marion_diffuse_iam_function_without_kwargs(): """ - Test PCHIP-interpolated custom IAM function from an IEC 61853-2 IAM measurement, - without any kwargs and with array input. + Test PCHIP-interpolated custom IAM function from an IEC 61853-2 IAM + measurement, without any kwargs and with array input. """ expected = { 'sky': np.array([0.95664428, 0.96958797, 0.95665529, 0.88137573]), - 'horizon': np.array([0.03718587, 0.94953826, 0.976997 , 0.94862772]), + 'horizon': np.array([0.03718587, 0.94953826, 0.976997, 0.94862772]), 'ground': np.array([0., 0.88137573, 0.95665529, 0.96958797]), } actual = _iam.marion_diffuse( @@ -354,7 +363,11 @@ def test_marion_diffuse_iam_with_kwargs(): 'ground': 0.878266931831978, } actual = _iam.marion_diffuse( - _iam.interp, 45.0, theta_ref=AOI_DATA, iam_ref=IAM_DATA, normalize=False + _iam.interp, + 45.0, + theta_ref=AOI_DATA, + iam_ref=IAM_DATA, + normalize=False, ) for k, v in expected.items(): diff --git a/pyproject.toml b/pyproject.toml index de51b7c22c..edd3e8c3fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ doc = [ 'solarfactors', ] test = [ + 'flake8', 'pytest', 'pytest-cov', 'pytest-mock', From d596e28dc8d8eb86003ba69a652699693f055bb0 Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Sat, 11 May 2024 23:54:43 -0600 Subject: [PATCH 04/34] Pin flake8 to match github workflow --- .github/workflows/flake8.yml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/flake8.yml b/.github/workflows/flake8.yml index 82b413236f..5033d63b77 100644 --- a/.github/workflows/flake8.yml +++ b/.github/workflows/flake8.yml @@ -12,7 +12,7 @@ jobs: with: python-version: '3.11' - name: Install Flake8 5.0.4 linter - run: pip install flake8==5.0.4 # use this version for --diff option + run: pip install flake8==5.0.4 # use this version for --diff option, should match version in pyproject.toml - name: Setup Flake8 output matcher for PR annotations run: echo '::add-matcher::.github/workflows/flake8-linter-matcher.json' - name: Fetch pull request target branch diff --git a/pyproject.toml b/pyproject.toml index edd3e8c3fe..a067d11ce4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ doc = [ 'solarfactors', ] test = [ - 'flake8', + 'flake8==5.0.4', # Should match version in github workflow flake8.yml. 'pytest', 'pytest-cov', 'pytest-mock', From 3e3f0d6bc022a85dafca64635d9023375a4206f9 Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Sun, 12 May 2024 00:01:17 -0600 Subject: [PATCH 05/34] Tidy up more --- pvlib/iam.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index faafb97c16..bcbc2e0b25 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -516,8 +516,8 @@ def pchip(aoi_data, iam_data): Note that scipy.interpolate.PchipInterpolator requires aoi_data be 1D monotonic increasing and without duplicates. """ - - iam_pchip_ = scipy.interpolate.PchipInterpolator( + # For efficiency, use closure over this created-once interpolator. + pchip_ = scipy.interpolate.PchipInterpolator( aoi_data, iam_data, extrapolate=False ) @@ -529,7 +529,7 @@ def iam(aoi): if np.any(np.logical_or(aoi < 0, aoi > 180)): raise ValueError("aoi not between 0 and 180, inclusive") - iam_ = iam_pchip_(aoi) + iam_ = pchip_(aoi) iam_[90 < aoi] = 0.0 return iam_ From f63a018129351c9ce9dad723f1b9fe0de98bbbd3 Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Sun, 12 May 2024 00:08:50 -0600 Subject: [PATCH 06/34] Improve test --- pvlib/tests/test_iam.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index 5768ee39d7..d25d4bb88e 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -221,7 +221,7 @@ def test_iam_interp(): 0, 10, 20, 30, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, ]) IAM_DATA = np.array([ - 1.0000, 0.9989, 1.0014, 1.0002, 0.9984, 0.9941, 0.9911, 0.9815, 0.9631, + 1.0000, 1.0000, 1.0014, 1.0002, 0.9984, 0.9941, 0.9911, 0.9815, 0.9631, 0.9352, 0.8922, 0.8134, 0.6778, 0.4541, 0.0000, ]) @@ -343,9 +343,9 @@ def test_marion_diffuse_iam_function_without_kwargs(): measurement, without any kwargs and with array input. """ expected = { - 'sky': np.array([0.95664428, 0.96958797, 0.95665529, 0.88137573]), - 'horizon': np.array([0.03718587, 0.94953826, 0.976997, 0.94862772]), - 'ground': np.array([0., 0.88137573, 0.95665529, 0.96958797]), + 'sky': np.array([0.95671526, 0.96967113, 0.95672627, 0.88137573]), + 'horizon': np.array([0.03718587, 0.94953826, 0.97722834, 0.94862772]), + 'ground': np.array([0., 0.88137573, 0.95672627, 0.96967113]), } actual = _iam.marion_diffuse( _iam.pchip(AOI_DATA, IAM_DATA), np.array([0.0, 45, 90, 135]) @@ -358,7 +358,7 @@ def test_marion_diffuse_iam_function_without_kwargs(): def test_marion_diffuse_iam_with_kwargs(): """Test custom IAM function (iam.interp) with kwargs and scalar input.""" expected = { - 'sky': 0.9687461532452274, + 'sky': 0.9688222974371822, 'horizon': 0.94824614710175, 'ground': 0.878266931831978, } From bc519bea2e82d009494a6e411ed378253e16ffad Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Tue, 14 May 2024 23:39:04 -0600 Subject: [PATCH 07/34] Only use pchip in tests --- pvlib/iam.py | 43 ++------------------- pvlib/tests/test_iam.py | 82 +++++++++++++++-------------------------- 2 files changed, 34 insertions(+), 91 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index bcbc2e0b25..ae7c3fee87 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -501,42 +501,6 @@ def interp(aoi, theta_ref, iam_ref, method='linear', normalize=True): return iam -def pchip(aoi_data, iam_data): - """ - Generate a piecewise-cubic hermite interpolating polynomial for - incident-angle modifier (IAM) data. Assumes aoi_data in [0, 90], in - degrees, with normalized iam_data in [0, 1], unitless. Typically, (0, 1) - and (90, 0) are included in the interpolation data. - - The resulting function requires aoi to be in [0, 180], in degrees. - - This function can work well for IAM measured according to IEC 61853-2, as - it preserves monotonicity between data points. - - Note that scipy.interpolate.PchipInterpolator requires aoi_data be 1D - monotonic increasing and without duplicates. - """ - # For efficiency, use closure over this created-once interpolator. - pchip_ = scipy.interpolate.PchipInterpolator( - aoi_data, iam_data, extrapolate=False - ) - - def iam(aoi): - """ - Compute unitless incident-angle modifier as a function of aoi, in - degrees. - """ - if np.any(np.logical_or(aoi < 0, aoi > 180)): - raise ValueError("aoi not between 0 and 180, inclusive") - - iam_ = pchip_(aoi) - iam_[90 < aoi] = 0.0 - - return iam_ - - return iam - - def sapm(aoi, module, upper=None): r""" Determine the incidence angle modifier (IAM) using the SAPM model. @@ -612,9 +576,10 @@ def marion_diffuse(model, surface_tilt, **kwargs): Parameters ---------- model : str or callable - The IAM function to evaluate across solid angle. If not callable, then - must be one of `'ashrae', 'martin_ruiz', 'physical', 'sapm', - 'schlick'`. + The IAM function to evaluate across solid angle. Must be one of + `'ashrae', 'martin_ruiz', 'physical', 'sapm', 'schlick'` or be + callable. If callable, then must take numeric AOI in degrees as + input and return the fractional IAM. surface_tilt : numeric Surface tilt angles in decimal degrees. diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index d25d4bb88e..0a656e5387 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -10,6 +10,7 @@ import pytest from .conftest import assert_series_equal from numpy.testing import assert_allclose, assert_equal +import scipy.interpolate from pvlib import iam as _iam @@ -214,55 +215,6 @@ def test_iam_interp(): _iam.interp(0.0, [0, 90], [1, -1]) -# Custom IAM function without kwargs, using IEC 61853-2 measurement data. -# Notice that the measured IAM data here are not stricly monotonic decreasing, -# but a PCHIP interpolant should go no higher than the highest data point. -AOI_DATA = np.array([ - 0, 10, 20, 30, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, -]) -IAM_DATA = np.array([ - 1.0000, 1.0000, 1.0014, 1.0002, 0.9984, 0.9941, 0.9911, 0.9815, 0.9631, - 0.9352, 0.8922, 0.8134, 0.6778, 0.4541, 0.0000, -]) - - -def test_pchip(): - """ - Test generating interpolating function for incident-angle modifier (IAM) - data. - """ - iam_pchip = _iam.pchip(AOI_DATA, IAM_DATA) - - # Data points should be interpolated. - assert_allclose(iam_pchip(AOI_DATA), IAM_DATA, rtol=0, atol=1e-16) - - # Degrees >90 and <=180 should be exactly zero. - assert_equal(iam_pchip(np.array([135, 180])), np.array([0.0, 0.0])) - - # Verify interpolated IAM curve is monotonic decreasing. - middle_aoi = (AOI_DATA[:-1] + AOI_DATA[1:]) / 2 - middle_iam = iam_pchip(middle_aoi) - - for idx in range(middle_aoi.size): - if IAM_DATA[idx] < IAM_DATA[idx+1]: - # Increasing IAM on interval. - assert IAM_DATA[idx] < middle_iam[idx] < IAM_DATA[idx+1] - elif IAM_DATA[idx] > IAM_DATA[idx+1]: - # Decreasing IAM on interval. - assert IAM_DATA[idx] > middle_iam[idx] > IAM_DATA[idx+1] - else: - # Constant IAM on interval. - assert IAM_DATA[idx] == middle_iam[idx] == IAM_DATA[idx+1] - - # Test out of bounds scalar. - with pytest.raises(ValueError): - iam_pchip(-45) - - # Test vector with out of bounds element. - with pytest.raises(ValueError): - iam_pchip(np.array([45, 270])) - - @pytest.mark.parametrize('aoi,expected', [ (45, 0.9975036250000002), (np.array([[-30, 30, 100, np.nan]]), @@ -337,19 +289,45 @@ def test_marion_diffuse_kwargs(): assert_allclose(actual[k], v, err_msg=f"component {k}") +# IEC 61853-2 measurement data for tests. +AOI_DATA = np.array([ + 0, 10, 20, 30, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, +]) +IAM_DATA = np.array([ + 1.0000, 1.0000, 1.0014, 1.0002, 0.9984, 0.9941, 0.9911, 0.9815, 0.9631, + 0.9352, 0.8922, 0.8134, 0.6778, 0.4541, 0.0000, +]) + + def test_marion_diffuse_iam_function_without_kwargs(): """ Test PCHIP-interpolated custom IAM function from an IEC 61853-2 IAM measurement, without any kwargs and with array input. """ + pchip = scipy.interpolate.PchipInterpolator( + AOI_DATA, IAM_DATA, extrapolate=False + ) + + # Custom IAM function without kwargs, using IEC 61853-2 measurement data. + def iam_pchip(aoi): + """ + Compute unitless incident-angle modifier as a function of aoi, in + degrees. + """ + if np.any(np.logical_or(aoi < 0, aoi > 180)): + raise ValueError("aoi not between 0 and 180, inclusive") + + iam_ = pchip(aoi) + iam_[90 < aoi] = 0.0 + + return iam_ + expected = { 'sky': np.array([0.95671526, 0.96967113, 0.95672627, 0.88137573]), 'horizon': np.array([0.03718587, 0.94953826, 0.97722834, 0.94862772]), 'ground': np.array([0., 0.88137573, 0.95672627, 0.96967113]), } - actual = _iam.marion_diffuse( - _iam.pchip(AOI_DATA, IAM_DATA), np.array([0.0, 45, 90, 135]) - ) + actual = _iam.marion_diffuse(iam_pchip, np.array([0.0, 45, 90, 135])) for k, v in expected.items(): assert_allclose(actual[k], v, rtol=2e-7, err_msg=f"component {k}") From d97d07717e6ce3f78490da72cbc94ff475a83f5a Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Tue, 14 May 2024 23:40:32 -0600 Subject: [PATCH 08/34] flake8 ftw --- pvlib/iam.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index ae7c3fee87..53d0191da6 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -576,7 +576,7 @@ def marion_diffuse(model, surface_tilt, **kwargs): Parameters ---------- model : str or callable - The IAM function to evaluate across solid angle. Must be one of + The IAM function to evaluate across solid angle. Must be one of `'ashrae', 'martin_ruiz', 'physical', 'sapm', 'schlick'` or be callable. If callable, then must take numeric AOI in degrees as input and return the fractional IAM. From c4c18d7340172cb7ec064435919ef105878f1391 Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Tue, 14 May 2024 23:42:11 -0600 Subject: [PATCH 09/34] flake8 more ftw --- pvlib/tests/test_iam.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index 0a656e5387..43aa389f9b 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -9,7 +9,7 @@ import pytest from .conftest import assert_series_equal -from numpy.testing import assert_allclose, assert_equal +from numpy.testing import assert_allclose import scipy.interpolate from pvlib import iam as _iam From 8727b04aada667f1f871539f59bd518a600caac2 Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Tue, 14 May 2024 23:54:59 -0600 Subject: [PATCH 10/34] Remove unused lines in test --- pvlib/tests/test_iam.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index 43aa389f9b..3da13f5832 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -314,9 +314,6 @@ def iam_pchip(aoi): Compute unitless incident-angle modifier as a function of aoi, in degrees. """ - if np.any(np.logical_or(aoi < 0, aoi > 180)): - raise ValueError("aoi not between 0 and 180, inclusive") - iam_ = pchip(aoi) iam_[90 < aoi] = 0.0 From 6322523728b44debe1dcff8a1ff1fe0cfd725b86 Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Sun, 30 Jun 2024 13:23:34 -0600 Subject: [PATCH 11/34] Update builtin models and models' parameters maps --- pvlib/iam.py | 497 +++++++++++++++++++-------------- pvlib/modelchain.py | 31 +- pvlib/tests/test_iam.py | 10 +- pvlib/tests/test_modelchain.py | 2 +- 4 files changed, 308 insertions(+), 232 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index 6958273d5b..dd7fb83ff0 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -7,23 +7,70 @@ IAM is typically a function of the angle of incidence (AOI) of the direct irradiance to the module's surface. """ +import functools import numpy as np import pandas as pd -import functools import scipy.interpolate from scipy.optimize import minimize + from pvlib.tools import cosd, sind, acosd -# a dict of required parameter names for each IAM model -# keys are the function names for the IAM models -_IAM_MODEL_PARAMS = { - 'ashrae': {'b'}, - 'physical': {'n', 'K', 'L'}, - 'martin_ruiz': {'a_r'}, - 'sapm': {'B0', 'B1', 'B2', 'B3', 'B4', 'B5'}, - 'interp': {'theta_ref', 'iam_ref'} -} + +def get_builtin_models(): + """Return a dictionary of all builtin IAM models.""" + + return { + 'ashrae': ashrae, + 'interp': interp, + 'martin_ruiz': martin_ruiz, + 'martin_ruiz_diffuse': martin_ruiz_diffuse, + 'physical': physical, + 'sapm': sapm, + 'schlick': schlick, + 'schlick_diffuse': schlick_diffuse, + } + + +def get_builtin_direct_models(): + """Return a dictionary of all builtin direct IAM models.""" + + return { + 'ashrae': ashrae, + 'interp': interp, + 'martin_ruiz': martin_ruiz, + 'physical': physical, + 'sapm': sapm, + 'schlick': schlick, + } + + +def get_builtin_diffuse_models(): + """Return a dictionary of builtin diffuse IAM models.""" + + return { + 'ashrae': ashrae, + 'interp': interp, + 'martin_ruiz_diffuse': martin_ruiz_diffuse, + 'physical': physical, + 'sapm': sapm, + 'schlick_diffuse': schlick_diffuse, + } + + +def get_builtin_models_params(): + """Return a dictionary of builtin IAM models' paramseter.""" + + return { + 'ashrae': {'b'}, + 'interp': {'theta_ref', 'iam_ref'}, + 'martin_ruiz': {'a_r'}, + 'martin_ruiz_diffuse': {'a_r', 'c1', 'c2'}, + 'physical': {'n', 'K', 'L'}, + 'sapm': {'B0', 'B1', 'B2', 'B3', 'B4', 'B5'}, + 'schlick': set(), + 'schlick_diffuse': set(), + } def ashrae(aoi, b=0.05): @@ -76,9 +123,13 @@ def ashrae(aoi, b=0.05): See Also -------- - pvlib.iam.physical - pvlib.iam.martin_ruiz pvlib.iam.interp + pvlib.iam.martin_ruiz + pvlib.iam.martin_ruiz_diffuse + pvlib.iam.physical + pvlib.iam.sapm + pvlib.iam.schlick + pvlib.iam.schlick_diffuse """ iam = 1 - b * (1 / np.cos(np.radians(aoi)) - 1) @@ -152,10 +203,13 @@ def physical(aoi, n=1.526, K=4.0, L=0.002, *, n_ar=None): See Also -------- - pvlib.iam.martin_ruiz pvlib.iam.ashrae pvlib.iam.interp + pvlib.iam.martin_ruiz + pvlib.iam.martin_ruiz_diffuse pvlib.iam.sapm + pvlib.iam.schlick + pvlib.iam.schlick_diffuse """ n1, n3 = 1, n if n_ar is None or np.allclose(n_ar, n1): @@ -287,11 +341,13 @@ def martin_ruiz(aoi, a_r=0.16): See Also -------- - pvlib.iam.martin_ruiz_diffuse - pvlib.iam.physical pvlib.iam.ashrae pvlib.iam.interp + pvlib.iam.martin_ruiz_diffuse + pvlib.iam.physical pvlib.iam.sapm + pvlib.iam.schlick + pvlib.iam.schlick_diffuse ''' # Contributed by Anton Driesse (@adriesse), PV Performance Labs. July, 2019 @@ -373,11 +429,13 @@ def martin_ruiz_diffuse(surface_tilt, a_r=0.16, c1=0.4244, c2=None): See Also -------- - pvlib.iam.martin_ruiz - pvlib.iam.physical pvlib.iam.ashrae pvlib.iam.interp + pvlib.iam.martin_ruiz + pvlib.iam.physical pvlib.iam.sapm + pvlib.iam.schlick + pvlib.iam.schlick_diffuse ''' # Contributed by Anton Driesse (@adriesse), PV Performance Labs. Oct. 2019 @@ -464,10 +522,13 @@ def interp(aoi, theta_ref, iam_ref, method='linear', normalize=True): See Also -------- - pvlib.iam.physical pvlib.iam.ashrae pvlib.iam.martin_ruiz + pvlib.iam.martin_ruiz_diffuse + pvlib.iam.physical pvlib.iam.sapm + pvlib.iam.schlick + pvlib.iam.schlick_diffuse ''' # Contributed by Anton Driesse (@adriesse), PV Performance Labs. July, 2019 @@ -546,10 +607,13 @@ def sapm(aoi, module, upper=None): See Also -------- - pvlib.iam.physical pvlib.iam.ashrae - pvlib.iam.martin_ruiz pvlib.iam.interp + pvlib.iam.martin_ruiz + pvlib.iam.martin_ruiz_diffuse + pvlib.iam.physical + pvlib.iam.schlick + pvlib.iam.schlick_diffuse """ aoi_coeff = [module['B5'], module['B4'], module['B3'], module['B2'], @@ -568,6 +632,175 @@ def sapm(aoi, module, upper=None): return iam +def schlick(aoi): + """ + Determine incidence angle modifier (IAM) for direct irradiance using the + Schlick approximation to the Fresnel equations. + + The Schlick approximation was proposed in [1]_ as a computationally + efficient alternative to computing the Fresnel factor in computer + graphics contexts. This implementation is a normalized form of the + equation in [1]_ so that it can be used as a PV IAM model. + Unlike other IAM models, this model has no ability to describe + different reflection profiles. + + In PV contexts, the Schlick approximation has been used as an analytically + integrable alternative to the Fresnel equations for estimating IAM + for diffuse irradiance [2]_ (see :py:func:`schlick_diffuse`). + + Parameters + ---------- + aoi : numeric + The angle of incidence (AOI) between the module normal vector and the + sun-beam vector. Angles of nan will result in nan. [degrees] + + Returns + ------- + iam : numeric + The incident angle modifier. + + References + ---------- + .. [1] Schlick, C. An inexpensive BRDF model for physically-based + rendering. Computer graphics forum 13 (1994). + + .. [2] Xie, Y., M. Sengupta, A. Habte, A. Andreas, "The 'Fresnel Equations' + for Diffuse radiation on Inclined photovoltaic Surfaces (FEDIS)", + Renewable and Sustainable Energy Reviews, vol. 161, 112362. June 2022. + :doi:`10.1016/j.rser.2022.112362` + + See Also + -------- + pvlib.iam.ashrae + pvlib.iam.interp + pvlib.iam.martin_ruiz + pvlib.iam.martin_ruiz_diffuse + pvlib.iam.physical + pvlib.iam.sapm + pvlib.iam.schlick + pvlib.iam.schlick_diffuse + """ + iam = 1 - (1 - cosd(aoi)) ** 5 + iam = np.where(np.abs(aoi) >= 90.0, 0.0, iam) + + # preserve input type + if np.isscalar(aoi): + iam = iam.item() + elif isinstance(aoi, pd.Series): + iam = pd.Series(iam, aoi.index) + + return iam + + +def schlick_diffuse(surface_tilt): + r""" + Determine the incidence angle modifiers (IAM) for diffuse sky and + ground-reflected irradiance on a tilted surface using the Schlick + incident angle model. + + The Schlick equation (or "Schlick's approximation") [1]_ is an + approximation to the Fresnel reflection factor which can be recast as + a simple photovoltaic IAM model like so: + + .. math:: + + IAM = 1 - (1 - \cos(aoi))^5 + + Unlike the Fresnel reflection factor itself, Schlick's approximation can + be integrated analytically to derive a closed-form equation for diffuse + IAM factors for the portions of the sky and ground visible + from a tilted surface if isotropic distributions are assumed. + This function implements the integration of the + Schlick approximation provided by Xie et al. [2]_. + + Parameters + ---------- + surface_tilt : numeric + Surface tilt angle measured from horizontal (e.g. surface facing + up = 0, surface facing horizon = 90). [degrees] + + Returns + ------- + iam_sky : numeric + The incident angle modifier for sky diffuse. + + iam_ground : numeric + The incident angle modifier for ground-reflected diffuse. + + Notes + ----- + The analytical integration of the Schlick approximation was derived + as part of the FEDIS diffuse IAM model [2]_. Compared with the model + implemented in this function, the FEDIS model includes an additional term + to account for reflection off a pyranometer's glass dome. Because that + reflection should already be accounted for in the instrument's calibration, + the pvlib authors believe it is inappropriate to account for pyranometer + reflection again in an IAM model. Thus, this function omits that term and + implements only the integrated Schlick approximation. + + Note also that the output of this function (which is an exact integration) + can be compared with the output of :py:func:`marion_diffuse` which + numerically integrates the Schlick approximation: + + .. code:: + + >>> pvlib.iam.marion_diffuse('schlick', surface_tilt=20) + {'sky': 0.9625000227247358, + 'horizon': 0.7688174948510073, + 'ground': 0.6267861879241405} + + >>> pvlib.iam.schlick_diffuse(surface_tilt=20) + (0.9624993421569652, 0.6269387554469255) + + References + ---------- + .. [1] Schlick, C. An inexpensive BRDF model for physically-based + rendering. Computer graphics forum 13 (1994). + + .. [2] Xie, Y., M. Sengupta, A. Habte, A. Andreas, "The 'Fresnel Equations' + for Diffuse radiation on Inclined photovoltaic Surfaces (FEDIS)", + Renewable and Sustainable Energy Reviews, vol. 161, 112362. June 2022. + :doi:`10.1016/j.rser.2022.112362` + + See Also + -------- + pvlib.iam.ashrae + pvlib.iam.interp + pvlib.iam.martin_ruiz + pvlib.iam.martin_ruiz_diffuse + pvlib.iam.physical + pvlib.iam.sapm + pvlib.iam.schlick + """ + # these calculations are as in [2]_, but with the refractive index + # weighting coefficient w set to 1.0 (so it is omitted) + + # relative transmittance of sky diffuse radiation by PV cover: + cosB = cosd(surface_tilt) + sinB = sind(surface_tilt) + cuk = (2 / (np.pi * (1 + cosB))) * ( + (30/7)*np.pi - (160/21)*np.radians(surface_tilt) - (10/3)*np.pi*cosB + + (160/21)*cosB*sinB - (5/3)*np.pi*cosB*sinB**2 + (20/7)*cosB*sinB**3 + - (5/16)*np.pi*cosB*sinB**4 + (16/105)*cosB*sinB**5 + ) # Eq 4 in [2] + + # relative transmittance of ground-reflected radiation by PV cover: + with np.errstate(divide='ignore', invalid='ignore'): # Eq 6 in [2] + cug = 40 / (21 * (1 - cosB)) - (1 + cosB) / (1 - cosB) * cuk + + cug = np.where(surface_tilt < 1e-6, 0, cug) + + # respect input types: + if np.isscalar(surface_tilt): + cuk = cuk.item() + cug = cug.item() + elif isinstance(surface_tilt, pd.Series): + cuk = pd.Series(cuk, surface_tilt.index) + cug = pd.Series(cug, surface_tilt.index) + + return cuk, cug + + def marion_diffuse(model, surface_tilt, **kwargs): """ Determine diffuse irradiance incidence angle modifiers using Marion's @@ -577,9 +810,9 @@ def marion_diffuse(model, surface_tilt, **kwargs): ---------- model : str or callable The IAM function to evaluate across solid angle. Must be one of - `'ashrae', 'martin_ruiz', 'physical', 'sapm', 'schlick'` or be - callable. If callable, then must take numeric AOI in degrees as - input and return the fractional IAM. + `'ashrae', `interp`, 'martin_ruiz', 'physical', 'sapm', 'schlick'` + or be callable. If callable, then must take numeric AOI in degrees + as input and return the fractional IAM. surface_tilt : numeric Surface tilt angles in decimal degrees. @@ -615,7 +848,7 @@ def marion_diffuse(model, surface_tilt, **kwargs): Examples -------- >>> marion_diffuse('physical', surface_tilt=20) - {'sky': 0.9539178294437575, + {'sky': 0.953917829443757 'horizon': 0.7652650139134007, 'ground': 0.6387140117795903} @@ -624,23 +857,12 @@ def marion_diffuse(model, surface_tilt, **kwargs): 'horizon': array([0.86478428, 0.91825792]), 'ground': array([0.77004435, 0.8522436 ])} """ - - models = { - 'ashrae': ashrae, - 'martin_ruiz': martin_ruiz, - 'physical': physical, - 'sapm': sapm, - 'schlick': schlick, - } - - if model in models: - iam_model = models[model] - elif callable(model): + if callable(model): + # A (diffuse) IAM model function was specified. iam_model = model else: - raise ValueError( - f"model must be one of: {list(models.keys())} or callable" - ) + # Check that a builtin diffuse IAM function has been specified. + iam_model = get_builtin_diffuse_models()[model] iam_function = functools.partial(iam_model, **kwargs) @@ -793,183 +1015,30 @@ def marion_integrate(function, surface_tilt, region, num=None): return Fd -def schlick(aoi): - """ - Determine incidence angle modifier (IAM) for direct irradiance using the - Schlick approximation to the Fresnel equations. - - The Schlick approximation was proposed in [1]_ as a computationally - efficient alternative to computing the Fresnel factor in computer - graphics contexts. This implementation is a normalized form of the - equation in [1]_ so that it can be used as a PV IAM model. - Unlike other IAM models, this model has no ability to describe - different reflection profiles. - - In PV contexts, the Schlick approximation has been used as an analytically - integrable alternative to the Fresnel equations for estimating IAM - for diffuse irradiance [2]_ (see :py:func:`schlick_diffuse`). - - Parameters - ---------- - aoi : numeric - The angle of incidence (AOI) between the module normal vector and the - sun-beam vector. Angles of nan will result in nan. [degrees] - - Returns - ------- - iam : numeric - The incident angle modifier. - - See Also - -------- - pvlib.iam.schlick_diffuse - - References - ---------- - .. [1] Schlick, C. An inexpensive BRDF model for physically-based - rendering. Computer graphics forum 13 (1994). - - .. [2] Xie, Y., M. Sengupta, A. Habte, A. Andreas, "The 'Fresnel Equations' - for Diffuse radiation on Inclined photovoltaic Surfaces (FEDIS)", - Renewable and Sustainable Energy Reviews, vol. 161, 112362. June 2022. - :doi:`10.1016/j.rser.2022.112362` - """ - iam = 1 - (1 - cosd(aoi)) ** 5 - iam = np.where(np.abs(aoi) >= 90.0, 0.0, iam) - - # preserve input type - if np.isscalar(aoi): - iam = iam.item() - elif isinstance(aoi, pd.Series): - iam = pd.Series(iam, aoi.index) - - return iam - - -def schlick_diffuse(surface_tilt): - r""" - Determine the incidence angle modifiers (IAM) for diffuse sky and - ground-reflected irradiance on a tilted surface using the Schlick - incident angle model. - - The Schlick equation (or "Schlick's approximation") [1]_ is an - approximation to the Fresnel reflection factor which can be recast as - a simple photovoltaic IAM model like so: - - .. math:: - - IAM = 1 - (1 - \cos(aoi))^5 - - Unlike the Fresnel reflection factor itself, Schlick's approximation can - be integrated analytically to derive a closed-form equation for diffuse - IAM factors for the portions of the sky and ground visible - from a tilted surface if isotropic distributions are assumed. - This function implements the integration of the - Schlick approximation provided by Xie et al. [2]_. - - Parameters - ---------- - surface_tilt : numeric - Surface tilt angle measured from horizontal (e.g. surface facing - up = 0, surface facing horizon = 90). [degrees] - - Returns - ------- - iam_sky : numeric - The incident angle modifier for sky diffuse. - - iam_ground : numeric - The incident angle modifier for ground-reflected diffuse. - - See Also - -------- - pvlib.iam.schlick - - Notes - ----- - The analytical integration of the Schlick approximation was derived - as part of the FEDIS diffuse IAM model [2]_. Compared with the model - implemented in this function, the FEDIS model includes an additional term - to account for reflection off a pyranometer's glass dome. Because that - reflection should already be accounted for in the instrument's calibration, - the pvlib authors believe it is inappropriate to account for pyranometer - reflection again in an IAM model. Thus, this function omits that term and - implements only the integrated Schlick approximation. - - Note also that the output of this function (which is an exact integration) - can be compared with the output of :py:func:`marion_diffuse` which - numerically integrates the Schlick approximation: - - .. code:: - - >>> pvlib.iam.marion_diffuse('schlick', surface_tilt=20) - {'sky': 0.9625000227247358, - 'horizon': 0.7688174948510073, - 'ground': 0.6267861879241405} - - >>> pvlib.iam.schlick_diffuse(surface_tilt=20) - (0.9624993421569652, 0.6269387554469255) - - References - ---------- - .. [1] Schlick, C. An inexpensive BRDF model for physically-based - rendering. Computer graphics forum 13 (1994). - - .. [2] Xie, Y., M. Sengupta, A. Habte, A. Andreas, "The 'Fresnel Equations' - for Diffuse radiation on Inclined photovoltaic Surfaces (FEDIS)", - Renewable and Sustainable Energy Reviews, vol. 161, 112362. June 2022. - :doi:`10.1016/j.rser.2022.112362` - """ - # these calculations are as in [2]_, but with the refractive index - # weighting coefficient w set to 1.0 (so it is omitted) - - # relative transmittance of sky diffuse radiation by PV cover: - cosB = cosd(surface_tilt) - sinB = sind(surface_tilt) - cuk = (2 / (np.pi * (1 + cosB))) * ( - (30/7)*np.pi - (160/21)*np.radians(surface_tilt) - (10/3)*np.pi*cosB - + (160/21)*cosB*sinB - (5/3)*np.pi*cosB*sinB**2 + (20/7)*cosB*sinB**3 - - (5/16)*np.pi*cosB*sinB**4 + (16/105)*cosB*sinB**5 - ) # Eq 4 in [2] - - # relative transmittance of ground-reflected radiation by PV cover: - with np.errstate(divide='ignore', invalid='ignore'): # Eq 6 in [2] - cug = 40 / (21 * (1 - cosB)) - (1 + cosB) / (1 - cosB) * cuk - - cug = np.where(surface_tilt < 1e-6, 0, cug) - - # respect input types: - if np.isscalar(surface_tilt): - cuk = cuk.item() - cug = cug.item() - elif isinstance(surface_tilt, pd.Series): - cuk = pd.Series(cuk, surface_tilt.index) - cug = pd.Series(cug, surface_tilt.index) - - return cuk, cug - +def _get_fittable_or_convertable_model(builtin_model_name): + # check that model is implemented and fittable or convertable + implemented_builtin_models = { + 'ashrae': ashrae, 'martin_ruiz': martin_ruiz, 'physical': physical, + } -def _get_model(model_name): - # check that model is implemented - model_dict = {'ashrae': ashrae, 'martin_ruiz': martin_ruiz, - 'physical': physical} try: - model = model_dict[model_name] - except KeyError: - raise NotImplementedError(f"The {model_name} model has not been " - "implemented") + return implemented_builtin_models[builtin_model_name] + except KeyError as exc: + raise NotImplementedError( + f"No implementation for model {builtin_model_name}" + ) from exc - return model +def _check_params(builtin_model_name, params): + # check that the parameters passed in with the model belong to the model + passed_params = set(params.keys()) + exp_params = get_builtin_models_params()[builtin_model_name] -def _check_params(model_name, params): - # check that the parameters passed in with the model - # belong to the model - exp_params = _IAM_MODEL_PARAMS[model_name] - if set(params.keys()) != exp_params: - raise ValueError(f"The {model_name} model was expecting to be passed " - "{', '.join(list(exp_params))}, but " - "was handed {', '.join(list(params.keys()))}") + if passed_params != exp_params: + raise ValueError( + f"The {builtin_model_name} model was expecting to be passed {exp_params}," + f"but was handed {passed_params}" + ) def _sin_weight(aoi): @@ -1185,8 +1254,8 @@ def convert(source_name, source_params, target_name, weight=_sin_weight, pvlib.iam.martin_ruiz pvlib.iam.physical """ - source = _get_model(source_name) - target = _get_model(target_name) + source = _get_fittable_or_convertable_model(source_name) + target = _get_fittable_or_convertable_model(target_name) aoi = np.linspace(0, 90, 91) _check_params(source_name, source_params) @@ -1283,7 +1352,7 @@ def fit(measured_aoi, measured_iam, model_name, weight=_sin_weight, xtol=None): pvlib.iam.martin_ruiz pvlib.iam.physical """ - target = _get_model(model_name) + target = _get_fittable_or_convertable_model(model_name) if model_name == "physical": bounds = [(0, 0.08), (1, 2)] diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py index 1db9d05b71..bbe9ec0ece 100644 --- a/pvlib/modelchain.py +++ b/pvlib/modelchain.py @@ -785,28 +785,33 @@ def aoi_model(self, model): else: self._aoi_model = partial(model, self) + + # FIXME What about diffuse versions of models? What about schlick and custom models? def infer_aoi_model(self): module_parameters = tuple( array.module_parameters for array in self.system.arrays) params = _common_keys(module_parameters) - if iam._IAM_MODEL_PARAMS['physical'] <= params: + + iam_model_params = iam.get_builtin_models_params() + + if iam_model_params['physical'] <= params: return self.physical_aoi_loss - elif iam._IAM_MODEL_PARAMS['sapm'] <= params: + elif iam_model_params['sapm'] <= params: return self.sapm_aoi_loss - elif iam._IAM_MODEL_PARAMS['ashrae'] <= params: + elif iam_model_params['ashrae'] <= params: return self.ashrae_aoi_loss - elif iam._IAM_MODEL_PARAMS['martin_ruiz'] <= params: + elif iam_model_params['martin_ruiz'] <= params: return self.martin_ruiz_aoi_loss - elif iam._IAM_MODEL_PARAMS['interp'] <= params: + elif iam_model_params['interp'] <= params: return self.interp_aoi_loss - else: - raise ValueError('could not infer AOI model from ' - 'system.arrays[i].module_parameters. Check that ' - 'the module_parameters for all Arrays in ' - 'system.arrays contain parameters for the ' - 'physical, aoi, ashrae, martin_ruiz or interp ' - 'model; explicitly set the model with the ' - 'aoi_model kwarg; or set aoi_model="no_loss".') + + raise ValueError('could not infer AOI model from ' + 'system.arrays[i].module_parameters. Check that ' + 'the module_parameters for all Arrays in ' + 'system.arrays contain parameters for the ' + 'physical, aoi, ashrae, martin_ruiz or interp ' + 'model; explicitly set the model with the ' + 'aoi_model kwarg; or set aoi_model="no_loss".') def ashrae_aoi_loss(self): self.results.aoi_modifier = self.system.get_iam( diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index 3da13f5832..6378d2e69a 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -331,7 +331,9 @@ def iam_pchip(aoi): def test_marion_diffuse_iam_with_kwargs(): - """Test custom IAM function (iam.interp) with kwargs and scalar input.""" + """ + Test custom IAM function (using iam.interp) with kwargs and scalar input. + """ expected = { 'sky': 0.9688222974371822, 'horizon': 0.94824614710175, @@ -350,7 +352,7 @@ def test_marion_diffuse_iam_with_kwargs(): def test_marion_diffuse_invalid(): - with pytest.raises(ValueError): + with pytest.raises(KeyError): _iam.marion_diffuse('not_a_model', 20) @@ -546,7 +548,7 @@ def scaled_weight(aoi): def test_convert_model_not_implemented(): - with pytest.raises(NotImplementedError, match='model has not been'): + with pytest.raises(NotImplementedError, match='No implementation for model foo'): _iam.convert('ashrae', {'b': 0.1}, 'foo') @@ -597,7 +599,7 @@ def scaled_weight(aoi): def test_fit_model_not_implemented(): - with pytest.raises(NotImplementedError, match='model has not been'): + with pytest.raises(NotImplementedError, match='No implementation for model foo'): _iam.fit(np.array([0, 10]), np.array([1, 0.99]), 'foo') diff --git a/pvlib/tests/test_modelchain.py b/pvlib/tests/test_modelchain.py index dcbd820f16..71dce1ec03 100644 --- a/pvlib/tests/test_modelchain.py +++ b/pvlib/tests/test_modelchain.py @@ -1506,7 +1506,7 @@ def test_aoi_model_user_func(sapm_dc_snl_ac_system, location, weather, mocker): 'sapm', 'ashrae', 'physical', 'martin_ruiz', 'interp' ]) def test_infer_aoi_model(location, system_no_aoi, aoi_model): - for k in iam._IAM_MODEL_PARAMS[aoi_model]: + for k in iam.get_builtin_models_params()[aoi_model]: system_no_aoi.arrays[0].module_parameters.update({k: 1.0}) mc = ModelChain(system_no_aoi, location, spectral_model='no_loss') assert isinstance(mc, ModelChain) From fe7833b567ca15d640dfdbc9e4dc72acd92143f8 Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Sun, 30 Jun 2024 13:31:47 -0600 Subject: [PATCH 12/34] Appease flake8 --- pvlib/iam.py | 6 +++--- pvlib/modelchain.py | 21 +++++++++++---------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index dd7fb83ff0..d774cbc15e 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -668,7 +668,7 @@ def schlick(aoi): for Diffuse radiation on Inclined photovoltaic Surfaces (FEDIS)", Renewable and Sustainable Energy Reviews, vol. 161, 112362. June 2022. :doi:`10.1016/j.rser.2022.112362` - + See Also -------- pvlib.iam.ashrae @@ -1036,8 +1036,8 @@ def _check_params(builtin_model_name, params): if passed_params != exp_params: raise ValueError( - f"The {builtin_model_name} model was expecting to be passed {exp_params}," - f"but was handed {passed_params}" + f"The {builtin_model_name} model was expecting to be passed" + f"{exp_params}, but was handed {passed_params}" ) diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py index bbe9ec0ece..f49b63e263 100644 --- a/pvlib/modelchain.py +++ b/pvlib/modelchain.py @@ -269,7 +269,7 @@ def _head(obj): '\n') lines = [] for attr in mc_attrs: - if not (attr.startswith('_') or attr=='times'): + if not (attr.startswith('_') or attr == 'times'): lines.append(f' {attr}: ' + _mcr_repr(getattr(self, attr))) desc4 = '\n'.join(lines) return (desc1 + desc2 + desc3 + desc4) @@ -386,7 +386,6 @@ def __init__(self, system, location, self.results = ModelChainResult() - @classmethod def with_pvwatts(cls, system, location, clearsky_model='ineichen', @@ -785,8 +784,9 @@ def aoi_model(self, model): else: self._aoi_model = partial(model, self) + # FIXME What about diffuse versions of models? What about schlick, + # schlick_diffuse, and custom models? - # FIXME What about diffuse versions of models? What about schlick and custom models? def infer_aoi_model(self): module_parameters = tuple( array.module_parameters for array in self.system.arrays) @@ -805,13 +805,14 @@ def infer_aoi_model(self): elif iam_model_params['interp'] <= params: return self.interp_aoi_loss - raise ValueError('could not infer AOI model from ' - 'system.arrays[i].module_parameters. Check that ' - 'the module_parameters for all Arrays in ' - 'system.arrays contain parameters for the ' - 'physical, aoi, ashrae, martin_ruiz or interp ' - 'model; explicitly set the model with the ' - 'aoi_model kwarg; or set aoi_model="no_loss".') + raise ValueError( + 'could not infer AOI model from ' + 'system.arrays[i].module_parameters. Check that the ' + 'module_parameters for all Arrays in system.arrays contain ' + 'parameters for the physical, aoi, ashrae, martin_ruiz or interp ' + 'model; explicitly set the model with the aoi_model kwarg; or ' + 'set aoi_model="no_loss".' + ) def ashrae_aoi_loss(self): self.results.aoi_modifier = self.system.get_iam( From 5d218d53d13a9b96822b48b686faacae2d037c87 Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Sun, 30 Jun 2024 13:33:18 -0600 Subject: [PATCH 13/34] Appease flake8 more --- pvlib/tests/test_iam.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index 6378d2e69a..26f5f3f8fd 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -548,7 +548,9 @@ def scaled_weight(aoi): def test_convert_model_not_implemented(): - with pytest.raises(NotImplementedError, match='No implementation for model foo'): + with pytest.raises( + NotImplementedError, match='No implementation for model foo' + ): _iam.convert('ashrae', {'b': 0.1}, 'foo') @@ -599,7 +601,9 @@ def scaled_weight(aoi): def test_fit_model_not_implemented(): - with pytest.raises(NotImplementedError, match='No implementation for model foo'): + with pytest.raises( + NotImplementedError, match='No implementation for model foo' + ): _iam.fit(np.array([0, 10]), np.array([1, 0.99]), 'foo') From bcc84296ae788894b64410cb1c3d528f02e673c5 Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Sun, 30 Jun 2024 13:41:09 -0600 Subject: [PATCH 14/34] Undo function moves --- pvlib/iam.py | 338 +++++++++++++++++++++++++-------------------------- 1 file changed, 169 insertions(+), 169 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index d774cbc15e..3fa380ed4d 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -632,175 +632,6 @@ def sapm(aoi, module, upper=None): return iam -def schlick(aoi): - """ - Determine incidence angle modifier (IAM) for direct irradiance using the - Schlick approximation to the Fresnel equations. - - The Schlick approximation was proposed in [1]_ as a computationally - efficient alternative to computing the Fresnel factor in computer - graphics contexts. This implementation is a normalized form of the - equation in [1]_ so that it can be used as a PV IAM model. - Unlike other IAM models, this model has no ability to describe - different reflection profiles. - - In PV contexts, the Schlick approximation has been used as an analytically - integrable alternative to the Fresnel equations for estimating IAM - for diffuse irradiance [2]_ (see :py:func:`schlick_diffuse`). - - Parameters - ---------- - aoi : numeric - The angle of incidence (AOI) between the module normal vector and the - sun-beam vector. Angles of nan will result in nan. [degrees] - - Returns - ------- - iam : numeric - The incident angle modifier. - - References - ---------- - .. [1] Schlick, C. An inexpensive BRDF model for physically-based - rendering. Computer graphics forum 13 (1994). - - .. [2] Xie, Y., M. Sengupta, A. Habte, A. Andreas, "The 'Fresnel Equations' - for Diffuse radiation on Inclined photovoltaic Surfaces (FEDIS)", - Renewable and Sustainable Energy Reviews, vol. 161, 112362. June 2022. - :doi:`10.1016/j.rser.2022.112362` - - See Also - -------- - pvlib.iam.ashrae - pvlib.iam.interp - pvlib.iam.martin_ruiz - pvlib.iam.martin_ruiz_diffuse - pvlib.iam.physical - pvlib.iam.sapm - pvlib.iam.schlick - pvlib.iam.schlick_diffuse - """ - iam = 1 - (1 - cosd(aoi)) ** 5 - iam = np.where(np.abs(aoi) >= 90.0, 0.0, iam) - - # preserve input type - if np.isscalar(aoi): - iam = iam.item() - elif isinstance(aoi, pd.Series): - iam = pd.Series(iam, aoi.index) - - return iam - - -def schlick_diffuse(surface_tilt): - r""" - Determine the incidence angle modifiers (IAM) for diffuse sky and - ground-reflected irradiance on a tilted surface using the Schlick - incident angle model. - - The Schlick equation (or "Schlick's approximation") [1]_ is an - approximation to the Fresnel reflection factor which can be recast as - a simple photovoltaic IAM model like so: - - .. math:: - - IAM = 1 - (1 - \cos(aoi))^5 - - Unlike the Fresnel reflection factor itself, Schlick's approximation can - be integrated analytically to derive a closed-form equation for diffuse - IAM factors for the portions of the sky and ground visible - from a tilted surface if isotropic distributions are assumed. - This function implements the integration of the - Schlick approximation provided by Xie et al. [2]_. - - Parameters - ---------- - surface_tilt : numeric - Surface tilt angle measured from horizontal (e.g. surface facing - up = 0, surface facing horizon = 90). [degrees] - - Returns - ------- - iam_sky : numeric - The incident angle modifier for sky diffuse. - - iam_ground : numeric - The incident angle modifier for ground-reflected diffuse. - - Notes - ----- - The analytical integration of the Schlick approximation was derived - as part of the FEDIS diffuse IAM model [2]_. Compared with the model - implemented in this function, the FEDIS model includes an additional term - to account for reflection off a pyranometer's glass dome. Because that - reflection should already be accounted for in the instrument's calibration, - the pvlib authors believe it is inappropriate to account for pyranometer - reflection again in an IAM model. Thus, this function omits that term and - implements only the integrated Schlick approximation. - - Note also that the output of this function (which is an exact integration) - can be compared with the output of :py:func:`marion_diffuse` which - numerically integrates the Schlick approximation: - - .. code:: - - >>> pvlib.iam.marion_diffuse('schlick', surface_tilt=20) - {'sky': 0.9625000227247358, - 'horizon': 0.7688174948510073, - 'ground': 0.6267861879241405} - - >>> pvlib.iam.schlick_diffuse(surface_tilt=20) - (0.9624993421569652, 0.6269387554469255) - - References - ---------- - .. [1] Schlick, C. An inexpensive BRDF model for physically-based - rendering. Computer graphics forum 13 (1994). - - .. [2] Xie, Y., M. Sengupta, A. Habte, A. Andreas, "The 'Fresnel Equations' - for Diffuse radiation on Inclined photovoltaic Surfaces (FEDIS)", - Renewable and Sustainable Energy Reviews, vol. 161, 112362. June 2022. - :doi:`10.1016/j.rser.2022.112362` - - See Also - -------- - pvlib.iam.ashrae - pvlib.iam.interp - pvlib.iam.martin_ruiz - pvlib.iam.martin_ruiz_diffuse - pvlib.iam.physical - pvlib.iam.sapm - pvlib.iam.schlick - """ - # these calculations are as in [2]_, but with the refractive index - # weighting coefficient w set to 1.0 (so it is omitted) - - # relative transmittance of sky diffuse radiation by PV cover: - cosB = cosd(surface_tilt) - sinB = sind(surface_tilt) - cuk = (2 / (np.pi * (1 + cosB))) * ( - (30/7)*np.pi - (160/21)*np.radians(surface_tilt) - (10/3)*np.pi*cosB - + (160/21)*cosB*sinB - (5/3)*np.pi*cosB*sinB**2 + (20/7)*cosB*sinB**3 - - (5/16)*np.pi*cosB*sinB**4 + (16/105)*cosB*sinB**5 - ) # Eq 4 in [2] - - # relative transmittance of ground-reflected radiation by PV cover: - with np.errstate(divide='ignore', invalid='ignore'): # Eq 6 in [2] - cug = 40 / (21 * (1 - cosB)) - (1 + cosB) / (1 - cosB) * cuk - - cug = np.where(surface_tilt < 1e-6, 0, cug) - - # respect input types: - if np.isscalar(surface_tilt): - cuk = cuk.item() - cug = cug.item() - elif isinstance(surface_tilt, pd.Series): - cuk = pd.Series(cuk, surface_tilt.index) - cug = pd.Series(cug, surface_tilt.index) - - return cuk, cug - - def marion_diffuse(model, surface_tilt, **kwargs): """ Determine diffuse irradiance incidence angle modifiers using Marion's @@ -1015,6 +846,175 @@ def marion_integrate(function, surface_tilt, region, num=None): return Fd +def schlick(aoi): + """ + Determine incidence angle modifier (IAM) for direct irradiance using the + Schlick approximation to the Fresnel equations. + + The Schlick approximation was proposed in [1]_ as a computationally + efficient alternative to computing the Fresnel factor in computer + graphics contexts. This implementation is a normalized form of the + equation in [1]_ so that it can be used as a PV IAM model. + Unlike other IAM models, this model has no ability to describe + different reflection profiles. + + In PV contexts, the Schlick approximation has been used as an analytically + integrable alternative to the Fresnel equations for estimating IAM + for diffuse irradiance [2]_ (see :py:func:`schlick_diffuse`). + + Parameters + ---------- + aoi : numeric + The angle of incidence (AOI) between the module normal vector and the + sun-beam vector. Angles of nan will result in nan. [degrees] + + Returns + ------- + iam : numeric + The incident angle modifier. + + References + ---------- + .. [1] Schlick, C. An inexpensive BRDF model for physically-based + rendering. Computer graphics forum 13 (1994). + + .. [2] Xie, Y., M. Sengupta, A. Habte, A. Andreas, "The 'Fresnel Equations' + for Diffuse radiation on Inclined photovoltaic Surfaces (FEDIS)", + Renewable and Sustainable Energy Reviews, vol. 161, 112362. June 2022. + :doi:`10.1016/j.rser.2022.112362` + + See Also + -------- + pvlib.iam.ashrae + pvlib.iam.interp + pvlib.iam.martin_ruiz + pvlib.iam.martin_ruiz_diffuse + pvlib.iam.physical + pvlib.iam.sapm + pvlib.iam.schlick + pvlib.iam.schlick_diffuse + """ + iam = 1 - (1 - cosd(aoi)) ** 5 + iam = np.where(np.abs(aoi) >= 90.0, 0.0, iam) + + # preserve input type + if np.isscalar(aoi): + iam = iam.item() + elif isinstance(aoi, pd.Series): + iam = pd.Series(iam, aoi.index) + + return iam + + +def schlick_diffuse(surface_tilt): + r""" + Determine the incidence angle modifiers (IAM) for diffuse sky and + ground-reflected irradiance on a tilted surface using the Schlick + incident angle model. + + The Schlick equation (or "Schlick's approximation") [1]_ is an + approximation to the Fresnel reflection factor which can be recast as + a simple photovoltaic IAM model like so: + + .. math:: + + IAM = 1 - (1 - \cos(aoi))^5 + + Unlike the Fresnel reflection factor itself, Schlick's approximation can + be integrated analytically to derive a closed-form equation for diffuse + IAM factors for the portions of the sky and ground visible + from a tilted surface if isotropic distributions are assumed. + This function implements the integration of the + Schlick approximation provided by Xie et al. [2]_. + + Parameters + ---------- + surface_tilt : numeric + Surface tilt angle measured from horizontal (e.g. surface facing + up = 0, surface facing horizon = 90). [degrees] + + Returns + ------- + iam_sky : numeric + The incident angle modifier for sky diffuse. + + iam_ground : numeric + The incident angle modifier for ground-reflected diffuse. + + Notes + ----- + The analytical integration of the Schlick approximation was derived + as part of the FEDIS diffuse IAM model [2]_. Compared with the model + implemented in this function, the FEDIS model includes an additional term + to account for reflection off a pyranometer's glass dome. Because that + reflection should already be accounted for in the instrument's calibration, + the pvlib authors believe it is inappropriate to account for pyranometer + reflection again in an IAM model. Thus, this function omits that term and + implements only the integrated Schlick approximation. + + Note also that the output of this function (which is an exact integration) + can be compared with the output of :py:func:`marion_diffuse` which + numerically integrates the Schlick approximation: + + .. code:: + + >>> pvlib.iam.marion_diffuse('schlick', surface_tilt=20) + {'sky': 0.9625000227247358, + 'horizon': 0.7688174948510073, + 'ground': 0.6267861879241405} + + >>> pvlib.iam.schlick_diffuse(surface_tilt=20) + (0.9624993421569652, 0.6269387554469255) + + References + ---------- + .. [1] Schlick, C. An inexpensive BRDF model for physically-based + rendering. Computer graphics forum 13 (1994). + + .. [2] Xie, Y., M. Sengupta, A. Habte, A. Andreas, "The 'Fresnel Equations' + for Diffuse radiation on Inclined photovoltaic Surfaces (FEDIS)", + Renewable and Sustainable Energy Reviews, vol. 161, 112362. June 2022. + :doi:`10.1016/j.rser.2022.112362` + + See Also + -------- + pvlib.iam.ashrae + pvlib.iam.interp + pvlib.iam.martin_ruiz + pvlib.iam.martin_ruiz_diffuse + pvlib.iam.physical + pvlib.iam.sapm + pvlib.iam.schlick + """ + # these calculations are as in [2]_, but with the refractive index + # weighting coefficient w set to 1.0 (so it is omitted) + + # relative transmittance of sky diffuse radiation by PV cover: + cosB = cosd(surface_tilt) + sinB = sind(surface_tilt) + cuk = (2 / (np.pi * (1 + cosB))) * ( + (30/7)*np.pi - (160/21)*np.radians(surface_tilt) - (10/3)*np.pi*cosB + + (160/21)*cosB*sinB - (5/3)*np.pi*cosB*sinB**2 + (20/7)*cosB*sinB**3 + - (5/16)*np.pi*cosB*sinB**4 + (16/105)*cosB*sinB**5 + ) # Eq 4 in [2] + + # relative transmittance of ground-reflected radiation by PV cover: + with np.errstate(divide='ignore', invalid='ignore'): # Eq 6 in [2] + cug = 40 / (21 * (1 - cosB)) - (1 + cosB) / (1 - cosB) * cuk + + cug = np.where(surface_tilt < 1e-6, 0, cug) + + # respect input types: + if np.isscalar(surface_tilt): + cuk = cuk.item() + cug = cug.item() + elif isinstance(surface_tilt, pd.Series): + cuk = pd.Series(cuk, surface_tilt.index) + cug = pd.Series(cug, surface_tilt.index) + + return cuk, cug + + def _get_fittable_or_convertable_model(builtin_model_name): # check that model is implemented and fittable or convertable implemented_builtin_models = { From deb466e1efa46350385768c0d4d17c1bc1bfb080 Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Sun, 30 Jun 2024 13:48:05 -0600 Subject: [PATCH 15/34] Revert to better exception message --- pvlib/iam.py | 8 +++++++- pvlib/tests/test_iam.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index 3fa380ed4d..0d68581722 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -693,7 +693,13 @@ def marion_diffuse(model, surface_tilt, **kwargs): iam_model = model else: # Check that a builtin diffuse IAM function has been specified. - iam_model = get_builtin_diffuse_models()[model] + builtin_diffuse_models = get_builtin_diffuse_models() + try: + iam_model = builtin_diffuse_models[model] + except KeyError as exc: + raise ValueError( + f'model must be one of: {set(builtin_diffuse_models.keys())}' + ) from exc iam_function = functools.partial(iam_model, **kwargs) diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index 26f5f3f8fd..42975026ee 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -352,7 +352,7 @@ def test_marion_diffuse_iam_with_kwargs(): def test_marion_diffuse_invalid(): - with pytest.raises(KeyError): + with pytest.raises(ValueError, match='model must be one of: '): _iam.marion_diffuse('not_a_model', 20) From 05319f3791ccc8cf5dd86c3eedaf9c492cf7f063 Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Sun, 30 Jun 2024 23:09:59 -0600 Subject: [PATCH 16/34] Make iam_model required for ModelChain --- pvlib/iam.py | 146 +++++++++++++------------------ pvlib/modelchain.py | 99 +++++++++------------ pvlib/pvsystem.py | 63 +++++++++----- pvlib/tests/test_modelchain.py | 153 ++++++++++++++------------------- 4 files changed, 206 insertions(+), 255 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index 0d68581722..33e890c95b 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -18,58 +18,38 @@ def get_builtin_models(): - """Return a dictionary of all builtin IAM models.""" - - return { - 'ashrae': ashrae, - 'interp': interp, - 'martin_ruiz': martin_ruiz, - 'martin_ruiz_diffuse': martin_ruiz_diffuse, - 'physical': physical, - 'sapm': sapm, - 'schlick': schlick, - 'schlick_diffuse': schlick_diffuse, - } - - -def get_builtin_direct_models(): - """Return a dictionary of all builtin direct IAM models.""" - - return { - 'ashrae': ashrae, - 'interp': interp, - 'martin_ruiz': martin_ruiz, - 'physical': physical, - 'sapm': sapm, - 'schlick': schlick, - } - - -def get_builtin_diffuse_models(): - """Return a dictionary of builtin diffuse IAM models.""" - - return { - 'ashrae': ashrae, - 'interp': interp, - 'martin_ruiz_diffuse': martin_ruiz_diffuse, - 'physical': physical, - 'sapm': sapm, - 'schlick_diffuse': schlick_diffuse, - } - - -def get_builtin_models_params(): - """Return a dictionary of builtin IAM models' paramseter.""" - + """Return a dictionary of builtin IAM models' usage information.""" return { - 'ashrae': {'b'}, - 'interp': {'theta_ref', 'iam_ref'}, - 'martin_ruiz': {'a_r'}, - 'martin_ruiz_diffuse': {'a_r', 'c1', 'c2'}, - 'physical': {'n', 'K', 'L'}, - 'sapm': {'B0', 'B1', 'B2', 'B3', 'B4', 'B5'}, - 'schlick': set(), - 'schlick_diffuse': set(), + 'ashrae': { + 'callable': ashrae, + 'params_required': set(), + 'params_optional': {'b'}, + }, + 'interp': { + 'callable': interp, + 'params_required': {'theta_ref', 'iam_ref'}, + 'params_optional': {'method', 'normalize'}, + }, + 'martin_ruiz': { + 'callable': martin_ruiz, + 'params_required': set(), + 'params_optional': {'a_r'}, + }, + 'physical': { + 'callable': physical, + 'params_required': set(), + 'params_optional': {'n', 'K', 'L'}, + }, + 'sapm': { + 'callable': sapm, + 'params_required': {'B0', 'B1', 'B2', 'B3', 'B4', 'B5'}, + 'params_optional': {'upper'}, + }, + 'schlick': { + 'callable': schlick, + 'params_required': set(), + 'params_optional': set(), + }, } @@ -125,11 +105,9 @@ def ashrae(aoi, b=0.05): -------- pvlib.iam.interp pvlib.iam.martin_ruiz - pvlib.iam.martin_ruiz_diffuse pvlib.iam.physical pvlib.iam.sapm pvlib.iam.schlick - pvlib.iam.schlick_diffuse """ iam = 1 - b * (1 / np.cos(np.radians(aoi)) - 1) @@ -206,10 +184,8 @@ def physical(aoi, n=1.526, K=4.0, L=0.002, *, n_ar=None): pvlib.iam.ashrae pvlib.iam.interp pvlib.iam.martin_ruiz - pvlib.iam.martin_ruiz_diffuse pvlib.iam.sapm pvlib.iam.schlick - pvlib.iam.schlick_diffuse """ n1, n3 = 1, n if n_ar is None or np.allclose(n_ar, n1): @@ -343,11 +319,10 @@ def martin_ruiz(aoi, a_r=0.16): -------- pvlib.iam.ashrae pvlib.iam.interp - pvlib.iam.martin_ruiz_diffuse pvlib.iam.physical pvlib.iam.sapm pvlib.iam.schlick - pvlib.iam.schlick_diffuse + pvlib.iam.martin_ruiz_diffuse ''' # Contributed by Anton Driesse (@adriesse), PV Performance Labs. July, 2019 @@ -429,12 +404,8 @@ def martin_ruiz_diffuse(surface_tilt, a_r=0.16, c1=0.4244, c2=None): See Also -------- - pvlib.iam.ashrae - pvlib.iam.interp pvlib.iam.martin_ruiz - pvlib.iam.physical - pvlib.iam.sapm - pvlib.iam.schlick + pvlib.iam.marion_diffuse pvlib.iam.schlick_diffuse ''' # Contributed by Anton Driesse (@adriesse), PV Performance Labs. Oct. 2019 @@ -524,11 +495,9 @@ def interp(aoi, theta_ref, iam_ref, method='linear', normalize=True): -------- pvlib.iam.ashrae pvlib.iam.martin_ruiz - pvlib.iam.martin_ruiz_diffuse pvlib.iam.physical pvlib.iam.sapm pvlib.iam.schlick - pvlib.iam.schlick_diffuse ''' # Contributed by Anton Driesse (@adriesse), PV Performance Labs. July, 2019 @@ -577,7 +546,7 @@ def sapm(aoi, module, upper=None): See the :py:func:`sapm` notes section for more details. upper : float, optional - Upper limit on the results. + Upper limit on the results. None means no upper limiting. Returns ------- @@ -610,10 +579,8 @@ def sapm(aoi, module, upper=None): pvlib.iam.ashrae pvlib.iam.interp pvlib.iam.martin_ruiz - pvlib.iam.martin_ruiz_diffuse pvlib.iam.physical pvlib.iam.schlick - pvlib.iam.schlick_diffuse """ aoi_coeff = [module['B5'], module['B4'], module['B3'], module['B2'], @@ -668,6 +635,13 @@ def marion_diffuse(model, surface_tilt, **kwargs): See Also -------- pvlib.iam.marion_integrate + pvlib.iam.ashrae + pvlib.iam.interp + pvlib.iam.martin_ruiz + pvlib.iam.physical + pvlib.iam.schlick + pvlib.iam.martin_ruiz_diffuse + pvlib.iam.schlick_diffuse References ---------- @@ -689,19 +663,20 @@ def marion_diffuse(model, surface_tilt, **kwargs): 'ground': array([0.77004435, 0.8522436 ])} """ if callable(model): - # A (diffuse) IAM model function was specified. - iam_model = model + # A callable IAM model function was specified. + model_callable = model else: - # Check that a builtin diffuse IAM function has been specified. - builtin_diffuse_models = get_builtin_diffuse_models() + # Check that a builtin IAM function was specified. + builtin_models = get_builtin_models() + try: - iam_model = builtin_diffuse_models[model] + model_callable = builtin_models[model]["callable"] except KeyError as exc: raise ValueError( - f'model must be one of: {set(builtin_diffuse_models.keys())}' + f'model must be one of: {builtin_models.keys()}' ) from exc - iam_function = functools.partial(iam_model, **kwargs) + iam_function = functools.partial(model_callable, **kwargs) return { region: marion_integrate(iam_function, surface_tilt, region) @@ -894,10 +869,8 @@ def schlick(aoi): pvlib.iam.ashrae pvlib.iam.interp pvlib.iam.martin_ruiz - pvlib.iam.martin_ruiz_diffuse pvlib.iam.physical pvlib.iam.sapm - pvlib.iam.schlick pvlib.iam.schlick_diffuse """ iam = 1 - (1 - cosd(aoi)) ** 5 @@ -984,13 +957,9 @@ def schlick_diffuse(surface_tilt): See Also -------- - pvlib.iam.ashrae - pvlib.iam.interp - pvlib.iam.martin_ruiz - pvlib.iam.martin_ruiz_diffuse - pvlib.iam.physical - pvlib.iam.sapm pvlib.iam.schlick + pvlib.iam.marion_diffuse + pvlib.iam.martin_ruiz_diffuse """ # these calculations are as in [2]_, but with the refractive index # weighting coefficient w set to 1.0 (so it is omitted) @@ -1036,14 +1005,17 @@ def _get_fittable_or_convertable_model(builtin_model_name): def _check_params(builtin_model_name, params): - # check that the parameters passed in with the model belong to the model - passed_params = set(params.keys()) - exp_params = get_builtin_models_params()[builtin_model_name] + # check that parameters passed in with IAM model belong to model + handed_params = set(params.keys()) + builtin_model = get_builtin_models()[builtin_model_name] + expected_params = builtin_model["params_required"].union( + builtin_model["params_optional"] + ) - if passed_params != exp_params: + if handed_params != expected_params: raise ValueError( f"The {builtin_model_name} model was expecting to be passed" - f"{exp_params}, but was handed {passed_params}" + f"{expected_params}, but was handed {handed_params}" ) diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py index f49b63e263..0632ea2eae 100644 --- a/pvlib/modelchain.py +++ b/pvlib/modelchain.py @@ -13,7 +13,7 @@ from dataclasses import dataclass, field from typing import Union, Tuple, Optional, TypeVar -from pvlib import pvsystem, iam +from pvlib import pvsystem import pvlib.irradiance # avoid name conflict with full import from pvlib.pvsystem import _DC_MODEL_PARAMS from pvlib.tools import _build_kwargs @@ -355,16 +355,23 @@ class ModelChain: Name of ModelChain instance. """ - def __init__(self, system, location, - clearsky_model='ineichen', - transposition_model='haydavies', - solar_position_method='nrel_numpy', - airmass_model='kastenyoung1989', - dc_model=None, ac_model=None, aoi_model=None, - spectral_model=None, temperature_model=None, - dc_ohmic_model='no_loss', - losses_model='no_loss', name=None): - + def __init__( + self, + system, + location, + clearsky_model='ineichen', + transposition_model='haydavies', + solar_position_method='nrel_numpy', + airmass_model='kastenyoung1989', + dc_model=None, + ac_model=None, + aoi_model=None, + spectral_model=None, + temperature_model=None, + dc_ohmic_model='no_loss', + losses_model='no_loss', + name=None, + ): self.name = name self.system = system @@ -763,75 +770,44 @@ def aoi_model(self): @aoi_model.setter def aoi_model(self, model): - if model is None: - self._aoi_model = self.infer_aoi_model() - elif isinstance(model, str): + if isinstance(model, str): model = model.lower() if model == 'ashrae': self._aoi_model = self.ashrae_aoi_loss - elif model == 'physical': - self._aoi_model = self.physical_aoi_loss - elif model == 'sapm': - self._aoi_model = self.sapm_aoi_loss - elif model == 'martin_ruiz': - self._aoi_model = self.martin_ruiz_aoi_loss elif model == 'interp': self._aoi_model = self.interp_aoi_loss + elif model == 'martin_ruiz': + self._aoi_model = self.martin_ruiz_aoi_loss elif model == 'no_loss': self._aoi_model = self.no_aoi_loss + elif model == 'physical': + self._aoi_model = self.physical_aoi_loss + elif model == 'sapm': + self._aoi_model = self.sapm_aoi_loss + elif model == 'schlick': + self._aoi_model = self.schlick_aoi_loss else: raise ValueError(model + ' is not a valid aoi loss model') - else: + elif callable(model): self._aoi_model = partial(model, self) - - # FIXME What about diffuse versions of models? What about schlick, - # schlick_diffuse, and custom models? - - def infer_aoi_model(self): - module_parameters = tuple( - array.module_parameters for array in self.system.arrays) - params = _common_keys(module_parameters) - - iam_model_params = iam.get_builtin_models_params() - - if iam_model_params['physical'] <= params: - return self.physical_aoi_loss - elif iam_model_params['sapm'] <= params: - return self.sapm_aoi_loss - elif iam_model_params['ashrae'] <= params: - return self.ashrae_aoi_loss - elif iam_model_params['martin_ruiz'] <= params: - return self.martin_ruiz_aoi_loss - elif iam_model_params['interp'] <= params: - return self.interp_aoi_loss - - raise ValueError( - 'could not infer AOI model from ' - 'system.arrays[i].module_parameters. Check that the ' - 'module_parameters for all Arrays in system.arrays contain ' - 'parameters for the physical, aoi, ashrae, martin_ruiz or interp ' - 'model; explicitly set the model with the aoi_model kwarg; or ' - 'set aoi_model="no_loss".' - ) + else: + raise ValueError(f"Unsupported AOI model {model}") def ashrae_aoi_loss(self): self.results.aoi_modifier = self.system.get_iam( - self.results.aoi, - iam_model='ashrae' + self.results.aoi, iam_model='ashrae' ) return self def physical_aoi_loss(self): self.results.aoi_modifier = self.system.get_iam( - self.results.aoi, - iam_model='physical' + self.results.aoi, iam_model='physical' ) return self def sapm_aoi_loss(self): self.results.aoi_modifier = self.system.get_iam( - self.results.aoi, - iam_model='sapm' + self.results.aoi, iam_model='sapm' ) return self @@ -843,8 +819,13 @@ def martin_ruiz_aoi_loss(self): def interp_aoi_loss(self): self.results.aoi_modifier = self.system.get_iam( - self.results.aoi, - iam_model='interp' + self.results.aoi, iam_model='interp' + ) + return self + + def schlick_aoi_loss(self): + self.results.aoi_modifier = self.system.get_iam( + self.results.aoi, iam_model='schlick' ) return self diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py index 35bf77f1d5..c5574cde23 100644 --- a/pvlib/pvsystem.py +++ b/pvlib/pvsystem.py @@ -8,7 +8,6 @@ import io import itertools from pathlib import Path -import inspect from urllib.request import urlopen import numpy as np from scipy import constants @@ -17,7 +16,7 @@ from abc import ABC, abstractmethod from typing import Optional, Union -from pvlib._deprecation import deprecated, warn_deprecated +from pvlib._deprecation import deprecated import pvlib # used to avoid albedo name collision in the Array class from pvlib import (atmosphere, iam, inverter, irradiance, @@ -391,8 +390,8 @@ def get_iam(self, aoi, iam_model='physical'): The angle of incidence in degrees. aoi_model : string, default 'physical' - The IAM model to be used. Valid strings are 'physical', 'ashrae', - 'martin_ruiz', 'sapm' and 'interp'. + The IAM model to be used. Valid strings are 'ashrae', 'interp', + 'martin_ruiz', 'physical', 'sapm', and 'schlick'. Returns ------- iam : numeric or tuple of numeric @@ -1139,12 +1138,15 @@ def get_irradiance(self, solar_zenith, solar_azimuth, dni, ghi, dhi, def get_iam(self, aoi, iam_model='physical'): """ - Determine the incidence angle modifier using the method specified by + Determine the incidence-angle modifier (IAM) for the given + angle of incidence (AOI) using the method specified by ``iam_model``. Parameters for the selected IAM model are expected to be in - ``Array.module_parameters``. Default parameters are available for - the 'physical', 'ashrae' and 'martin_ruiz' models. + ``Array.module_parameters``. A minimal set of default parameters + are available for the 'ashrae', 'martin_ruiz', and 'physical' + models, but not for 'interp', 'sapm'. The 'schlick' model does not + take any parameters. Parameters ---------- @@ -1152,13 +1154,13 @@ def get_iam(self, aoi, iam_model='physical'): The angle of incidence in degrees. aoi_model : string, default 'physical' - The IAM model to be used. Valid strings are 'physical', 'ashrae', - 'martin_ruiz', 'sapm' and 'interp'. + The IAM model to be used. Valid strings are 'ashrae', 'interp', + 'martin_ruiz', 'physical', 'sapm', and 'schlick'. Returns ------- iam : numeric - The AOI modifier. + The IAM at the specified AOI. Raises ------ @@ -1166,18 +1168,35 @@ def get_iam(self, aoi, iam_model='physical'): if `iam_model` is not a valid model name. """ model = iam_model.lower() - if model in ['ashrae', 'physical', 'martin_ruiz', 'interp']: - func = getattr(iam, model) # get function at pvlib.iam - # get all parameters from function signature to retrieve them from - # module_parameters if present - params = set(inspect.signature(func).parameters.keys()) - params.discard('aoi') # exclude aoi so it can't be repeated - kwargs = _build_kwargs(params, self.module_parameters) - return func(aoi, **kwargs) - elif model == 'sapm': - return iam.sapm(aoi, self.module_parameters) - else: - raise ValueError(model + ' is not a valid IAM model') + + try: + model_info = iam.get_builtin_models()[model] + except KeyError as exc: + raise ValueError(f'{iam_model} is not a valid IAM model') from exc + + for param in model_info["params_required"]: + if param not in self.module_parameters: + raise KeyError( + f"Missing required {param} in module_parameters" + ) + + params_optional = _build_kwargs( + model_info["params_optional"], self.module_parameters + ) + + if model == "sapm": + # sapm has exceptional interface requiring module_parameters. + return model_info["callable"]( + aoi, self.module_parameters, **params_optional + ) + + params_required = _build_kwargs( + model_info["params_required"], self.module_parameters + ) + + return model_info["callable"]( + aoi, **params_required, **params_optional + ) def get_cell_temperature(self, poa_global, temp_air, wind_speed, model, effective_irradiance=None): diff --git a/pvlib/tests/test_modelchain.py b/pvlib/tests/test_modelchain.py index 71dce1ec03..0b24bad303 100644 --- a/pvlib/tests/test_modelchain.py +++ b/pvlib/tests/test_modelchain.py @@ -2,17 +2,14 @@ import numpy as np import pandas as pd +import pytest from pvlib import iam, modelchain, pvsystem, temperature, inverter from pvlib.modelchain import ModelChain from pvlib.pvsystem import PVSystem from pvlib.location import Location -from pvlib._deprecation import pvlibDeprecationWarning from .conftest import assert_series_equal, assert_frame_equal -import pytest - -from .conftest import fail_on_pvlib_version @pytest.fixture(scope='function') @@ -332,7 +329,7 @@ def sapm_dc_snl_ac_system_same_arrays(sapm_module_params, def test_ModelChain_creation(sapm_dc_snl_ac_system, location): - ModelChain(sapm_dc_snl_ac_system, location) + ModelChain.with_sapm(sapm_dc_snl_ac_system, location) def test_with_sapm(sapm_dc_snl_ac_system, location, weather): @@ -349,7 +346,7 @@ def test_with_pvwatts(pvwatts_dc_pvwatts_ac_system, location, weather): def test_run_model_with_irradiance(sapm_dc_snl_ac_system, location): - mc = ModelChain(sapm_dc_snl_ac_system, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system, location) times = pd.date_range('20160101 1200-0700', periods=2, freq='6h') irradiance = pd.DataFrame({'dni': 900, 'ghi': 600, 'dhi': 150}, index=times) @@ -488,7 +485,7 @@ def test_prepare_inputs_multi_weather( sapm_dc_snl_ac_system_Array, location, input_type): times = pd.date_range(start='20160101 1200-0700', end='20160101 1800-0700', freq='6h') - mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) weather = pd.DataFrame({'ghi': 1, 'dhi': 1, 'dni': 1}, index=times) mc.prepare_inputs(input_type((weather, weather))) @@ -503,7 +500,7 @@ def test_prepare_inputs_albedo_in_weather( sapm_dc_snl_ac_system_Array, location, input_type): times = pd.date_range(start='20160101 1200-0700', end='20160101 1800-0700', freq='6h') - mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) weather = pd.DataFrame({'ghi': 1, 'dhi': 1, 'dni': 1, 'albedo': 0.5}, index=times) # weather as a single DataFrame @@ -517,7 +514,7 @@ def test_prepare_inputs_albedo_in_weather( def test_prepare_inputs_no_irradiance(sapm_dc_snl_ac_system, location): - mc = ModelChain(sapm_dc_snl_ac_system, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system, location) weather = pd.DataFrame() with pytest.raises(ValueError): mc.prepare_inputs(weather) @@ -527,7 +524,7 @@ def test_prepare_inputs_arrays_one_missing_irradiance( sapm_dc_snl_ac_system_Array, location): """If any of the input DataFrames is missing a column then a ValueError is raised.""" - mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) weather = pd.DataFrame( {'ghi': [1], 'dhi': [1], 'dni': [1]} ) @@ -545,7 +542,7 @@ def test_prepare_inputs_arrays_one_missing_irradiance( @pytest.mark.parametrize("input_type", [tuple, list]) def test_prepare_inputs_weather_wrong_length( sapm_dc_snl_ac_system_Array, location, input_type): - mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) weather = pd.DataFrame({'ghi': [1], 'dhi': [1], 'dni': [1]}) with pytest.raises(ValueError, match="Input must be same length as number of Arrays " @@ -562,7 +559,7 @@ def test_ModelChain_times_error_arrays(sapm_dc_snl_ac_system_Array, location): DataFrames. """ error_str = r"Input DataFrames must have same index\." - mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) irradiance = {'ghi': [1, 2], 'dhi': [1, 2], 'dni': [1, 2]} times_one = pd.date_range(start='1/1/2020', freq='6h', periods=2) times_two = pd.date_range(start='1/1/2020 00:15', freq='6h', periods=2) @@ -585,7 +582,7 @@ def test_ModelChain_times_arrays(sapm_dc_snl_ac_system_Array, location): """ModelChain.times is assigned a single index given multiple weather DataFrames. """ - mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) irradiance_one = {'ghi': [1, 2], 'dhi': [1, 2], 'dni': [1, 2]} irradiance_two = {'ghi': [2, 1], 'dhi': [2, 1], 'dni': [2, 1]} times = pd.date_range(start='1/1/2020', freq='6h', periods=2) @@ -593,7 +590,7 @@ def test_ModelChain_times_arrays(sapm_dc_snl_ac_system_Array, location): weather_two = pd.DataFrame(irradiance_two, index=times) mc.prepare_inputs((weather_one, weather_two)) assert mc.results.times.equals(times) - mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) mc.prepare_inputs(weather_one) assert mc.results.times.equals(times) @@ -601,7 +598,7 @@ def test_ModelChain_times_arrays(sapm_dc_snl_ac_system_Array, location): @pytest.mark.parametrize("missing", ['dhi', 'ghi', 'dni']) def test_prepare_inputs_missing_irrad_component( sapm_dc_snl_ac_system, location, missing): - mc = ModelChain(sapm_dc_snl_ac_system, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system, location) weather = pd.DataFrame({'dhi': [1, 2], 'dni': [1, 2], 'ghi': [1, 2]}) weather.drop(columns=missing, inplace=True) with pytest.raises(ValueError): @@ -632,8 +629,9 @@ def test_run_model_arrays_weather(sapm_dc_snl_ac_system_same_arrays, def test_run_model_perez(sapm_dc_snl_ac_system, location): - mc = ModelChain(sapm_dc_snl_ac_system, location, - transposition_model='perez') + mc = ModelChain.with_sapm( + sapm_dc_snl_ac_system, location, transposition_model='perez' + ) times = pd.date_range('20160101 1200-0700', periods=2, freq='6h') irradiance = pd.DataFrame({'dni': 900, 'ghi': 600, 'dhi': 150}, index=times) @@ -645,9 +643,11 @@ def test_run_model_perez(sapm_dc_snl_ac_system, location): def test_run_model_gueymard_perez(sapm_dc_snl_ac_system, location): - mc = ModelChain(sapm_dc_snl_ac_system, location, - airmass_model='gueymard1993', - transposition_model='perez') + mc = ModelChain.with_sapm( + sapm_dc_snl_ac_system, location, + airmass_model='gueymard1993', + transposition_model='perez', + ) times = pd.date_range('20160101 1200-0700', periods=2, freq='6h') irradiance = pd.DataFrame({'dni': 900, 'ghi': 600, 'dhi': 150}, index=times) @@ -663,8 +663,7 @@ def test_run_model_with_weather_sapm_temp(sapm_dc_snl_ac_system, location, # test with sapm cell temperature model weather['wind_speed'] = 5 weather['temp_air'] = 10 - mc = ModelChain(sapm_dc_snl_ac_system, location) - mc.temperature_model = 'sapm' + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system, location) m_sapm = mocker.spy(sapm_dc_snl_ac_system, 'get_cell_temperature') mc.run_model(weather) assert m_sapm.call_count == 1 @@ -684,8 +683,12 @@ def test_run_model_with_weather_pvsyst_temp(sapm_dc_snl_ac_system, location, sapm_dc_snl_ac_system.arrays[0].racking_model = 'freestanding' sapm_dc_snl_ac_system.arrays[0].temperature_model_parameters = \ temperature._temperature_model_params('pvsyst', 'freestanding') - mc = ModelChain(sapm_dc_snl_ac_system, location) - mc.temperature_model = 'pvsyst' + mc = ModelChain( + sapm_dc_snl_ac_system, + location, + aoi_model='sapm', + temperature_model='pvsyst', + ) m_pvsyst = mocker.spy(sapm_dc_snl_ac_system, 'get_cell_temperature') mc.run_model(weather) assert m_pvsyst.call_count == 1 @@ -703,8 +706,12 @@ def test_run_model_with_weather_faiman_temp(sapm_dc_snl_ac_system, location, sapm_dc_snl_ac_system.arrays[0].temperature_model_parameters = { 'u0': 25.0, 'u1': 6.84 } - mc = ModelChain(sapm_dc_snl_ac_system, location) - mc.temperature_model = 'faiman' + mc = ModelChain( + sapm_dc_snl_ac_system, + location, + aoi_model='sapm', + temperature_model='faiman', + ) m_faiman = mocker.spy(sapm_dc_snl_ac_system, 'get_cell_temperature') mc.run_model(weather) assert m_faiman.call_count == 1 @@ -721,8 +728,12 @@ def test_run_model_with_weather_fuentes_temp(sapm_dc_snl_ac_system, location, sapm_dc_snl_ac_system.arrays[0].temperature_model_parameters = { 'noct_installed': 45, 'surface_tilt': 30, } - mc = ModelChain(sapm_dc_snl_ac_system, location) - mc.temperature_model = 'fuentes' + mc = ModelChain( + sapm_dc_snl_ac_system, + location, + aoi_model='sapm', + temperature_model='fuentes', + ) m_fuentes = mocker.spy(sapm_dc_snl_ac_system, 'get_cell_temperature') mc.run_model(weather) assert m_fuentes.call_count == 1 @@ -739,8 +750,12 @@ def test_run_model_with_weather_noct_sam_temp(sapm_dc_snl_ac_system, location, sapm_dc_snl_ac_system.arrays[0].temperature_model_parameters = { 'noct': 45, 'module_efficiency': 0.2 } - mc = ModelChain(sapm_dc_snl_ac_system, location) - mc.temperature_model = 'noct_sam' + mc = ModelChain( + sapm_dc_snl_ac_system, + location, + aoi_model='sapm', + temperature_model='noct_sam' + ) m_noct_sam = mocker.spy(sapm_dc_snl_ac_system, 'get_cell_temperature') mc.run_model(weather) assert m_noct_sam.call_count == 1 @@ -755,7 +770,7 @@ def test_run_model_with_weather_noct_sam_temp(sapm_dc_snl_ac_system, location, def test__assign_total_irrad(sapm_dc_snl_ac_system, location, weather, total_irrad): data = pd.concat([weather, total_irrad], axis=1) - mc = ModelChain(sapm_dc_snl_ac_system, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system, location) mc._assign_total_irrad(data) assert_frame_equal(mc.results.total_irrad, total_irrad) @@ -763,7 +778,7 @@ def test__assign_total_irrad(sapm_dc_snl_ac_system, location, weather, def test_prepare_inputs_from_poa(sapm_dc_snl_ac_system, location, weather, total_irrad): data = pd.concat([weather, total_irrad], axis=1) - mc = ModelChain(sapm_dc_snl_ac_system, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system, location) mc.prepare_inputs_from_poa(data) weather_expected = weather.copy() weather_expected['temp_air'] = 20 @@ -782,7 +797,7 @@ def test_prepare_inputs_from_poa(sapm_dc_snl_ac_system, location, def test_prepare_inputs_from_poa_multi_data( sapm_dc_snl_ac_system_Array, location, total_irrad, weather, input_type): - mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) poa = pd.concat([weather, total_irrad], axis=1) mc.prepare_inputs_from_poa(input_type((poa, poa))) num_arrays = sapm_dc_snl_ac_system_Array.num_arrays @@ -796,7 +811,7 @@ def test_prepare_inputs_from_poa_wrong_number_arrays( len_error = r"Input must be same length as number of Arrays in system\. " \ r"Expected 2, got [0-9]+\." type_error = r"Input must be a tuple of length 2, got .*\." - mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) poa = pd.concat([weather, total_irrad], axis=1) with pytest.raises(TypeError, match=type_error): mc.prepare_inputs_from_poa(poa) @@ -809,7 +824,7 @@ def test_prepare_inputs_from_poa_wrong_number_arrays( def test_prepare_inputs_from_poa_arrays_different_indices( sapm_dc_snl_ac_system_Array, location, total_irrad, weather): error_str = r"Input DataFrames must have same index\." - mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) poa = pd.concat([weather, total_irrad], axis=1) with pytest.raises(ValueError, match=error_str): mc.prepare_inputs_from_poa((poa, poa.shift(periods=1, freq='6h'))) @@ -817,7 +832,7 @@ def test_prepare_inputs_from_poa_arrays_different_indices( def test_prepare_inputs_from_poa_arrays_missing_column( sapm_dc_snl_ac_system_Array, location, weather, total_irrad): - mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) poa = pd.concat([weather, total_irrad], axis=1) with pytest.raises(ValueError, match=r"Incomplete input data\. " r"Data needs to contain .*\. " @@ -1065,7 +1080,7 @@ def test_run_model_from_effective_irradiance_arrays_error( data = weather.copy() data[['poa_global', 'poa_diffuse', 'poa_direct']] = total_irrad data['effetive_irradiance'] = data['poa_global'] - mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) len_error = r"Input must be same length as number of Arrays in system\. " \ r"Expected 2, got [0-9]+\." type_error = r"Input must be a tuple of length 2, got DataFrame\." @@ -1090,7 +1105,7 @@ def test_run_model_from_effective_irradiance_arrays( data[['poa_global', 'poa_diffuse', 'poa_direct']] = total_irrad data['effective_irradiance'] = data['poa_global'] data['cell_temperature'] = 40 - mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) mc.run_model_from_effective_irradiance(input_type((data, data))) # arrays have different orientation, but should give same dc power # because we are the same passing effective irradiance and cell @@ -1109,14 +1124,14 @@ def test_run_model_from_effective_irradiance_minimal_input( data = pd.DataFrame({'effective_irradiance': total_irrad['poa_global'], 'cell_temperature': 40}, index=total_irrad.index) - mc = ModelChain(sapm_dc_snl_ac_system, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system, location) mc.run_model_from_effective_irradiance(data) # make sure, for a single Array, the result is the correct type and value assert_series_equal(mc.results.cell_temperature, data['cell_temperature']) assert not mc.results.dc.empty assert not mc.results.ac.empty # test with multiple arrays - mc = ModelChain(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) mc.run_model_from_effective_irradiance((data, data)) assert_frame_equal(mc.results.dc[0], mc.results.dc[1]) assert not mc.results.ac.empty @@ -1502,42 +1517,6 @@ def test_aoi_model_user_func(sapm_dc_snl_ac_system, location, weather, mocker): assert mc.results.ac.iloc[1] < 1 -@pytest.mark.parametrize('aoi_model', [ - 'sapm', 'ashrae', 'physical', 'martin_ruiz', 'interp' -]) -def test_infer_aoi_model(location, system_no_aoi, aoi_model): - for k in iam.get_builtin_models_params()[aoi_model]: - system_no_aoi.arrays[0].module_parameters.update({k: 1.0}) - mc = ModelChain(system_no_aoi, location, spectral_model='no_loss') - assert isinstance(mc, ModelChain) - - -@pytest.mark.parametrize('aoi_model,model_kwargs', [ - # model_kwargs has both required and optional kwargs; test all - ('physical', - {'n': 1.526, 'K': 4.0, 'L': 0.002, # required - 'n_ar': 1.8}), # extra - ('interp', - {'theta_ref': (0, 75, 85, 90), 'iam_ref': (1, 0.8, 0.42, 0), # required - 'method': 'cubic', 'normalize': False})]) # extra -def test_infer_aoi_model_with_extra_params(location, system_no_aoi, aoi_model, - model_kwargs, weather, mocker): - # test extra parameters not defined at iam._IAM_MODEL_PARAMS are passed - m = mocker.spy(iam, aoi_model) - system_no_aoi.arrays[0].module_parameters.update(**model_kwargs) - mc = ModelChain(system_no_aoi, location, spectral_model='no_loss') - assert isinstance(mc, ModelChain) - mc.run_model(weather=weather) - _, call_kwargs = m.call_args - assert call_kwargs == model_kwargs - - -def test_infer_aoi_model_invalid(location, system_no_aoi): - exc_text = 'could not infer AOI model' - with pytest.raises(ValueError, match=exc_text): - ModelChain(system_no_aoi, location, spectral_model='no_loss') - - def constant_spectral_loss(mc): mc.results.spectral_modifier = 0.9 @@ -1774,8 +1753,9 @@ def test_bad_get_orientation(): # tests for PVSystem with multiple Arrays def test_with_sapm_pvsystem_arrays(sapm_dc_snl_ac_system_Array, location, weather): - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location, - ac_model='sandia') + mc = ModelChain.with_sapm( + sapm_dc_snl_ac_system_Array, location, ac_model='sandia' + ) assert mc.dc_model == mc.sapm assert mc.ac_model == mc.sandia_inverter mc.run_model(weather) @@ -1789,7 +1769,7 @@ def test_ModelChain_no_extra_kwargs(sapm_dc_snl_ac_system, location): def test_complete_irradiance_clean_run(sapm_dc_snl_ac_system, location): """The DataFrame should not change if all columns are passed""" - mc = ModelChain(sapm_dc_snl_ac_system, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system, location) times = pd.date_range('2010-07-05 9:00:00', periods=2, freq='h') i = pd.DataFrame( {'dni': [2, 3], 'dhi': [4, 6], 'ghi': [9, 5]}, index=times) @@ -1806,7 +1786,7 @@ def test_complete_irradiance_clean_run(sapm_dc_snl_ac_system, location): def test_complete_irradiance(sapm_dc_snl_ac_system, location, mocker): """Check calculations""" - mc = ModelChain(sapm_dc_snl_ac_system, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system, location) times = pd.date_range('2010-07-05 7:00:00-0700', periods=2, freq='h') i = pd.DataFrame({'dni': [49.756966, 62.153947], 'ghi': [372.103976116, 497.087579068], @@ -1844,7 +1824,7 @@ def test_complete_irradiance_arrays( weather = pd.DataFrame({'dni': [2, 3], 'dhi': [4, 6], 'ghi': [9, 5]}, index=times) - mc = ModelChain(sapm_dc_snl_ac_system_same_arrays, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_same_arrays, location) with pytest.raises(ValueError, match=r"Input DataFrames must have same index\."): mc.complete_irradiance(input_type((weather, weather[1:]))) @@ -1856,7 +1836,7 @@ def test_complete_irradiance_arrays( pd.Series([4, 6], index=times, name='dhi')) assert_series_equal(mc_weather['ghi'], pd.Series([9, 5], index=times, name='ghi')) - mc = ModelChain(sapm_dc_snl_ac_system_same_arrays, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_same_arrays, location) mc.complete_irradiance(input_type((weather[['ghi', 'dhi']], weather[['dhi', 'dni']]))) assert 'dni' in mc.results.weather[0].columns @@ -1874,7 +1854,7 @@ def test_complete_irradiance_arrays( @pytest.mark.parametrize("input_type", [tuple, list]) def test_complete_irradiance_arrays_wrong_length( sapm_dc_snl_ac_system_same_arrays, location, input_type): - mc = ModelChain(sapm_dc_snl_ac_system_same_arrays, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_same_arrays, location) times = pd.date_range(start='2020-01-01 0700-0700', periods=2, freq='h') weather = pd.DataFrame({'dni': [2, 3], 'dhi': [4, 6], @@ -1888,7 +1868,7 @@ def test_complete_irradiance_arrays_wrong_length( def test_unknown_attribute(sapm_dc_snl_ac_system, location): - mc = ModelChain(sapm_dc_snl_ac_system, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system, location) with pytest.raises(AttributeError): mc.unknown_attribute @@ -1990,8 +1970,7 @@ def test__irrad_for_celltemp(): def test_ModelChain___repr__(sapm_dc_snl_ac_system, location): - mc = ModelChain(sapm_dc_snl_ac_system, location, - name='my mc') + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system, location, name='my mc') expected = '\n'.join([ 'ModelChain: ', @@ -2012,7 +1991,7 @@ def test_ModelChain___repr__(sapm_dc_snl_ac_system, location): def test_ModelChainResult___repr__(sapm_dc_snl_ac_system, location, weather): - mc = ModelChain(sapm_dc_snl_ac_system, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system, location) mc.run_model(weather) mcres = mc.results.__repr__() mc_attrs = dir(mc.results) From dd8408fa42ca4f3a57fb9e50528bae3769648078 Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Mon, 1 Jul 2024 01:14:52 -0600 Subject: [PATCH 17/34] Restore infer_aoi_model --- pvlib/iam.py | 6 +- pvlib/modelchain.py | 73 +++++++++++++++++++++-- pvlib/tests/test_modelchain.py | 105 ++++++++++++++++++++++----------- 3 files changed, 143 insertions(+), 41 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index 33e890c95b..3c8342bf30 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -38,7 +38,7 @@ def get_builtin_models(): 'physical': { 'callable': physical, 'params_required': set(), - 'params_optional': {'n', 'K', 'L'}, + 'params_optional': {'n', 'K', 'L', 'n_ar'}, }, 'sapm': { 'callable': sapm, @@ -122,7 +122,7 @@ def ashrae(aoi, b=0.05): return iam -def physical(aoi, n=1.526, K=4.0, L=0.002, *, n_ar=None): +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``, glazing thickness ``L`` and refractive @@ -1005,7 +1005,7 @@ def _get_fittable_or_convertable_model(builtin_model_name): def _check_params(builtin_model_name, params): - # check that parameters passed in with IAM model belong to model + # check that parameters passed in with IAM model belong to the model handed_params = set(params.keys()) builtin_model = get_builtin_models()[builtin_model_name] expected_params = builtin_model["params_required"].union( diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py index 0632ea2eae..d7aab56208 100644 --- a/pvlib/modelchain.py +++ b/pvlib/modelchain.py @@ -326,9 +326,10 @@ class ModelChain: aoi_model : str, or function, optional If not specified, the model will be inferred from the parameters that are common to all of system.arrays[i].module_parameters. - Valid strings are 'physical', 'ashrae', 'sapm', 'martin_ruiz', - 'interp' and 'no_loss'. The ModelChain instance will be passed as the - first argument to a user-defined function. + Valid strings are 'ashrae', 'interp', 'martin_ruiz', 'physical', + 'sapm', 'schlick', 'no_loss'. + The ModelChain instance will be passed as the first argument to a + user-defined function. spectral_model : str, or function, optional If not specified, the model will be inferred from the parameters that @@ -770,7 +771,9 @@ def aoi_model(self): @aoi_model.setter def aoi_model(self, model): - if isinstance(model, str): + if model is None: + self._aoi_model = self.infer_aoi_model() + elif isinstance(model, str): model = model.lower() if model == 'ashrae': self._aoi_model = self.ashrae_aoi_loss @@ -793,6 +796,68 @@ def aoi_model(self, model): else: raise ValueError(f"Unsupported AOI model {model}") + def infer_aoi_model(self): + """ + Infer AOI model by checking for at least one required or optional + model parameter in module_parameter collected across all arrays. + """ + module_parameters = tuple( + array.module_parameters for array in self.system.arrays + ) + params = _common_keys(module_parameters) + builtin_models = pvlib.iam.get_builtin_models() + + if any( + param in params for + param in builtin_models['ashrae']["params_required"].union( + builtin_models['ashrae']["params_optional"] + ) + ): + return self.ashrae_aoi_loss + + if any( + param in params for + param in builtin_models['interp']["params_required"].union( + builtin_models['interp']["params_optional"] + ) + ): + return self.interp_aoi_loss + + if any( + param in params for + param in builtin_models['martin_ruiz']["params_required"].union( + builtin_models['martin_ruiz']["params_optional"] + ) + ): + return self.martin_ruiz_aoi_loss + + if any( + param in params for + param in builtin_models['physical']["params_required"].union( + builtin_models['physical']["params_optional"] + ) + ): + return self.physical_aoi_loss + + if any( + param in params for + param in builtin_models['sapm']["params_required"].union( + builtin_models['sapm']["params_optional"] + ) + ): + return self.sapm_aoi_loss + + # schlick model has no parameters to distinguish. + + raise ValueError( + 'Could not infer AOI model from ' + 'system.arrays[i].module_parameters. Check that the ' + 'module_parameters for all Arrays in system.arrays contain ' + 'parameters for the ashrae, interp, martin_ruiz, physical, or ' + 'sapm model; explicitly set the model (esp. the parameter-free ' + 'schlick) with the aoi_model kwarg or set aoi_model="no_loss".' + ) + def ashrae_aoi_loss(self): self.results.aoi_modifier = self.system.get_iam( self.results.aoi, iam_model='ashrae' diff --git a/pvlib/tests/test_modelchain.py b/pvlib/tests/test_modelchain.py index 0b24bad303..3c4c8124b7 100644 --- a/pvlib/tests/test_modelchain.py +++ b/pvlib/tests/test_modelchain.py @@ -329,11 +329,11 @@ def sapm_dc_snl_ac_system_same_arrays(sapm_module_params, def test_ModelChain_creation(sapm_dc_snl_ac_system, location): - ModelChain.with_sapm(sapm_dc_snl_ac_system, location) + ModelChain(sapm_dc_snl_ac_system, location) def test_with_sapm(sapm_dc_snl_ac_system, location, weather): - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system, location) + mc = ModelChain(sapm_dc_snl_ac_system, location) assert mc.dc_model == mc.sapm mc.run_model(weather) @@ -346,7 +346,7 @@ def test_with_pvwatts(pvwatts_dc_pvwatts_ac_system, location, weather): def test_run_model_with_irradiance(sapm_dc_snl_ac_system, location): - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system, location) + mc = ModelChain(sapm_dc_snl_ac_system, location) times = pd.date_range('20160101 1200-0700', periods=2, freq='6h') irradiance = pd.DataFrame({'dni': 900, 'ghi': 600, 'dhi': 150}, index=times) @@ -485,7 +485,7 @@ def test_prepare_inputs_multi_weather( sapm_dc_snl_ac_system_Array, location, input_type): times = pd.date_range(start='20160101 1200-0700', end='20160101 1800-0700', freq='6h') - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) weather = pd.DataFrame({'ghi': 1, 'dhi': 1, 'dni': 1}, index=times) mc.prepare_inputs(input_type((weather, weather))) @@ -500,7 +500,7 @@ def test_prepare_inputs_albedo_in_weather( sapm_dc_snl_ac_system_Array, location, input_type): times = pd.date_range(start='20160101 1200-0700', end='20160101 1800-0700', freq='6h') - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) weather = pd.DataFrame({'ghi': 1, 'dhi': 1, 'dni': 1, 'albedo': 0.5}, index=times) # weather as a single DataFrame @@ -514,7 +514,7 @@ def test_prepare_inputs_albedo_in_weather( def test_prepare_inputs_no_irradiance(sapm_dc_snl_ac_system, location): - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system, location) + mc = ModelChain(sapm_dc_snl_ac_system, location) weather = pd.DataFrame() with pytest.raises(ValueError): mc.prepare_inputs(weather) @@ -524,7 +524,7 @@ def test_prepare_inputs_arrays_one_missing_irradiance( sapm_dc_snl_ac_system_Array, location): """If any of the input DataFrames is missing a column then a ValueError is raised.""" - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) weather = pd.DataFrame( {'ghi': [1], 'dhi': [1], 'dni': [1]} ) @@ -542,7 +542,7 @@ def test_prepare_inputs_arrays_one_missing_irradiance( @pytest.mark.parametrize("input_type", [tuple, list]) def test_prepare_inputs_weather_wrong_length( sapm_dc_snl_ac_system_Array, location, input_type): - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) weather = pd.DataFrame({'ghi': [1], 'dhi': [1], 'dni': [1]}) with pytest.raises(ValueError, match="Input must be same length as number of Arrays " @@ -559,7 +559,7 @@ def test_ModelChain_times_error_arrays(sapm_dc_snl_ac_system_Array, location): DataFrames. """ error_str = r"Input DataFrames must have same index\." - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) irradiance = {'ghi': [1, 2], 'dhi': [1, 2], 'dni': [1, 2]} times_one = pd.date_range(start='1/1/2020', freq='6h', periods=2) times_two = pd.date_range(start='1/1/2020 00:15', freq='6h', periods=2) @@ -582,7 +582,7 @@ def test_ModelChain_times_arrays(sapm_dc_snl_ac_system_Array, location): """ModelChain.times is assigned a single index given multiple weather DataFrames. """ - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) irradiance_one = {'ghi': [1, 2], 'dhi': [1, 2], 'dni': [1, 2]} irradiance_two = {'ghi': [2, 1], 'dhi': [2, 1], 'dni': [2, 1]} times = pd.date_range(start='1/1/2020', freq='6h', periods=2) @@ -590,7 +590,7 @@ def test_ModelChain_times_arrays(sapm_dc_snl_ac_system_Array, location): weather_two = pd.DataFrame(irradiance_two, index=times) mc.prepare_inputs((weather_one, weather_two)) assert mc.results.times.equals(times) - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) mc.prepare_inputs(weather_one) assert mc.results.times.equals(times) @@ -598,7 +598,7 @@ def test_ModelChain_times_arrays(sapm_dc_snl_ac_system_Array, location): @pytest.mark.parametrize("missing", ['dhi', 'ghi', 'dni']) def test_prepare_inputs_missing_irrad_component( sapm_dc_snl_ac_system, location, missing): - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system, location) + mc = ModelChain(sapm_dc_snl_ac_system, location) weather = pd.DataFrame({'dhi': [1, 2], 'dni': [1, 2], 'ghi': [1, 2]}) weather.drop(columns=missing, inplace=True) with pytest.raises(ValueError): @@ -629,7 +629,7 @@ def test_run_model_arrays_weather(sapm_dc_snl_ac_system_same_arrays, def test_run_model_perez(sapm_dc_snl_ac_system, location): - mc = ModelChain.with_sapm( + mc = ModelChain( sapm_dc_snl_ac_system, location, transposition_model='perez' ) times = pd.date_range('20160101 1200-0700', periods=2, freq='6h') @@ -643,7 +643,7 @@ def test_run_model_perez(sapm_dc_snl_ac_system, location): def test_run_model_gueymard_perez(sapm_dc_snl_ac_system, location): - mc = ModelChain.with_sapm( + mc = ModelChain( sapm_dc_snl_ac_system, location, airmass_model='gueymard1993', transposition_model='perez', @@ -663,7 +663,7 @@ def test_run_model_with_weather_sapm_temp(sapm_dc_snl_ac_system, location, # test with sapm cell temperature model weather['wind_speed'] = 5 weather['temp_air'] = 10 - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system, location) + mc = ModelChain(sapm_dc_snl_ac_system, location) m_sapm = mocker.spy(sapm_dc_snl_ac_system, 'get_cell_temperature') mc.run_model(weather) assert m_sapm.call_count == 1 @@ -770,7 +770,7 @@ def test_run_model_with_weather_noct_sam_temp(sapm_dc_snl_ac_system, location, def test__assign_total_irrad(sapm_dc_snl_ac_system, location, weather, total_irrad): data = pd.concat([weather, total_irrad], axis=1) - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system, location) + mc = ModelChain(sapm_dc_snl_ac_system, location) mc._assign_total_irrad(data) assert_frame_equal(mc.results.total_irrad, total_irrad) @@ -778,7 +778,7 @@ def test__assign_total_irrad(sapm_dc_snl_ac_system, location, weather, def test_prepare_inputs_from_poa(sapm_dc_snl_ac_system, location, weather, total_irrad): data = pd.concat([weather, total_irrad], axis=1) - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system, location) + mc = ModelChain(sapm_dc_snl_ac_system, location) mc.prepare_inputs_from_poa(data) weather_expected = weather.copy() weather_expected['temp_air'] = 20 @@ -797,7 +797,7 @@ def test_prepare_inputs_from_poa(sapm_dc_snl_ac_system, location, def test_prepare_inputs_from_poa_multi_data( sapm_dc_snl_ac_system_Array, location, total_irrad, weather, input_type): - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) poa = pd.concat([weather, total_irrad], axis=1) mc.prepare_inputs_from_poa(input_type((poa, poa))) num_arrays = sapm_dc_snl_ac_system_Array.num_arrays @@ -811,7 +811,7 @@ def test_prepare_inputs_from_poa_wrong_number_arrays( len_error = r"Input must be same length as number of Arrays in system\. " \ r"Expected 2, got [0-9]+\." type_error = r"Input must be a tuple of length 2, got .*\." - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) poa = pd.concat([weather, total_irrad], axis=1) with pytest.raises(TypeError, match=type_error): mc.prepare_inputs_from_poa(poa) @@ -824,7 +824,7 @@ def test_prepare_inputs_from_poa_wrong_number_arrays( def test_prepare_inputs_from_poa_arrays_different_indices( sapm_dc_snl_ac_system_Array, location, total_irrad, weather): error_str = r"Input DataFrames must have same index\." - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) poa = pd.concat([weather, total_irrad], axis=1) with pytest.raises(ValueError, match=error_str): mc.prepare_inputs_from_poa((poa, poa.shift(periods=1, freq='6h'))) @@ -832,7 +832,7 @@ def test_prepare_inputs_from_poa_arrays_different_indices( def test_prepare_inputs_from_poa_arrays_missing_column( sapm_dc_snl_ac_system_Array, location, weather, total_irrad): - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) poa = pd.concat([weather, total_irrad], axis=1) with pytest.raises(ValueError, match=r"Incomplete input data\. " r"Data needs to contain .*\. " @@ -1080,7 +1080,7 @@ def test_run_model_from_effective_irradiance_arrays_error( data = weather.copy() data[['poa_global', 'poa_diffuse', 'poa_direct']] = total_irrad data['effetive_irradiance'] = data['poa_global'] - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) len_error = r"Input must be same length as number of Arrays in system\. " \ r"Expected 2, got [0-9]+\." type_error = r"Input must be a tuple of length 2, got DataFrame\." @@ -1105,7 +1105,7 @@ def test_run_model_from_effective_irradiance_arrays( data[['poa_global', 'poa_diffuse', 'poa_direct']] = total_irrad data['effective_irradiance'] = data['poa_global'] data['cell_temperature'] = 40 - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) mc.run_model_from_effective_irradiance(input_type((data, data))) # arrays have different orientation, but should give same dc power # because we are the same passing effective irradiance and cell @@ -1124,14 +1124,14 @@ def test_run_model_from_effective_irradiance_minimal_input( data = pd.DataFrame({'effective_irradiance': total_irrad['poa_global'], 'cell_temperature': 40}, index=total_irrad.index) - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system, location) + mc = ModelChain(sapm_dc_snl_ac_system, location) mc.run_model_from_effective_irradiance(data) # make sure, for a single Array, the result is the correct type and value assert_series_equal(mc.results.cell_temperature, data['cell_temperature']) assert not mc.results.dc.empty assert not mc.results.ac.empty # test with multiple arrays - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location) + mc = ModelChain(sapm_dc_snl_ac_system_Array, location) mc.run_model_from_effective_irradiance((data, data)) assert_frame_equal(mc.results.dc[0], mc.results.dc[1]) assert not mc.results.ac.empty @@ -1517,6 +1517,43 @@ def test_aoi_model_user_func(sapm_dc_snl_ac_system, location, weather, mocker): assert mc.results.ac.iloc[1] < 1 +@pytest.mark.parametrize('aoi_model', [ + 'ashrae', 'interp', 'martin_ruiz', 'physical', 'sapm' +]) +def test_infer_aoi_model(location, system_no_aoi, aoi_model): + builtin_models = iam.get_builtin_models()[aoi_model] + params = builtin_models["params_required"].union( + builtin_models["params_optional"] + ) + system_no_aoi.arrays[0].module_parameters.update({params.pop(): 1.0}) + mc = ModelChain(system_no_aoi, location, spectral_model='no_loss') + assert isinstance(mc, ModelChain) + + +@pytest.mark.parametrize('aoi_model,model_kwargs', [ + # model_kwargs has both required and optional kwargs; test all + ('physical', + {'n': 1.526, 'K': 4.0, 'L': 0.002}), # optional + ('interp', + {'theta_ref': (0, 75, 85, 90), 'iam_ref': (1, 0.8, 0.42, 0), # required + 'method': 'cubic', 'normalize': False})]) # extra +def test_infer_aoi_model_with_extra_params(location, system_no_aoi, aoi_model, + model_kwargs, weather, mocker): + # test extra parameters not defined at iam._IAM_MODEL_PARAMS are passed + m = mocker.spy(iam, aoi_model) + system_no_aoi.arrays[0].module_parameters.update(**model_kwargs) + mc = ModelChain(system_no_aoi, location, spectral_model='no_loss') + assert isinstance(mc, ModelChain) + mc.run_model(weather=weather) + _, call_kwargs = m.call_args + assert call_kwargs == model_kwargs + + +def test_infer_aoi_model_invalid(location, system_no_aoi): + with pytest.raises(ValueError, match='Could not infer AOI model'): + ModelChain(system_no_aoi, location, spectral_model='no_loss') + + def constant_spectral_loss(mc): mc.results.spectral_modifier = 0.9 @@ -1753,7 +1790,7 @@ def test_bad_get_orientation(): # tests for PVSystem with multiple Arrays def test_with_sapm_pvsystem_arrays(sapm_dc_snl_ac_system_Array, location, weather): - mc = ModelChain.with_sapm( + mc = ModelChain( sapm_dc_snl_ac_system_Array, location, ac_model='sandia' ) assert mc.dc_model == mc.sapm @@ -1769,7 +1806,7 @@ def test_ModelChain_no_extra_kwargs(sapm_dc_snl_ac_system, location): def test_complete_irradiance_clean_run(sapm_dc_snl_ac_system, location): """The DataFrame should not change if all columns are passed""" - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system, location) + mc = ModelChain(sapm_dc_snl_ac_system, location) times = pd.date_range('2010-07-05 9:00:00', periods=2, freq='h') i = pd.DataFrame( {'dni': [2, 3], 'dhi': [4, 6], 'ghi': [9, 5]}, index=times) @@ -1786,7 +1823,7 @@ def test_complete_irradiance_clean_run(sapm_dc_snl_ac_system, location): def test_complete_irradiance(sapm_dc_snl_ac_system, location, mocker): """Check calculations""" - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system, location) + mc = ModelChain(sapm_dc_snl_ac_system, location) times = pd.date_range('2010-07-05 7:00:00-0700', periods=2, freq='h') i = pd.DataFrame({'dni': [49.756966, 62.153947], 'ghi': [372.103976116, 497.087579068], @@ -1824,7 +1861,7 @@ def test_complete_irradiance_arrays( weather = pd.DataFrame({'dni': [2, 3], 'dhi': [4, 6], 'ghi': [9, 5]}, index=times) - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_same_arrays, location) + mc = ModelChain(sapm_dc_snl_ac_system_same_arrays, location) with pytest.raises(ValueError, match=r"Input DataFrames must have same index\."): mc.complete_irradiance(input_type((weather, weather[1:]))) @@ -1836,7 +1873,7 @@ def test_complete_irradiance_arrays( pd.Series([4, 6], index=times, name='dhi')) assert_series_equal(mc_weather['ghi'], pd.Series([9, 5], index=times, name='ghi')) - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_same_arrays, location) + mc = ModelChain(sapm_dc_snl_ac_system_same_arrays, location) mc.complete_irradiance(input_type((weather[['ghi', 'dhi']], weather[['dhi', 'dni']]))) assert 'dni' in mc.results.weather[0].columns @@ -1854,7 +1891,7 @@ def test_complete_irradiance_arrays( @pytest.mark.parametrize("input_type", [tuple, list]) def test_complete_irradiance_arrays_wrong_length( sapm_dc_snl_ac_system_same_arrays, location, input_type): - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_same_arrays, location) + mc = ModelChain(sapm_dc_snl_ac_system_same_arrays, location) times = pd.date_range(start='2020-01-01 0700-0700', periods=2, freq='h') weather = pd.DataFrame({'dni': [2, 3], 'dhi': [4, 6], @@ -1868,7 +1905,7 @@ def test_complete_irradiance_arrays_wrong_length( def test_unknown_attribute(sapm_dc_snl_ac_system, location): - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system, location) + mc = ModelChain(sapm_dc_snl_ac_system, location) with pytest.raises(AttributeError): mc.unknown_attribute @@ -1970,7 +2007,7 @@ def test__irrad_for_celltemp(): def test_ModelChain___repr__(sapm_dc_snl_ac_system, location): - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system, location, name='my mc') + mc = ModelChain(sapm_dc_snl_ac_system, location, name='my mc') expected = '\n'.join([ 'ModelChain: ', @@ -1991,7 +2028,7 @@ def test_ModelChain___repr__(sapm_dc_snl_ac_system, location): def test_ModelChainResult___repr__(sapm_dc_snl_ac_system, location, weather): - mc = ModelChain.with_sapm(sapm_dc_snl_ac_system, location) + mc = ModelChain(sapm_dc_snl_ac_system, location) mc.run_model(weather) mcres = mc.results.__repr__() mc_attrs = dir(mc.results) From 1bd8f0de8c006cb95ab0a37ce0cd2f270f333386 Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Mon, 1 Jul 2024 01:23:07 -0600 Subject: [PATCH 18/34] Revert additional changes --- pvlib/modelchain.py | 10 +++--- pvlib/tests/test_modelchain.py | 56 ++++++++++++---------------------- 2 files changed, 24 insertions(+), 42 deletions(-) diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py index d7aab56208..09d2c31322 100644 --- a/pvlib/modelchain.py +++ b/pvlib/modelchain.py @@ -808,7 +808,7 @@ def infer_aoi_model(self): builtin_models = pvlib.iam.get_builtin_models() if any( - param in params for + param in params for param in builtin_models['ashrae']["params_required"].union( builtin_models['ashrae']["params_optional"] ) @@ -816,7 +816,7 @@ def infer_aoi_model(self): return self.ashrae_aoi_loss if any( - param in params for + param in params for param in builtin_models['interp']["params_required"].union( builtin_models['interp']["params_optional"] ) @@ -824,7 +824,7 @@ def infer_aoi_model(self): return self.interp_aoi_loss if any( - param in params for + param in params for param in builtin_models['martin_ruiz']["params_required"].union( builtin_models['martin_ruiz']["params_optional"] ) @@ -832,7 +832,7 @@ def infer_aoi_model(self): return self.martin_ruiz_aoi_loss if any( - param in params for + param in params for param in builtin_models['physical']["params_required"].union( builtin_models['physical']["params_optional"] ) @@ -840,7 +840,7 @@ def infer_aoi_model(self): return self.physical_aoi_loss if any( - param in params for + param in params for param in builtin_models['sapm']["params_required"].union( builtin_models['sapm']["params_optional"] ) diff --git a/pvlib/tests/test_modelchain.py b/pvlib/tests/test_modelchain.py index 3c4c8124b7..98b73b0546 100644 --- a/pvlib/tests/test_modelchain.py +++ b/pvlib/tests/test_modelchain.py @@ -333,7 +333,7 @@ def test_ModelChain_creation(sapm_dc_snl_ac_system, location): def test_with_sapm(sapm_dc_snl_ac_system, location, weather): - mc = ModelChain(sapm_dc_snl_ac_system, location) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system, location) assert mc.dc_model == mc.sapm mc.run_model(weather) @@ -629,9 +629,8 @@ def test_run_model_arrays_weather(sapm_dc_snl_ac_system_same_arrays, def test_run_model_perez(sapm_dc_snl_ac_system, location): - mc = ModelChain( - sapm_dc_snl_ac_system, location, transposition_model='perez' - ) + mc = ModelChain(sapm_dc_snl_ac_system, location, + transposition_model='perez') times = pd.date_range('20160101 1200-0700', periods=2, freq='6h') irradiance = pd.DataFrame({'dni': 900, 'ghi': 600, 'dhi': 150}, index=times) @@ -643,11 +642,9 @@ def test_run_model_perez(sapm_dc_snl_ac_system, location): def test_run_model_gueymard_perez(sapm_dc_snl_ac_system, location): - mc = ModelChain( - sapm_dc_snl_ac_system, location, - airmass_model='gueymard1993', - transposition_model='perez', - ) + mc = ModelChain(sapm_dc_snl_ac_system, location, + airmass_model='gueymard1993', + transposition_model='perez') times = pd.date_range('20160101 1200-0700', periods=2, freq='6h') irradiance = pd.DataFrame({'dni': 900, 'ghi': 600, 'dhi': 150}, index=times) @@ -664,6 +661,7 @@ def test_run_model_with_weather_sapm_temp(sapm_dc_snl_ac_system, location, weather['wind_speed'] = 5 weather['temp_air'] = 10 mc = ModelChain(sapm_dc_snl_ac_system, location) + mc.temperature_model = 'sapm' m_sapm = mocker.spy(sapm_dc_snl_ac_system, 'get_cell_temperature') mc.run_model(weather) assert m_sapm.call_count == 1 @@ -683,12 +681,8 @@ def test_run_model_with_weather_pvsyst_temp(sapm_dc_snl_ac_system, location, sapm_dc_snl_ac_system.arrays[0].racking_model = 'freestanding' sapm_dc_snl_ac_system.arrays[0].temperature_model_parameters = \ temperature._temperature_model_params('pvsyst', 'freestanding') - mc = ModelChain( - sapm_dc_snl_ac_system, - location, - aoi_model='sapm', - temperature_model='pvsyst', - ) + mc = ModelChain(sapm_dc_snl_ac_system, location) + mc.temperature_model = 'pvsyst' m_pvsyst = mocker.spy(sapm_dc_snl_ac_system, 'get_cell_temperature') mc.run_model(weather) assert m_pvsyst.call_count == 1 @@ -706,12 +700,8 @@ def test_run_model_with_weather_faiman_temp(sapm_dc_snl_ac_system, location, sapm_dc_snl_ac_system.arrays[0].temperature_model_parameters = { 'u0': 25.0, 'u1': 6.84 } - mc = ModelChain( - sapm_dc_snl_ac_system, - location, - aoi_model='sapm', - temperature_model='faiman', - ) + mc = ModelChain(sapm_dc_snl_ac_system, location) + mc.temperature_model = 'faiman' m_faiman = mocker.spy(sapm_dc_snl_ac_system, 'get_cell_temperature') mc.run_model(weather) assert m_faiman.call_count == 1 @@ -728,12 +718,8 @@ def test_run_model_with_weather_fuentes_temp(sapm_dc_snl_ac_system, location, sapm_dc_snl_ac_system.arrays[0].temperature_model_parameters = { 'noct_installed': 45, 'surface_tilt': 30, } - mc = ModelChain( - sapm_dc_snl_ac_system, - location, - aoi_model='sapm', - temperature_model='fuentes', - ) + mc = ModelChain(sapm_dc_snl_ac_system, location) + mc.temperature_model = 'fuentes' m_fuentes = mocker.spy(sapm_dc_snl_ac_system, 'get_cell_temperature') mc.run_model(weather) assert m_fuentes.call_count == 1 @@ -750,12 +736,8 @@ def test_run_model_with_weather_noct_sam_temp(sapm_dc_snl_ac_system, location, sapm_dc_snl_ac_system.arrays[0].temperature_model_parameters = { 'noct': 45, 'module_efficiency': 0.2 } - mc = ModelChain( - sapm_dc_snl_ac_system, - location, - aoi_model='sapm', - temperature_model='noct_sam' - ) + mc = ModelChain(sapm_dc_snl_ac_system, location) + mc.temperature_model = 'noct_sam' m_noct_sam = mocker.spy(sapm_dc_snl_ac_system, 'get_cell_temperature') mc.run_model(weather) assert m_noct_sam.call_count == 1 @@ -1790,9 +1772,8 @@ def test_bad_get_orientation(): # tests for PVSystem with multiple Arrays def test_with_sapm_pvsystem_arrays(sapm_dc_snl_ac_system_Array, location, weather): - mc = ModelChain( - sapm_dc_snl_ac_system_Array, location, ac_model='sandia' - ) + mc = ModelChain.with_sapm(sapm_dc_snl_ac_system_Array, location, + ac_model='sandia') assert mc.dc_model == mc.sapm assert mc.ac_model == mc.sandia_inverter mc.run_model(weather) @@ -2007,7 +1988,8 @@ def test__irrad_for_celltemp(): def test_ModelChain___repr__(sapm_dc_snl_ac_system, location): - mc = ModelChain(sapm_dc_snl_ac_system, location, name='my mc') + mc = ModelChain(sapm_dc_snl_ac_system, location, + name='my mc') expected = '\n'.join([ 'ModelChain: ', From 0aff7c7986328adfee9bf9ad6cb116328bdd77b2 Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Mon, 1 Jul 2024 01:48:07 -0600 Subject: [PATCH 19/34] Address failing test and minimize diff --- pvlib/iam.py | 7 ++++++- pvlib/modelchain.py | 34 +++++++++++++--------------------- pvlib/tests/test_modelchain.py | 4 ++-- 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index 3c8342bf30..34b7bb13fd 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -670,12 +670,14 @@ def marion_diffuse(model, surface_tilt, **kwargs): builtin_models = get_builtin_models() try: - model_callable = builtin_models[model]["callable"] + model = builtin_models[model] except KeyError as exc: raise ValueError( f'model must be one of: {builtin_models.keys()}' ) from exc + model_callable = model["callable"] + iam_function = functools.partial(model_callable, **kwargs) return { @@ -1012,6 +1014,9 @@ def _check_params(builtin_model_name, params): builtin_model["params_optional"] ) + if builtin_model_name == 'physical': + expected_params.remove('n_ar') # Not required. + if handed_params != expected_params: raise ValueError( f"The {builtin_model_name} model was expecting to be passed" diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py index 09d2c31322..52d35522d0 100644 --- a/pvlib/modelchain.py +++ b/pvlib/modelchain.py @@ -327,9 +327,8 @@ class ModelChain: If not specified, the model will be inferred from the parameters that are common to all of system.arrays[i].module_parameters. Valid strings are 'ashrae', 'interp', 'martin_ruiz', 'physical', - 'sapm', 'schlick', 'no_loss'. - The ModelChain instance will be passed as the first argument to a - user-defined function. + 'sapm', 'schlick', 'no_loss'. The ModelChain instance will be passed + as the first argument to a user-defined function. spectral_model : str, or function, optional If not specified, the model will be inferred from the parameters that @@ -356,23 +355,16 @@ class ModelChain: Name of ModelChain instance. """ - def __init__( - self, - system, - location, - clearsky_model='ineichen', - transposition_model='haydavies', - solar_position_method='nrel_numpy', - airmass_model='kastenyoung1989', - dc_model=None, - ac_model=None, - aoi_model=None, - spectral_model=None, - temperature_model=None, - dc_ohmic_model='no_loss', - losses_model='no_loss', - name=None, - ): + def __init__(self, system, location, + clearsky_model='ineichen', + transposition_model='haydavies', + solar_position_method='nrel_numpy', + airmass_model='kastenyoung1989', + dc_model=None, ac_model=None, aoi_model=None, + spectral_model=None, temperature_model=None, + dc_ohmic_model='no_loss', + losses_model='no_loss', name=None): + self.name = name self.system = system @@ -850,7 +842,7 @@ def infer_aoi_model(self): # schlick model has no parameters to distinguish. raise ValueError( - 'Could not infer AOI model from ' + 'could not infer AOI model from ' 'system.arrays[i].module_parameters. Check that the ' 'module_parameters for all Arrays in system.arrays contain ' 'parameters for the ashrae, interp, martin_ruiz, physical, or ' diff --git a/pvlib/tests/test_modelchain.py b/pvlib/tests/test_modelchain.py index 98b73b0546..c91cd5f88a 100644 --- a/pvlib/tests/test_modelchain.py +++ b/pvlib/tests/test_modelchain.py @@ -1518,7 +1518,7 @@ def test_infer_aoi_model(location, system_no_aoi, aoi_model): {'n': 1.526, 'K': 4.0, 'L': 0.002}), # optional ('interp', {'theta_ref': (0, 75, 85, 90), 'iam_ref': (1, 0.8, 0.42, 0), # required - 'method': 'cubic', 'normalize': False})]) # extra + 'method': 'cubic', 'normalize': False})]) # optional def test_infer_aoi_model_with_extra_params(location, system_no_aoi, aoi_model, model_kwargs, weather, mocker): # test extra parameters not defined at iam._IAM_MODEL_PARAMS are passed @@ -1532,7 +1532,7 @@ def test_infer_aoi_model_with_extra_params(location, system_no_aoi, aoi_model, def test_infer_aoi_model_invalid(location, system_no_aoi): - with pytest.raises(ValueError, match='Could not infer AOI model'): + with pytest.raises(ValueError, match='could not infer AOI model'): ModelChain(system_no_aoi, location, spectral_model='no_loss') From 567374f1d9d66777264d41a7ffcd6f3565d4d778 Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Mon, 1 Jul 2024 08:09:16 -0600 Subject: [PATCH 20/34] Get ModelChain codecov --- pvlib/modelchain.py | 5 ++--- pvlib/tests/test_modelchain.py | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py index 52d35522d0..22366a3ff1 100644 --- a/pvlib/modelchain.py +++ b/pvlib/modelchain.py @@ -783,10 +783,9 @@ def aoi_model(self, model): self._aoi_model = self.schlick_aoi_loss else: raise ValueError(model + ' is not a valid aoi loss model') - elif callable(model): - self._aoi_model = partial(model, self) else: - raise ValueError(f"Unsupported AOI model {model}") + # Assume callable model. + self._aoi_model = partial(model, self) def infer_aoi_model(self): """ diff --git a/pvlib/tests/test_modelchain.py b/pvlib/tests/test_modelchain.py index c91cd5f88a..13975ecace 100644 --- a/pvlib/tests/test_modelchain.py +++ b/pvlib/tests/test_modelchain.py @@ -1425,7 +1425,7 @@ def constant_aoi_loss(mc): @pytest.mark.parametrize('aoi_model', [ - 'sapm', 'ashrae', 'physical', 'martin_ruiz' + 'ashrae', 'martin_ruiz', 'physical', 'sapm', 'schlick' # not interp ]) def test_aoi_models(sapm_dc_snl_ac_system, location, aoi_model, weather, mocker): @@ -1441,7 +1441,7 @@ def test_aoi_models(sapm_dc_snl_ac_system, location, aoi_model, @pytest.mark.parametrize('aoi_model', [ - 'sapm', 'ashrae', 'physical', 'martin_ruiz' + 'ashrae', 'martin_ruiz', 'physical', 'sapm', 'schlick' # not interp ]) def test_aoi_models_singleon_weather_single_array( sapm_dc_snl_ac_system, location, aoi_model, weather): From e41621a138049070190e7ff5ae26e4bc3d8dcc71 Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Mon, 1 Jul 2024 08:36:58 -0600 Subject: [PATCH 21/34] Add/improve test coverage --- pvlib/tests/test_pvsystem.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/pvlib/tests/test_pvsystem.py b/pvlib/tests/test_pvsystem.py index c98c201af4..4a54c90627 100644 --- a/pvlib/tests/test_pvsystem.py +++ b/pvlib/tests/test_pvsystem.py @@ -1,4 +1,5 @@ from collections import OrderedDict +from copy import deepcopy import itertools import numpy as np @@ -25,16 +26,29 @@ @pytest.mark.parametrize('iam_model,model_params', [ ('ashrae', {'b': 0.05}), - ('physical', {'K': 4, 'L': 0.002, 'n': 1.526}), + ('interp', {'iam_ref': (1., 0.85), 'theta_ref': (0., 80.)}), ('martin_ruiz', {'a_r': 0.16}), + ('physical', {'K': 4, 'L': 0.002, 'n': 1.526}), + ('sapm', + { + k: v for k, v in pvsystem.retrieve_sam( + 'SandiaMod')['Canadian_Solar_CS5P_220M___2009_'].items() + if k in ('B0', 'B1', 'B2', 'B3', 'B4', 'B5') + } + ), + ('schlick', {}), ]) def test_PVSystem_get_iam(mocker, iam_model, model_params): m = mocker.spy(_iam, iam_model) system = pvsystem.PVSystem(module_parameters=model_params) - thetas = 1 + thetas = 45 iam = system.get_iam(thetas, iam_model=iam_model) - m.assert_called_with(thetas, **model_params) - assert iam < 1. + if iam_model == "sapm": + # sapm has exceptional interface. + m.assert_called_with(thetas, model_params) + else: + m.assert_called_with(thetas, **model_params) + assert 0 < iam < 1 def test_PVSystem_multi_array_get_iam(): From 6a0d14dd319ab67136ecdf683a65e84799028926 Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Mon, 1 Jul 2024 08:58:38 -0600 Subject: [PATCH 22/34] Add docstring and cover bad model name --- pvlib/iam.py | 25 ++++++++++++++++++++++++- pvlib/tests/test_pvsystem.py | 10 ++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index 34b7bb13fd..5b759accdc 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -18,7 +18,30 @@ def get_builtin_models(): - """Return a dictionary of builtin IAM models' usage information.""" + """ + Return builtin IAM models' usage information. + + Returns + ------- + info : dict + A dictionary of dictionaries keyed by builtin IAM model name, with + each model dictionary containing: + - callable : callable + The callable model function + - params_required : set of str + The callable's required parameters + - params_optional : set of str + The callable's optional parameters + + See Also + -------- + pvlib.iam.ashrae + pvlib.iam.interp + pvlib.iam.martin_ruiz + pvlib.iam.physical + pvlib.iam.sapm + pvlib.iam.schlick + """ return { 'ashrae': { 'callable': ashrae, diff --git a/pvlib/tests/test_pvsystem.py b/pvlib/tests/test_pvsystem.py index 4a54c90627..6158132fcb 100644 --- a/pvlib/tests/test_pvsystem.py +++ b/pvlib/tests/test_pvsystem.py @@ -51,6 +51,16 @@ def test_PVSystem_get_iam(mocker, iam_model, model_params): assert 0 < iam < 1 +def test_PVSystem_get_iam_unsupported_model(): + iam_model = 'foobar' + system = pvsystem.PVSystem() + + with pytest.raises( + ValueError, match=f'{iam_model} is not a valid IAM model' + ): + system.get_iam(45, iam_model=iam_model) + + def test_PVSystem_multi_array_get_iam(): model_params = {'b': 0.05} system = pvsystem.PVSystem( From a580ec6d1dae11fba8a70ae25ee3266ac6ccb397 Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Mon, 1 Jul 2024 09:06:31 -0600 Subject: [PATCH 23/34] Appease flake8 --- pvlib/iam.py | 4 ++-- pvlib/tests/test_pvsystem.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index 5b759accdc..f08515b46e 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -19,8 +19,8 @@ def get_builtin_models(): """ - Return builtin IAM models' usage information. - + Get builtin IAM models' usage information. + Returns ------- info : dict diff --git a/pvlib/tests/test_pvsystem.py b/pvlib/tests/test_pvsystem.py index 6158132fcb..58430b6df8 100644 --- a/pvlib/tests/test_pvsystem.py +++ b/pvlib/tests/test_pvsystem.py @@ -1,5 +1,4 @@ from collections import OrderedDict -from copy import deepcopy import itertools import numpy as np @@ -29,7 +28,8 @@ ('interp', {'iam_ref': (1., 0.85), 'theta_ref': (0., 80.)}), ('martin_ruiz', {'a_r': 0.16}), ('physical', {'K': 4, 'L': 0.002, 'n': 1.526}), - ('sapm', + ( + 'sapm', { k: v for k, v in pvsystem.retrieve_sam( 'SandiaMod')['Canadian_Solar_CS5P_220M___2009_'].items() From 2bb3fb644699ef1796a4de675234d4fe02304ca4 Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Mon, 1 Jul 2024 09:19:26 -0600 Subject: [PATCH 24/34] Cover required params check --- pvlib/pvsystem.py | 6 ++---- pvlib/tests/test_pvsystem.py | 22 +++++++++++++++++++++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py index c5574cde23..5c80946678 100644 --- a/pvlib/pvsystem.py +++ b/pvlib/pvsystem.py @@ -1172,13 +1172,11 @@ def get_iam(self, aoi, iam_model='physical'): try: model_info = iam.get_builtin_models()[model] except KeyError as exc: - raise ValueError(f'{iam_model} is not a valid IAM model') from exc + raise ValueError(f'{iam_model} is not a valid iam_model') from exc for param in model_info["params_required"]: if param not in self.module_parameters: - raise KeyError( - f"Missing required {param} in module_parameters" - ) + raise KeyError(f"{param} is missing in module_parameters") params_optional = _build_kwargs( model_info["params_optional"], self.module_parameters diff --git a/pvlib/tests/test_pvsystem.py b/pvlib/tests/test_pvsystem.py index 58430b6df8..2e71b5e592 100644 --- a/pvlib/tests/test_pvsystem.py +++ b/pvlib/tests/test_pvsystem.py @@ -56,7 +56,27 @@ def test_PVSystem_get_iam_unsupported_model(): system = pvsystem.PVSystem() with pytest.raises( - ValueError, match=f'{iam_model} is not a valid IAM model' + ValueError, match=f'{iam_model} is not a valid iam_model' + ): + system.get_iam(45, iam_model=iam_model) + + +@pytest.mark.parametrize('iam_model,model_params', [ + ('interp', {'iam_ref': (1., 0.85)}), # missing theta_ref + ( + 'sapm', + { + k: v for k, v in pvsystem.retrieve_sam( + 'SandiaMod')['Canadian_Solar_CS5P_220M___2009_'].items() + if k in ('B0', 'B1', 'B2', 'B3', 'B4') # missing B5 + } + ), +]) +def test_PVSystem_get_iam_missing_required_param(iam_model, model_params): + system = pvsystem.PVSystem(module_parameters=model_params) + + with pytest.raises( + KeyError, match="is missing in module_parameters" ): system.get_iam(45, iam_model=iam_model) From b6fe2bf6b9269fc623666bb77d5092cb70030456 Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Mon, 1 Jul 2024 09:21:50 -0600 Subject: [PATCH 25/34] Minimize unrelated diff --- pvlib/modelchain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py index 22366a3ff1..8c12e9cbbe 100644 --- a/pvlib/modelchain.py +++ b/pvlib/modelchain.py @@ -269,7 +269,7 @@ def _head(obj): '\n') lines = [] for attr in mc_attrs: - if not (attr.startswith('_') or attr == 'times'): + if not (attr.startswith('_') or attr=='times'): lines.append(f' {attr}: ' + _mcr_repr(getattr(self, attr))) desc4 = '\n'.join(lines) return (desc1 + desc2 + desc3 + desc4) From 2c0d11b4283cd1fce24bd86d92378a2d67eab743 Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Mon, 1 Jul 2024 10:29:02 -0600 Subject: [PATCH 26/34] Test get_builtin_models --- pvlib/iam.py | 30 +++++++++++++----------- pvlib/pvsystem.py | 4 ++-- pvlib/tests/test_iam.py | 52 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 16 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index f08515b46e..ea44e8e429 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -26,12 +26,13 @@ def get_builtin_models(): info : dict A dictionary of dictionaries keyed by builtin IAM model name, with each model dictionary containing: - - callable : callable + + * 'func': callable The callable model function - - params_required : set of str - The callable's required parameters - - params_optional : set of str - The callable's optional parameters + * 'params_required': set of str + The model function's required parameters + * 'params_optional': set of str + The model function's optional parameters See Also -------- @@ -44,32 +45,33 @@ def get_builtin_models(): """ return { 'ashrae': { - 'callable': ashrae, + 'func': ashrae, 'params_required': set(), 'params_optional': {'b'}, }, 'interp': { - 'callable': interp, + 'func': interp, 'params_required': {'theta_ref', 'iam_ref'}, 'params_optional': {'method', 'normalize'}, }, 'martin_ruiz': { - 'callable': martin_ruiz, + 'func': martin_ruiz, 'params_required': set(), 'params_optional': {'a_r'}, }, 'physical': { - 'callable': physical, + 'func': physical, 'params_required': set(), 'params_optional': {'n', 'K', 'L', 'n_ar'}, }, 'sapm': { - 'callable': sapm, + 'func': sapm, + # param_required must appear in module parameter. 'params_required': {'B0', 'B1', 'B2', 'B3', 'B4', 'B5'}, 'params_optional': {'upper'}, }, 'schlick': { - 'callable': schlick, + 'func': schlick, 'params_required': set(), 'params_optional': set(), }, @@ -687,7 +689,7 @@ def marion_diffuse(model, surface_tilt, **kwargs): """ if callable(model): # A callable IAM model function was specified. - model_callable = model + func = model else: # Check that a builtin IAM function was specified. builtin_models = get_builtin_models() @@ -699,9 +701,9 @@ def marion_diffuse(model, surface_tilt, **kwargs): f'model must be one of: {builtin_models.keys()}' ) from exc - model_callable = model["callable"] + func = model["func"] - iam_function = functools.partial(model_callable, **kwargs) + iam_function = functools.partial(func, **kwargs) return { region: marion_integrate(iam_function, surface_tilt, region) diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py index 5c80946678..9b9e1b1692 100644 --- a/pvlib/pvsystem.py +++ b/pvlib/pvsystem.py @@ -1184,7 +1184,7 @@ def get_iam(self, aoi, iam_model='physical'): if model == "sapm": # sapm has exceptional interface requiring module_parameters. - return model_info["callable"]( + return model_info["func"]( aoi, self.module_parameters, **params_optional ) @@ -1192,7 +1192,7 @@ def get_iam(self, aoi, iam_model='physical'): model_info["params_required"], self.module_parameters ) - return model_info["callable"]( + return model_info["func"]( aoi, **params_required, **params_optional ) diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index 42975026ee..f263463c8d 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -3,6 +3,7 @@ @author: cwhanse """ +import inspect import numpy as np import pandas as pd @@ -15,6 +16,57 @@ from pvlib import iam as _iam +def test_get_builtin_models(): + builtin_models = _iam.get_builtin_models() + + models = set(builtin_models.keys()) + models_expected = { + "ashrae", "interp", "martin_ruiz", "physical", "sapm", "schlick" + } + assert set(models) == models_expected + + for model in models: + builtin_model = builtin_models[model] + + if model == "sapm": + # sapm has exceptional interface requiring module_parameters. + params_required_expected = set( + k for k, v in inspect.signature( + builtin_model["func"] + ).parameters.items() if v.default is inspect.Parameter.empty + ) + assert {"aoi", "module"} == params_required_expected, model + + assert builtin_model["params_required"] == \ + {'B0', 'B1', 'B2', 'B3', 'B4', 'B5'}, model + else: + params_required_expected = set( + k for k, v in inspect.signature( + builtin_model["func"] + ).parameters.items() if v.default is inspect.Parameter.empty + ) + assert builtin_model["params_required"].union( + {"aoi"} + ) == params_required_expected, model + + params_optional_expected = set( + k for k, v in inspect.signature( + builtin_model["func"] + ).parameters.items() if v.default is not inspect.Parameter.empty + ) + assert builtin_model["params_optional"] == \ + params_optional_expected, model + + +def get_default_args(func): + signature = inspect.signature(func) + return { + k: v.default + for k, v in signature.parameters.items() + if v.default is not inspect.Parameter.empty + } + + def test_ashrae(): thetas = np.array([-90., -67.5, -45., -22.5, 0., 22.5, 45., 67.5, 89., 90., np.nan]) From 083c3c9f38ea9cd4e7401748526a8a500e26abc7 Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Mon, 1 Jul 2024 10:32:17 -0600 Subject: [PATCH 27/34] Improve comment --- pvlib/iam.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index ea44e8e429..efd3866c1e 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -66,7 +66,8 @@ def get_builtin_models(): }, 'sapm': { 'func': sapm, - # param_required must appear in module parameter. + # Exceptional interface: Parameters inside params_required must + # appear in the required module dictionary parameter. 'params_required': {'B0', 'B1', 'B2', 'B3', 'B4', 'B5'}, 'params_optional': {'upper'}, }, From ce7577c70601f30eb52faeb4cea5a1014dbbc0ee Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Mon, 1 Jul 2024 10:39:15 -0600 Subject: [PATCH 28/34] Remove spurious function --- pvlib/tests/test_iam.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index f263463c8d..d63d61bf74 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -58,15 +58,6 @@ def test_get_builtin_models(): params_optional_expected, model -def get_default_args(func): - signature = inspect.signature(func) - return { - k: v.default - for k, v in signature.parameters.items() - if v.default is not inspect.Parameter.empty - } - - def test_ashrae(): thetas = np.array([-90., -67.5, -45., -22.5, 0., 22.5, 45., 67.5, 89., 90., np.nan]) From a037bae730a91eeb16fa4ecb9641a19bfc9e6e9e Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Sat, 21 Dec 2024 09:50:06 -0700 Subject: [PATCH 29/34] Update modelchain docstrings --- pvlib/modelchain.py | 38 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py index 6e8f702d75..22c460113c 100644 --- a/pvlib/modelchain.py +++ b/pvlib/modelchain.py @@ -289,12 +289,12 @@ class ModelChain: Parameters ---------- system : PVSystem - A :py:class:`~pvlib.pvsystem.PVSystem` object that represents - the connected set of modules, inverters, etc. + A :py:class:`~pvlib.pvsystem.PVSystem` object that represents the + connected set of modules, inverters, etc. location : Location - A :py:class:`~pvlib.location.Location` object that represents - the physical location at which to evaluate the model. + A :py:class:`~pvlib.location.Location` object that represents the + physical location at which to evaluate the model. clearsky_model : str, default 'ineichen' Passed to location.get_clearsky. Only used when DNI is not found in @@ -311,31 +311,29 @@ class ModelChain: dc_model : str, or function, optional If not specified, the model will be inferred from the parameters that - are common to all of system.arrays[i].module_parameters. - Valid strings are 'sapm', 'desoto', 'cec', 'pvsyst', 'pvwatts'. - The ModelChain instance will be passed as the first argument - to a user-defined function. + are common to all of system.arrays[i].module_parameters. Valid strings + are 'sapm', 'desoto', 'cec', 'pvsyst', 'pvwatts'. The ModelChain + instance will be passed as the first argument to a user-defined + function. ac_model : str, or function, optional If not specified, the model will be inferred from the parameters that - are common to all of system.inverter_parameters. - Valid strings are 'sandia', 'adr', 'pvwatts'. The - ModelChain instance will be passed as the first argument to a - user-defined function. + are common to all of system.inverter_parameters. Valid strings are + 'sandia', 'adr', 'pvwatts'. The ModelChain instance will be passed as + the first argument to a user-defined function. aoi_model : str, or function, optional If not specified, the model will be inferred from the parameters that - are common to all of system.arrays[i].module_parameters. - Valid strings are 'ashrae', 'interp', 'martin_ruiz', 'physical', - 'sapm', 'schlick', 'no_loss'. The ModelChain instance will be passed - as the first argument to a user-defined function. + are common to all of system.arrays[i].module_parameters. Valid strings + are 'ashrae', 'interp', 'martin_ruiz', 'physical', 'sapm', 'schlick', + 'no_loss'. The ModelChain instance will be passed as the first + argument to a user-defined function. spectral_model : str, or function, optional If not specified, the model will be inferred from the parameters that - are common to all of system.arrays[i].module_parameters. - Valid strings are 'sapm', 'first_solar', 'no_loss'. - The ModelChain instance will be passed as the first argument to - a user-defined function. + are common to all of system.arrays[i].module_parameters. Valid strings + are 'sapm', 'first_solar', 'no_loss'. The ModelChain instance will be + passed as the first argument to a user-defined function. temperature_model : str or function, optional Valid strings are: 'sapm', 'pvsyst', 'faiman', 'fuentes', 'noct_sam'. From ec3790b37685e748577b97a92992d4b9499f4441 Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Sat, 21 Dec 2024 15:38:38 -0700 Subject: [PATCH 30/34] Make sapm iam function use common interface --- pvlib/iam.py | 30 ++++++++------ pvlib/modelchain.py | 7 ++-- pvlib/pvsystem.py | 26 ++++++------ pvlib/tests/test_iam.py | 76 +++++++++++++++++++++------------- pvlib/tests/test_modelchain.py | 3 +- pvlib/tests/test_pvsystem.py | 16 ++++--- pyproject.toml | 10 ++--- 7 files changed, 100 insertions(+), 68 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index 229f7df06f..a9a606804d 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -17,7 +17,7 @@ from pvlib.tools import cosd, sind, acosd -def get_builtin_models(): +def _get_builtin_models(): """ Get builtin IAM models' usage information. @@ -66,8 +66,6 @@ def get_builtin_models(): }, 'sapm': { 'func': sapm, - # Exceptional interface: Parameters inside params_required must - # appear in the required module dictionary parameter. 'params_required': {'B0', 'B1', 'B2', 'B3', 'B4', 'B5'}, 'params_optional': {'upper'}, }, @@ -557,7 +555,7 @@ def interp(aoi, theta_ref, iam_ref, method='linear', normalize=True): return iam -def sapm(aoi, module, upper=None): +def sapm(aoi, B0, B1, B2, B3, B4, B5, upper=None): r""" Determine the incidence angle modifier (IAM) using the SAPM model. @@ -567,9 +565,17 @@ def sapm(aoi, module, upper=None): Angle of incidence in degrees. Negative input angles will return zeros. - module : dict-like - A dict or Series with the SAPM IAM model parameters. - See the :py:func:`sapm` notes section for more details. + B0 : The coefficient of the degree-0 polynomial term. + + B1 : The coefficient of the degree-1 polynomial term. + + B2 : The coefficient of the degree-2 polynomial term. + + B3 : The coefficient of the degree-3 polynomial term. + + B4 : The coefficient of the degree-4 polynomial term. + + B5 : The coefficient of the degree-5 polynomial term. upper : float, optional Upper limit on the results. None means no upper limiting. @@ -609,10 +615,7 @@ def sapm(aoi, module, upper=None): pvlib.iam.schlick """ - aoi_coeff = [module['B5'], module['B4'], module['B3'], module['B2'], - module['B1'], module['B0']] - - iam = np.polyval(aoi_coeff, aoi) + iam = np.polyval([B5, B4, B3, B2, B1, B0], aoi) iam = np.clip(iam, 0, upper) # nan tolerant masking aoi_lt_0 = np.full_like(aoi, False, dtype='bool') @@ -665,6 +668,7 @@ def marion_diffuse(model, surface_tilt, **kwargs): pvlib.iam.interp pvlib.iam.martin_ruiz pvlib.iam.physical + pvlib.iam.sapm pvlib.iam.schlick pvlib.iam.martin_ruiz_diffuse pvlib.iam.schlick_diffuse @@ -693,7 +697,7 @@ def marion_diffuse(model, surface_tilt, **kwargs): func = model else: # Check that a builtin IAM function was specified. - builtin_models = get_builtin_models() + builtin_models = _get_builtin_models() try: model = builtin_models[model] @@ -1035,7 +1039,7 @@ def _get_fittable_or_convertable_model(builtin_model_name): def _check_params(builtin_model_name, params): # check that parameters passed in with IAM model belong to the model handed_params = set(params.keys()) - builtin_model = get_builtin_models()[builtin_model_name] + builtin_model = _get_builtin_models()[builtin_model_name] expected_params = builtin_model["params_required"].union( builtin_model["params_optional"] ) diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py index 22c460113c..7b798a1301 100644 --- a/pvlib/modelchain.py +++ b/pvlib/modelchain.py @@ -6,12 +6,13 @@ the time to read the source code for the module. """ +from dataclasses import dataclass, field from functools import partial import itertools +from typing import Optional, Tuple, TypeVar, Union import warnings + import pandas as pd -from dataclasses import dataclass, field -from typing import Union, Tuple, Optional, TypeVar from pvlib import pvsystem import pvlib.irradiance # avoid name conflict with full import @@ -794,7 +795,7 @@ def infer_aoi_model(self): array.module_parameters for array in self.system.arrays ) params = _common_keys(module_parameters) - builtin_models = pvlib.iam.get_builtin_models() + builtin_models = pvlib.iam._get_builtin_models() if any( param in params for diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py index 6381a5ad18..b93f5df3a1 100644 --- a/pvlib/pvsystem.py +++ b/pvlib/pvsystem.py @@ -1192,7 +1192,7 @@ def get_iam(self, aoi, iam_model='physical'): model = iam_model.lower() try: - model_info = iam.get_builtin_models()[model] + model_info = iam._get_builtin_models()[model] except KeyError as exc: raise ValueError(f'{iam_model} is not a valid iam_model') from exc @@ -1200,20 +1200,14 @@ def get_iam(self, aoi, iam_model='physical'): if param not in self.module_parameters: raise KeyError(f"{param} is missing in module_parameters") - params_optional = _build_kwargs( - model_info["params_optional"], self.module_parameters - ) - - if model == "sapm": - # sapm has exceptional interface requiring module_parameters. - return model_info["func"]( - aoi, self.module_parameters, **params_optional - ) - params_required = _build_kwargs( model_info["params_required"], self.module_parameters ) + params_optional = _build_kwargs( + model_info["params_optional"], self.module_parameters + ) + return model_info["func"]( aoi, **params_required, **params_optional ) @@ -2397,7 +2391,15 @@ def sapm_effective_irradiance(poa_direct, poa_diffuse, airmass_absolute, aoi, """ F1 = spectrum.spectral_factor_sapm(airmass_absolute, module) - F2 = iam.sapm(aoi, module) + F2 = iam.sapm( + aoi, + module["B0"], + module["B1"], + module["B2"], + module["B3"], + module["B4"], + module["B5"], + ) Ee = F1 * (poa_direct * F2 + module['FD'] * poa_diffuse) diff --git a/pvlib/tests/test_iam.py b/pvlib/tests/test_iam.py index d63d61bf74..eb76cf5984 100644 --- a/pvlib/tests/test_iam.py +++ b/pvlib/tests/test_iam.py @@ -6,18 +6,17 @@ import inspect import numpy as np +from numpy.testing import assert_allclose import pandas as pd - import pytest -from .conftest import assert_series_equal -from numpy.testing import assert_allclose import scipy.interpolate from pvlib import iam as _iam +from pvlib.tests.conftest import assert_series_equal def test_get_builtin_models(): - builtin_models = _iam.get_builtin_models() + builtin_models = _iam._get_builtin_models() models = set(builtin_models.keys()) models_expected = { @@ -28,26 +27,14 @@ def test_get_builtin_models(): for model in models: builtin_model = builtin_models[model] - if model == "sapm": - # sapm has exceptional interface requiring module_parameters. - params_required_expected = set( - k for k, v in inspect.signature( - builtin_model["func"] - ).parameters.items() if v.default is inspect.Parameter.empty - ) - assert {"aoi", "module"} == params_required_expected, model - - assert builtin_model["params_required"] == \ - {'B0', 'B1', 'B2', 'B3', 'B4', 'B5'}, model - else: - params_required_expected = set( - k for k, v in inspect.signature( - builtin_model["func"] - ).parameters.items() if v.default is inspect.Parameter.empty - ) - assert builtin_model["params_required"].union( - {"aoi"} - ) == params_required_expected, model + params_required_expected = set( + k for k, v in inspect.signature( + builtin_model["func"] + ).parameters.items() if v.default is inspect.Parameter.empty + ) + assert builtin_model["params_required"].union( + {"aoi"} + ) == params_required_expected, model params_optional_expected = set( k for k, v in inspect.signature( @@ -266,7 +253,15 @@ def test_iam_interp(): ]) def test_sapm(sapm_module_params, aoi, expected): - out = _iam.sapm(aoi, sapm_module_params) + out = _iam.sapm( + aoi, + sapm_module_params["B0"], + sapm_module_params["B1"], + sapm_module_params["B2"], + sapm_module_params["B3"], + sapm_module_params["B4"], + sapm_module_params["B5"], + ) if isinstance(aoi, pd.Series): assert_series_equal(out, expected, check_less_precise=4) @@ -276,13 +271,38 @@ def test_sapm(sapm_module_params, aoi, expected): def test_sapm_limits(): module_parameters = {'B0': 5, 'B1': 0, 'B2': 0, 'B3': 0, 'B4': 0, 'B5': 0} - assert _iam.sapm(1, module_parameters) == 5 + assert _iam.sapm( + 1, + module_parameters["B0"], + module_parameters["B1"], + module_parameters["B2"], + module_parameters["B3"], + module_parameters["B4"], + module_parameters["B5"], + ) == 5 module_parameters = {'B0': 5, 'B1': 0, 'B2': 0, 'B3': 0, 'B4': 0, 'B5': 0} - assert _iam.sapm(1, module_parameters, upper=1) == 1 + assert _iam.sapm( + 1, + module_parameters["B0"], + module_parameters["B1"], + module_parameters["B2"], + module_parameters["B3"], + module_parameters["B4"], + module_parameters["B5"], + upper=1, + ) == 1 module_parameters = {'B0': -5, 'B1': 0, 'B2': 0, 'B3': 0, 'B4': 0, 'B5': 0} - assert _iam.sapm(1, module_parameters) == 0 + assert _iam.sapm( + 1, + module_parameters["B0"], + module_parameters["B1"], + module_parameters["B2"], + module_parameters["B3"], + module_parameters["B4"], + module_parameters["B5"], + ) == 0 def test_marion_diffuse_model(mocker): diff --git a/pvlib/tests/test_modelchain.py b/pvlib/tests/test_modelchain.py index 13975ecace..62d5aa771b 100644 --- a/pvlib/tests/test_modelchain.py +++ b/pvlib/tests/test_modelchain.py @@ -1500,10 +1500,11 @@ def test_aoi_model_user_func(sapm_dc_snl_ac_system, location, weather, mocker): @pytest.mark.parametrize('aoi_model', [ + # "schlick" omitted, cannot be distinguished from "no_loss" AOI model. 'ashrae', 'interp', 'martin_ruiz', 'physical', 'sapm' ]) def test_infer_aoi_model(location, system_no_aoi, aoi_model): - builtin_models = iam.get_builtin_models()[aoi_model] + builtin_models = iam._get_builtin_models()[aoi_model] params = builtin_models["params_required"].union( builtin_models["params_optional"] ) diff --git a/pvlib/tests/test_pvsystem.py b/pvlib/tests/test_pvsystem.py index 31fa14b8bc..b575f8cc5c 100644 --- a/pvlib/tests/test_pvsystem.py +++ b/pvlib/tests/test_pvsystem.py @@ -43,11 +43,7 @@ def test_PVSystem_get_iam(mocker, iam_model, model_params): system = pvsystem.PVSystem(module_parameters=model_params) thetas = 45 iam = system.get_iam(thetas, iam_model=iam_model) - if iam_model == "sapm": - # sapm has exceptional interface. - m.assert_called_with(thetas, model_params) - else: - m.assert_called_with(thetas, **model_params) + m.assert_called_with(thetas, **model_params) assert 0 < iam < 1 @@ -102,7 +98,15 @@ def test_PVSystem_get_iam_sapm(sapm_module_params, mocker): mocker.spy(_iam, 'sapm') aoi = 0 out = system.get_iam(aoi, 'sapm') - _iam.sapm.assert_called_once_with(aoi, sapm_module_params) + _iam.sapm.assert_called_once_with( + aoi, + sapm_module_params["B0"], + sapm_module_params["B1"], + sapm_module_params["B2"], + sapm_module_params["B3"], + sapm_module_params["B4"], + sapm_module_params["B5"], + ) assert_allclose(out, 1.0, atol=0.01) diff --git a/pyproject.toml b/pyproject.toml index f4806130e7..d1583421e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ description = "A set of functions and classes for simulating the performance of authors = [ { name = "pvlib python Developers", email = "pvlib-admin@googlegroups.com" }, ] -requires-python = ">=3.9" +requires-python = ">=3.9, <3.13" dependencies = [ 'numpy >= 1.19.3', 'pandas >= 1.3.0', @@ -49,9 +49,9 @@ dynamic = ["version"] optional = [ 'cython', 'ephem', - 'nrel-pysam', - 'numba >= 0.17.0', - 'solarfactors', + 'nrel-pysam; python_version < "3.13"', + 'numba >= 0.17.0; python_version < "3.13"', + 'solarfactors; python_version < "3.12"', 'statsmodels', ] doc = [ @@ -65,7 +65,7 @@ doc = [ 'pillow', 'sphinx-toggleprompt == 0.5.2', 'sphinx-favicon', - 'solarfactors', + 'solarfactors; python_version < "3.12"', 'sphinx-hoverxref', ] test = [ From 0b4a91d4fc0e906458f08e19f6d4b20b684f2a32 Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Sat, 21 Dec 2024 15:46:50 -0700 Subject: [PATCH 31/34] Appease flake8 --- pvlib/iam.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index a9a606804d..6abed7cc4c 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -566,15 +566,15 @@ def sapm(aoi, B0, B1, B2, B3, B4, B5, upper=None): zeros. B0 : The coefficient of the degree-0 polynomial term. - + B1 : The coefficient of the degree-1 polynomial term. - + B2 : The coefficient of the degree-2 polynomial term. - + B3 : The coefficient of the degree-3 polynomial term. - + B4 : The coefficient of the degree-4 polynomial term. - + B5 : The coefficient of the degree-5 polynomial term. upper : float, optional From c64e6b990ff733d987c08105620c701c1a3bda1b Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Sat, 21 Dec 2024 20:36:46 -0700 Subject: [PATCH 32/34] Harmonize IAM and spectral correction and update IAM inference --- pvlib/iam.py | 194 +++++++++++++++----------- pvlib/modelchain.py | 61 ++++---- pvlib/pvsystem.py | 20 ++- pvlib/spectrum/mismatch.py | 44 +++--- pvlib/tests/spectrum/test_mismatch.py | 9 +- pvlib/tests/test_modelchain.py | 72 +++++++--- pvlib/tests/test_pvsystem.py | 19 ++- 7 files changed, 263 insertions(+), 156 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index 6abed7cc4c..eb9326245b 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -17,66 +17,6 @@ from pvlib.tools import cosd, sind, acosd -def _get_builtin_models(): - """ - Get builtin IAM models' usage information. - - Returns - ------- - info : dict - A dictionary of dictionaries keyed by builtin IAM model name, with - each model dictionary containing: - - * 'func': callable - The callable model function - * 'params_required': set of str - The model function's required parameters - * 'params_optional': set of str - The model function's optional parameters - - See Also - -------- - pvlib.iam.ashrae - pvlib.iam.interp - pvlib.iam.martin_ruiz - pvlib.iam.physical - pvlib.iam.sapm - pvlib.iam.schlick - """ - return { - 'ashrae': { - 'func': ashrae, - 'params_required': set(), - 'params_optional': {'b'}, - }, - 'interp': { - 'func': interp, - 'params_required': {'theta_ref', 'iam_ref'}, - 'params_optional': {'method', 'normalize'}, - }, - 'martin_ruiz': { - 'func': martin_ruiz, - 'params_required': set(), - 'params_optional': {'a_r'}, - }, - 'physical': { - 'func': physical, - 'params_required': set(), - 'params_optional': {'n', 'K', 'L', 'n_ar'}, - }, - 'sapm': { - 'func': sapm, - 'params_required': {'B0', 'B1', 'B2', 'B3', 'B4', 'B5'}, - 'params_optional': {'upper'}, - }, - 'schlick': { - 'func': schlick, - 'params_required': set(), - 'params_optional': set(), - }, - } - - def ashrae(aoi, b=0.05): r""" Determine the incidence angle modifier using the ASHRAE transmission @@ -370,7 +310,7 @@ def martin_ruiz(aoi, a_r=0.16): def martin_ruiz_diffuse(surface_tilt, a_r=0.16, c1=0.4244, c2=None): ''' - Determine the incidence angle modifiers (iam) for diffuse sky and + Determine the incidence angle modifiers (IAM) for diffuse sky and ground-reflected irradiance using the Martin and Ruiz incident angle model. Parameters @@ -557,7 +497,17 @@ def interp(aoi, theta_ref, iam_ref, method='linear', normalize=True): def sapm(aoi, B0, B1, B2, B3, B4, B5, upper=None): r""" - Determine the incidence angle modifier (IAM) using the SAPM model. + Caclulate the incidence angle modifier (IAM), :math:`f_2`, using the + Sandia Array Performance Model (SAPM). + + The SAPM incidence angle modifier is part of the broader Sandia Array + Performance Model, which defines five points on an IV curve using empirical + module-specific coefficients. Module coefficients for the SAPM are + available in the SAPM database and can be retrieved for use through + :py:func:`pvlib.pvsystem.retrieve_sam()`. More details on the SAPM can be + found in [1]_, while a full description of the procedure to determine the + empirical model coefficients, including those for the SAPM incidence angle + modifier, can be found in [2]_. Parameters ---------- @@ -565,17 +515,23 @@ def sapm(aoi, B0, B1, B2, B3, B4, B5, upper=None): Angle of incidence in degrees. Negative input angles will return zeros. - B0 : The coefficient of the degree-0 polynomial term. + B0 : float + The coefficient of the degree-0 polynomial term. - B1 : The coefficient of the degree-1 polynomial term. + B1 : float + The coefficient of the degree-1 polynomial term. - B2 : The coefficient of the degree-2 polynomial term. + B2 : float + The coefficient of the degree-2 polynomial term. - B3 : The coefficient of the degree-3 polynomial term. + B3 : float + The coefficient of the degree-3 polynomial term. - B4 : The coefficient of the degree-4 polynomial term. + B4 : float + The coefficient of the degree-4 polynomial term. - B5 : The coefficient of the degree-5 polynomial term. + B5 : float + The coefficient of the degree-5 polynomial term. upper : float, optional Upper limit on the results. None means no upper limiting. @@ -583,10 +539,22 @@ def sapm(aoi, B0, B1, B2, B3, B4, B5, upper=None): Returns ------- iam : numeric - The SAPM angle of incidence loss coefficient, termed F2 in [1]_. + The SAPM angle of incidence loss coefficient, :math:`f_2` in [1]_. Notes ----- + The SAPM spectral correction functions parameterises :math:`f_2` as a + fifth-order polynomial function of angle of incidence: + + .. math:: + + f_2 = b_0 + b_1 AOI + b_2 AOI^2 + b_3 AOI^3 + b_4 AOI^4 + b_5 AOI^5. + + where :math:`f_2` is the spectral mismatch factor, :math:`b_{0-5}` are + the module-specific coefficients, and :math:`AOI` is the angle of + incidence. More detail on how this incidence angle modifier function was + developed can be found in [3]_. Its measurement is described in [4]_. + The SAPM [1]_ traditionally does not define an upper limit on the AOI loss function and values slightly exceeding 1 may exist for moderate angles of incidence (15-40 degrees). However, users may consider @@ -594,17 +562,23 @@ def sapm(aoi, B0, B1, B2, B3, B4, B5, upper=None): References ---------- - .. [1] King, D. et al, 2004, "Sandia Photovoltaic Array Performance - Model", SAND Report 3535, Sandia National Laboratories, Albuquerque, - NM. - - .. [2] B.H. King et al, "Procedure to Determine Coefficients for the - Sandia Array Performance Model (SAPM)," SAND2016-5284, Sandia - National Laboratories (2016). - - .. [3] B.H. King et al, "Recent Advancements in Outdoor Measurement - Techniques for Angle of Incidence Effects," 42nd IEEE PVSC (2015). - :doi:`10.1109/PVSC.2015.7355849` + .. [1] King, D., Kratochvil, J., and Boyson W. (2004), "Sandia + Photovoltaic Array Performance Model", (No. SAND2004-3535), Sandia + National Laboratories, Albuquerque, NM (United States). + :doi:`10.2172/919131` + .. [2] King, B., Hansen, C., Riley, D., Robinson, C., and Pratt, L. + (2016). Procedure to determine coefficients for the Sandia Array + Performance Model (SAPM) (No. SAND2016-5284). Sandia National + Laboratories, Albuquerque, NM (United States). + :doi:`10.2172/1256510` + .. [3] King, D., Kratochvil, J., and Boyson, W. "Measuring solar spectral + and angle-of-incidence effects on photovoltaic modules and solar + irradiance sensors." Conference Record of the 26th IEEE Potovoltaic + Specialists Conference (PVSC). IEEE, 1997. + :doi:`10.1109/PVSC.1997.654283` + .. [4] B.H. King et al, "Recent Advancements in Outdoor Measurement + Techniques for Angle of Incidence Effects," 42nd IEEE PVSC (2015). + :doi:`10.1109/PVSC.2015.7355849` See Also -------- @@ -1022,6 +996,66 @@ def schlick_diffuse(surface_tilt): return cuk, cug +def _get_builtin_models(): + """ + Get builtin IAM models' usage information. + + Returns + ------- + info : dict + A dictionary of dictionaries keyed by builtin IAM model name, with + each model dictionary containing: + + * 'func': callable + The callable model function + * 'params_required': set of str + The model function's required parameters + * 'params_optional': set of str + The model function's optional parameters + + See Also + -------- + pvlib.iam.ashrae + pvlib.iam.interp + pvlib.iam.martin_ruiz + pvlib.iam.physical + pvlib.iam.sapm + pvlib.iam.schlick + """ + return { + 'ashrae': { + 'func': ashrae, + 'params_required': set(), + 'params_optional': {'b'}, + }, + 'interp': { + 'func': interp, + 'params_required': {'theta_ref', 'iam_ref'}, + 'params_optional': {'method', 'normalize'}, + }, + 'martin_ruiz': { + 'func': martin_ruiz, + 'params_required': set(), + 'params_optional': {'a_r'}, + }, + 'physical': { + 'func': physical, + 'params_required': set(), + 'params_optional': {'n', 'K', 'L', 'n_ar'}, + }, + 'sapm': { + 'func': sapm, + 'params_required': {'B0', 'B1', 'B2', 'B3', 'B4', 'B5'}, + 'params_optional': {'upper'}, + }, + 'schlick': { + 'func': schlick, + 'params_required': set(), + 'params_optional': set(), + }, + } + + def _get_fittable_or_convertable_model(builtin_model_name): # check that model is implemented and fittable or convertable implemented_builtin_models = { diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py index 7b798a1301..365afd7b4f 100644 --- a/pvlib/modelchain.py +++ b/pvlib/modelchain.py @@ -782,14 +782,16 @@ def aoi_model(self, model): self._aoi_model = self.schlick_aoi_loss else: raise ValueError(model + ' is not a valid aoi loss model') - else: - # Assume callable model. + elif callable(model): self._aoi_model = partial(model, self) + else: + raise ValueError(model + ' is not a valid aoi loss model') def infer_aoi_model(self): """ - Infer AOI model by checking for at least one required or optional - model parameter in module_parameter collected across all arrays. + Infer AOI model by checking for all required model paramters or at + least one optional model parameter in module_parameter collected + across all arrays. """ module_parameters = tuple( array.module_parameters for array in self.system.arrays @@ -797,47 +799,42 @@ def infer_aoi_model(self): params = _common_keys(module_parameters) builtin_models = pvlib.iam._get_builtin_models() - if any( - param in params for - param in builtin_models['ashrae']["params_required"].union( - builtin_models['ashrae']["params_optional"] - ) + if (builtin_models['ashrae']["params_required"] and ( + builtin_models['ashrae']["params_required"] <= params)) or ( + not builtin_models['ashrae']["params_required"] and + (builtin_models['ashrae']["params_optional"] & params) ): return self.ashrae_aoi_loss - if any( - param in params for - param in builtin_models['interp']["params_required"].union( - builtin_models['interp']["params_optional"] - ) + if (builtin_models['interp']["params_required"] and ( + builtin_models['interp']["params_required"] <= params)) or ( + not builtin_models['interp']["params_required"] and + (builtin_models['interp']["params_optional"] & params) ): return self.interp_aoi_loss - if any( - param in params for - param in builtin_models['martin_ruiz']["params_required"].union( - builtin_models['martin_ruiz']["params_optional"] - ) + if (builtin_models['martin_ruiz']["params_required"] and ( + builtin_models['martin_ruiz']["params_required"] <= params)) or ( + not builtin_models['martin_ruiz']["params_required"] and + (builtin_models['martin_ruiz']["params_optional"] & params) ): return self.martin_ruiz_aoi_loss - if any( - param in params for - param in builtin_models['physical']["params_required"].union( - builtin_models['physical']["params_optional"] - ) + if (builtin_models['physical']["params_required"] and ( + builtin_models['physical']["params_required"] <= params)) or ( + not builtin_models['physical']["params_required"] and + (builtin_models['physical']["params_optional"] & params) ): return self.physical_aoi_loss - if any( - param in params for - param in builtin_models['sapm']["params_required"].union( - builtin_models['sapm']["params_optional"] - ) + if (builtin_models['sapm']["params_required"] and ( + builtin_models['sapm']["params_required"] <= params)) or ( + not builtin_models['sapm']["params_required"] and + (builtin_models['sapm']["params_optional"] & params) ): return self.sapm_aoi_loss - # schlick model has no parameters to distinguish. + # schlick model has no parameters to distinguish, esp. from no_loss. raise ValueError( 'could not infer AOI model from ' @@ -909,8 +906,10 @@ def spectral_model(self, model): self._spectral_model = self.no_spectral_loss else: raise ValueError(model + ' is not a valid spectral loss model') - else: + elif callable(model): self._spectral_model = partial(model, self) + else: + raise ValueError(model + ' is not a valid spectral loss model') def infer_spectral_model(self): """Infer spectral model from system attributes.""" diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py index b93f5df3a1..f4c76723fb 100644 --- a/pvlib/pvsystem.py +++ b/pvlib/pvsystem.py @@ -633,9 +633,14 @@ def sapm_spectral_loss(self, airmass_absolute): The SAPM spectral loss coefficient. """ return tuple( - spectrum.spectral_factor_sapm(airmass_absolute, - array.module_parameters) - for array in self.arrays + spectrum.spectral_factor_sapm( + airmass_absolute, + array.module_parameters["A0"], + array.module_parameters["A1"], + array.module_parameters["A2"], + array.module_parameters["A3"], + array.module_parameters["A4"], + ) for array in self.arrays ) @_unwrap_single_value @@ -2390,7 +2395,14 @@ def sapm_effective_irradiance(poa_direct, poa_diffuse, airmass_absolute, aoi, pvlib.pvsystem.sapm """ - F1 = spectrum.spectral_factor_sapm(airmass_absolute, module) + F1 = spectrum.spectral_factor_sapm( + airmass_absolute, + module["A0"], + module["A1"], + module["A2"], + module["A3"], + module["A4"], + ) F2 = iam.sapm( aoi, module["B0"], diff --git a/pvlib/spectrum/mismatch.py b/pvlib/spectrum/mismatch.py index 3afc210e73..dfde1a5a53 100644 --- a/pvlib/spectrum/mismatch.py +++ b/pvlib/spectrum/mismatch.py @@ -295,20 +295,19 @@ def spectral_factor_firstsolar(precipitable_water, airmass_absolute, return modifier -def spectral_factor_sapm(airmass_absolute, module): +def spectral_factor_sapm(airmass_absolute, A0, A1, A2, A3, A4): """ - Calculates the spectral mismatch factor, :math:`f_1`, - using the Sandia Array Performance Model approach. + Calculates the spectral mismatch factor, :math:`f_1`, using the Sandia + Array Performance Model (SAPM) approach. - The SAPM spectral factor function is part of the broader Sandia Array + The SAPM incidence angle modifier is part of the broader Sandia Array Performance Model, which defines five points on an IV curve using empirical module-specific coefficients. Module coefficients for the SAPM are - available in the SAPM database and can be retrieved for use in the - ``module`` parameter through - :py:func:`pvlib.pvsystem.retrieve_sam()`. More details on the - SAPM can be found in [1]_, while a full description of the procedure to - determine the empirical model coefficients, including those for the SAPM - spectral correction, can be found in [2]_. + available in the SAPM database and can be retrieved for use through + :py:func:`pvlib.pvsystem.retrieve_sam()`. More details on the SAPM can be + found in [1]_, while a full description of the procedure to determine the + empirical model coefficients, including those for the SAPM spectral + correction, can be found in [2]_. Parameters ---------- @@ -317,10 +316,20 @@ def spectral_factor_sapm(airmass_absolute, module): Note: ``np.nan`` airmass values will result in 0 output. - module : dict-like - A dict, Series, or DataFrame defining the SAPM parameters. - Must contain keys `'A0'` through `'A4'`. - See the :py:func:`pvlib.pvsystem.sapm` notes section for more details. + A0 : float + The coefficient of the degree-0 polynomial term. + + A1 : float + The coefficient of the degree-1 polynomial term. + + A2 : float + The coefficient of the degree-2 polynomial term. + + A3 : float + The coefficient of the degree-3 polynomial term. + + A4 : float + The coefficient of the degree-4 polynomial term. Returns ------- @@ -330,7 +339,7 @@ def spectral_factor_sapm(airmass_absolute, module): Notes ----- The SAPM spectral correction functions parameterises :math:`f_1` as a - fourth order polynomial function of absolute air mass: + fourth-order polynomial function of absolute air mass: .. math:: @@ -361,10 +370,7 @@ def spectral_factor_sapm(airmass_absolute, module): """ - am_coeff = [module['A4'], module['A3'], module['A2'], module['A1'], - module['A0']] - - spectral_loss = np.polyval(am_coeff, airmass_absolute) + spectral_loss = np.polyval([A4, A3, A2, A1, A0], airmass_absolute) spectral_loss = np.where(np.isnan(spectral_loss), 0, spectral_loss) diff --git a/pvlib/tests/spectrum/test_mismatch.py b/pvlib/tests/spectrum/test_mismatch.py index 5397a81f46..992462b5c6 100644 --- a/pvlib/tests/spectrum/test_mismatch.py +++ b/pvlib/tests/spectrum/test_mismatch.py @@ -148,7 +148,14 @@ def test_spectral_factor_firstsolar_range(): ]) def test_spectral_factor_sapm(sapm_module_params, airmass, expected): - out = spectrum.spectral_factor_sapm(airmass, sapm_module_params) + out = spectrum.spectral_factor_sapm( + airmass, + sapm_module_params["A0"], + sapm_module_params["A1"], + sapm_module_params["A2"], + sapm_module_params["A3"], + sapm_module_params["A4"], + ) if isinstance(airmass, pd.Series): assert_series_equal(out, expected, check_less_precise=4) diff --git a/pvlib/tests/test_modelchain.py b/pvlib/tests/test_modelchain.py index 62d5aa771b..3b349f677b 100644 --- a/pvlib/tests/test_modelchain.py +++ b/pvlib/tests/test_modelchain.py @@ -1499,30 +1499,66 @@ def test_aoi_model_user_func(sapm_dc_snl_ac_system, location, weather, mocker): assert mc.results.ac.iloc[1] < 1 -@pytest.mark.parametrize('aoi_model', [ +@pytest.mark.parametrize( + 'aoi_model', # "schlick" omitted, cannot be distinguished from "no_loss" AOI model. - 'ashrae', 'interp', 'martin_ruiz', 'physical', 'sapm' -]) + [ + {'params': {'b': 0.025}}, + {'params': {'iam_ref': (1., 0.85), 'theta_ref': (0., 80.)}}, + {'params': {'a_r': 0.1}}, + {'params': {'n': 1.5}}, + { + 'params': { + 'B0': 1, + 'B1': -0.002438, + 'B2': 0.0003103, + 'B3': -0.00001246, + 'B4': 2.11E-07, + 'B5': -1.36E-09, + } + }, + ], + ids=['ashrae', 'interp', 'martin_ruiz', 'physical', 'sapm'], +) def test_infer_aoi_model(location, system_no_aoi, aoi_model): - builtin_models = iam._get_builtin_models()[aoi_model] - params = builtin_models["params_required"].union( - builtin_models["params_optional"] - ) - system_no_aoi.arrays[0].module_parameters.update({params.pop(): 1.0}) + system_no_aoi.arrays[0].module_parameters.update(aoi_model["params"]) mc = ModelChain(system_no_aoi, location, spectral_model='no_loss') assert isinstance(mc, ModelChain) -@pytest.mark.parametrize('aoi_model,model_kwargs', [ - # model_kwargs has both required and optional kwargs; test all - ('physical', - {'n': 1.526, 'K': 4.0, 'L': 0.002}), # optional - ('interp', - {'theta_ref': (0, 75, 85, 90), 'iam_ref': (1, 0.8, 0.42, 0), # required - 'method': 'cubic', 'normalize': False})]) # optional -def test_infer_aoi_model_with_extra_params(location, system_no_aoi, aoi_model, - model_kwargs, weather, mocker): - # test extra parameters not defined at iam._IAM_MODEL_PARAMS are passed +@pytest.mark.parametrize( + 'aoi_model,model_kwargs', + [ + ( + 'sapm', + { + # required + 'B0': 1, + 'B1': -0.002438, + 'B2': 0.0003103, + 'B3': -0.00001246, + 'B4': 2.11E-07, + 'B5': -1.36E-09, + # optional + 'upper': 1., + } + ), + ( + 'interp', + { + # required + 'theta_ref': (0, 75, 85, 90), + 'iam_ref': (1, 0.8, 0.42, 0), + # optional + 'method': 'cubic', + 'normalize': False, + } + ) + ] +) +def test_infer_aoi_model_with_required_and_optional_params( + location, system_no_aoi, aoi_model, model_kwargs, weather, mocker +): m = mocker.spy(iam, aoi_model) system_no_aoi.arrays[0].module_parameters.update(**model_kwargs) mc = ModelChain(system_no_aoi, location, spectral_model='no_loss') diff --git a/pvlib/tests/test_pvsystem.py b/pvlib/tests/test_pvsystem.py index b575f8cc5c..dd7648f6df 100644 --- a/pvlib/tests/test_pvsystem.py +++ b/pvlib/tests/test_pvsystem.py @@ -284,7 +284,14 @@ def test_PVSystem_multi_array_sapm(sapm_module_params): def test_sapm_spectral_loss_deprecated(sapm_module_params): with pytest.warns(pvlibDeprecationWarning, match='Use pvlib.spectrum.spectral_factor_sapm'): - pvsystem.sapm_spectral_loss(1, sapm_module_params) + pvsystem.sapm_spectral_loss( + 1, + sapm_module_params["A0"], + sapm_module_params["A1"], + sapm_module_params["A2"], + sapm_module_params["A3"], + sapm_module_params["A4"], + ) def test_PVSystem_sapm_spectral_loss(sapm_module_params, mocker): @@ -292,8 +299,14 @@ def test_PVSystem_sapm_spectral_loss(sapm_module_params, mocker): system = pvsystem.PVSystem(module_parameters=sapm_module_params) airmass = 2 out = system.sapm_spectral_loss(airmass) - spectrum.spectral_factor_sapm.assert_called_once_with(airmass, - sapm_module_params) + spectrum.spectral_factor_sapm.assert_called_once_with( + airmass, + sapm_module_params["A0"], + sapm_module_params["A1"], + sapm_module_params["A2"], + sapm_module_params["A3"], + sapm_module_params["A4"], +) assert_allclose(out, 1, atol=0.5) From 08ae199a226ed9b455a3e290d669d5d84545ccd4 Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Sat, 21 Dec 2024 20:41:35 -0700 Subject: [PATCH 33/34] Appease the linter --- pvlib/tests/test_modelchain.py | 2 +- pvlib/tests/test_pvsystem.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pvlib/tests/test_modelchain.py b/pvlib/tests/test_modelchain.py index 3b349f677b..e37aebfd99 100644 --- a/pvlib/tests/test_modelchain.py +++ b/pvlib/tests/test_modelchain.py @@ -1542,7 +1542,7 @@ def test_infer_aoi_model(location, system_no_aoi, aoi_model): # optional 'upper': 1., } - ), + ), ( 'interp', { diff --git a/pvlib/tests/test_pvsystem.py b/pvlib/tests/test_pvsystem.py index dd7648f6df..9372234aff 100644 --- a/pvlib/tests/test_pvsystem.py +++ b/pvlib/tests/test_pvsystem.py @@ -306,7 +306,7 @@ def test_PVSystem_sapm_spectral_loss(sapm_module_params, mocker): sapm_module_params["A2"], sapm_module_params["A3"], sapm_module_params["A4"], -) + ) assert_allclose(out, 1, atol=0.5) From 6e8421c42ba021e2ff8ef4b68a15283c6bee4d6c Mon Sep 17 00:00:00 2001 From: Mark Campanelli Date: Sat, 21 Dec 2024 20:51:20 -0700 Subject: [PATCH 34/34] Fix spectral correction example --- docs/examples/spectrum/spectral_factor.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/examples/spectrum/spectral_factor.py b/docs/examples/spectrum/spectral_factor.py index 83ee488fb4..a1638340c4 100644 --- a/docs/examples/spectrum/spectral_factor.py +++ b/docs/examples/spectrum/spectral_factor.py @@ -46,7 +46,8 @@ # spectral factor functions: # # - :py:func:`~pvlib.spectrum.spectral_factor_sapm`, which requires only -# the absolute airmass, :math:`AM_a` +# the absolute airmass, :math:`AM_a` and five SAPM coefficients :math:`A0` - +# :math:`A4` # - :py:func:`~pvlib.spectrum.spectral_factor_pvspec`, which requires # :math:`AM_a` and the clearsky index, :math:`k_c` # - :py:func:`~pvlib.spectrum.spectral_factor_firstsolar`, which requires @@ -104,7 +105,14 @@ module = pvlib.pvsystem.retrieve_sam('SandiaMod')['LG_LG290N1C_G3__2013_'] # # Calculate M using the three models for an mc-Si PV module. -m_sapm = pvlib.spectrum.spectral_factor_sapm(airmass_absolute, module) +m_sapm = pvlib.spectrum.spectral_factor_sapm( + airmass_absolute, + module["A0"], + module["A1"], + module["A2"], + module["A3"], + module["A4"], +) m_pvspec = pvlib.spectrum.spectral_factor_pvspec(airmass_absolute, kc, 'multisi') m_fs = pvlib.spectrum.spectral_factor_firstsolar(w, airmass_absolute,