diff --git a/docs/examples/irradiance-transposition/plot_rtranpose_limitations.py b/docs/examples/irradiance-transposition/plot_rtranpose_limitations.py index 8df8339ad4..fca9557db2 100644 --- a/docs/examples/irradiance-transposition/plot_rtranpose_limitations.py +++ b/docs/examples/irradiance-transposition/plot_rtranpose_limitations.py @@ -28,7 +28,7 @@ # # In this example we look at a single point in time and consider a full range # of possible GHI and POA global values as shown in figures 3 and 4 of [1]_. -# Then we use :py:meth:`pvlib.irradiance.ghi_from_poa_driesse_2023` to estimate +# Then we use :py:meth:`pvlib.irradiance.ghi_from_poa_driesse_2024` to estimate # the original GHI from POA global. # # References @@ -45,7 +45,7 @@ from pvlib.irradiance import (erbs_driesse, get_total_irradiance, - ghi_from_poa_driesse_2023, + ghi_from_poa_driesse_2024, ) matplotlib.rcParams['axes.grid'] = True @@ -92,7 +92,7 @@ poa_test = 200 -ghi_hat = ghi_from_poa_driesse_2023(surface_tilt, surface_azimuth, +ghi_hat = ghi_from_poa_driesse_2024(surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, poa_test, dni_extra, @@ -156,7 +156,7 @@ # out, other times not. # -result = ghi_from_poa_driesse_2023(surface_tilt, surface_azimuth, +result = ghi_from_poa_driesse_2024(surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, poa_global, dni_extra, diff --git a/docs/examples/irradiance-transposition/plot_rtranpose_year.py b/docs/examples/irradiance-transposition/plot_rtranpose_year.py index c0b9860bba..b373ef1a25 100644 --- a/docs/examples/irradiance-transposition/plot_rtranpose_year.py +++ b/docs/examples/irradiance-transposition/plot_rtranpose_year.py @@ -21,7 +21,7 @@ # Recovering GHI from POA irradiance is termed "reverse transposition." # # In this example we start with a TMY file and calculate POA global irradiance. -# Then we use :py:meth:`pvlib.irradiance.ghi_from_poa_driesse_2023` to estimate +# Then we use :py:meth:`pvlib.irradiance.ghi_from_poa_driesse_2024` to estimate # the original GHI from POA global. Details of the method found in [1]_. # # Another method for reverse tranposition called GTI-DIRINT is also @@ -49,7 +49,7 @@ from pvlib import iotools, location from pvlib.irradiance import (get_extra_radiation, get_total_irradiance, - ghi_from_poa_driesse_2023, + ghi_from_poa_driesse_2024, aoi, ) @@ -114,7 +114,7 @@ start = time.process_time() -df['ghi_rev'] = ghi_from_poa_driesse_2023(TILT, ORIENT, +df['ghi_rev'] = ghi_from_poa_driesse_2024(TILT, ORIENT, solpos.apparent_zenith, solpos.azimuth, df.poa_global, diff --git a/docs/sphinx/source/reference/irradiance/reverse-transposition.rst b/docs/sphinx/source/reference/irradiance/reverse-transposition.rst index 31ce27bedd..55e1d40e36 100644 --- a/docs/sphinx/source/reference/irradiance/reverse-transposition.rst +++ b/docs/sphinx/source/reference/irradiance/reverse-transposition.rst @@ -6,5 +6,5 @@ Reverse transposition models .. autosummary:: :toctree: ../generated/ - irradiance.ghi_from_poa_driesse_2023 + irradiance.ghi_from_poa_driesse_2024 irradiance.gti_dirint diff --git a/docs/sphinx/source/whatsnew/v0.15.2.rst b/docs/sphinx/source/whatsnew/v0.15.2.rst index 35d7dc675c..94cda592f9 100644 --- a/docs/sphinx/source/whatsnew/v0.15.2.rst +++ b/docs/sphinx/source/whatsnew/v0.15.2.rst @@ -10,10 +10,17 @@ Breaking Changes Deprecations ~~~~~~~~~~~~ +* Rename :py:func:`!pvlib.irradiance.ghi_from_poa_driesse_2023` to + :py:func:`~pvlib.irradiance.ghi_from_poa_driesse_2024`. The year now reflects + the publication date. The old name will be removed in v0.17.0. + (:issue:`2774`, :pull:`2777`) Bug fixes ~~~~~~~~~ +* Added test coverage for :py:func:`pvlib.irradiance.dirint` with + ``np.array`` and ``pd.Series`` inputs. + (:issue:`2751`, :pull:`2752`) * Corrects a bug in :py:func:`pvlib.temperature.fuentes`. If inputs were data type integer, users can expect modeled cell temperature values to increase slightly. @@ -26,16 +33,18 @@ Bug fixes represent the end of the averaging interval, consistent with ERA5 conventions. (:issue:`2772`, :pull:`2773`) +* :py:func:`pvlib.iotools.read_nsrdb_psm4` now parses the file header with the + :py:mod:`csv` module instead of a naive ``str.split(',')``, so quoted column + names containing commas (e.g. the material names in spectral-on-demand files) + are no longer split into spurious columns. (:issue:`2736`, :pull:`2771`) Enhancements ~~~~~~~~~~~~ -* Add the ``front_side_fraction`` parameter to - :py:func:`pvlib.snow.loss_townsend` to support Townsend snow-loss - workflows for bifacial systems. (:issue:`2755`, :pull:`2756`) - -* Added mapping of the parameter ``"albedo"`` in - :py:func:`~pvlib.iotools.get_nasa_power` when ``map_variables=True`` - (:pull:`2753`) +* Add the following parameters to :py:func:`~pvlib.iotools.get_nasa_power` + when ``map_variables=True``: ``temp_dew``, ``precipitable_water``, + ``relative_humidity``, ``ghi_extra``, ``dhi_clear``, ``longwave_down``, + and ``albedo``. + (:issue:`2731`, :pull:`2753`, :pull:`2762`) Documentation @@ -67,7 +76,10 @@ Maintenance Contributors ~~~~~~~~~~~~ * :ghuser:`Omesh37` +* :ghuser:`gaoflow` * Cliff Hansen (:ghuser:`cwhanse`) * :ghuser:`shethkajal7` * Arthur Onno (:ghuser:`ArthurOnnoTerabase`) * Adam R. Jensen (:ghuser:`AdamRJensen`) +* Karl Hill (:ghuser:`karlhillx`) +* Rajiv Daxini (:ghuser:`RDaxini`) diff --git a/pvlib/iotools/nasa_power.py b/pvlib/iotools/nasa_power.py index 47dad5f2d3..7ab3829495 100644 --- a/pvlib/iotools/nasa_power.py +++ b/pvlib/iotools/nasa_power.py @@ -14,11 +14,19 @@ 'ALLSKY_SFC_SW_DWN': 'ghi', 'ALLSKY_SFC_SW_DIFF': 'dhi', 'ALLSKY_SFC_SW_DNI': 'dni', + 'ALLSKY_SRF_ALB': 'albedo', + 'ALLSKY_SFC_LW_DWN': 'longwave_down', + 'CLRSKY_SFC_SW_DIFF': 'dhi_clear', + 'CLRSKY_SFC_SW_DNI': 'dni_clear', 'CLRSKY_SFC_SW_DWN': 'ghi_clear', + 'PS': 'pressure', + 'RH2M': 'relative_humidity', 'T2M': 'temp_air', + 'T2MDEW': 'temp_dew', + 'TQV': 'precipitable_water', + 'TOA_SW_DWN': 'ghi_extra', 'WS2M': 'wind_speed_2m', 'WS10M': 'wind_speed', - 'ALLSKY_SRF_ALB': 'albedo', } @@ -82,6 +90,12 @@ def get_nasa_power(latitude, longitude, start, end, requests.HTTPError Raises an error when an incorrect request is made. + Notes + ----- + When ``map_variables=True`` the following unit conversions are applied: + pressure is converted from kPa to Pa, and precipitable water + is converted from kg/m² (mm) to cm. + Returns ------- data : pd.DataFrame @@ -150,5 +164,11 @@ def get_nasa_power(latitude, longitude, start, end, # Rename according to pvlib convention if map_variables: df = df.rename(columns=VARIABLE_MAP) + # PS is returned in kPa; convert to Pa for pvlib compatibility. + if 'pressure' in df.columns: + df['pressure'] = df['pressure'] * 1000 + # TQV is returned in kg/m^2 (=mm); convert to cm for compatibility + if 'precipitable_water' in df.columns: + df['precipitable_water'] = df['precipitable_water'] / 10 return df, meta diff --git a/pvlib/iotools/psm4.py b/pvlib/iotools/psm4.py index 9eb760f382..fc8d098a09 100644 --- a/pvlib/iotools/psm4.py +++ b/pvlib/iotools/psm4.py @@ -6,6 +6,7 @@ https://developer.nlr.gov/docs/solar/nsrdb/nsrdb-GOES-full-disc-v4-0-0-download/ """ +import csv import io from urllib.parse import urljoin import requests @@ -723,11 +724,16 @@ def read_nsrdb_psm4(filename, map_variables=True): `_ """ with tools._file_context_manager(filename) as fbuf: + # The first 3 header lines are parsed with the csv module rather than a + # naive str.split(',') so that quoted fields containing commas are kept + # intact. Spectral-on-demand files, for instance, have column names + # like '"GaAs (Bauhuis et al., 2009)"' whose embedded commas would + # otherwise be split into spurious columns (see GH #2736). # The first 2 lines of the response are headers with metadata - metadata_fields = fbuf.readline().split(',') - metadata_values = fbuf.readline().split(',') + metadata_fields = next(csv.reader([fbuf.readline()])) + metadata_values = next(csv.reader([fbuf.readline()])) # get the column names so we can set the dtypes - columns = fbuf.readline().split(',') + columns = next(csv.reader([fbuf.readline()])) columns[-1] = columns[-1].strip() # strip trailing newline # Since the header has so many columns, excel saves blank cols in the # data below the header lines. diff --git a/pvlib/irradiance.py b/pvlib/irradiance.py index 50f02426de..efdbfaa647 100644 --- a/pvlib/irradiance.py +++ b/pvlib/irradiance.py @@ -16,7 +16,7 @@ from pvlib import atmosphere, solarposition, tools import pvlib # used to avoid dni name collision in complete_irradiance -from pvlib._deprecation import pvlibDeprecationWarning +from pvlib._deprecation import pvlibDeprecationWarning, deprecated import warnings @@ -132,7 +132,9 @@ def _handle_extra_radiation_types(datetime_or_doy, epoch_year): # a better way to do it. if isinstance(datetime_or_doy, pd.DatetimeIndex): to_doy = tools._pandas_to_doy # won't be evaluated unless necessary - def to_datetimeindex(x): return x # noqa: E306 + + def to_datetimeindex(x): + return x # noqa: E306 to_output = partial(pd.Series, index=datetime_or_doy) elif isinstance(datetime_or_doy, pd.Timestamp): to_doy = tools._pandas_to_doy @@ -146,12 +148,14 @@ def to_datetimeindex(x): return x # noqa: E306 tools._datetimelike_scalar_to_datetimeindex to_output = tools._scalar_out elif np.isscalar(datetime_or_doy): # ints and floats of various types - def to_doy(x): return x # noqa: E306 + def to_doy(x): + return x # noqa: E306 to_datetimeindex = partial(tools._doy_to_datetimeindex, epoch_year=epoch_year) to_output = tools._scalar_out else: # assume that we have an array-like object of doy - def to_doy(x): return x # noqa: E306 + def to_doy(x): + return x # noqa: E306 to_datetimeindex = partial(tools._doy_to_datetimeindex, epoch_year=epoch_year) to_output = tools._array_out @@ -873,7 +877,7 @@ def haydavies(surface_tilt, surface_azimuth, dhi, dni, dni_extra, def reindl(surface_tilt, surface_azimuth, dhi, dni, ghi, dni_extra, - solar_zenith, solar_azimuth): + solar_zenith, solar_azimuth, return_components=False): r''' Determine the diffuse irradiance from the sky on a tilted surface using the Reindl (1990) model. @@ -912,10 +916,27 @@ def reindl(surface_tilt, surface_azimuth, dhi, dni, ghi, dni_extra, solar_azimuth : numeric Solar azimuth angles. See :term:`solar_azimuth`. [°] + return_components : bool, default False + Flag used to decide whether to return the calculated diffuse components + or not. + Returns ------- - poa_sky_diffuse : numeric - The sky diffuse component of the solar radiation. [Wm⁻²] + numeric, Dict, or DataFrame + Return type controlled by ``return_components`` argument. + If ``return_components=False``, `sky_diffuse` is returned. + If ``return_components=True``, `diffuse_components` is returned. + + sky_diffuse : numeric + The sky diffuse component of the solar radiation on a tilted + surface. + + diffuse_components : Dict (array input) or DataFrame (Series input) + Keys/columns are: + * poa_sky_diffuse: Total sky diffuse + * poa_isotropic + * poa_circumsolar + * poa_horizon Notes ----- @@ -939,8 +960,12 @@ def reindl(surface_tilt, surface_azimuth, dhi, dni, ghi, dni_extra, Implementation is based on Loutzenhiser et al. (2007) [3]_, Equation 8. The beam and ground reflectance portion of the equation have been removed, therefore the model described here generates - ONLY the diffuse radiation from the sky and circumsolar, so the form of the - equation varies slightly from Equation 8 in [3]_. + ONLY the diffuse radiation from the sky, circumsolar, and horizon + brightening, so the form of the equation varies slightly from Equation 8 + in [3]_. + + For clarity, the horizon component in `reindl` corresponds to the term + added on top of the `hay–davies` formulation, on which `reindl` builds. References ---------- @@ -973,16 +998,31 @@ def reindl(surface_tilt, surface_azimuth, dhi, dni, ghi, dni_extra, HB = dni * cos_solar_zenith HB = np.maximum(HB, 0) - # these are the () and [] sub-terms of the second term of eqn 8 - term1 = 1 - AI - term2 = 0.5 * (1 + tools.cosd(surface_tilt)) + SVF = (1 + tools.cosd(surface_tilt)) / 2 + with np.errstate(invalid='ignore', divide='ignore'): hb_to_ghi = np.where(ghi == 0, 0, np.divide(HB, ghi)) - term3 = 1 + np.sqrt(hb_to_ghi) * (tools.sind(0.5 * surface_tilt)**3) - sky_diffuse = dhi * (AI * Rb + term1 * term2 * term3) - sky_diffuse = np.maximum(sky_diffuse, 0) + h = np.sqrt(hb_to_ghi) * (tools.sind(surface_tilt / 2) ** 3) - return sky_diffuse + term1 = (1 - AI) * SVF + term2 = AI * Rb + term3 = (1 - AI) * SVF * h + + sky_diffuse = dhi * (term1 + term2 + term3) + + if return_components: + diffuse_components = { + 'poa_sky_diffuse': sky_diffuse, + 'poa_isotropic': dhi * term1, + 'poa_circumsolar': dhi * term2, + 'poa_horizon': dhi * term3 + } + + if isinstance(sky_diffuse, pd.Series): + diffuse_components = pd.DataFrame(diffuse_components) + return diffuse_components + else: + return sky_diffuse def king(surface_tilt, dhi, ghi, solar_zenith): @@ -1442,7 +1482,7 @@ def _poa_from_ghi(surface_tilt, surface_azimuth, Transposition function that includes decomposition of GHI using the continuous Erbs-Driesse model. - Helper function for ghi_from_poa_driesse_2023. + Helper function for ghi_from_poa_driesse_2024. ''' # Contributed by Anton Driesse (@adriesse), PV Performance Labs. Nov., 2023 @@ -1468,7 +1508,7 @@ def _ghi_from_poa(surface_tilt, surface_azimuth, ''' Reverse transposition function that uses the scalar bisection from scipy. - Helper function for ghi_from_poa_driesse_2023. + Helper function for ghi_from_poa_driesse_2024. ''' # Contributed by Anton Driesse (@adriesse), PV Performance Labs. Nov., 2023 @@ -1512,7 +1552,7 @@ def poa_error(ghi): return ghi, conv, niter -def ghi_from_poa_driesse_2023(surface_tilt, surface_azimuth, +def ghi_from_poa_driesse_2024(surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, poa_global, dni_extra, airmass=None, albedo=0.25, @@ -1614,6 +1654,14 @@ def ghi_from_poa_driesse_2023(surface_tilt, surface_azimuth, return ghi +ghi_from_poa_driesse_2023 = deprecated( + since="0.15.2", + name="pvlib.irradiance.ghi_from_poa_driesse_2023", + alternative="pvlib.irradiance.ghi_from_poa_driesse_2024", + removal="0.17.0", +)(ghi_from_poa_driesse_2024) + + def clearsky_index(ghi, ghi_clear, max_clearsky_index=2.0): """ Calculate the clearsky index. @@ -1964,14 +2012,14 @@ def dirint(ghi, solar_zenith, times, pressure=101325., use_delta_kt_prime=True, Returns ------- - dni : array-like - The modeled direct normal irradiance, as provided by the - DIRINT model. [Wm⁻²] + dni : pd.Series + Estimated direct normal irradiance. [Wm⁻²] Notes ----- - DIRINT model requires time series data (ie. one of the inputs must - be a vector of length > 2). + The DIRINT model was developed for time series data with length > 2. + The implementation in pvlib assumes the data are periodic which may + affect the first and last DNI values. References ---------- @@ -2109,6 +2157,7 @@ def _dirint_bins(times, kt_prime, zenith, w, delta_kt_prime): ------- tuple of kt_prime_bin, zenith_bin, w_bin, delta_kt_prime_bin """ + # @wholmgren: the following bin assignments use MATLAB's 1-indexing. # Later, we'll subtract 1 to conform to Python's 0-indexing. diff --git a/tests/iotools/test_nasa_power.py b/tests/iotools/test_nasa_power.py index 4a0afe3587..dd09d7b1ca 100644 --- a/tests/iotools/test_nasa_power.py +++ b/tests/iotools/test_nasa_power.py @@ -98,3 +98,110 @@ def test_get_nasa_power_duplicate_parameter_name(data_index): start=data_index[0], end=data_index[-1], parameters=2*['ALLSKY_SFC_SW_DWN']) + + +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_nasa_power_all_variable_map_parameters_valid(): + """ + Every NASA POWER parameter name in VARIABLE_MAP must be accepted by the + live API. A typo or stale name (e.g. CLRSKY_DIFF vs CLRSKY_SFC_SW_DIFF) + causes the API to return an HTTPError, which would fail this test. + + NASA POWER allows max 15 parameters per request; VARIABLE_MAP is split + into two batches to stay within that limit. + """ + from pvlib.iotools.nasa_power import VARIABLE_MAP + nasa_params = list(VARIABLE_MAP.keys()) + # Split into two batches; API limit is 15. + half = (len(nasa_params) + 1) // 2 + batch1 = nasa_params[:half] + batch2 = nasa_params[half:] + + for batch in [batch1, batch2]: + data, meta = pvlib.iotools.get_nasa_power( + latitude=44.76, + longitude=7.64, + start='2025-02-02', + end='2025-02-02', + parameters=batch, + map_variables=False, + ) + missing = set(batch) - set(data.columns) + assert not missing, f"NASA POWER did not return: {sorted(missing)}" + + +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_nasa_power_all_variable_map_renamed(): + """ + With map_variables=True every NASA name must be renamed to its pvlib + equivalent. Catches duplicate-target collisions (e.g. two entries both + mapping to 'temp_dew', which silently drops a column under pandas rename). + """ + from pvlib.iotools.nasa_power import VARIABLE_MAP + nasa_params = list(VARIABLE_MAP.keys()) + pvlib_names = set(VARIABLE_MAP.values()) + + data, _ = pvlib.iotools.get_nasa_power( + latitude=44.76, + longitude=7.64, + start='2025-02-02', + end='2025-02-02', + parameters=nasa_params, + map_variables=True, + ) + + missing = pvlib_names - set(data.columns) + assert not missing, ( + f"map_variables=True dropped pvlib columns: {sorted(missing)}. " + "Likely cause: duplicate target names in VARIABLE_MAP." + ) + + +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_nasa_power_pressure_unit_conversion(): + """ + NASA POWER returns PS in kPa; pvlib convention is Pa. + Sea-level surface pressure should be ~101 kPa = ~101325 Pa, not ~101. + """ + data, _ = pvlib.iotools.get_nasa_power( + latitude=0.0, # sea-level equatorial point + longitude=-30.0, + start='2025-02-02', + end='2025-02-02', + parameters=['PS'], + map_variables=True, + ) + mean_pressure = data['pressure'].mean() + # Anywhere on earth, surface pressure in Pa is between ~50k and ~110k. + # If the conversion is missing, value will be ~100 (kPa) and fail this. + assert 50_000 < mean_pressure < 110_000, ( + f"PS not converted from kPa to Pa. Got mean={mean_pressure}" + ) + + +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_nasa_power_precipitable_water_unit_conversion(): + """ + NASA POWER returns TQV in kg/m^2 (= mm of water column); + pvlib convention is cm. Typical atmospheric column is 1-5 cm, + never above ~7 cm. If the /10 conversion is missing, values will + be 10-50 and fail this bound. + """ + data, _ = pvlib.iotools.get_nasa_power( + latitude=0.0, # tropics: high water vapor, worst case for bounds + longitude=-60.0, + start='2025-02-02', + end='2025-02-02', + parameters=['TQV'], + map_variables=True, + ) + mean_pw = data['precipitable_water'].mean() + # pvlib precipitable_water is in cm. Tropical column is <~7 cm. + # Missing /10 conversion would give 10-70 (kg/m^2 = mm). + assert 0 < mean_pw < 10, ( + f"TQV not converted from kg/m^2 to cm. Got mean={mean_pw}" + ) diff --git a/tests/iotools/test_psm4.py b/tests/iotools/test_psm4.py index 3b4313b070..c16a714aa3 100644 --- a/tests/iotools/test_psm4.py +++ b/tests/iotools/test_psm4.py @@ -185,6 +185,31 @@ def test_read_nsrdb_psm4_map_variables(): assert_index_equal(data.columns, pd.Index(columns_mapped)) +def test_read_nsrdb_psm4_quoted_columns_with_commas(): + """spectral-on-demand files have quoted column names containing commas; + these must not be split into spurious columns (GH #2736)""" + # Minimal NSRDB file whose column header (3rd line) has quoted material + # names with embedded commas, which is valid CSV. A naive str.split(',') + # would break these into extra columns and raise on read. + content = ( + "Source,Location ID,City,State,Country,Latitude,Longitude,Time Zone," + "Elevation,Local Time Zone,Version\n" + "NSRDB,1,-,-,-,40.0,-105.0,-7,1600,-7,4.0.1\n" + 'Year,Month,Day,Hour,Minute,GHI,"GaAs (Bauhuis et al., 2009)",' + '"InGaP (Gray, 2008)"\n' + "2023,1,1,0,0,0,0.1,0.2\n" + "2023,1,1,1,0,5,0.3,0.4\n" + ) + data, metadata = psm4.read_nsrdb_psm4(StringIO(content), + map_variables=False) + assert list(data.columns) == [ + 'Year', 'Month', 'Day', 'Hour', 'Minute', 'GHI', + 'GaAs (Bauhuis et al., 2009)', 'InGaP (Gray, 2008)'] + assert data.shape == (2, 8) + # the embedded-comma data columns round-trip as floats + assert data['GaAs (Bauhuis et al., 2009)'].tolist() == [0.1, 0.3] + + @pytest.mark.remote_data @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) def test_get_nsrdb_psm4_aggregated_parameter_mapping(nlr_api_key): diff --git a/tests/test_irradiance.py b/tests/test_irradiance.py index a416636ae9..730711ce06 100644 --- a/tests/test_irradiance.py +++ b/tests/test_irradiance.py @@ -251,6 +251,43 @@ def test_reindl(irrad_data, ephem_data, dni_et): assert_allclose(result, [0., 27.9412, 104.1317, 34.1663], atol=1e-4) +def test_reindl_components(irrad_data, ephem_data, dni_et): + keys = ['poa_sky_diffuse', 'poa_isotropic', 'poa_circumsolar', + 'poa_horizon'] + expected = pd.DataFrame(np.array( + [[0, 27.941170, 104.131724, 34.166258], + [0, 27.177514, 30.181807, 27.983728], + [0, 0, 72.813055, 5.207138], + [0, 0.763656, 1.136862, 0.975393]]).T, + columns=keys, + index=irrad_data.index + ) + # pandas + result = irradiance.reindl( + 40, 180, irrad_data['dhi'], irrad_data['dni'], irrad_data['ghi'], + dni_et, ephem_data['apparent_zenith'], ephem_data['azimuth'], + return_components=True) + assert_frame_equal(result, expected, check_less_precise=4) + # numpy + result = irradiance.reindl( + 40, 180, irrad_data['dhi'].values, irrad_data['dni'].values, + irrad_data['ghi'].values, dni_et, ephem_data['apparent_zenith'].values, + ephem_data['azimuth'].values, return_components=True) + for key in keys: + assert_allclose(result[key], expected[key], atol=1e-4) + assert isinstance(result, dict) + # scalar + result = irradiance.reindl( + 40, 180, irrad_data['dhi'].values[-1], irrad_data['dni'].values[-1], + irrad_data['ghi'].values[-1], dni_et[-1], + ephem_data['apparent_zenith'].values[-1], + ephem_data['azimuth'].values[-1], + return_components=True) + for key in keys: + assert_allclose(result[key], expected[key].iloc[-1], atol=1e-4) + assert isinstance(result, dict) + + def test_king(irrad_data, ephem_data): result = irradiance.king(40, irrad_data['dhi'], irrad_data['ghi'], ephem_data['apparent_zenith']) @@ -792,7 +829,7 @@ def test_ghi_from_poa_driesse(mocker): surface_azimuth = 180 # test core function - output = irradiance.ghi_from_poa_driesse_2023( + output = irradiance.ghi_from_poa_driesse_2024( surface_tilt, surface_azimuth, zenith, azimuth, poa_global, dni_extra=1366.1) @@ -802,7 +839,7 @@ def test_ghi_from_poa_driesse(mocker): # test series output poa_global = pd.Series([20, 300, 1000], index=times) - output = irradiance.ghi_from_poa_driesse_2023( + output = irradiance.ghi_from_poa_driesse_2024( surface_tilt, surface_azimuth, zenith, azimuth, poa_global, dni_extra=1366.1) @@ -811,7 +848,7 @@ def test_ghi_from_poa_driesse(mocker): # test full_output option and special cases poa_global = np.array([0, 1500, np.nan]) - ghi, conv, niter = irradiance.ghi_from_poa_driesse_2023( + ghi, conv, niter = irradiance.ghi_from_poa_driesse_2024( surface_tilt, surface_azimuth, zenith, azimuth, poa_global, dni_extra=1366.1, full_output=True) @@ -828,19 +865,26 @@ def test_ghi_from_poa_driesse(mocker): poa_global = pd.Series([20, 300, 1000], index=times) # test exception xtol = -3.14159 # negative value raises exception in scipy.optimize.bisect - with pytest.raises(ValueError, match=rf"xtol too small \({xtol:g} <= 0\)"): - output = irradiance.ghi_from_poa_driesse_2023( + with pytest.raises(ValueError, match=rf"xtol too small \({xtol} <= 0\)"): + output = irradiance.ghi_from_poa_driesse_2024( surface_tilt, surface_azimuth, zenith, azimuth, poa_global, dni_extra=1366.1, xtol=xtol) # test propagation xtol = 3.141592 bisect_spy = mocker.spy(irradiance, "bisect") - output = irradiance.ghi_from_poa_driesse_2023( + output = irradiance.ghi_from_poa_driesse_2024( surface_tilt, surface_azimuth, zenith, azimuth, poa_global, dni_extra=1366.1, xtol=xtol) assert bisect_spy.call_args[1]["xtol"] == xtol +def test_ghi_from_poa_driesse_2023_deprecated(): + with pytest.warns(pvlibDeprecationWarning, + match="ghi_from_poa_driesse_2024"): + irradiance.ghi_from_poa_driesse_2023( + 30, 180, 20, 180, 500, dni_extra=1366.1) + + def test_gti_dirint(): times = pd.DatetimeIndex( ['2014-06-24T06-0700', '2014-06-24T09-0700', '2014-06-24T12-0700']) @@ -1140,6 +1184,31 @@ def test_dirindex_min_cos_zenith_max_zenith(): assert_series_equal(out, expected) +def test_dirint_array_inputs(): + """np.array and pd.Series inputs work correctly. GH #2751""" + times = pd.date_range('2023-06-21 10:00', periods=2, freq='h', tz='UTC') + + # np.array input → should return pd.Series + result = irradiance.dirint( + ghi=np.array([500.0, 400.0]), + solar_zenith=np.array([45.0, 50.0]), + times=times, + use_delta_kt_prime=False + ) + assert isinstance(result, pd.Series) + assert result.iloc[0] > 0 + + # pd.Series input → should return pd.Series + times2 = pd.date_range('2023-06-21 10:00', periods=3, freq='h', tz='UTC') + result2 = irradiance.dirint( + ghi=pd.Series([400, 500, 300], index=times2), + solar_zenith=pd.Series([50, 40, 60], index=times2), + times=times2 + ) + assert isinstance(result2, pd.Series) + assert (result2 >= 0).all() + + def test_dni(): ghi = pd.Series([90, 100, 100, 100, 100]) dhi = pd.Series([100, 90, 50, 50, 50])