Skip to content

Commit e5fa03c

Browse files
echedey-lsmikofskikandersolaradriessewholmgren
authored
Projected zenith convenience function (#1904)
* Function prototype * Update shading.rst * Update shading.py * Minimal test * Implementation From NREL paper * Fix, fix, fix, fix & format * Format issues * Extend tests (compare with singleaxis) & format with ruff * Format fixes * Upgrade tests * Array -> Axis * type * Whatsnew * xd * bruh * Minor Python optimization a la tracking.singleaxis * Comment and minor optimizations * Typo found by Mikofski Reported at: #1725 (comment) Confirmed via "Slope-Aware Backtracking for Single-Axis Trackers", paragraph after Eq. 1 Co-Authored-By: Mark Mikofski <bwana.marko@yahoo.com> * Surface -> Axis Co-Authored-By: Kevin Anderson <57452607+kandersolar@users.noreply.github.com> * Elevation -> Zenith Co-Authored-By: Kevin Anderson <57452607+kandersolar@users.noreply.github.com> * Elev -> Zenith Co-Authored-By: Kevin Anderson <57452607+kandersolar@users.noreply.github.com> * Update shading.py * Update docstring Co-Authored-By: Anton Driesse <9001027+adriesse@users.noreply.github.com> * Add comments from `tracking.singleaxis` Co-Authored-By: Will Holmgren <william.holmgren@gmail.com> Co-Authored-By: Mark Mikofski <bwana.marko@yahoo.com> * Singleaxis implementation port & test addition, based on old pvlib.tracking.singleaxis * Update v0.10.4.rst * Linter * Code review Co-Authored-By: Cliff Hansen <5393711+cwhanse@users.noreply.github.com> * Add Fig 5 [1] (still gotta check the built output) * Add caption, change size and describe in alternate text * rST fixes ? * Figures have captions, images do not https://pandemic-overview.readthedocs.io/en/latest/myGuides/reStructuredText-Images-and-Figures-Examples.html#id18 * Flip arguments order * I forgot 💀 * Linter are you happy now? * Remove port test and add edge cases test Co-Authored-By: Kevin Anderson <57452607+kandersolar@users.noreply.github.com> * Update test_shading.py Co-Authored-By: Kevin Anderson <57452607+kandersolar@users.noreply.github.com> * Indentation xd * Update test_shading.py * I forgot how to code * Align data * Docstring suggestion from Kevin Co-Authored-By: Kevin Anderson <57452607+kandersolar@users.noreply.github.com> * Update link to example? * Link, please work * Update shading.py * Update shading.py * Update shading.py * Update shading.py * Update shading.py * Update shading.py * Update shading.py * Update shading.py * Lintaaaaaaarrrgh Fixed the link finally * Update pvlib/shading.py Co-authored-by: Kevin Anderson <kevin.anderso@gmail.com> --------- Co-authored-by: Mark Mikofski <bwana.marko@yahoo.com> Co-authored-by: Kevin Anderson <57452607+kandersolar@users.noreply.github.com> Co-authored-by: Anton Driesse <9001027+adriesse@users.noreply.github.com> Co-authored-by: Will Holmgren <william.holmgren@gmail.com> Co-authored-by: Cliff Hansen <5393711+cwhanse@users.noreply.github.com> Co-authored-by: Kevin Anderson <kevin.anderso@gmail.com>
1 parent 923e025 commit e5fa03c

File tree

6 files changed

+253
-50
lines changed

6 files changed

+253
-50
lines changed
Loading

docs/sphinx/source/reference/effects_on_pv_system_output/shading.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ Shading
99
shading.ground_angle
1010
shading.masking_angle
1111
shading.masking_angle_passias
12-
shading.sky_diffuse_passias
12+
shading.sky_diffuse_passias
13+
shading.projected_solar_zenith_angle

docs/sphinx/source/whatsnew/v0.10.4.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@ v0.10.4 (Anticipated March, 2024)
88
Enhancements
99
~~~~~~~~~~~~
1010
* Added the Huld PV model used by PVGIS (:pull:`1940`)
11+
* Added function :py:func:`pvlib.shading.projected_solar_zenith_angle`,
12+
a common calculation in shading and tracking. (:issue:`1734`, :pull:`1904`)
1113
* Added :py:func:`~pvlib.iotools.get_solrad` for fetching irradiance data from
1214
the SOLRAD ground station network. (:pull:`1967`)
1315
* Added metadata parsing to :py:func:`~pvlib.iotools.read_solrad` to follow the standard iotools
1416
convention of returning a tuple of (data, meta). Previously the function only returned a dataframe. (:pull:`1968`)
1517

18+
1619
Bug fixes
1720
~~~~~~~~~
1821
* Fixed an error in solar position calculations when using

pvlib/shading.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,3 +232,113 @@ def sky_diffuse_passias(masking_angle):
232232
Available at https://www.nrel.gov/docs/fy18osti/67399.pdf
233233
"""
234234
return 1 - cosd(masking_angle/2)**2
235+
236+
237+
def projected_solar_zenith_angle(solar_zenith, solar_azimuth,
238+
axis_tilt, axis_azimuth):
239+
r"""
240+
Calculate projected solar zenith angle in degrees.
241+
242+
This solar zenith angle is projected onto the plane whose normal vector is
243+
defined by ``axis_tilt`` and ``axis_azimuth``. The normal vector is in the
244+
direction of ``axis_azimuth`` (clockwise from north) and tilted from
245+
horizontal by ``axis_tilt``. See Figure 5 in [1]_:
246+
247+
.. figure:: ../../_images/Anderson_Mikofski_2020_Fig5.jpg
248+
:alt: Wire diagram of coordinates systems to obtain the projected angle.
249+
:align: center
250+
:scale: 50 %
251+
252+
Fig. 5, [1]_: Solar coordinates projection onto tracker rotation plane.
253+
254+
Parameters
255+
----------
256+
solar_zenith : numeric
257+
Sun's apparent zenith in degrees.
258+
solar_azimuth : numeric
259+
Sun's azimuth in degrees.
260+
axis_tilt : numeric
261+
Axis tilt angle in degrees. From horizontal plane to array plane.
262+
axis_azimuth : numeric
263+
Axis azimuth angle in degrees.
264+
North = 0°; East = 90°; South = 180°; West = 270°
265+
266+
Returns
267+
-------
268+
Projected_solar_zenith : numeric
269+
In degrees.
270+
271+
Notes
272+
-----
273+
This projection has a variety of applications in PV. For example:
274+
275+
- Projecting the sun's position onto the plane perpendicular to
276+
the axis of a single-axis tracker (i.e. the plane
277+
whose normal vector coincides with the tracker torque tube)
278+
yields the tracker rotation angle that maximizes direct irradiance
279+
capture. This tracking strategy is called *true-tracking*. Learn more
280+
about tracking in
281+
:ref:`sphx_glr_gallery_solar-tracking_plot_single_axis_tracking.py`.
282+
283+
- Self-shading in large PV arrays is often modeled by assuming
284+
a simplified 2-D array geometry where the sun's position is
285+
projected onto the plane perpendicular to the PV rows.
286+
The projected zenith angle is then used for calculations
287+
regarding row-to-row shading.
288+
289+
Examples
290+
--------
291+
Calculate the ideal true-tracking angle for a horizontal north-south
292+
single-axis tracker:
293+
294+
>>> rotation = projected_solar_zenith_angle(solar_zenith, solar_azimuth,
295+
>>> axis_tilt=0, axis_azimuth=180)
296+
297+
Calculate the projected zenith angle in a south-facing fixed tilt array
298+
(note: the ``axis_azimuth`` of a fixed-tilt row points along the length
299+
of the row):
300+
301+
>>> psza = projected_solar_zenith_angle(solar_zenith, solar_azimuth,
302+
>>> axis_tilt=0, axis_azimuth=90)
303+
304+
References
305+
----------
306+
.. [1] K. Anderson and M. Mikofski, 'Slope-Aware Backtracking for
307+
Single-Axis Trackers', National Renewable Energy Lab. (NREL), Golden,
308+
CO (United States);
309+
NREL/TP-5K00-76626, Jul. 2020. :doi:`10.2172/1660126`.
310+
311+
See Also
312+
--------
313+
pvlib.solarposition.get_solarposition
314+
"""
315+
# Assume the tracker reference frame is right-handed. Positive y-axis is
316+
# oriented along tracking axis; from north, the y-axis is rotated clockwise
317+
# by the axis azimuth and tilted from horizontal by the axis tilt. The
318+
# positive x-axis is 90 deg clockwise from the y-axis and parallel to
319+
# horizontal (e.g., if the y-axis is south, the x-axis is west); the
320+
# positive z-axis is normal to the x and y axes, pointed upward.
321+
322+
# Since elevation = 90 - zenith, sin(90-x) = cos(x) & cos(90-x) = sin(x):
323+
# Notation from [1], modified to use zenith instead of elevation
324+
# cos(elevation) = sin(zenith) and sin(elevation) = cos(zenith)
325+
# Avoid recalculating these values
326+
sind_solar_zenith = sind(solar_zenith)
327+
cosd_axis_azimuth = cosd(axis_azimuth)
328+
sind_axis_azimuth = sind(axis_azimuth)
329+
sind_axis_tilt = sind(axis_tilt)
330+
331+
# Sun's x, y, z coords
332+
sx = sind_solar_zenith * sind(solar_azimuth)
333+
sy = sind_solar_zenith * cosd(solar_azimuth)
334+
sz = cosd(solar_zenith)
335+
# Eq. (4); sx', sz' values from sun coordinates projected onto surface
336+
sx_prime = sx * cosd_axis_azimuth - sy * sind_axis_azimuth
337+
sz_prime = (
338+
sx * sind_axis_azimuth * sind_axis_tilt
339+
+ sy * sind_axis_tilt * cosd_axis_azimuth
340+
+ sz * cosd(axis_tilt)
341+
)
342+
# Eq. (5); angle between sun's beam and surface
343+
theta_T = np.degrees(np.arctan2(sx_prime, sz_prime))
344+
return theta_T

pvlib/tests/test_shading.py

Lines changed: 130 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,31 @@
22
import pandas as pd
33

44
from pandas.testing import assert_series_equal
5+
from numpy.testing import assert_allclose
56
import pytest
7+
from datetime import timezone, timedelta
68

79
from pvlib import shading
810

911

1012
@pytest.fixture
1113
def test_system():
12-
syst = {'height': 1.0,
13-
'pitch': 2.,
14-
'surface_tilt': 30.,
15-
'surface_azimuth': 180.,
16-
'rotation': -30.} # rotation of right edge relative to horizontal
17-
syst['gcr'] = 1.0 / syst['pitch']
14+
syst = {
15+
"height": 1.0,
16+
"pitch": 2.0,
17+
"surface_tilt": 30.0,
18+
"surface_azimuth": 180.0,
19+
"rotation": -30.0,
20+
} # rotation of right edge relative to horizontal
21+
syst["gcr"] = 1.0 / syst["pitch"]
1822
return syst
1923

2024

2125
def test__ground_angle(test_system):
2226
ts = test_system
23-
x = np.array([0., 0.5, 1.0])
24-
angles = shading.ground_angle(
25-
ts['surface_tilt'], ts['gcr'], x)
26-
expected_angles = np.array([0., 5.866738789543952, 9.896090638982903])
27+
x = np.array([0.0, 0.5, 1.0])
28+
angles = shading.ground_angle(ts["surface_tilt"], ts["gcr"], x)
29+
expected_angles = np.array([0.0, 5.866738789543952, 9.896090638982903])
2730
assert np.allclose(angles, expected_angles)
2831

2932

@@ -37,7 +40,7 @@ def test__ground_angle_zero_gcr():
3740

3841
@pytest.fixture
3942
def surface_tilt():
40-
idx = pd.date_range('2019-01-01', freq='h', periods=3)
43+
idx = pd.date_range("2019-01-01", freq="h", periods=3)
4144
return pd.Series([0, 20, 90], index=idx)
4245

4346

@@ -104,3 +107,119 @@ def test_sky_diffuse_passias_scalar(average_masking_angle, shading_loss):
104107
for angle, loss in zip(average_masking_angle, shading_loss):
105108
actual_loss = shading.sky_diffuse_passias(angle)
106109
assert np.isclose(loss, actual_loss)
110+
111+
112+
@pytest.fixture
113+
def true_tracking_angle_and_inputs_NREL():
114+
# data from NREL 'Slope-Aware Backtracking for Single-Axis Trackers'
115+
# doi.org/10.2172/1660126 ; Accessed on 2023-11-06.
116+
tzinfo = timezone(timedelta(hours=-5))
117+
axis_tilt_angle = 9.666 # deg
118+
axis_azimuth_angle = 195.0 # deg
119+
timedata = pd.DataFrame(
120+
columns=("Apparent Elevation", "Solar Azimuth", "True-Tracking"),
121+
data=(
122+
(2.404287, 122.791770, -84.440),
123+
(11.263058, 133.288729, -72.604),
124+
(18.733558, 145.285552, -59.861),
125+
(24.109076, 158.939435, -45.578),
126+
(26.810735, 173.931802, -28.764),
127+
(26.482495, 189.371536, -8.475),
128+
(23.170447, 204.136810, 15.120),
129+
(17.296785, 217.446538, 39.562),
130+
(9.461862, 229.102218, 61.587),
131+
(0.524817, 239.330401, 79.530),
132+
),
133+
)
134+
timedata.index = pd.date_range(
135+
"2019-01-01T08", "2019-01-01T17", freq="1H", tz=tzinfo
136+
)
137+
timedata["Apparent Zenith"] = 90.0 - timedata["Apparent Elevation"]
138+
return (axis_tilt_angle, axis_azimuth_angle, timedata)
139+
140+
141+
@pytest.fixture
142+
def projected_solar_zenith_angle_edge_cases():
143+
premises_and_result_matrix = pd.DataFrame(
144+
data=[
145+
# s_zen | s_azm | ax_tilt | ax_azm | psza
146+
[ 0, 0, 0, 0, 0],
147+
[ 0, 180, 0, 0, 0],
148+
[ 0, 0, 0, 180, 0],
149+
[ 0, 180, 0, 180, 0],
150+
[ 45, 0, 0, 180, 0],
151+
[ 45, 90, 0, 180, -45],
152+
[ 45, 270, 0, 180, 45],
153+
[ 45, 90, 90, 180, -90],
154+
[ 45, 270, 90, 180, 90],
155+
[ 45, 90, 90, 0, 90],
156+
[ 45, 270, 90, 0, -90],
157+
[ 45, 45, 90, 180, -135],
158+
[ 45, 315, 90, 180, 135],
159+
],
160+
columns=["solar_zenith", "solar_azimuth", "axis_tilt", "axis_azimuth",
161+
"psza"],
162+
)
163+
return premises_and_result_matrix
164+
165+
166+
def test_projected_solar_zenith_angle_numeric(
167+
true_tracking_angle_and_inputs_NREL,
168+
projected_solar_zenith_angle_edge_cases
169+
):
170+
psza_func = shading.projected_solar_zenith_angle
171+
axis_tilt, axis_azimuth, timedata = true_tracking_angle_and_inputs_NREL
172+
# test against data provided by NREL
173+
psz = psza_func(
174+
timedata["Apparent Zenith"],
175+
timedata["Solar Azimuth"],
176+
axis_tilt,
177+
axis_azimuth,
178+
)
179+
assert_allclose(psz, timedata["True-Tracking"], atol=1e-3)
180+
# test by changing axis azimuth and tilt
181+
psza = psza_func(
182+
timedata["Apparent Zenith"],
183+
timedata["Solar Azimuth"],
184+
-axis_tilt,
185+
axis_azimuth - 180,
186+
)
187+
assert_allclose(psza, -timedata["True-Tracking"], atol=1e-3)
188+
189+
# test edge cases
190+
solar_zenith, solar_azimuth, axis_tilt, axis_azimuth, psza_expected = (
191+
v for _, v in projected_solar_zenith_angle_edge_cases.items()
192+
)
193+
psza = psza_func(
194+
solar_zenith,
195+
solar_azimuth,
196+
axis_tilt,
197+
axis_azimuth,
198+
)
199+
assert_allclose(psza, psza_expected, atol=1e-9)
200+
201+
202+
@pytest.mark.parametrize(
203+
"cast_type, cast_func",
204+
[
205+
(float, lambda x: float(x)),
206+
(np.ndarray, lambda x: np.array([x])),
207+
(pd.Series, lambda x: pd.Series(data=[x])),
208+
],
209+
)
210+
def test_projected_solar_zenith_angle_datatypes(
211+
cast_type, cast_func, true_tracking_angle_and_inputs_NREL
212+
):
213+
psz_func = shading.projected_solar_zenith_angle
214+
axis_tilt, axis_azimuth, timedata = true_tracking_angle_and_inputs_NREL
215+
sun_apparent_zenith = timedata["Apparent Zenith"].iloc[0]
216+
sun_azimuth = timedata["Solar Azimuth"].iloc[0]
217+
218+
axis_tilt, axis_azimuth, sun_apparent_zenith, sun_azimuth = (
219+
cast_func(sun_apparent_zenith),
220+
cast_func(sun_azimuth),
221+
cast_func(axis_tilt),
222+
cast_func(axis_azimuth),
223+
)
224+
psz = psz_func(sun_apparent_zenith, axis_azimuth, axis_tilt, axis_azimuth)
225+
assert isinstance(psz, cast_type)

pvlib/tracking.py

Lines changed: 8 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from pvlib.tools import cosd, sind, tand, acosd, asind
55
from pvlib import irradiance
6+
from pvlib import shading
67

78

89
def singleaxis(apparent_zenith, apparent_azimuth,
@@ -126,51 +127,20 @@ def singleaxis(apparent_zenith, apparent_azimuth,
126127
if apparent_azimuth.ndim > 1 or apparent_zenith.ndim > 1:
127128
raise ValueError('Input dimensions must not exceed 1')
128129

129-
# Calculate sun position x, y, z using coordinate system as in [1], Eq 1.
130-
131-
# NOTE: solar elevation = 90 - solar zenith, then use trig identities:
132-
# sin(90-x) = cos(x) & cos(90-x) = sin(x)
133-
sin_zenith = sind(apparent_zenith)
134-
x = sin_zenith * sind(apparent_azimuth)
135-
y = sin_zenith * cosd(apparent_azimuth)
136-
z = cosd(apparent_zenith)
137-
138-
# Assume the tracker reference frame is right-handed. Positive y-axis is
139-
# oriented along tracking axis; from north, the y-axis is rotated clockwise
140-
# by the axis azimuth and tilted from horizontal by the axis tilt. The
141-
# positive x-axis is 90 deg clockwise from the y-axis and parallel to
142-
# horizontal (e.g., if the y-axis is south, the x-axis is west); the
143-
# positive z-axis is normal to the x and y axes, pointed upward.
144-
145-
# Calculate sun position (xp, yp, zp) in tracker coordinate system using
146-
# [1] Eq 4.
147-
148-
cos_axis_azimuth = cosd(axis_azimuth)
149-
sin_axis_azimuth = sind(axis_azimuth)
150-
cos_axis_tilt = cosd(axis_tilt)
151-
sin_axis_tilt = sind(axis_tilt)
152-
xp = x*cos_axis_azimuth - y*sin_axis_azimuth
153-
# not necessary to calculate y'
154-
# yp = (x*cos_axis_tilt*sin_axis_azimuth
155-
# + y*cos_axis_tilt*cos_axis_azimuth
156-
# - z*sin_axis_tilt)
157-
zp = (x*sin_axis_tilt*sin_axis_azimuth
158-
+ y*sin_axis_tilt*cos_axis_azimuth
159-
+ z*cos_axis_tilt)
160-
161130
# The ideal tracking angle wid is the rotation to place the sun position
162-
# vector (xp, yp, zp) in the (y, z) plane, which is normal to the panel and
131+
# vector (xp, yp, zp) in the (x, z) plane, which is normal to the panel and
163132
# contains the axis of rotation. wid = 0 indicates that the panel is
164133
# horizontal. Here, our convention is that a clockwise rotation is
165134
# positive, to view rotation angles in the same frame of reference as
166135
# azimuth. For example, for a system with tracking axis oriented south, a
167136
# rotation toward the east is negative, and a rotation to the west is
168137
# positive. This is a right-handed rotation around the tracker y-axis.
169-
170-
# Calculate angle from x-y plane to projection of sun vector onto x-z plane
171-
# using [1] Eq. 5.
172-
173-
wid = np.degrees(np.arctan2(xp, zp))
138+
wid = shading.projected_solar_zenith_angle(
139+
axis_tilt=axis_tilt,
140+
axis_azimuth=axis_azimuth,
141+
solar_zenith=apparent_zenith,
142+
solar_azimuth=apparent_azimuth,
143+
)
174144

175145
# filter for sun above panel horizon
176146
zen_gt_90 = apparent_zenith > 90

0 commit comments

Comments
 (0)