From aa3ad066c245704e20a1a4dc44a074aed4defedf Mon Sep 17 00:00:00 2001 From: DresdenGman Date: Tue, 3 Nov 2026 21:53:47 +0000 Subject: [PATCH 1/2] Clip kt_prime at DIRINT coefficient lookup to avoid NaN irradiances The DIRINT coefficient table only covers kt_prime up to 1, so gti_dirint returned NaN DNI/DHI whenever the iteration produced kt_prime above 1, including at AOI >= 90 timestamps. Clip the value at the entrance of _dirint_coeffs, as agreed in the issue discussion, so out-of-range values use the boundary coefficients instead. The kt_prime series itself is left unchanged everywhere else, and dirint is unaffected because it already limits kt_prime to 1. --- docs/sphinx/source/whatsnew/v0.16.2.rst | 4 +++ pvlib/irradiance.py | 6 ++++ tests/test_irradiance.py | 41 +++++++++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/docs/sphinx/source/whatsnew/v0.16.2.rst b/docs/sphinx/source/whatsnew/v0.16.2.rst index 5893d8bf46..f756381418 100644 --- a/docs/sphinx/source/whatsnew/v0.16.2.rst +++ b/docs/sphinx/source/whatsnew/v0.16.2.rst @@ -14,6 +14,10 @@ Deprecations Bug fixes ~~~~~~~~~ +* :py:func:`pvlib.irradiance.gti_dirint` no longer returns NaN + irradiances when the zenith-independent clearness index exceeds 1; + the value used for the DIRINT coefficient lookup is now clipped to + the coefficient 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..f4ea9062e8 100644 --- a/tests/test_irradiance.py +++ b/tests/test_irradiance.py @@ -933,6 +933,27 @@ 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] + + def test_dirint_min_cos_zenith_max_zenith(): # map out behavior under difficult conditions with various # limiting kwargs settings @@ -1115,6 +1136,26 @@ def test_gti_dirint(): assert_frame_equal(output, expected) +def test_gti_dirint_kt_prime_gt_one(): + # 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 + + output = irradiance.gti_dirint( + poa_global, aoi, zenith, azimuth, times, surface_tilt, + surface_azimuth) + + assert not output.isna().any().any() + + def test_gti_dirint_data_error(): times = pd.DatetimeIndex( ['2014-06-24T06-0700', '2014-06-24T09-0700', '2014-06-24T12-0700']) From ac3a5ec0fea87e77418b21b440937a4d27cea455 Mon Sep 17 00:00:00 2001 From: DresdenGman Date: Tue, 3 Nov 2026 22:01:02 +0000 Subject: [PATCH 2/2] Address code-gate review: narrow whats-new claim, strengthen 1815 tests - whats-new: only claim the NaN cause this fix removes (kt_prime exceeding the DIRINT coefficient table) - test_dirint_coeffs_kt_prime_gt_one: compare against a manually capped reference lookup, and cover non-sentinel w/delta_kt_prime - test_gti_dirint_kt_prime_gt_one: spy on _dirint_coeffs to prove kt_prime > 1 is exercised, and assert the full expected frame --- docs/sphinx/source/whatsnew/v0.16.2.rst | 7 ++-- tests/test_irradiance.py | 50 ++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/docs/sphinx/source/whatsnew/v0.16.2.rst b/docs/sphinx/source/whatsnew/v0.16.2.rst index f756381418..575c4d68ef 100644 --- a/docs/sphinx/source/whatsnew/v0.16.2.rst +++ b/docs/sphinx/source/whatsnew/v0.16.2.rst @@ -15,9 +15,10 @@ Deprecations Bug fixes ~~~~~~~~~ * :py:func:`pvlib.irradiance.gti_dirint` no longer returns NaN - irradiances when the zenith-independent clearness index exceeds 1; - the value used for the DIRINT coefficient lookup is now clipped to - the coefficient table boundary. (:issue:`1815`) + 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/tests/test_irradiance.py b/tests/test_irradiance.py index f4ea9062e8..7e132b65f3 100644 --- a/tests/test_irradiance.py +++ b/tests/test_irradiance.py @@ -953,6 +953,35 @@ def test_dirint_coeffs_kt_prime_gt_one(): 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 @@ -1136,7 +1165,7 @@ def test_gti_dirint(): assert_frame_equal(output, expected) -def test_gti_dirint_kt_prime_gt_one(): +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 @@ -1149,12 +1178,31 @@ def test_gti_dirint_kt_prime_gt_one(): 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(