Skip to content

Commit e1926d3

Browse files
Add max_shading_elevation (#21)
* Squashed commit of the following: commit f435e1c Author: Adam R. Jensen <[email protected]> Date: Wed Mar 2 15:52:15 2022 +0100 Add tests (#16) * Create twoaxistrackerfield.py * Add TwoAxisTrackerField to __init__.py and documentation.rst * Add _calculate_l_min to layout * Fix linter error * Update twoaxistrackerfield.py * Reorder layout ValueError warnings * Fix typo * Remove layout_type from generate_field_layout function * Add layout_type code to TwoAxisTrackerField * L_min -> min_tracker_spacing * Add whatsnew entry * Fix linting errors * Update twoaxistrackerfield.py * Correct horizon shading * Update twoaxistracking/twoaxistrackerfield.py Co-authored-by: Kevin Anderson <[email protected]> * Update twoaxistracking/twoaxistrackerfield.py Co-authored-by: Kevin Anderson <[email protected]> * Update twoaxistracking/twoaxistrackerfield.py Co-authored-by: Kevin Anderson <[email protected]> * Return scalar when scalar is passed * Correct use of np.isscalar * Update documentation * Update whatsnew * Rework intro_tutorial.ipynb * Update README.md * Create test_layout.py * Update test_layout.py * Use test folder in package folder * Delete test_placeholder.py * Add test files * Fix linting errors * Update scalar test * Add pandas to test in setup.cfg * Revert "package metadata and doc build updates (#17)" This reverts commit e9b5810. * Undo commits * Revert "Undo commits" This reverts commit 147ba2a. * Add test files * Switch to using np.testing.assert_allclose * Add test for sloped field layout * Move test_twoaxistracker to twoaxistracker PR * Add conftest.py to avoid duplicate fixtures * Remove imports of fixtures from conftest * Remove unnecessary imports * Increase test tolerance for test_field_slope * Add multi-polygon active area test * Add empty __init__.py * Use "from .conftest" as Kevin said.. * Base square_field_layout_slope fixture off square_field_layout * Fix linter error * Remove backslash in conftest.py Co-authored-by: Kevin Anderson <[email protected]> * Add get_max_shading_elevation func Also, correct the relative slope calculation * Correct relative_slope in conftest * Add max elevation to TwoAxisTrackerField * Update _field_layout_plot function * Make get_horizon_elevation_angle separate function * Add documentation entries * Add test coverage * Fix linter * Minor documentation updates * Update twoaxistracking_logo.svg Include the shading from the base of the reference collector. * Update twoaxistracking/layout.py Co-authored-by: Kevin Anderson <[email protected]> * Implement changes from review by kanderso-nrel * Fix linter error * Rewrite max_shading_elevation function * Fix linter * Add check for total area enclosing active areas * Updates addressing review by kanderso-nrel Co-authored-by: Kevin Anderson <[email protected]>
1 parent aba2c97 commit e1926d3

File tree

11 files changed

+200
-12
lines changed

11 files changed

+200
-12
lines changed
Lines changed: 1 addition & 1 deletion
Loading

docs/source/documentation.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,5 @@ Code documentation
1212
TwoAxisTrackerField
1313
TwoAxisTrackerField.get_shaded_fraction
1414
TwoAxisTrackerField.plot_field_layout
15+
layout.max_shading_elevation
16+
shading.horizon_elevation_angle

twoaxistracking/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# Import of functions that should be accessible from the package top-level
12
from .layout import generate_field_layout # noqa: F401
23
from .shading import shaded_fraction # noqa: F401
34
from .twoaxistrackerfield import TwoAxisTrackerField # noqa: F401

twoaxistracking/layout.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,65 @@ def generate_field_layout(gcr, total_collector_area, min_tracker_spacing,
121121
relative_azimuth = np.mod(450-np.rad2deg(np.arctan2(Y, X)), 360)
122122
# Relative slope of collectors
123123
# positive means collector is higher than reference collector
124-
relative_slope = -np.cos(np.deg2rad(slope_azimuth - relative_azimuth)) * slope_tilt # noqa: E501
124+
relative_slope = np.rad2deg(np.arctan(-np.cos(np.deg2rad(slope_azimuth - relative_azimuth))
125+
* np.tan(np.deg2rad(slope_tilt))))
125126

126127
return X, Y, Z, tracker_distance, relative_azimuth, relative_slope
128+
129+
130+
def max_shading_elevation(total_collector_geometry, tracker_distance,
131+
relative_slope):
132+
"""Calculate the maximum elevation angle for which shading can occur.
133+
134+
Parameters
135+
----------
136+
total_collector_geometry: Shapely Polygon
137+
Polygon corresponding to the total collector area.
138+
tracker_distance: array of floats
139+
Distances between neighboring trackers and the reference tracker.
140+
relative_slope: array of floats
141+
Slope between neighboring trackers and reference tracker. A positive
142+
slope means neighboring collector is higher than reference collector.
143+
144+
Returns
145+
-------
146+
max_shading_elevation: float
147+
The highest solar elevation angle for which shading can occur for a
148+
given field layout and collector geometry [degrees]
149+
150+
Note
151+
----
152+
The maximum shading elevation angle is calculated for all neighboring
153+
trackers using the bounding box geometry and the bounding circle. For
154+
rectangular collectors (as approximated when using the bounding box), the
155+
maximum shading elevation occurs when one of the upper corners of the
156+
projected shading geometry and the lower corner of the reference collector
157+
intersects. For circular collectors (as approximated by the bounding
158+
cirlce), the maximum elevation occurs when the projected shadow is directly
159+
below the reference collector and the two circles tangent to each other.
160+
161+
The maximum elevation is calculated using both the bounding box and the
162+
bounding circle, and the minimum of these two elevations is returned. For
163+
rectangular and circular collectors, the maximum elevation is exact,
164+
whereas for other geometries, the returned elevation is a conservative
165+
estimate.
166+
"""
167+
# Calculate extent of box bounding the total collector geometry
168+
x_min, y_min, x_max, y_max = total_collector_geometry.bounds
169+
# Collector dimensions
170+
x_dim = x_max - x_min
171+
y_dim = y_max - y_min
172+
delta_gamma_rad = np.arcsin(x_dim / tracker_distance)
173+
# Calculate max elevation based on the bounding box (rectangular)
174+
max_elevations_rectangular = np.rad2deg(np.arcsin(
175+
y_dim * np.cos(np.deg2rad(relative_slope)) /
176+
(tracker_distance * np.cos(delta_gamma_rad)))) + relative_slope
177+
# Calculate max elevations using the minimum bounding diameter (circular)
178+
D_min = _calculate_min_tracker_spacing(total_collector_geometry)
179+
max_elevations_circular = np.rad2deg(np.arcsin(
180+
(D_min * np.cos(np.deg2rad(relative_slope)))/tracker_distance)) \
181+
+ relative_slope
182+
# Compute max elevation
183+
max_elevation = np.nanmin([np.nanmax(max_elevations_rectangular),
184+
np.nanmax(max_elevations_circular)])
185+
return max_elevation

twoaxistracking/plotting.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ def _plot_field_layout(X, Y, Z, min_tracker_spacing):
1515
# to correctly display the middle color when all tracker Z coords are zero
1616
cmap = cm.viridis_r
1717
colors = cmap(norm(Z))
18-
fig, ax = plt.subplots(figsize=(6, 6), subplot_kw={'aspect': 'equal'})
18+
fig, ax = plt.subplots(figsize=(4, 4), subplot_kw={'aspect': 'equal'})
1919
# Plot a circle for each neighboring collector (diameter equals min_tracker_spacing)
2020
ax.add_collection(collections.EllipseCollection(
2121
widths=min_tracker_spacing, heights=min_tracker_spacing, angles=0,
@@ -26,6 +26,8 @@ def _plot_field_layout(X, Y, Z, min_tracker_spacing):
2626
widths=min_tracker_spacing, heights=min_tracker_spacing, angles=0,
2727
units='xy', facecolors='red', edgecolors=("black",), linewidths=(1,),
2828
offsets=[0, 0], transOffset=ax.transData))
29+
ax.set_xlabel('Tracker position (east-west direction)')
30+
ax.set_ylabel('Tracker position (north-south direction)')
2931
fig.colorbar(cm.ScalarMappable(norm=norm, cmap=cmap), ax=ax, shrink=0.8,
3032
label='Relative tracker height (vertical)')
3133
# Set limits
@@ -62,7 +64,7 @@ def _plot_shading(active_collector_geometry, unshaded_geometry,
6264
shading_geometries, facecolor='blue', linewidth=0.5, alpha=0.5)
6365

6466
fig, axes = plt.subplots(1, 2, subplot_kw=dict(aspect='equal'))
65-
axes[0].set_title('Total area and shading areas')
67+
axes[0].set_title('Active area and shading areas')
6668
axes[0].add_collection(active_patches, autolim=True)
6769
axes[0].add_collection(shading_patches, autolim=True)
6870
axes[1].set_title('Unshaded area')

twoaxistracking/shading.py

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,37 @@
33
from twoaxistracking import plotting
44

55

6+
def horizon_elevation_angle(azimuth, slope_azimuth, slope_tilt):
7+
"""Calculate horizon elevation angle caused by a sloped field.
8+
9+
Parameters
10+
----------
11+
azimuth : float
12+
Azimuth angle for which the horizon elevation angle is to be calculated
13+
[degrees]
14+
slope_azimuth : float
15+
Direction of normal to slope on horizontal [degrees]
16+
slope_tilt : float
17+
Tilt of slope relative to horizontal [degrees]
18+
19+
Returns
20+
-------
21+
horizon_elevation_angle : float
22+
Horizon elevation angle [degrees]
23+
"""
24+
horizon_elevation_angle = np.rad2deg(np.arctan(
25+
- np.cos(np.deg2rad(slope_azimuth - azimuth))
26+
* np.tan(np.deg2rad(slope_tilt))))
27+
# Horizon elevation angle cannot be less than zero
28+
horizon_elevation_angle = np.clip(horizon_elevation_angle, a_min=0, a_max=None)
29+
return horizon_elevation_angle
30+
31+
632
def shaded_fraction(solar_elevation, solar_azimuth,
733
total_collector_geometry, active_collector_geometry,
834
min_tracker_spacing, tracker_distance, relative_azimuth,
9-
relative_slope, slope_azimuth=0, slope_tilt=0, plot=False):
35+
relative_slope, slope_azimuth=0, slope_tilt=0,
36+
max_shading_elevation=90, plot=False):
1037
"""Calculate the shaded fraction for any layout of two-axis tracking collectors.
1138
1239
Parameters
@@ -29,12 +56,18 @@ def shaded_fraction(solar_elevation, solar_azimuth,
2956
relative_slope: array of floats
3057
Slope between neighboring trackers and reference tracker. A positive
3158
slope means neighboring collector is higher than reference collector.
32-
slope_azimuth : float
59+
slope_azimuth : float, optional
3360
Direction of normal to slope on horizontal [degrees]. Used to determine
3461
horizon shading.
35-
slope_tilt : float
62+
slope_tilt : float, optional
3663
Tilt of slope relative to horizontal [degrees]. Used to determine
3764
horizon shading.
65+
max_shading_elevation : float, optional
66+
The maximum elevation angle for which shading may occur. Specifying the
67+
max_shading_elevation skips the calculations for which the solar
68+
elevation angle is higher and sets the shaded fraction to zero.
69+
This reduces the calculation time and results in the same output
70+
assuming the correct value has been provided.
3871
plot: bool, default: True
3972
Whether to plot the projected shadows and unshaded area.
4073
@@ -46,11 +79,13 @@ def shaded_fraction(solar_elevation, solar_azimuth,
4679
# If the sun is below the horizon, set the shaded fraction to nan
4780
if solar_elevation < 0:
4881
return np.nan
82+
# Set shaded fraction to 0 (unshaded) if solar elevation is higher than
83+
# max_shading_elevation
84+
elif solar_elevation > max_shading_elevation:
85+
return 0
4986
# Set shaded fraction to 1 (fully shaded) if the solar elevation is below
5087
# the horizon line caused by the tilted ground
51-
elif np.tan(np.deg2rad(solar_elevation)) <= (
52-
- np.cos(np.deg2rad(slope_azimuth-solar_azimuth))
53-
* np.tan(np.deg2rad(slope_tilt))):
88+
elif solar_elevation <= horizon_elevation_angle(solar_azimuth, slope_azimuth, slope_tilt):
5489
return 1
5590

5691
azimuth_difference = solar_azimuth - relative_azimuth

twoaxistracking/tests/conftest.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ def rectangular_geometry():
1212
return collector_geometry, total_collector_area, min_tracker_spacing
1313

1414

15+
@pytest.fixture
16+
def circular_geometry():
17+
# A circular collector centered at (0,0) and has a radius of 2
18+
collector_geometry = geometry.Point(0, 0).buffer(2)
19+
total_collector_area = collector_geometry.area
20+
min_tracker_spacing = layout._calculate_min_tracker_spacing(collector_geometry)
21+
return collector_geometry, total_collector_area, min_tracker_spacing
22+
23+
1524
@pytest.fixture
1625
def active_geometry_split():
1726
active_collector_geometry = geometry.MultiPolygon([
@@ -41,8 +50,8 @@ def square_field_layout_sloped(square_field_layout):
4150
X, Y, _, tracker_distance, relative_azimuth, _ = square_field_layout
4251
Z = np.array([0.12372765, 0.06186383, 0, 0.06186383,
4352
-0.06186383, 0, -0.06186383, -0.12372765])
44-
relative_slope = np.array([5, 3.53553391, 0, 3.53553391,
45-
-3.53553391, 0, -3.53553391, -5])
53+
relative_slope = np.array([5, 3.540025, 0, 3.540025,
54+
-3.540025, 0, -3.540025, -5])
4655
return X, Y, Z, tracker_distance, relative_azimuth, relative_slope
4756

4857

twoaxistracking/tests/test_layout.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,33 @@ def test_neighbor_order(rectangular_geometry):
145145
offset=0,
146146
rotation=0)
147147
assert len(X) == (7*7-1)
148+
149+
150+
def test_calculation_of_max_shading_elevation_rectangle(rectangular_geometry, square_field_layout):
151+
# Test that the maximum elevation angle for which shading can occur is
152+
# calculated correctly for rectangular collectors
153+
collector_geometry, total_collector_area, min_tracker_spacing = rectangular_geometry
154+
X, Y, Z, tracker_distance, relative_azimuth, relative_slope = \
155+
square_field_layout
156+
max_shading_elevation = layout.max_shading_elevation(
157+
collector_geometry, tracker_distance, relative_slope)
158+
np.testing.assert_allclose(max_shading_elevation, 16.77865488)
159+
160+
161+
def test_calculation_of_max_shading_elevation_circle(circular_geometry):
162+
# Test that the maximum elevation angle for which shading can occur is
163+
# calculated correctly for closely packed circular collectors
164+
collector_geometry, total_collector_area, min_tracker_spacing = circular_geometry
165+
X, Y, Z, tracker_distance, relative_azimuth, relative_slope = \
166+
layout.generate_field_layout(
167+
gcr=0.5,
168+
total_collector_area=total_collector_area,
169+
min_tracker_spacing=min_tracker_spacing,
170+
neighbor_order=2,
171+
aspect_ratio=1,
172+
offset=0,
173+
rotation=0)
174+
175+
max_shading_elevation = layout.max_shading_elevation(
176+
collector_geometry, tracker_distance, relative_slope)
177+
np.testing.assert_allclose(max_shading_elevation, 43.16784217)

twoaxistracking/tests/test_shading.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,25 @@ def test_shading_below_hill_horizon(rectangular_geometry, square_field_layout):
101101
slope_tilt=10,
102102
plot=False)
103103
assert shaded_fraction == 1
104+
105+
106+
def test_shading_max_shading_elevation(rectangular_geometry, square_field_layout):
107+
# Test that shaded_fraction is set to one when the solar elevation angle
108+
# is greater than the max_shading_elevation (even though shading may occur)
109+
collector_geometry, total_collector_area, min_tracker_spacing = rectangular_geometry
110+
X, Y, Z, tracker_distance, relative_azimuth, relative_slope = \
111+
square_field_layout
112+
shaded_fraction = shading.shaded_fraction(
113+
solar_elevation=3, # low solar elevation angle with guaranteed shading
114+
solar_azimuth=180,
115+
total_collector_geometry=collector_geometry,
116+
active_collector_geometry=collector_geometry,
117+
min_tracker_spacing=min_tracker_spacing,
118+
tracker_distance=tracker_distance,
119+
relative_azimuth=relative_azimuth,
120+
relative_slope=relative_slope,
121+
slope_azimuth=0,
122+
slope_tilt=10,
123+
max_shading_elevation=2, # lower than true max angle for testing purposes
124+
plot=False)
125+
assert shaded_fraction == 0

twoaxistracking/tests/test_twoaxistrackerfield.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,8 @@ def test_calculation_of_shaded_fraction_array(rectangular_geometry, solar_positi
179179
def test_calculation_of_shaded_fraction_float(rectangular_geometry):
180180
# Test if shaded fraction is calculated correct when solar elevation and
181181
# azimuth are scalar
182+
# Also tests that no error is raised when total and active geometries are
183+
# identical.
182184
collector_geometry, total_collector_area, min_tracker_spacing = rectangular_geometry
183185
field = twoaxistrackerfield.TwoAxisTrackerField(
184186
total_collector_geometry=collector_geometry,
@@ -192,3 +194,19 @@ def test_calculation_of_shaded_fraction_float(rectangular_geometry):
192194
result = field.get_shaded_fraction(40, 180)
193195
np.testing.assert_allclose(result, 0)
194196
assert np.isscalar(result)
197+
198+
199+
def test_total_collector_geometry_encloses_active_areas(rectangular_geometry, circular_geometry):
200+
# Test that ValueError is raised if the aperture collector geometry is not
201+
# completely enclosed by the total collector geometry
202+
rectangular_collector, total_collector_area, min_tracker_spacing = rectangular_geometry
203+
circular_collector, total_collector_area, min_tracker_spacing = circular_geometry
204+
with pytest.raises(ValueError, match="does not completely enclose"):
205+
_ = twoaxistrackerfield.TwoAxisTrackerField(
206+
total_collector_geometry=rectangular_collector,
207+
active_collector_geometry=circular_collector,
208+
neighbor_order=1,
209+
gcr=0.1,
210+
aspect_ratio=1,
211+
offset=0,
212+
rotation=0)

0 commit comments

Comments
 (0)