diff --git a/docs/sphinx/source/whatsnew/v0.16.2.rst b/docs/sphinx/source/whatsnew/v0.16.2.rst index 5893d8bf46..575c4d68ef 100644 --- a/docs/sphinx/source/whatsnew/v0.16.2.rst +++ b/docs/sphinx/source/whatsnew/v0.16.2.rst @@ -14,6 +14,11 @@ Deprecations Bug fixes ~~~~~~~~~ +* :py:func:`pvlib.irradiance.gti_dirint` no longer returns NaN + irradiances caused by the zenith-independent clearness index + exceeding the DIRINT coefficient table; the value used for the + coefficient lookup is now clipped to the table boundary. + (:issue:`1815`) * Correct the Series name in ``ModelChain.results.dc_ohmic_losses`` from ``i_mp`` to ``p_dc_ohmic_loss`` when using ``dc_ohms_from_percent``. (:issue:`1959`, :pull:`2912`) diff --git a/pvlib/irradiance.py b/pvlib/irradiance.py index 5436ed235c..37b53c80c9 100644 --- a/pvlib/irradiance.py +++ b/pvlib/irradiance.py @@ -2220,6 +2220,12 @@ def _dirint_coeffs(times, kt_prime, solar_zenith, w, delta_kt_prime): ------- dirint_coeffs : array-like """ + # The coefficient table only covers kt_prime up to 1, so clip larger + # values to the table boundary instead of returning NaN coefficients + # for them. Only the value used for the coefficient lookup is + # clipped; kt_prime itself is unchanged everywhere else. See GH 1815. + kt_prime = np.minimum(kt_prime, 1.) + kt_prime_bin, zenith_bin, w_bin, delta_kt_prime_bin = \ _dirint_bins(times, kt_prime, solar_zenith, w, delta_kt_prime) diff --git a/tests/test_irradiance.py b/tests/test_irradiance.py index 1ee62db6f8..7e132b65f3 100644 --- a/tests/test_irradiance.py +++ b/tests/test_irradiance.py @@ -933,6 +933,56 @@ def test_dirint_coeffs(): assert coeffs[3, 2, 6, 3] == 1.032260 +def test_dirint_coeffs_kt_prime_gt_one(): + # regression test for gh-1815: the coefficient table only covers + # kt_prime up to 1, so larger values must be clipped to the table + # boundary instead of producing NaN coefficients + times = pd.DatetimeIndex(['2014-06-24T12-0700', '2014-06-24T13-0700', + '2014-06-24T14-0700', '2014-06-24T15-0700']) + kt_prime = pd.Series([0.5, 1.0, 1.2, 2.0], index=times) + zenith = pd.Series(20., index=times) + w = pd.Series(-1, index=times) + delta_kt_prime = pd.Series(-1, index=times) + + coeffs = irradiance._dirint_coeffs(times, kt_prime, zenith, w, + delta_kt_prime) + + assert not np.isnan(coeffs).any() + # in-range values are unaffected, out-of-range values get the + # coefficients of the boundary kt_prime = 1 + assert coeffs[0] != coeffs[1] + assert coeffs[1] == coeffs[2] == coeffs[3] + + # reference: looking up the manually capped kt_prime must give + # exactly the same coefficients, i.e. the out-of-range values are + # clipped to the table boundary and not just binned together + kt_prime_capped = pd.Series([0.5, 1.0, 1.0, 1.0], index=times) + coeffs_capped = irradiance._dirint_coeffs( + times, kt_prime_capped, zenith, w, delta_kt_prime) + np.testing.assert_array_equal(coeffs, coeffs_capped) + + # non-sentinel w and delta_kt_prime values: the clip must not + # disturb the other binning dimensions -- the result still matches + # the capped reference, and changing either w or delta_kt_prime + # alone still changes the selected coefficients + w = pd.Series(1.5, index=times) + delta_kt_prime = pd.Series(0.05, index=times) + coeffs_nonsentinel = irradiance._dirint_coeffs( + times, kt_prime, zenith, w, delta_kt_prime) + coeffs_nonsentinel_capped = irradiance._dirint_coeffs( + times, kt_prime_capped, zenith, w, delta_kt_prime) + np.testing.assert_array_equal(coeffs_nonsentinel, + coeffs_nonsentinel_capped) + assert not (coeffs_nonsentinel == coeffs).all() + coeffs_w = irradiance._dirint_coeffs( + times, kt_prime, zenith, w, pd.Series(-1, index=times)) + assert not (coeffs_w == coeffs).all() + coeffs_delta = irradiance._dirint_coeffs( + times, kt_prime, zenith, pd.Series(-1, index=times), + delta_kt_prime) + assert not (coeffs_delta == coeffs).all() + + def test_dirint_min_cos_zenith_max_zenith(): # map out behavior under difficult conditions with various # limiting kwargs settings @@ -1115,6 +1165,45 @@ def test_gti_dirint(): assert_frame_equal(output, expected) +def test_gti_dirint_kt_prime_gt_one(mocker): + # regression test for gh-1815: poa_global values large enough to + # make kt_prime exceed 1 used to produce NaN dni and dhi, including + # for AOI >= 90 degrees + times = pd.DatetimeIndex( + ['2014-06-24T06-0700', '2014-06-24T09-0700', '2014-06-24T12-0700']) + poa_global = np.array([40, 600, 2000]) + aoi = np.array([100, 70, 10]) + zenith = np.array([80, 45, 20]) + azimuth = np.array([90, 135, 180]) + surface_tilt = 30 + surface_azimuth = 180 + + coeffs_spy = mocker.spy(irradiance, '_dirint_coeffs') + + output = irradiance.gti_dirint( + poa_global, aoi, zenith, azimuth, times, surface_tilt, + surface_azimuth) + + assert not output.isna().any().any() + + # the scenario really does exercise the kt_prime > 1 path: the + # coefficient lookup is called with unclipped kt_prime values, some + # of which exceed 1 (max seen is about 1.54) + kt_prime_seen = [call[0][1] for call in coeffs_spy.call_args_list] + assert max(np.max(kt_prime) for kt_prime in kt_prime_seen) > 1 + + # expected values produced by the fixed implementation; its output + # for the original (unscaled, kt_prime <= 1) test_gti_dirint inputs + # is unchanged, as verified against the pre-fix behavior + expected = pd.DataFrame(array( + [[92.98152465, 298.18105688, 41.20292751], + [524.21478016, 284.52281756, 323.02676646], + [1908.37778828, 0., 1908.37778828]]), + columns=['ghi', 'dni', 'dhi'], index=times) + + assert_frame_equal(output, expected) + + def test_gti_dirint_data_error(): times = pd.DatetimeIndex( ['2014-06-24T06-0700', '2014-06-24T09-0700', '2014-06-24T12-0700'])