diff --git a/docs/sphinx/source/_images/FSLR_irrad_shade_loss_slope_terrain.png b/docs/sphinx/source/_images/FSLR_irrad_shade_loss_slope_terrain.png new file mode 100644 index 0000000000..d2f01c2e88 Binary files /dev/null and b/docs/sphinx/source/_images/FSLR_irrad_shade_loss_slope_terrain.png 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 a68dd94b2a..189f597dce 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 @@ -8,4 +8,7 @@ Shading shading.masking_angle shading.masking_angle_passias - shading.sky_diffuse_passias \ No newline at end of file + shading.sky_diffuse_passias + shading.tracker_shaded_fraction + shading.linear_shade_loss + \ No newline at end of file diff --git a/docs/sphinx/source/whatsnew/v0.9.6.rst b/docs/sphinx/source/whatsnew/v0.9.6.rst index 7d1271086f..e09ff4eb1c 100644 --- a/docs/sphinx/source/whatsnew/v0.9.6.rst +++ b/docs/sphinx/source/whatsnew/v0.9.6.rst @@ -11,12 +11,16 @@ Deprecations Enhancements ~~~~~~~~~~~~ - +* added functions `pvlib.shading.tracker_shaded_fraction` and + `pvlib.shading.linear_shade_loss` to calculate row-to-row shade and apply + linear shade loss for thin film CdTe modules like First Solar. + (:issue:`1689`, :issue:`1690`, :pull:`1725`) Bug fixes ~~~~~~~~~ * `data` can no longer be left unspecified in - :py:meth:`pvlib.modelchain.ModelChain.run_model_from_effective_irradiance`. (:issue:`1713`, :pull:`1720`) + :py:meth:`pvlib.modelchain.ModelChain.run_model_from_effective_irradiance`. + (:issue:`1713`, :pull:`1720`) Testing ~~~~~~~ @@ -41,6 +45,6 @@ Contributors * Lakshya Garg (:ghuser:`Lakshyadevelops`) * Adam R. Jensen (:ghuser:`adamrjensen`) * Siddharth Kaul (:ghuser:`k10blogger`) +* Mark A. Mikofski (:ghuser:`mikofski`) * Kshitiz Gupta (:ghuser:`kshitiz305`) * Stefan de Lange (:ghuser:`langestefan`) - diff --git a/pvlib/shading.py b/pvlib/shading.py index 01d725207c..9e511d600e 100644 --- a/pvlib/shading.py +++ b/pvlib/shading.py @@ -191,3 +191,117 @@ def sky_diffuse_passias(masking_angle): Available at https://www.nrel.gov/docs/fy18osti/67399.pdf """ return 1 - cosd(masking_angle/2)**2 + + +def tracker_shaded_fraction(tracker_theta, gcr, projected_solar_zenith, + cross_axis_slope=0): + r""" + Shaded fraction for trackers with a common angle on an east-west slope. + + Parameters + ---------- + tracker_theta : numeric + The tracker rotation angle in degrees from horizontal. + gcr : float + The ground coverage ratio as a fraction equal to the collector width + over the horizontal row-to-row pitch. + projected_solar_zenith : numeric + Zenith angle in degrees of the solar vector projected into the plane + perpendicular to the tracker axes. + cross_axis_slope : float, default 0 + Angle of the plane containing the tracker axes in degrees from + horizontal. + + Returns + ------- + shaded_fraction : numeric + The fraction of the collector width shaded by an adjacent row. A + value of 1 is completely shaded and zero is no shade. + + See also + -------- + pvlib.shading.linear_shade_loss + + + The shaded fraction is derived using trigonometery and similar triangles + from the tracker rotation :math:`\beta`, the ground slope :math:`\theta_g`, + the projected solar zenith (psz) :math:`\theta`, the collector width + :math:`L`, the row-to-row pitch :math:`P`, and the shadow length :math:`z` + as shown in the image below. + + .. image:: /_images/FSLR_irrad_shade_loss_slope_terrain.png + + The ratio of the shadow length to the pitch, :math:`z/P`, is given by the + following relation where the ground coverage ratio (GCR) is :math:`L/P`: + + .. math:: + \frac{z/P}{\sin{\left(\frac{\pi}{2}-\beta+\theta\right)}} + = \frac{GCR}{\sin{\left(\frac{\pi}{2}-\theta-\theta_g\right)}} + + Then the shaded fraction :math:`w/L` is derived from :math:`z/P` as + follows: + + .. math:: + \frac{w}{L} = 1 - \frac{P}{z\cos{\theta_g}} + + Finally, shade is zero if :math:`z\cos{\theta_g}/P \le 1`. + + References + ---------- + Mark A. Mikofski, "First Solar Irradiance Shade Losses on Sloped Terrain," + PVPMC, 2023 + """ + theta_g_rad = np.radians(cross_axis_slope) + # angle opposite shadow cast on the ground, z + angle_z = ( + np.pi / 2 - np.radians(tracker_theta) + + np.radians(projected_solar_zenith)) + # angle opposite the collector width, L + angle_gcr = ( + np.pi / 2 - np.radians(projected_solar_zenith) + - theta_g_rad) + # ratio of shadow, z, to pitch, P + zp = gcr * np.sin(angle_z) / np.sin(angle_gcr) + # there's only row-to-row shade loss if the shadow on the ground, z, is + # longer than row-to-row pitch projected on the ground, P*cos(theta_g) + zp_cos_g = zp*np.cos(theta_g_rad) + # shaded fraction (fs) + fs = np.where(zp_cos_g <= 1, 0, 1 - 1/zp_cos_g) + return fs + + +def linear_shade_loss(shaded_fraction, diffuse_fraction): + """ + Fraction of power lost to linear shade loss applicable to monolithic thin + film modules like First Solar CdTe, where the shadow is perpendicular to + cell scribe lines. + + Parameters + ---------- + shaded_fraction : numeric + The fraction of the collector width shaded by an adjacent row. A + value of 1 is completely shaded and zero is no shade. + diffuse_fraction : numeric + The ratio of diffuse plane of array (poa) irradiance to global poa. + A value of 1 is completely diffuse and zero is no diffuse. + + Returns + ------- + linear_shade_loss : numeric + The fraction of power lost due to linear shading. A value of 1 is all + power lost and zero is no loss. + + See also + -------- + pvlib.shading.tracker_shaded_fraction + + Example + ------- + >>> from pvlib import shading + >>> fs = shading.tracker_shaded_fraction(45.0, 0.8, 45.0, 0) + >>> loss = shading.linear_shade_loss(fs, 0.2) + >>> P_no_shade = 100 # [kWdc] DC output from modules + >>> P_linear_shade = P_no_shade * (1-loss) # [kWdc] output after loss + # 90.71067811865476 [kWdc] + """ + return shaded_fraction * (1 - diffuse_fraction) diff --git a/pvlib/tests/test_shading.py b/pvlib/tests/test_shading.py index 0558cb76ad..a2d90b89aa 100644 --- a/pvlib/tests/test_shading.py +++ b/pvlib/tests/test_shading.py @@ -76,3 +76,46 @@ def test_sky_diffuse_passias_scalar(average_masking_angle, shading_loss): for angle, loss in zip(average_masking_angle, shading_loss): actual_loss = shading.sky_diffuse_passias(angle) assert np.isclose(loss, actual_loss) + + +@pytest.fixture +def expected_fs(): + # trivial case, 80% gcr, no slope, trackers & psz at 45-deg + z0 = np.sqrt(2*0.8*0.8) + # another trivial case, 60% gcr, no slope, trackers & psz at 60-deg + z1 = 2*0.6 + # 30-deg isosceles, 60% gcr, no slope, 30-deg trackers, psz at 60-deg + z2 = 0.6*np.sqrt(3) + z = np.array([z0, z1, z2]) + return 1 - 1/z + + +def test_tracker_shade_fraction(expected_fs): + """closes gh1690""" + fs = shading.tracker_shaded_fraction(45.0, 0.8, 45.0) + assert np.isclose(fs, expected_fs[0]) + # same trivial case with 40%, shadow is only 0.565-m long < 1-m r2r P + zero_fs = shading.tracker_shaded_fraction(45.0, 0.4, 45.0) + assert np.isclose(zero_fs, 0) + # test vectors + tracker_theta = [45.0, 60.0, 30.0] + gcr = [0.8, 0.6, 0.6] + psz = [45.0, 60.0, 60.0] + slope = [0]*3 + fs_vec = shading.tracker_shaded_fraction( + tracker_theta, gcr, psz, slope) + assert np.allclose(fs_vec, expected_fs) + + +def test_linear_shade_loss(expected_fs): + loss = shading.linear_shade_loss(expected_fs[0], 0.2) + assert np.isclose(loss, 0.09289321881345258) + # if no diffuse, shade fraction is the loss + loss_no_df = shading.linear_shade_loss(expected_fs[0], 0) + assert np.isclose(loss_no_df, expected_fs[0]) + # if all diffuse, no shade loss + no_loss = shading.linear_shade_loss(expected_fs[0], 1.0) + assert np.isclose(no_loss, 0) + vec_loss = shading.linear_shade_loss(expected_fs, 0.2) + expected_loss = np.array([0.09289322, 0.13333333, 0.03019964]) + assert np.allclose(vec_loss, expected_loss)