diff --git a/docs/examples/shading/plot_martinez_shade_loss.py b/docs/examples/shading/plot_martinez_shade_loss.py new file mode 100644 index 0000000000..625be33bc8 --- /dev/null +++ b/docs/examples/shading/plot_martinez_shade_loss.py @@ -0,0 +1,272 @@ +""" +Modelling shading losses in modules with bypass diodes +====================================================== +""" + +# %% +# This example illustrates how to use the loss model proposed by Martinez et +# al. [1]_. The model proposes a power output losses factor by adjusting +# the incident direct and circumsolar beam irradiance fraction of a PV module +# based on the number of shaded *blocks*. A *block* is defined as a group of +# cells protected by a bypass diode. More information on *blocks* can be found +# in the original paper [1]_ and in the +# :py:func:`pvlib.shading.direct_martinez` documentation. +# +# The following key functions are used in this example: +# +# 1. :py:func:`pvlib.shading.direct_martinez` to calculate the power output +# losses fraction due to shading. +# 2. :py:func:`pvlib.shading.shaded_fraction1d` to calculate the fraction of +# shaded surface and consequently the number of shaded *blocks* due to +# row-to-row shading. +# 3. :py:func:`pvlib.tracking.singleaxis` to calculate the rotation angle of +# the trackers. +# +# .. sectionauthor:: Echedey Luis +# +# Problem description +# ------------------- +# Let's consider a PV system with the following characteristics: +# +# - Two north-south single-axis trackers, each one having 6 modules. +# - The rows have the same true-tracking tilt angles. True tracking +# is chosen in this example, so shading is significant. +# - Terrain slope is 7 degrees downward to the east. +# - Row axes are horizontal. +# - The modules are comprised of multiple cells. We will compare these cases: +# - modules with one bypass diode +# - modules with three bypass diodes +# - half-cut cell modules with three bypass diodes in portrait and landscape +# +# Setting up the system +# ---------------------- +# Let's start by defining the system characteristics, location and the time +# range for the analysis. + +import pvlib +import pandas as pd +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.dates import ConciseDateFormatter + +pitch = 4 # meters +width = 1.5 # meters +gcr = width / pitch # ground coverage ratio +N_modules_per_row = 6 +axis_azimuth = 180 # N-S axis +axis_tilt = 0 # flat because the axis is perpendicular to the slope +cross_axis_tilt = -7 # 7 degrees downward to the east + +latitude, longitude = 40.2712, -3.7277 +locus = pvlib.location.Location( + latitude, + longitude, + tz="Europe/Madrid", + altitude=pvlib.location.lookup_altitude(latitude, longitude), +) + +times = pd.date_range("2001-04-11T04", "2001-04-11T20", freq="10min") + +# %% +# True-tracking algorithm and shaded fraction +# ------------------------------------------- +# Since this model is about row-to-row shading, we will use the true-tracking +# algorithm to calculate the trackers rotation. Back-tracking eliminates +# shading between rows, and since this example is about shading, we will not +# use it. +# +# Then, the next step is to calculate the fraction of shaded surface. This is +# done using :py:func:`pvlib.shading.shaded_fraction1d`. Using this function is +# straightforward with the variables we already have defined. +# Then, we can calculate the number of shaded blocks by rounding up the shaded +# fraction by the number of blocks along the shaded length. + +# Calculate solar position to get single-axis tracker rotation and irradiance +solar_pos = locus.get_solarposition(times) +solar_apparent_zenith, solar_azimuth = ( + solar_pos["apparent_zenith"], + solar_pos["azimuth"], +) # unpack for better readability + +tracking_result = pvlib.tracking.singleaxis( + apparent_zenith=solar_apparent_zenith, + apparent_azimuth=solar_azimuth, + axis_tilt=axis_tilt, + axis_azimuth=axis_azimuth, + max_angle=(-90 + cross_axis_tilt, 90 + cross_axis_tilt), # (min, max) + backtrack=False, + gcr=gcr, + cross_axis_tilt=cross_axis_tilt, +) + +tracker_theta, aoi, surface_tilt, surface_azimuth = ( + tracking_result["tracker_theta"], + tracking_result["aoi"], + tracking_result["surface_tilt"], + tracking_result["surface_azimuth"], +) # unpack for better readability + +# Calculate the shade fraction +shaded_fraction = pvlib.shading.shaded_fraction1d( + solar_apparent_zenith, + solar_azimuth, + axis_azimuth, + axis_tilt=axis_tilt, + shaded_row_rotation=tracker_theta, + shading_row_rotation=tracker_theta, + collector_width=width, + pitch=pitch, + cross_axis_slope=cross_axis_tilt, +) + +# %% +# Number of shaded blocks +# ----------------------- +# The number of shaded blocks depends on the module configuration and number +# of bypass diodes. For example, +# modules with one bypass diode will behave like one block. +# On the other hand, modules with three bypass diodes will have three blocks, +# except for the half-cut cell modules, which will have six blocks; 2x3 blocks +# where the two rows are along the longest side of the module. +# We can argue that the dimensions of the system change when you switch from +# portrait to landscape, but for this example, we will consider it the same. +# +# The number of shaded blocks is calculated by rounding up the shaded fraction +# by the number of blocks along the shaded length. So let's define the number +# of blocks for each module configuration: +# +# - 1 bypass diode: 1 block +# - 3 bypass diodes: 3 blocks in landscape; 1 in portrait +# - 3 bypass diodes half-cut cells: +# - 2 blocks in portrait +# - 3 blocks in landscape +# +# .. figure:: ../../_images/PV_module_layout_cesardd.jpg +# :align: center +# :width: 75% +# :alt: Normal and half-cut cells module layouts +# +# Left: common module layout. Right: half-cut cells module layout. +# Each module has three bypass diodes. On the left, they connect cell +# columns 1-2, 2-3 & 3-4. On the right, they connect cell columns 1-2, 3-4 & +# 5-6. +# *Source: César Domínguez. CC BY-SA 4.0, Wikimedia Commons* +# +# In the image above, each orange U-shaped string section is a block. +# By symmetry, the yellow inverted-U's of the subcircuit are also blocks. +# For this reason, the half-cut cell modules have 6 blocks in total: two along +# the longest side and three along the shortest side. + +blocks_per_module = { + "1 bypass diode": 1, + "3 bypass diodes": 3, + "3 bypass diodes half-cut, portrait": 2, + "3 bypass diodes half-cut, landscape": 3, +} + +# Calculate the number of shaded blocks during the day +shaded_blocks_per_module = { + k: np.ceil(blocks_N * shaded_fraction) + for k, blocks_N in blocks_per_module.items() +} + +# %% +# Plane of array irradiance example data +# -------------------------------------- +# To calculate the power output losses due to shading, we need the plane of +# array irradiance. For this example, we will use synthetic data: + +clearsky = locus.get_clearsky( + times, solar_position=solar_pos, model="ineichen" +) +dni_extra = pvlib.irradiance.get_extra_radiation(times) +airmass = pvlib.atmosphere.get_relative_airmass(solar_apparent_zenith) +sky_diffuse = pvlib.irradiance.perez_driesse( + surface_tilt, surface_azimuth, clearsky["dhi"], clearsky["dni"], + solar_apparent_zenith, solar_azimuth, dni_extra, airmass, +) # fmt: skip +poa_components = pvlib.irradiance.poa_components( + aoi, clearsky["dni"], sky_diffuse, poa_ground_diffuse=0 +) # ignore ground diffuse for brevity +poa_global, poa_direct = ( + poa_components["poa_global"], + poa_components["poa_direct"], +) + +# %% +# Results +# ------- +# Now that we have the number of shaded blocks for each module configuration, +# we can apply the model and estimate the power loss due to shading. +# +# Note that this model is not linear with the shaded blocks ratio, so there is +# a difference between applying it to just a module or a whole row. + +shade_losses_per_module = { + k: pvlib.shading.direct_martinez( + poa_global=poa_global, + poa_direct=poa_direct, + shaded_fraction=shaded_fraction, + shaded_blocks=module_shaded_blocks, + total_blocks=blocks_per_module[k], + ) + for k, module_shaded_blocks in shaded_blocks_per_module.items() +} + +shade_losses_per_row = { + k: pvlib.shading.direct_martinez( + poa_global=poa_global, + poa_direct=poa_direct, + shaded_fraction=shaded_fraction, + shaded_blocks=module_shaded_blocks * N_modules_per_row, + total_blocks=blocks_per_module[k] * N_modules_per_row, + ) + for k, module_shaded_blocks in shaded_blocks_per_module.items() +} + +# %% +# Plotting the results +# ^^^^^^^^^^^^^^^^^^^^ + +fig, (ax1, ax2) = plt.subplots(2, 1, sharex=True) +fig.suptitle("Martinez power losses due to shading") +for k, shade_losses in shade_losses_per_module.items(): + linestyle = "--" if k == "3 bypass diodes half-cut, landscape" else "-" + ax1.plot(times, shade_losses, label=k, linestyle=linestyle) +ax1.legend(loc="upper center") +ax1.grid() +ax1.set_xlabel("Time") +ax1.xaxis.set_major_formatter( + ConciseDateFormatter("%H:%M", tz="Europe/Madrid") +) +ax1.set_ylabel(r"$P_{out}$ losses") +ax1.set_title("Per module") + +for k, shade_losses in shade_losses_per_row.items(): + linestyle = "--" if k == "3 bypass diodes half-cut, landscape" else "-" + ax2.plot(times, shade_losses, label=k, linestyle=linestyle) +ax2.legend(loc="upper center") +ax2.grid() +ax2.set_xlabel("Time") +ax2.xaxis.set_major_formatter( + ConciseDateFormatter("%H:%M", tz="Europe/Madrid") +) +ax2.set_ylabel(r"$P_{out}$ losses") +ax2.set_title("Per row") +fig.tight_layout() +fig.show() + +# %% +# Note how the half-cut cell module in portrait performs better than the +# normal module with three bypass diodes. This is because the number of shaded +# blocks is less along the shaded length is higher in the half-cut module. +# This is the reason why half-cut cell modules are preferred in portrait +# orientation. + +# %% +# References +# ---------- +# .. [1] F. Martínez-Moreno, J. Muñoz, and E. Lorenzo, 'Experimental model +# to estimate shading losses on PV arrays', Solar Energy Materials and +# Solar Cells, vol. 94, no. 12, pp. 2298-2303, Dec. 2010, +# :doi:`10.1016/j.solmat.2010.07.029`. diff --git a/docs/sphinx/source/_images/PV_module_layout_cesardd.jpg b/docs/sphinx/source/_images/PV_module_layout_cesardd.jpg new file mode 100644 index 0000000000..385fe8dcbb Binary files /dev/null and b/docs/sphinx/source/_images/PV_module_layout_cesardd.jpg differ diff --git a/docs/sphinx/source/reference/effects_on_pv_system_output/shading.rst b/docs/sphinx/source/reference/effects_on_pv_system_output/shading.rst index 57cfe0b806..2d0b94547f 100644 --- a/docs/sphinx/source/reference/effects_on_pv_system_output/shading.rst +++ b/docs/sphinx/source/reference/effects_on_pv_system_output/shading.rst @@ -12,4 +12,4 @@ Shading shading.sky_diffuse_passias shading.projected_solar_zenith_angle shading.shaded_fraction1d - + shading.direct_martinez diff --git a/docs/sphinx/source/whatsnew/v0.11.0.rst b/docs/sphinx/source/whatsnew/v0.11.0.rst index b306654624..9cdd5e8680 100644 --- a/docs/sphinx/source/whatsnew/v0.11.0.rst +++ b/docs/sphinx/source/whatsnew/v0.11.0.rst @@ -57,6 +57,9 @@ Enhancements * Added extraterrestrial and direct spectra of the ASTM G173-03 standard with the new function :py:func:`pvlib.spectrum.get_reference_spectra`. (:issue:`1963`, :pull:`2039`) +* Added function :py:func:`pvlib.shading.direct_martinez` to calculate + shading losses by taking into account the amount of bypass diodes of a module. + (:issue:`2063`, :pull:`2070`) * Add function :py:func:`pvlib.irradiance.diffuse_par_spitters` to calculate the diffuse fraction of Photosynthetically Active Radiation (PAR) from the global diffuse fraction and the solar zenith. diff --git a/pvlib/shading.py b/pvlib/shading.py index cbbf80a8f6..12040e91be 100644 --- a/pvlib/shading.py +++ b/pvlib/shading.py @@ -553,3 +553,142 @@ def shaded_fraction1d( ) return np.clip(t_asterisk, 0, 1) + + +def direct_martinez( + poa_global, + poa_direct, + shaded_fraction, + shaded_blocks, + total_blocks, +): + r""" + A shading loss power factor for non-monolithic silicon + modules and arrays with an arbitrary number of bypass diodes. + + This experimental model reduces the direct and circumsolar + irradiance reaching the module's cells based on the number of *blocks* + affected by the shadow. + More on blocks in the *Notes* section and in [1]_. + + .. versionadded:: 0.11.0 + + Parameters + ---------- + poa_global : numeric + Plane of array global irradiance. [W/m²]. + poa_direct : numeric + Plane of array direct and circumsolar irradiance. [W/m²]. + shaded_fraction : numeric + Fraction of module surface area that is shaded. [Unitless]. + shaded_blocks : numeric + Number of blocks affected by the shadow. [Unitless]. + If a floating point number is provided, it will be rounded up. + total_blocks : int + Number of total blocks. Unitless. + + Returns + ------- + shading_losses : numeric + Fraction of DC power lost due to shading. [Unitless] + + Notes + ----- + The implemented equations are (6) and (8) from [1]_: + + .. math:: + + (1 - F_{ES}) = (1 - F_{GS}) \left(1 - \frac{N_{SB}}{N_{TB} + 1}\right) + \quad \text{(6)} + + \left(1 - \frac{P_{S}}{P_{NS}}\right) = \left(1 - + \frac{\left[(B + D^{CIR})(1 - F_{ES}) + D^{ISO} + R\right]}{G}\right) + \quad \text{(8)} + + In (6), :math:`(1 - F_{ES})` is the correction factor to be multiplied by + the direct and circumsolar irradiance, :math:`F_{GS}` is the shaded + fraction of the collector, :math:`N_{SB}` is the number of shaded blocks + and :math:`N_{TB}` is the number of total blocks. + + In (8), :math:`\frac{P_{S}}{P_{NS}}` is the fraction of DC power lost due + to shading, :math:`P_{S}` is the power output of the shaded module, + :math:`P_{NS}` is the power output of the non-shaded module, + :math:`B + D^{CIR}` is the beam and circumsolar irradiance, + :math:`D^{ISO} + R` is the sum of diffuse and albedo irradiances and + :math:`G` is the global irradiance. + + **Blocks terminology:** + + A *block* is defined in [1]_ as a group of solar cells protected by a + bypass diode. Also, a *block* is shaded when at least one of its + cells is partially shaded. + + The total number of blocks and their layout depend on the module(s) used. + Many manufacturers don't specify this information explicitly. + However, these values can be inferred from: + + - the number of bypass diodes + - where and how many junction boxes are present on the back of the module + - whether or not the module is comprised of *half-cut cells* + + The latter two are heavily correlated. + + For example: + + 1. A module with 1 bypass diode behaves as 1 block. + 2. A module with 3 bypass diodes and 1 junction box is likely to have 3 + blocks. + 3. A half-cut module with 3 junction boxes (split junction boxes) is + likely to have 3x2 blocks. The number of blocks along the longest + side of the module is 2 and along the shortest side is 3. + 4. A module without bypass diodes doesn't constitute a block, but may be + part of one. + + Examples + -------- + Minimal example. For a complete example, see + :ref:`sphx_glr_gallery_shading_plot_martinez_shade_loss.py`. + + >>> import numpy as np + >>> from pvlib import shading + >>> total_blocks = 3 # blocks along the vertical of the module + >>> POA_direct_and_circumsolar, POA_diffuse = 600, 80 # W/m² + >>> POA_global = POA_direct_and_circumsolar + POA_diffuse + >>> P_out_unshaded = 3000 # W + >>> # calculation of the shaded fraction for the collector + >>> shaded_fraction = shading.shaded_fraction1d( + >>> solar_zenith=80, solar_azimuth=180, + >>> axis_azimuth=90, shaded_row_rotation=25, + >>> collector_width=0.5, pitch=1, surface_to_axis_offset=0, + >>> cross_axis_slope=5.711, shading_row_rotation=50) + >>> # calculation of the number of shaded blocks + >>> shaded_blocks = np.ceil(total_blocks*shaded_fraction) + >>> # apply the Martinez power losses to the calculated shading + >>> loss_fraction = shading.direct_martinez( + >>> POA_global, POA_direct_and_circumsolar, + >>> shaded_fraction, shaded_blocks, total_blocks) + >>> P_out_corrected = P_out_unshaded * (1 - loss_fraction) + + See Also + -------- + shaded_fraction1d : to calculate 1-dimensional shaded fraction + + References + ---------- + .. [1] F. Martínez-Moreno, J. Muñoz, and E. Lorenzo, 'Experimental model + to estimate shading losses on PV arrays', Solar Energy Materials and + Solar Cells, vol. 94, no. 12, pp. 2298-2303, Dec. 2010, + :doi:`10.1016/j.solmat.2010.07.029`. + """ # Contributed by Echedey Luis, 2024 + beam_factor = ( # Eq. (6) of [1] + (1 - shaded_fraction) + * (1 - np.ceil(shaded_blocks) / (1 + total_blocks)) + ) + return ( # Eq. (8) of [1] + 1 + - ( + poa_direct * beam_factor + + (poa_global - poa_direct) # diffuse and albedo + ) + / poa_global + ) diff --git a/pvlib/tests/test_shading.py b/pvlib/tests/test_shading.py index b8bf8929ae..f83f2db47b 100644 --- a/pvlib/tests/test_shading.py +++ b/pvlib/tests/test_shading.py @@ -327,3 +327,65 @@ def test_shaded_fraction1d_unprovided_shading_row_rotation(): premises = test_data.drop(columns=["expected_sf"]) sf = shading.shaded_fraction1d(**premises) assert_allclose(sf, expected_sf, atol=1e-2) + + +@pytest.fixture +def direct_martinez_Table2(): + """ + Original data used in [1] (see pvlib.shading.direct_martinez) to validate + the model. Some of the data is provided in Table 2. + Returns tuple with (input: pandas.DataFrame, output: pandas.Series) + Output is power loss: 1 - (P_shaded / P_unshaded) + """ + test_data = pd.DataFrame( + columns=[ + "F_GS-H", + "F_GS-V", + "shaded_blocks", + "poa_direct", + "poa_diffuse", + "power_loss_model", + ], + data=[ + # F-H, F-V, Nsb, direct, diffuse, power_loss + # original data sourced from researchers + [1.00, 0.09, 16, 846.59, 59.42, 0.8844], + [1.00, 0.18, 16, 841.85, 59.69, 0.8888], + [1.00, 0.36, 16, 843.38, 59.22, 0.8994], + [0.04, 0.64, 1, 851.90, 59.40, 0.0783], + [0.17, 0.45, 3, 862.86, 58.40, 0.2237], + [0.29, 0.27, 5, 864.14, 58.11, 0.3282], + [0.50, 0.09, 8, 863.23, 58.31, 0.4634], + [0.13, 1.00, 2, 870.14, 58.02, 0.2137], + [0.25, 1.00, 4, 876.57, 57.98, 0.4000], + [0.38, 1.00, 6, 866.86, 58.89, 0.5577], + [0.50, 1.00, 8, 874.58, 58.44, 0.6892], + [0.58, 0.82, 10, 876.80, 58.16, 0.7359], + [0.75, 0.73, 12, 866.89, 58.73, 0.8113], + [0.92, 0.64, 15, 861.48, 59.66, 0.8894], + # custom edge cases + [0.00, 0.00, 0, 800.00, 50.00, 0.0000], + [1.00, 1.00, 16, 900.00, 00.00, 1.0000], + [0.00, 1.00, 16, 000.00, 00.00, np.nan], + [1.00, 0.00, 0, 000.00, 00.00, np.nan], + [1.00, 0.00, 0, -50.00, 50.00, np.nan], # zero poa_global + [1.00, 0.00, 0, 50.00, -50.00, np.nan], # zero poa_global + ] + ) # fmt: skip + test_data["total_blocks"] = 16 # total blocks is 16 for all cases + test_data["shaded_fraction"] = test_data["F_GS-H"] * test_data["F_GS-V"] + test_data["poa_global"] = ( + test_data["poa_direct"] + test_data["poa_diffuse"] + ) + test_data = test_data.drop(columns=["F_GS-H", "F_GS-V", "poa_diffuse"]) + return ( + test_data.drop(columns="power_loss_model"), + test_data["power_loss_model"], + ) + + +def test_direct_martinez(direct_martinez_Table2): + """Tests pvlib.shading.direct_martinez""" + test_data, power_losses_expected = direct_martinez_Table2 + power_losses = shading.direct_martinez(**test_data) + assert_allclose(power_losses, power_losses_expected, atol=5e-3)