From d93aabb6805b3c32d632dfd55a7083c4c0118b71 Mon Sep 17 00:00:00 2001 From: RDaxini Date: Thu, 8 Oct 2026 16:40:22 -0600 Subject: [PATCH 1/9] set up + draft docstring --- pvlib/spectrum/spectral_binning.py | 99 ++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 pvlib/spectrum/spectral_binning.py diff --git a/pvlib/spectrum/spectral_binning.py b/pvlib/spectrum/spectral_binning.py new file mode 100644 index 0000000000..7c4d862f81 --- /dev/null +++ b/pvlib/spectrum/spectral_binning.py @@ -0,0 +1,99 @@ +""" +The ``spectral_binning`` module in the ``spectrum`` package provides functions +for calculations related to reducing (binning) large spectral irradiance +datasets into smaller, representative datasets. +""" + +import pvlib +import numpy as np +import pandas as pd + +from pvlib.spectrum.irradiance import average_photon_energy + + +def spectral_binning(spectra, n_bins): + r""" + Bin spectral irradiance data into a specified number of bins. + + Parameters + ---------- + spectra : pandas.Series or pandas.DataFrame + + Spectral irradiance, must be positive [Wm⁻²nm⁻¹]. + See :term:`spectra`. + + A single spectrum must be a :py:class:`pandas.Series` with wavelength + [nm] as the index, while multiple spectra must be rows in a + :py:class:`pandas.DataFrame` with column headers as wavelength [nm] + + n_bins : int + The number of bins to reduce the spectral data into. + + Returns + ------- + binned_spectra : pandas.Series or pandas.DataFrame + The binned spectral irradiance data. The structure matches the input + ``spectra``: a single spectrum is returned as a + :py:class:`pandas.Series`, while multiple spectra are returned as a + :py:class:`pandas.DataFrame`. + bins : DataFrame with ape_min, ape_max, ape (APE of the proxy + spectrum), duration [s], energy [Wh m-2], n_spectra + labels : Series, the bin number of each input spectrum + (users can group temperature etc. by this) + + Notes + ----- + This function implements the equal-energy spectral binning method of + Witteck et al. [1]_, following the approach of Garcia et al. [2]_. It + reduces a large set of spectra (for example, a year of measurements) to a + small number of representative "proxy" spectra while conserving the total + incident energy. This lowers the cost of energy yield simulations and + highlights which spectra contribute most of the energy. + + Each spectrum is described by two quantities. The first is its average + photon energy (APE, :math:`E_\mathrm{ape}`; see + :py:func:`~pvlib.spectrum.average_photon_energy`), a device-independent + measure of spectral shape, where a higher APE indicates a bluer spectrum. + The second is the energy it contributes, + + .. math:: + + E = G \cdot \Delta t, + + where :math:`G = \int \Phi(\lambda) \, d\lambda` is the broadband + irradiance of the spectrum :math:`\Phi`, and :math:`\Delta t` is the time + interval the spectrum represents. + + The spectra are sorted by APE and the cumulative energy is partitioned into + ``n_bins`` bins that each contain an equal share of the total energy. + Unlike binning by equal APE width or by equal spectrum count, equal-energy + binning concentrates the bins where most of the energy is delivered, so the + representation is most accurate for the spectra that matter most to the + energy yield. + + Each bin :math:`k` groups :math:`n_k` spectra :math:`\Phi_{kl}`, where + spectrum :math:`l` represents a time :math:`t_{kl}`. The bin is summarised + by a single proxy spectrum :math:`\Phi_k^\mathrm{p}`, the time-weighted + mean of its members, + + .. math:: + + \Phi_k^\mathrm{p} = \frac{1}{t_k} \sum_{l=1}^{n_k} \Phi_{kl} \, t_{kl}, + \qquad t_k = \sum_{l=1}^{n_k} t_{kl}. + + Because the mean is energy-conserving, the proxy spectra carry the same + total energy as the original dataset. The APE depends on the wavelength + range of the input, so all spectra must share a common wavelength grid. + + References + ---------- + .. [1] R. Witteck, J. F. Geisz, E. L. Warren, and W. E. McMahon, "Spectral + Effects on the Energy Harvesting Efficiency of Two- and Four-Terminal + Tandem Photovoltaics," Solar RRL, vol. 8, no. 3, p. 2300782, 2024, + :doi:`10.1002/solr.202300782`. + .. [2] I. García, W. E. McMahon, A. Habte, J. F. Geisz, M. A. Steiner, + M. Sengupta, and D. J. Friedman, "Spectral binning for energy + production calculations and multijunction solar cell design," Progress + in Photovoltaics: Research and Applications, vol. 26, no. 1, pp. 48-54, + 2018, :doi:`10.1002/pip.2943`. + """ From da11f73212747e0b52fe100f1a157798ae05917d Mon Sep 17 00:00:00 2001 From: RDaxini Date: Thu, 8 Oct 2026 16:57:54 -0600 Subject: [PATCH 2/9] Update __init__.py --- pvlib/spectrum/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pvlib/spectrum/__init__.py b/pvlib/spectrum/__init__.py index 1e551c83fa..599d55f6ed 100644 --- a/pvlib/spectrum/__init__.py +++ b/pvlib/spectrum/__init__.py @@ -17,3 +17,6 @@ sr_to_qe, qe_to_sr, ) +from pvlib.spectrum.spectral_binning import ( # noqa: F401 + spectral_binning, +) From ceae6f90524f881c56ce25d966d5cb052d5babc6 Mon Sep 17 00:00:00 2001 From: Daxini Date: Mon, 12 Oct 2026 10:22:49 -0600 Subject: [PATCH 3/9] import trapezoid --- pvlib/spectrum/spectral_binning.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pvlib/spectrum/spectral_binning.py b/pvlib/spectrum/spectral_binning.py index 7c4d862f81..1493c6ca6b 100644 --- a/pvlib/spectrum/spectral_binning.py +++ b/pvlib/spectrum/spectral_binning.py @@ -8,6 +8,7 @@ import numpy as np import pandas as pd +from scipy.integrate import trapezoid from pvlib.spectrum.irradiance import average_photon_energy From 1c6be611315e9c0548ed980ba305108ffa516d6a Mon Sep 17 00:00:00 2001 From: Daxini Date: Mon, 12 Oct 2026 10:24:24 -0600 Subject: [PATCH 4/9] simplify docstring --- pvlib/spectrum/spectral_binning.py | 87 +++++++++++------------------- 1 file changed, 32 insertions(+), 55 deletions(-) diff --git a/pvlib/spectrum/spectral_binning.py b/pvlib/spectrum/spectral_binning.py index 1493c6ca6b..a2ffbe89ca 100644 --- a/pvlib/spectrum/spectral_binning.py +++ b/pvlib/spectrum/spectral_binning.py @@ -14,77 +14,54 @@ def spectral_binning(spectra, n_bins): r""" - Bin spectral irradiance data into a specified number of bins. + Reduce a set of spectra to a few representative spectra by equal-energy + binning. + + Spectra are sorted from red-rich to blue-rich by average photon energy + (APE) and split into bins that each hold an equal share of the total + incident energy. Each bin is represented by its mean "proxy" spectrum. Parameters ---------- - spectra : pandas.Series or pandas.DataFrame - - Spectral irradiance, must be positive [Wm⁻²nm⁻¹]. + spectra : pandas.DataFrame + Spectral irradiance, one spectrum per row with wavelength [nm] as + column headers. Must be positive. [Wm⁻²nm⁻¹] See :term:`spectra`. - - A single spectrum must be a :py:class:`pandas.Series` with wavelength - [nm] as the index, while multiple spectra must be rows in a - :py:class:`pandas.DataFrame` with column headers as wavelength [nm] - n_bins : int - The number of bins to reduce the spectral data into. + Number of bins. Returns ------- - binned_spectra : pandas.Series or pandas.DataFrame - The binned spectral irradiance data. The structure matches the input - ``spectra``: a single spectrum is returned as a - :py:class:`pandas.Series`, while multiple spectra are returned as a - :py:class:`pandas.DataFrame`. - bins : DataFrame with ape_min, ape_max, ape (APE of the proxy - spectrum), duration [s], energy [Wh m-2], n_spectra - labels : Series, the bin number of each input spectrum - (users can group temperature etc. by this) + proxy_spectra : pandas.DataFrame + Mean spectrum of each bin, indexed by bin number. [Wm⁻²nm⁻¹] + bins : pandas.DataFrame + Bin properties: ``ape_min``, ``ape_max`` and ``ape`` (APE of the + proxy spectrum) [eV], ``n_spectra``, and ``energy_fraction`` (share + of the total incident energy). + labels : pandas.Series + Bin number of each input spectrum. ```` for spectra excluded + because their irradiance is zero or NaN. Notes ----- - This function implements the equal-energy spectral binning method of - Witteck et al. [1]_, following the approach of Garcia et al. [2]_. It - reduces a large set of spectra (for example, a year of measurements) to a - small number of representative "proxy" spectra while conserving the total - incident energy. This lowers the cost of energy yield simulations and - highlights which spectra contribute most of the energy. - - Each spectrum is described by two quantities. The first is its average - photon energy (APE, :math:`E_\mathrm{ape}`; see - :py:func:`~pvlib.spectrum.average_photon_energy`), a device-independent - measure of spectral shape, where a higher APE indicates a bluer spectrum. - The second is the energy it contributes, + This function implements the spectral binning method of [1]_, adapted + from [2]_. Spectra are sorted by APE (see + :py:func:`~pvlib.spectrum.average_photon_energy`) and their cumulative + broadband irradiance :math:`G = \int \Phi(\lambda) \, d\lambda` is split + into ``n_bins`` equal parts. Bin :math:`k` is represented by the mean of + its :math:`n_k` spectra, .. math:: - E = G \cdot \Delta t, - - where :math:`G = \int \Phi(\lambda) \, d\lambda` is the broadband - irradiance of the spectrum :math:`\Phi`, and :math:`\Delta t` is the time - interval the spectrum represents. - - The spectra are sorted by APE and the cumulative energy is partitioned into - ``n_bins`` bins that each contain an equal share of the total energy. - Unlike binning by equal APE width or by equal spectrum count, equal-energy - binning concentrates the bins where most of the energy is delivered, so the - representation is most accurate for the spectra that matter most to the - energy yield. - - Each bin :math:`k` groups :math:`n_k` spectra :math:`\Phi_{kl}`, where - spectrum :math:`l` represents a time :math:`t_{kl}`. The bin is summarised - by a single proxy spectrum :math:`\Phi_k^\mathrm{p}`, the time-weighted - mean of its members, - - .. math:: + \Phi_k^\mathrm{p} = \frac{1}{n_k} \sum_{l=1}^{n_k} \Phi_{kl}, - \Phi_k^\mathrm{p} = \frac{1}{t_k} \sum_{l=1}^{n_k} \Phi_{kl} \, t_{kl}, - \qquad t_k = \sum_{l=1}^{n_k} t_{kl}. + which conserves the total incident energy. Fewer than ``n_bins`` bins are + returned if a single spectrum holds more than ``1/n_bins`` of the total. - Because the mean is energy-conserving, the proxy spectra carry the same - total energy as the original dataset. The APE depends on the wavelength - range of the input, so all spectra must share a common wavelength grid. + Each spectrum is assumed to represent an equal time interval, so data with + unequal intervals should be resampled first. Night-time gaps have no + effect, but missing daylight data bias the result towards the conditions + present. References ---------- From bde3e922c4b00c408d0385c5dc7a247f254f8b93 Mon Sep 17 00:00:00 2001 From: Daxini Date: Mon, 12 Oct 2026 10:26:38 -0600 Subject: [PATCH 5/9] add helper to decide bin for each spectrum --- pvlib/spectrum/spectral_binning.py | 36 ++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/pvlib/spectrum/spectral_binning.py b/pvlib/spectrum/spectral_binning.py index a2ffbe89ca..01ea833358 100644 --- a/pvlib/spectrum/spectral_binning.py +++ b/pvlib/spectrum/spectral_binning.py @@ -12,6 +12,42 @@ from pvlib.spectrum.irradiance import average_photon_energy +def _assign_equal_energy_bins(ape, energy, n_bins): + """ + Assign each spectrum to one of ``n_bins`` equal-energy bins. + + Parameters + ---------- + ape : pandas.Series + Average photon energy of each spectrum. [eV] + energy : pandas.Series + Energy of each spectrum, aligned with ``ape``. Any consistent unit. + Must be positive. + n_bins : int + Number of bins. + + Returns + ------- + labels : pandas.Series of int + Bin index of each spectrum, with the same index as ``ape``. Bins may + be empty if a single spectrum holds more than ``1/n_bins`` of the + total energy. + + Notes + ----- + Spectra are sorted by ``ape`` and assigned to bin ``k`` (0-indexed) if + their cumulative energy lies in ``(k*E/n_bins, (k+1)*E/n_bins]``, where + ``E`` is the total energy. + """ + order = np.argsort(ape.to_numpy(), kind='stable') + cum_energy = np.cumsum(energy.to_numpy()[order]) + thresholds = cum_energy[-1] * np.arange(1, n_bins) / n_bins + labels = np.empty(len(ape), dtype=int) + labels[order] = np.searchsorted(thresholds, cum_energy, side='left') + + return pd.Series(labels, index=ape.index, name='bin') + + def spectral_binning(spectra, n_bins): r""" Reduce a set of spectra to a few representative spectra by equal-energy From e3b4bee9849398df73d53fb4f7f2db966cd87bae Mon Sep 17 00:00:00 2001 From: Daxini Date: Mon, 12 Oct 2026 10:27:08 -0600 Subject: [PATCH 6/9] remove pvlib import --- pvlib/spectrum/spectral_binning.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pvlib/spectrum/spectral_binning.py b/pvlib/spectrum/spectral_binning.py index 01ea833358..8643b1058d 100644 --- a/pvlib/spectrum/spectral_binning.py +++ b/pvlib/spectrum/spectral_binning.py @@ -4,7 +4,6 @@ datasets into smaller, representative datasets. """ -import pvlib import numpy as np import pandas as pd From 5300ab067baf6bc85648f76c660abd0f15696f5b Mon Sep 17 00:00:00 2001 From: Daxini Date: Mon, 12 Oct 2026 11:19:48 -0600 Subject: [PATCH 7/9] core --- pvlib/spectrum/spectral_binning.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/pvlib/spectrum/spectral_binning.py b/pvlib/spectrum/spectral_binning.py index 8643b1058d..22c3bfb5d8 100644 --- a/pvlib/spectrum/spectral_binning.py +++ b/pvlib/spectrum/spectral_binning.py @@ -74,8 +74,8 @@ def spectral_binning(spectra, n_bins): proxy spectrum) [eV], ``n_spectra``, and ``energy_fraction`` (share of the total incident energy). labels : pandas.Series - Bin number of each input spectrum. ```` for spectra excluded - because their irradiance is zero or NaN. + Bin number of each input spectrum. ``np.nan`` for spectra whose APE + is undefined, e.g. all-zero or NaN spectra. Notes ----- @@ -110,3 +110,27 @@ def spectral_binning(spectra, n_bins): in Photovoltaics: Research and Applications, vol. 26, no. 1, pp. 48-54, 2018, :doi:`10.1002/pip.2943`. """ + ape = average_photon_energy(spectra) + wavelength = spectra.columns.to_numpy(dtype=float) + irradiance = pd.Series(trapezoid(spectra, x=wavelength, axis=1), + index=spectra.index) + + # APE is NaN for all-zero or NaN spectra, which can't be sorted + valid = ape.notna() + ape, irradiance = ape[valid], irradiance[valid] + + # equal time intervals, so energy is proportional to irradiance + labels = _assign_equal_energy_bins(ape, irradiance, n_bins) + + proxy_spectra = spectra[valid].groupby(labels).mean() + + bins = pd.DataFrame({ + 'ape_min': ape.groupby(labels).min(), + 'ape_max': ape.groupby(labels).max(), + 'ape': average_photon_energy(proxy_spectra), + 'n_spectra': labels.value_counts().sort_index(), + 'energy_fraction': irradiance.groupby(labels).sum() / irradiance.sum(), + }) + + labels = labels.reindex(spectra.index) + return proxy_spectra, bins, labels From ad048dc86140c9e3a12e0ca2051812bd7e6a9871 Mon Sep 17 00:00:00 2001 From: Daxini Date: Wed, 14 Oct 2026 10:15:25 -0600 Subject: [PATCH 8/9] improve readability, clarify return bins < n_bins --- pvlib/spectrum/spectral_binning.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/pvlib/spectrum/spectral_binning.py b/pvlib/spectrum/spectral_binning.py index 22c3bfb5d8..9f1fe35993 100644 --- a/pvlib/spectrum/spectral_binning.py +++ b/pvlib/spectrum/spectral_binning.py @@ -80,18 +80,22 @@ def spectral_binning(spectra, n_bins): Notes ----- This function implements the spectral binning method of [1]_, adapted - from [2]_. Spectra are sorted by APE (see - :py:func:`~pvlib.spectrum.average_photon_energy`) and their cumulative - broadband irradiance :math:`G = \int \Phi(\lambda) \, d\lambda` is split - into ``n_bins`` equal parts. Bin :math:`k` is represented by the mean of - its :math:`n_k` spectra, + from [2]_. First, the spectra are sorted by APE (see + :py:func:`~pvlib.spectrum.average_photon_energy`). Next, they are divided + into ``n_bins`` bins of equal energy, using the cumulative sum of their + broadband irradiance :math:`G = \int \Phi(\lambda) \, d\lambda`. Finally, + each bin :math:`k` is represented by the mean of its :math:`n_k` spectra, .. math:: \Phi_k^\mathrm{p} = \frac{1}{n_k} \sum_{l=1}^{n_k} \Phi_{kl}, - which conserves the total incident energy. Fewer than ``n_bins`` bins are - returned if a single spectrum holds more than ``1/n_bins`` of the total. + which conserves the total incident energy. + + A spectrum cannot be split between bins, so a spectrum holding more than + ``1/n_bins`` of the total energy can span an entire bin and leave it + empty. Empty bins are omitted, so fewer than ``n_bins`` bins can be + returned, e.g. when ``n_bins`` exceeds the number of spectra. Each spectrum is assumed to represent an equal time interval, so data with unequal intervals should be resampled first. Night-time gaps have no From bcd1b0852dfa95d1fd3e36dfccb41cf3f6d8f31a Mon Sep 17 00:00:00 2001 From: Daxini Date: Wed, 14 Oct 2026 10:18:52 -0600 Subject: [PATCH 9/9] Update spectrum.rst --- .../source/reference/effects_on_pv_system_output/spectrum.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/sphinx/source/reference/effects_on_pv_system_output/spectrum.rst b/docs/sphinx/source/reference/effects_on_pv_system_output/spectrum.rst index b453a300ee..1f94edde7c 100644 --- a/docs/sphinx/source/reference/effects_on_pv_system_output/spectrum.rst +++ b/docs/sphinx/source/reference/effects_on_pv_system_output/spectrum.rst @@ -19,3 +19,4 @@ Spectrum spectrum.sr_to_qe spectrum.qe_to_sr spectrum.average_photon_energy + spectrum.spectral_binning