Skip to content

Commit 6bde351

Browse files
authored
Differentiate between total/gross and active/aperture area (#13)
* Add gross_ and aperture_geometry options to shaded_fraction * from matplotlib import collections * Update geometry names to total and active * Minor doc changes * Update intro_tutorial.ipynb * Update whatsnew.md
1 parent 8275155 commit 6bde351

File tree

5 files changed

+104
-76
lines changed

5 files changed

+104
-76
lines changed

docs/source/notebooks/intro_tutorial.ipynb

Lines changed: 20 additions & 11 deletions
Large diffs are not rendered by default.

docs/source/whatsnew.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
- Added automatic documentation using Sphinx and autosummary
1111
- Added ``__init__.py`` file
1212
- Documentation is now hosted at [readthedocs](https://twoaxistracking.readthedocs.io/)
13+
- Tilted fields can now be simulated by specifyig the keywords ``slope_azimuth`` and
14+
``slope_tilt`` (see PR#7).
15+
- The code now is able to differentiate between the active area and total area (see PR#11).
16+
1317

1418
### Changed
1519
- Divide code into modules: shading, plotting, and layout
@@ -19,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1923
"twoaxistracking"
2024

2125
### Testing
26+
- Linting using flake8 was added in PR#11
2227

2328
## [0.1.0] - 2022-01-25
2429
This was the first release, containing the main functions and notebooks.

twoaxistracking/layout.py

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,15 @@
33

44

55
def _rotate_origin(x, y, rotation_deg):
6-
"""Rotate a set of 2D points counterclockwise around the origin (0, 0).
7-
"""
6+
"""Rotate a set of 2D points counterclockwise around the origin (0, 0)."""
87
rotation_rad = np.deg2rad(rotation_deg)
98
# Rotation is set negative to make counterclockwise rotation
109
xx = x * np.cos(-rotation_rad) + y * np.sin(-rotation_rad)
1110
yy = -x * np.sin(-rotation_rad) + y * np.cos(-rotation_rad)
1211
return xx, yy
1312

1413

15-
def generate_field_layout(gcr, collector_area, L_min, neighbor_order,
14+
def generate_field_layout(gcr, total_collector_area, L_min, neighbor_order,
1615
aspect_ratio=None, offset=None, rotation=None,
1716
layout_type=None, slope_azimuth=0,
1817
slope_tilt=0, plot=False):
@@ -35,19 +34,18 @@ def generate_field_layout(gcr, collector_area, L_min, neighbor_order,
3534
----------
3635
gcr: float
3736
Ground cover ratio. Ratio of collector area to ground area.
38-
collector_area: float
37+
total_collector_area: float
3938
Surface area of one collector.
4039
L_min: float
41-
Minimum distance between collectors. Calculated as the maximum distance
42-
between any two points on the collector surface area.
40+
Minimum distance between collectors.
4341
neighbor_order: int
4442
Order of neighbors to include in layout. neighbor_order=1 includes only
4543
the 8 directly adjacent collectors.
4644
aspect_ratio: float, optional
4745
Ratio of the spacing in the primary direction to the secondary.
4846
offset: float, optional
4947
Relative row offset in the secondary direction as fraction of the
50-
spacing in the secondary direction. -0.5 <= offset < 0.5.
48+
spacing in the primary direction. -0.5 <= offset < 0.5.
5149
rotation: float, optional
5250
Counterclockwise rotation of the field in degrees. 0 <= rotation < 180
5351
layout_type: {square, square_rotated, hexagon_e_w, hexagon_n_s}, optional
@@ -93,7 +91,7 @@ def generate_field_layout(gcr, collector_area, L_min, neighbor_order,
9391
aspect_ratio = 1
9492
offset = 0
9593
rotation = 45
96-
# Hexagonal layouts are defined by an aspect ratio=0.866 and offset=-0.5
94+
# Hexagonal layouts are defined by aspect_ratio=0.866 and offset=-0.5
9795
elif layout_type == 'hexagonal_n_s':
9896
aspect_ratio = np.sqrt(3)/2
9997
offset = -0.5
@@ -112,18 +110,18 @@ def generate_field_layout(gcr, collector_area, L_min, neighbor_order,
112110
# Check parameters are within their ranges
113111
if aspect_ratio < np.sqrt(1-offset**2):
114112
raise ValueError('Aspect ratio is too low and not feasible')
115-
if aspect_ratio > collector_area/(gcr*L_min**2):
113+
if aspect_ratio > total_collector_area/(gcr*L_min**2):
116114
raise ValueError('Apsect ratio is too high and not feasible')
117115
if (offset < -0.5) | (offset >= 0.5):
118116
raise ValueError('The specified offset is outside the valid range.')
119117
if (rotation < 0) | (rotation >= 180):
120118
raise ValueError('The specified rotation is outside the valid range.')
121119
# Check if mimimum and maximum ground cover ratios are exceded
122-
gcr_max = collector_area / (L_min**2 * np.sqrt(1-offset**2))
120+
gcr_max = total_collector_area / (L_min**2 * np.sqrt(1-offset**2))
123121
if (gcr < 0) or (gcr > gcr_max):
124122
raise ValueError('Maximum ground cover ratio exceded.')
125123
# Check if Lmin is physically possible given the collector area.
126-
if (L_min < np.sqrt(4*collector_area/np.pi)):
124+
if (L_min < np.sqrt(4*total_collector_area/np.pi)):
127125
raise ValueError('Lmin is not physically possible.')
128126

129127
N = 1 + 2 * neighbor_order # Number of collectors along each side
@@ -147,7 +145,7 @@ def generate_field_layout(gcr, collector_area, L_min, neighbor_order,
147145
- Y * np.cos(np.deg2rad(slope_azimuth)) * \
148146
np.tan(np.deg2rad(slope_tilt))
149147
# Calculate and apply the scaling factor based on GCR
150-
scaling = np.sqrt(collector_area / (gcr * aspect_ratio))
148+
scaling = np.sqrt(total_collector_area / (gcr * aspect_ratio))
151149
X, Y = X*scaling, Y*scaling
152150

153151
# Calculate distance and angle of shading trackers relative to the center

twoaxistracking/plotting.py

Lines changed: 46 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,70 @@
11
import matplotlib.pyplot as plt
2-
from matplotlib.collections import EllipseCollection
3-
from matplotlib.collections import PatchCollection
4-
from matplotlib.patches import Polygon
2+
from matplotlib import collections
3+
from matplotlib import patches
4+
from shapely import geometry
55
import matplotlib.colors as mcolors
66
from matplotlib import cm
77

88

99
def _plot_field_layout(X, Y, Z, L_min):
1010
"""Plot field layout."""
11+
# Collector heights is illustrated with colors from a colormap
12+
norm = mcolors.Normalize(vmin=min(Z)-0.000001, vmax=max(Z)+0.000001)
1113
# 0.000001 is added/subtracted for the limits in order for the colormap
1214
# to correctly display the middle color when all tracker Z coords are zero
13-
norm = mcolors.Normalize(vmin=min(Z)-0.000001, vmax=max(Z)+0.000001)
1415
cmap = cm.viridis_r
1516
colors = cmap(norm(Z))
1617
fig, ax = plt.subplots(figsize=(6, 6), subplot_kw={'aspect': 'equal'})
17-
# Plot a circle with a diameter equal to L_min
18-
ax.add_collection(EllipseCollection(widths=L_min, heights=L_min,
19-
angles=0, units='xy',
20-
facecolors=colors,
21-
edgecolors=("black",),
22-
linewidths=(1,),
23-
offsets=list(zip(X, Y)),
24-
transOffset=ax.transData))
18+
# Plot a circle for each neighboring collector (diameter equals L_min)
19+
ax.add_collection(collections.EllipseCollection(
20+
widths=L_min, heights=L_min, angles=0, units='xy', facecolors=colors,
21+
edgecolors=("black",), linewidths=(1,), offsets=list(zip(X, Y)),
22+
transOffset=ax.transData))
2523
# Similarly, add a circle for the origin
26-
ax.add_collection(EllipseCollection(widths=L_min, heights=L_min,
27-
angles=0, units='xy',
28-
facecolors='red',
29-
edgecolors=("black",),
30-
linewidths=(1,), offsets=[0, 0],
31-
transOffset=ax.transData))
24+
ax.add_collection(collections.EllipseCollection(
25+
widths=L_min, heights=L_min, angles=0, units='xy', facecolors='red',
26+
edgecolors=("black",), linewidths=(1,), offsets=[0, 0],
27+
transOffset=ax.transData))
3228
plt.axis('equal')
3329
fig.colorbar(cm.ScalarMappable(norm=norm, cmap=cmap), ax=ax, shrink=0.8,
3430
label='Relative tracker height (vertical)')
31+
# Set limits
3532
lower_lim = min(min(X), min(Y)) - L_min
3633
upper_lim = max(max(X), max(Y)) + L_min
3734
ax.set_ylim(lower_lim, upper_lim)
3835
ax.set_xlim(lower_lim, upper_lim)
3936

4037

41-
def _plot_shading(collector_geometry, unshaded_geomtry, shade_geometries):
38+
def _polygons_to_patch_collection(geometries, **kwargs):
39+
"""Convert Shapely Polygon or MultiPolygon to matplotlib PathCollection.
40+
41+
kwargs are passed to PatchCollection
42+
"""
43+
# Convert geometries to a MultiPolygon if it is a Polygon
44+
if isinstance(geometries, geometry.Polygon):
45+
geometries = geometry.MultiPolygon([geometries])
46+
exteriors = [patches.Polygon(g.exterior) for g in geometries]
47+
path_collection = collections.PatchCollection(exteriors, **kwargs)
48+
return path_collection
49+
50+
51+
def _plot_shading(active_collector_geometry, unshaded_geometry,
52+
shading_geometries, L_min):
4253
"""Plot the shaded and unshaded area for a specific solar position."""
43-
shade_exterios = [Polygon(g.exterior) for g in shade_geometries]
44-
shade_patches = PatchCollection(shade_exterios, facecolor='blue',
45-
linewidth=0.5, alpha=0.5)
46-
collector_patch = PatchCollection(
47-
[Polygon(collector_geometry.exterior)],
48-
facecolor='red', linewidth=0.5, alpha=0.5)
49-
unshaded_patch = PatchCollection([Polygon(unshaded_geomtry.exterior)],
50-
facecolor='green', linewidth=0.5,
51-
alpha=0.5)
52-
fig, ax = plt.subplots(1, 2, subplot_kw=dict(aspect='equal'))
53-
ax[0].add_collection(collector_patch, autolim=True)
54-
ax[0].add_collection(shade_patches, autolim=True)
55-
ax[1].add_collection(unshaded_patch, autolim=True)
56-
ax[0].set_xlim(-6, 6), ax[1].set_xlim(-6, 6)
57-
ax[0].set_ylim(-2, 2), ax[1].set_ylim(-2, 2)
54+
active_patches = _polygons_to_patch_collection(
55+
active_collector_geometry, facecolor='red', linewidth=0.5, alpha=0.5)
56+
unshaded_patches = _polygons_to_patch_collection(
57+
unshaded_geometry, facecolor='green', linewidth=0.5, alpha=0.5)
58+
shading_patches = _polygons_to_patch_collection(
59+
shading_geometries, facecolor='blue', linewidth=0.5, alpha=0.5)
60+
61+
fig, axes = plt.subplots(1, 2, subplot_kw=dict(aspect='equal'))
62+
axes[0].set_title('Unshaded and shading areas')
63+
axes[0].add_collection(active_patches, autolim=True)
64+
axes[0].add_collection(shading_patches, autolim=True)
65+
axes[1].set_title('Unshaded area')
66+
axes[1].add_collection(unshaded_patches, autolim=True)
67+
for ax in axes:
68+
ax.set_xlim(-L_min, L_min)
69+
ax.set_ylim(-L_min, L_min)
5870
plt.show()

twoaxistracking/shading.py

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ def _rotate_origin(x, y, rotation_deg):
1313

1414

1515
def shaded_fraction(solar_elevation, solar_azimuth,
16-
collector_geometry, L_min, tracker_distance,
17-
relative_azimuth, relative_slope,
16+
total_collector_geometry, active_collector_geometry,
17+
L_min, tracker_distance, relative_azimuth, relative_slope,
1818
slope_azimuth=0, slope_tilt=0, plot=False):
1919
"""Calculate the shaded fraction for any layout of two-axis tracking collectors.
2020
@@ -24,8 +24,10 @@ def shaded_fraction(solar_elevation, solar_azimuth,
2424
Solar elevation angle in degrees.
2525
solar_azimuth: float
2626
Solar azimuth angle in degrees.
27-
collector_geometry: Shapely geometry object
28-
The collector aperture geometry.
27+
total_collector_geometry: Shapely Polygon
28+
Polygon corresponding to the total collector area.
29+
active_collector_geometry: Shapely Polygon or MultiPolygon
30+
One or more polygons defining the active collector area.
2931
L_min: float
3032
Minimum distance between collectors. Used for selecting possible
3133
shading collectors.
@@ -37,11 +39,13 @@ def shaded_fraction(solar_elevation, solar_azimuth,
3739
Slope between neighboring trackers and reference tracker. A positive
3840
slope means neighboring collector is higher than reference collector.
3941
slope_azimuth : float
40-
Direction of normal to slope on horizontal [degrees].
42+
Direction of normal to slope on horizontal [degrees]. Used to determine
43+
horizon shading.
4144
slope_tilt : float
42-
Tilt of slope relative to horizontal [degrees].
45+
Tilt of slope relative to horizontal [degrees]. Used to determine
46+
horizon shading.
4347
plot: bool, default: True
44-
Whether to plot the projected shadows.
48+
Whether to plot the projected shadows and unshaded area.
4549
4650
Returns
4751
-------
@@ -51,7 +55,7 @@ def shaded_fraction(solar_elevation, solar_azimuth,
5155
# If the sun is below the horizon, set the shaded fraction to nan
5256
if solar_elevation < 0:
5357
return np.nan
54-
# Set shading fraction to 1 (fully shaded) if the solar elevation is below
58+
# Set shaded fraction to 1 (fully shaded) if the solar elevation is below
5559
# the horizon line caused by the tilted ground
5660
elif solar_elevation < - np.cos(np.deg2rad(slope_azimuth-solar_azimuth)) * slope_tilt:
5761
return 1
@@ -67,22 +71,22 @@ def shaded_fraction(solar_elevation, solar_azimuth,
6771
np.sin(np.deg2rad(solar_elevation-relative_slope[mask])) / \
6872
np.cos(np.deg2rad(relative_slope[mask]))
6973

70-
# Initialize the unshaded area as the collector area
71-
unshaded_geomtry = collector_geometry
72-
shade_geometries = []
74+
# Initialize the unshaded area as the collector active collector area
75+
unshaded_geometry = active_collector_geometry
76+
shading_geometries = []
7377
for i, (x, y) in enumerate(zip(xoff, yoff)):
7478
if np.sqrt(x**2+y**2) < L_min:
75-
# Project the geometry of the shading collector onto the plane
76-
# of the investigated collector
77-
shade_geometry = shapely.affinity.translate(collector_geometry, x, y)
79+
# Project the geometry of the shading collector (total area) onto
80+
# the plane of the reference collector
81+
shading_geometry = shapely.affinity.translate(total_collector_geometry, x, y) # noqa: E501
7882
# Update the unshaded area based on overlapping shade
79-
unshaded_geomtry = unshaded_geomtry.difference(shade_geometry)
83+
unshaded_geometry = unshaded_geometry.difference(shading_geometry)
8084
if plot:
81-
shade_geometries.append(shade_geometry)
85+
shading_geometries.append(shading_geometry)
8286

8387
if plot:
84-
plotting._plot_shading(
85-
collector_geometry, unshaded_geomtry, shade_geometries)
88+
plotting._plot_shading(active_collector_geometry, unshaded_geometry,
89+
shading_geometries, L_min)
8690

87-
shaded_fraction = 1 - unshaded_geomtry.area / collector_geometry.area
91+
shaded_fraction = 1 - unshaded_geometry.area / active_collector_geometry.area
8892
return shaded_fraction

0 commit comments

Comments
 (0)