diff --git a/setup.cfg b/setup.cfg index 20d2ef3..648022e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -28,6 +28,7 @@ install_requires = test = pytest pytest-cov + pandas doc = sphinx==4.4.0 myst-nb==0.13.2 diff --git a/twoaxistracking/plotting.py b/twoaxistracking/plotting.py index 6d58832..1d485d9 100644 --- a/twoaxistracking/plotting.py +++ b/twoaxistracking/plotting.py @@ -4,6 +4,7 @@ from shapely import geometry import matplotlib.colors as mcolors from matplotlib import cm +import numpy as np def _plot_field_layout(X, Y, Z, min_tracker_spacing): @@ -40,10 +41,12 @@ def _polygons_to_patch_collection(geometries, **kwargs): kwargs are passed to PatchCollection """ - # Convert geometries to a MultiPolygon if it is a Polygon + # Convert geometries to a list if isinstance(geometries, geometry.Polygon): - geometries = geometry.MultiPolygon([geometries]) - exteriors = [patches.Polygon(g.exterior) for g in geometries] + geometries = [geometries] + elif isinstance(geometries, geometry.MultiPolygon): + geometries = list(geometries.geoms) + exteriors = [patches.Polygon(np.array(g.exterior)) for g in geometries] path_collection = collections.PatchCollection(exteriors, **kwargs) return path_collection diff --git a/twoaxistracking/tests/__init__.py b/twoaxistracking/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/twoaxistracking/tests/conftest.py b/twoaxistracking/tests/conftest.py new file mode 100644 index 0000000..3432638 --- /dev/null +++ b/twoaxistracking/tests/conftest.py @@ -0,0 +1,50 @@ +import pytest +from shapely import geometry +import numpy as np +from twoaxistracking import layout + + +@pytest.fixture +def rectangular_geometry(): + collector_geometry = geometry.box(-2, -1, 2, 1) + total_collector_area = collector_geometry.area + min_tracker_spacing = layout._calculate_min_tracker_spacing(collector_geometry) + return collector_geometry, total_collector_area, min_tracker_spacing + + +@pytest.fixture +def active_geometry_split(): + active_collector_geometry = geometry.MultiPolygon([ + geometry.box(-1.9, -0.9, -0.1, -0.1), + geometry.box(0.1, -0.9, 1.9, -0.1), + geometry.box(-1.9, 0.1, -0.1, 0.9), + geometry.box(0.1, 0.1, 1.9, 0.9)]) + return active_collector_geometry + + +@pytest.fixture +def square_field_layout(): + # Corresponds to GCR 0.125 with the rectangular_geometry + X = np.array([-8, 0, 8, -8, 8, -8, 0, 8]) + Y = np.array([-8, -8, -8, 0, 0, 8, 8, 8]) + tracker_distance = (X**2 + Y**2)**0.5 + relative_azimuth = np.array([225, 180, 135, 270, 90, 315, 0, 45]) + Z = np.zeros(8) + relative_slope = np.zeros(8) + return X, Y, Z, tracker_distance, relative_azimuth, relative_slope + + +@pytest.fixture +def square_field_layout_sloped(square_field_layout): + # Corresponds to GCR 0.125 with the rectangular_geometry and tilted slope + # Based on the square_field_layout + X, Y, _, tracker_distance, relative_azimuth, _ = square_field_layout + Z = np.array([0.12372765, 0.06186383, 0, 0.06186383, + -0.06186383, 0, -0.06186383, -0.12372765]) + relative_slope = np.array([5, 3.53553391, 0, 3.53553391, + -3.53553391, 0, -3.53553391, -5]) + return X, Y, Z, tracker_distance, relative_azimuth, relative_slope + + +def assert_isinstance(obj, klass): + assert isinstance(obj, klass), f'got {type(obj)}, expected {klass}' diff --git a/twoaxistracking/tests/test_layout.py b/twoaxistracking/tests/test_layout.py new file mode 100644 index 0000000..786ff17 --- /dev/null +++ b/twoaxistracking/tests/test_layout.py @@ -0,0 +1,147 @@ +from twoaxistracking import layout +from shapely import geometry +import numpy as np +import pytest + + +def test_min_tracker_spacing_rectangle(rectangular_geometry): + # Test calculation of min_tracker_spacing for a rectangular collector + min_tracker_spacing = layout._calculate_min_tracker_spacing(rectangular_geometry[0]) + np.testing.assert_allclose(min_tracker_spacing, np.sqrt(4**2+2**2)) + + +def test_min_tracker_spacing_circle(): + # Test calculation of min_tracker_spacing for a circular collector with radius 1 + collector_geometry = geometry.Point(0, 0).buffer(1) + min_tracker_spacing = layout._calculate_min_tracker_spacing(collector_geometry) + assert min_tracker_spacing == 2 + + +def test_min_tracker_spacing_circle_offcenter(): + # Test calculation of min_tracker_spacing for a circular collector with radius 1 rotating + # off-center around the point (0, 1) + collector_geometry = geometry.Point(0, 1).buffer(1) + min_tracker_spacing = layout._calculate_min_tracker_spacing(collector_geometry) + assert min_tracker_spacing == 4 + + +def test_min_tracker_spacingpolygon(): + # Test calculation of min_tracker_spacing for a polygon + collector_geometry = geometry.Polygon([(-1, -1), (3, 2), (4, 4), (1, 2), (-1, -1)]) + min_tracker_spacing = layout._calculate_min_tracker_spacing(collector_geometry) + np.testing.assert_allclose(min_tracker_spacing, 2 * np.sqrt(4**2 + 4**2)) + + +def test_square_layout_generation(rectangular_geometry, square_field_layout): + # Test that a square field layout is returned correctly + collector_geometry, total_collector_area, min_tracker_spacing = rectangular_geometry + X_exp, Y_exp, Z_exp, tracker_distance_exp, relative_azimuth_exp, relative_slope_exp = \ + square_field_layout + + X, Y, Z, tracker_distance, relative_azimuth, relative_slope = \ + layout.generate_field_layout( + gcr=0.125, + total_collector_area=total_collector_area, + min_tracker_spacing=min_tracker_spacing, + neighbor_order=1, + aspect_ratio=1, + offset=0, + rotation=0) + np.testing.assert_allclose(X, X_exp) + np.testing.assert_allclose(Y, Y_exp) + np.testing.assert_allclose(Z, Z_exp) + np.testing.assert_allclose(tracker_distance_exp, tracker_distance_exp) + np.testing.assert_allclose(relative_azimuth, relative_azimuth_exp) + np.testing.assert_allclose(relative_slope, relative_slope_exp) + + +def test_field_slope(rectangular_geometry, square_field_layout_sloped): + # Test that a square field layout on tilted surface is returned correctly + collector_geometry, total_collector_area, min_tracker_spacing = rectangular_geometry + X_exp, Y_exp, Z_exp, tracker_distance_exp, relative_azimuth_exp, relative_slope_exp = \ + square_field_layout_sloped + X, Y, Z, tracker_distance, relative_azimuth, relative_slope = \ + layout.generate_field_layout( + gcr=0.125, + total_collector_area=total_collector_area, + min_tracker_spacing=min_tracker_spacing, + neighbor_order=1, + aspect_ratio=1, + offset=0, + rotation=0, + slope_azimuth=45, + slope_tilt=5) + np.testing.assert_allclose(X, X_exp) + np.testing.assert_allclose(Y, Y_exp) + np.testing.assert_allclose(Z, Z_exp, atol=10**-9) + np.testing.assert_allclose(tracker_distance_exp, tracker_distance_exp) + np.testing.assert_allclose(relative_azimuth, relative_azimuth_exp) + np.testing.assert_allclose(relative_slope, relative_slope_exp, atol=10**-9) + + +def test_layout_generation_value_error(rectangular_geometry): + # Test if value errors are correctly raised + collector_geometry, total_collector_area, min_tracker_spacing = rectangular_geometry + + # Test if ValueError is raised if offset is out of range + with pytest.raises(ValueError, match="offset is outside the valid range"): + _ = layout.generate_field_layout( + gcr=0.25, total_collector_area=total_collector_area, + min_tracker_spacing=min_tracker_spacing, neighbor_order=1, + aspect_ratio=1, offset=1.1, rotation=0) + + # Test if ValueError is raised if aspect ratio is too low + with pytest.raises(ValueError, match="Aspect ratio is too low"): + _ = layout.generate_field_layout( + gcr=0.25, total_collector_area=total_collector_area, + min_tracker_spacing=min_tracker_spacing, neighbor_order=1, + aspect_ratio=0.6, offset=0, rotation=0) + + # Test if ValueError is raised if aspect ratio is too high + with pytest.raises(ValueError, match="Aspect ratio is too high"): + _ = layout.generate_field_layout( + gcr=0.25, total_collector_area=total_collector_area, + min_tracker_spacing=min_tracker_spacing, neighbor_order=1, + aspect_ratio=5, offset=0, rotation=0) + + # Test if ValueError is raised if rotation is greater than 180 degrees + with pytest.raises(ValueError, match="rotation is outside the valid range"): + _ = layout.generate_field_layout( + gcr=0.25, total_collector_area=total_collector_area, + min_tracker_spacing=min_tracker_spacing, neighbor_order=1, + aspect_ratio=1.2, offset=0, rotation=190) + + # Test if ValueError is raised if rotation is less than 0 + with pytest.raises(ValueError, match="rotation is outside the valid range"): + _ = layout.generate_field_layout( + gcr=0.5, total_collector_area=total_collector_area, + min_tracker_spacing=min_tracker_spacing, neighbor_order=1, + aspect_ratio=1, offset=0, rotation=-1) + + # Test if ValueError is raised if min_tracker_spacing is outside valid range + with pytest.raises(ValueError, match="Lmin is not physically possible"): + _ = layout.generate_field_layout( + gcr=0.25, total_collector_area=total_collector_area, + min_tracker_spacing=1, neighbor_order=1, aspect_ratio=1.2, + offset=0, rotation=90) + + # Test if ValueError is raised if maximum ground cover ratio is exceeded + with pytest.raises(ValueError, match="Maximum ground cover ratio exceded"): + _ = layout.generate_field_layout( + gcr=0.5, total_collector_area=total_collector_area, + min_tracker_spacing=min_tracker_spacing, neighbor_order=1, + aspect_ratio=1, offset=0, rotation=0) + + +def test_neighbor_order(rectangular_geometry): + collector_geometry, total_collector_area, min_tracker_spacing = rectangular_geometry + X, Y, Z, tracker_distance, relative_azimuth, relative_slope = \ + layout.generate_field_layout( + gcr=0.125, + total_collector_area=total_collector_area, + min_tracker_spacing=min_tracker_spacing, + neighbor_order=3, + aspect_ratio=1, + offset=0, + rotation=0) + assert len(X) == (7*7-1) diff --git a/twoaxistracking/tests/test_plotting.py b/twoaxistracking/tests/test_plotting.py new file mode 100644 index 0000000..0168e03 --- /dev/null +++ b/twoaxistracking/tests/test_plotting.py @@ -0,0 +1,42 @@ +import matplotlib.pyplot as plt +from twoaxistracking import plotting, twoaxistrackerfield +from .conftest import assert_isinstance +import numpy as np + + +def test_field_layout_plot(): + X = np.array([-8, 0, 8, -8, 8, -8, 0, 8]) + Y = np.array([-8, -8, -8, 0, 0, 8, 8, 8]) + Z = np.array([0.1237, 0.0619, 0, 0.0619, -0.0619, 0, -0.0619, -0.1237]) + L_min = 4.4721 + result = plotting._plot_field_layout(X, Y, Z, L_min) + assert_isinstance(result, plt.Figure) + plt.close('all') + + +def test_shading_plot(rectangular_geometry, active_geometry_split): + collector_geometry, total_collector_area, min_tracker_spacing = rectangular_geometry + result = plotting._plot_shading( + active_geometry_split, + collector_geometry, + collector_geometry, + min_tracker_spacing) + assert_isinstance(result, plt.Figure) + plt.close('all') + + +def test_plotting_of_field_layout(rectangular_geometry): + # Test if plot_field_layout returns a figure object + collector_geometry, total_collector_area, min_tracker_spacing = rectangular_geometry + field = twoaxistrackerfield.TwoAxisTrackerField( + total_collector_geometry=collector_geometry, + active_collector_geometry=collector_geometry, + neighbor_order=1, + gcr=0.25, + aspect_ratio=1, + offset=0.45, + rotation=30, + ) + result = field.plot_field_layout() + assert_isinstance(result, plt.Figure) + plt.close('all') diff --git a/twoaxistracking/tests/test_shading.py b/twoaxistracking/tests/test_shading.py new file mode 100644 index 0000000..df47c87 --- /dev/null +++ b/twoaxistracking/tests/test_shading.py @@ -0,0 +1,103 @@ +from twoaxistracking import shading +import numpy as np + + +def test_shading(rectangular_geometry, active_geometry_split, square_field_layout): + # Test shading calculation + # Also plots the geometry (ensures no errors are raised) + collector_geometry, total_collector_area, min_tracker_spacing = rectangular_geometry + X, Y, Z, tracker_distance, relative_azimuth, relative_slope = \ + square_field_layout + shaded_fraction = shading.shaded_fraction( + solar_elevation=3, + solar_azimuth=120, + total_collector_geometry=collector_geometry, + active_collector_geometry=active_geometry_split, + min_tracker_spacing=min_tracker_spacing, + tracker_distance=tracker_distance, + relative_azimuth=relative_azimuth, + relative_slope=relative_slope, + slope_azimuth=0, + slope_tilt=0, + plot=True) + np.testing.assert_allclose(shaded_fraction, 0.190320666774) + + +def test_shading_zero_solar_elevation(rectangular_geometry, square_field_layout): + # Test shading when geometries completely overlap + collector_geometry, total_collector_area, min_tracker_spacing = rectangular_geometry + X, Y, Z, tracker_distance, relative_azimuth, relative_slope = \ + square_field_layout + shaded_fraction = shading.shaded_fraction( + solar_elevation=0, + solar_azimuth=180, + total_collector_geometry=collector_geometry, + active_collector_geometry=collector_geometry, + min_tracker_spacing=min_tracker_spacing, + tracker_distance=tracker_distance, + relative_azimuth=relative_azimuth, + relative_slope=relative_slope, + slope_azimuth=0, + slope_tilt=0, + plot=False) + assert shaded_fraction == 1 + + +def test_no_shading(rectangular_geometry, square_field_layout): + # Test shading calculation when there is no shading (high solar elevation) + collector_geometry, total_collector_area, min_tracker_spacing = rectangular_geometry + X, Y, Z, tracker_distance, relative_azimuth, relative_slope = \ + square_field_layout + shaded_fraction = shading.shaded_fraction( + solar_elevation=45, + solar_azimuth=180, + total_collector_geometry=collector_geometry, + active_collector_geometry=collector_geometry, + min_tracker_spacing=min_tracker_spacing, + tracker_distance=tracker_distance, + relative_azimuth=relative_azimuth, + relative_slope=relative_slope, + slope_azimuth=0, + slope_tilt=0, + plot=False) + assert shaded_fraction == 0 + + +def test_shading_below_horizon(rectangular_geometry, square_field_layout): + # Test shading calculation when sun is below the horizon (elevation<0) + collector_geometry, total_collector_area, min_tracker_spacing = rectangular_geometry + X, Y, Z, tracker_distance, relative_azimuth, relative_slope = \ + square_field_layout + shaded_fraction = shading.shaded_fraction( + solar_elevation=-5.1, + solar_azimuth=180, + total_collector_geometry=collector_geometry, + active_collector_geometry=collector_geometry, + min_tracker_spacing=min_tracker_spacing, + tracker_distance=tracker_distance, + relative_azimuth=relative_azimuth, + relative_slope=relative_slope, + slope_azimuth=0, + slope_tilt=0, + plot=False) + assert np.isnan(shaded_fraction) + + +def test_shading_below_hill_horizon(rectangular_geometry, square_field_layout): + # Test shading when sun is below horizon line caused by sloped surface + collector_geometry, total_collector_area, min_tracker_spacing = rectangular_geometry + X, Y, Z, tracker_distance, relative_azimuth, relative_slope = \ + square_field_layout + shaded_fraction = shading.shaded_fraction( + solar_elevation=9, + solar_azimuth=180, + total_collector_geometry=collector_geometry, + active_collector_geometry=collector_geometry, + min_tracker_spacing=min_tracker_spacing, + tracker_distance=tracker_distance, + relative_azimuth=relative_azimuth, + relative_slope=relative_slope, + slope_azimuth=0, + slope_tilt=10, + plot=False) + assert shaded_fraction == 1 diff --git a/twoaxistracking/tests/test_twoaxistrackerfield.py b/twoaxistracking/tests/test_twoaxistrackerfield.py index d8935ca..6be0c3f 100644 --- a/twoaxistracking/tests/test_twoaxistrackerfield.py +++ b/twoaxistracking/tests/test_twoaxistrackerfield.py @@ -1,18 +1,9 @@ -from twoaxistracking import layout, twoaxistrackerfield -from shapely import geometry +from twoaxistracking import twoaxistrackerfield import numpy as np import pandas as pd import pytest -@pytest.fixture -def rectangular_geometry(): - collector_geometry = geometry.box(-2, -1, 2, 1) - total_collector_area = collector_geometry.area - min_tracker_spacing = layout._calculate_min_tracker_spacing(collector_geometry) - return collector_geometry, total_collector_area, min_tracker_spacing - - def test_invalid_layout_type(rectangular_geometry): # Test if ValueError is raised when an incorrect layout_type is specified collector_geometry, total_collector_area, min_tracker_spacing = rectangular_geometry diff --git a/twoaxistracking/twoaxistrackerfield.py b/twoaxistracking/twoaxistrackerfield.py index 4ab2fb8..976f3bd 100644 --- a/twoaxistracking/twoaxistrackerfield.py +++ b/twoaxistracking/twoaxistrackerfield.py @@ -167,7 +167,7 @@ def get_shaded_fraction(self, solar_elevation, solar_azimuth, relative_slope=self.relative_slope, slope_azimuth=self.slope_azimuth, slope_tilt=self.slope_tilt, - plot=False) + plot=plot) shaded_fractions.append(shaded_fraction) # Return the shaded_fractions as the same type as the input