Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Linear shade gh1690 #1725

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@ Shading

shading.masking_angle
shading.masking_angle_passias
shading.sky_diffuse_passias
shading.sky_diffuse_passias
shading.tracker_shaded_fraction
shading.linear_shade_loss

10 changes: 7 additions & 3 deletions docs/sphinx/source/whatsnew/v0.9.6.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
~~~~~~~
Expand All @@ -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`)

114 changes: 114 additions & 0 deletions pvlib/shading.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +208 to +210
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like it would be a lot easier for the user if the parameter was simply solar_zenith and the calculation to the projected solar zenith could be done behind the scenes (there should be a function for that in pvlib somewhere).

Copy link
Member Author

@mikofski mikofski May 11, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The calculation for psz requires at least one additional argument, the solar azimuth and is not a trivial calculation:

sp = pvl.solarposition.get_solarposition(times, latitude, longitude, ...)
proj_zenith = np.degrees(np.arctan2(
    np.sin(np.radians(sp.azimuth))*np.sin(np.radians(sp.apparent_zenith)),
    np.cos(np.radians(sp.apparent_zenith))))

It is already in pvlib here:

wid = np.degrees(np.arctan2(xp, zp))

which made me discover a typo in the comments here:

# vector (xp, yp, zp) in the (y, z) plane, which is normal to the panel and

should be the (x, z) plane, which is corrected lower down here:

# Calculate angle from x-y plane to projection of sun vector onto x-z plane

Anyway, I agree, it would be helpful to have PSZ as it's own function. And if it already exists in addition to tracking.py:432 then we should consolidate. I think it's out of scope for this PR, though so I'll open another one for that.

Copy link
Member Author

@mikofski mikofski May 11, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've created #1734 to propose a stand alone PSZ function, but in the process, realized that the existing use in tracking.py actually calculates the solar vector as observed in the reference frame of the tracker axes, which may be tilted north or south. So this new function would need to be generalized to different reference frames.

I'm coming around to this idea. One instant advantage of this standalone function would be generalization of this linear shade loss function to arbitrary slopes that have both NS and EW tilt! Thanks! However, I still think it's best to address separately, then return to this function to enhance it with the axis tilt and the solar azimuth.

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)
43 changes: 43 additions & 0 deletions pvlib/tests/test_shading.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)