From 5e52d564a8a4a16f96c46fa4246d6896bb35d9dc Mon Sep 17 00:00:00 2001 From: misi9170 Date: Thu, 5 Sep 2024 08:24:02 -0600 Subject: [PATCH 01/15] Beginning to build grid optimization based on boundary grid optimization. --- .../layout_optimization_gridded.py | 657 ++++++++++++++++++ tests/layout_optimization_integration_test.py | 68 +- 2 files changed, 710 insertions(+), 15 deletions(-) create mode 100644 floris/optimization/layout_optimization/layout_optimization_gridded.py diff --git a/floris/optimization/layout_optimization/layout_optimization_gridded.py b/floris/optimization/layout_optimization/layout_optimization_gridded.py new file mode 100644 index 000000000..eea053b6b --- /dev/null +++ b/floris/optimization/layout_optimization/layout_optimization_gridded.py @@ -0,0 +1,657 @@ +from __future__ import annotations + +import matplotlib.pyplot as plt +import numpy as np +from scipy.spatial.distance import cdist +from shapely.geometry import ( + LineString, + Point, + Polygon, +) + +from .layout_optimization_base import LayoutOptimization + + +class LayoutOptimizationGridded(LayoutOptimization): + def __init__( + self, + fmodel, + boundaries, + spacing: float | None=None, + spacing_D: float | None=None, + rotation_step: float=5.0, + rotation_range: tuple[float, float]=(0.0, 360.0), + hexagonal_packing: bool=False, + enable_geometric_yaw: bool=False, + use_value: bool=False, + + ): + # Save boundaries + # Handle spacing information + if spacing is not None and spacing_D is not None: + raise ValueError("Only one of spacing and spacing_D can be defined.") + if spacing is None and spacing_D is None: + raise ValueError("Either spacing or spacing_D must be defined.") + if spacing_D is not None: + spacing = spacing_D * fmodel.core.farm.rotor_diameters[0] + if len(np.unique(fmodel.core.farm.rotor_diameters)) > 1: + self.logger.warning(( + "Found multiple turbine diameters. Using diameter of first turbine to set" + f" spacing to {spacing}m." + )) + + # Initialize the base class + super().__init__( + fmodel, + boundaries, + min_dist=spacing, + enable_geometric_yaw=enable_geometric_yaw, + use_value=use_value, + ) + + # Create the default grid + + # use spacing, hexagonal packing, and boundaries to create a grid. + + # Create test rotations + self.rotations = np.arange(rotation_range[0], rotation_range[1], rotation_step) + + def _discontinuous_grid( + self, + nrows, + ncols, + farm_width, + farm_height, + shear, + rotation, + center_x, + center_y, + shrink_boundary, + boundary_x, + boundary_y, + eps=1e-3, + ): + """ + Map from grid design variables to turbine x and y locations. + Includes integer design variables and the formulation + results in a discontinous design space. + + TODO: shrink_boundary doesn't work well with concave boundaries, + or with boundary angles less than 90 deg + + Args: + nrows (Int): number of rows in the grid. + ncols (Int): number of columns in the grid. + farm_width (Float): total grid width (before shear). + farm_height (Float): total grid height. + shear (Float): grid shear (rad). + rotation (Float): rotation about grid center (rad). + center_x (Float): location of grid x center. + center_y (Float): location of grid y center. + shrink_boundary (Float): how much to shrink the boundary that the grid can occupy. + boundary_x (Array(Float)): x boundary points. + boundary_y (Array(Float)): y boundary points. + + Returns: + grid_x (Array(Float)): turbine x locations. + grid_y (Array(Float)): turbine y locations. + """ + # create grid + nrows = int(nrows) + ncols = int(ncols) + xlocs = np.linspace(0.0, farm_width, ncols) + ylocs = np.linspace(0.0, farm_height, nrows) + y_spacing = ylocs[1] - ylocs[0] + nturbs = nrows * ncols + grid_x = np.zeros(nturbs) + grid_y = np.zeros(nturbs) + turb = 0 + for i in range(nrows): + for j in range(ncols): + grid_x[turb] = xlocs[j] + float(i) * y_spacing * np.tan(shear) + grid_y[turb] = ylocs[i] + turb += 1 + + # rotate + grid_x, grid_y = ( + np.cos(rotation) * grid_x - np.sin(rotation) * grid_y, + np.sin(rotation) * grid_x + np.cos(rotation) * grid_y, + ) + + # move center of grid + grid_x = (grid_x - np.mean(grid_x)) + center_x + grid_y = (grid_y - np.mean(grid_y)) + center_y + + # arrange the boundary + + # boundary = np.zeros((len(boundary_x),2)) + # boundary[:,0] = boundary_x[:] + # boundary[:,1] = boundary_y[:] + # poly = Polygon(boundary) + # centroid = poly.centroid + + # boundary[:,0] = (boundary_x[:]-centroid.x)*boundary_mult + centroid.x + # boundary[:,1] = (boundary_y[:]-centroid.y)*boundary_mult + centroid.y + # poly = Polygon(boundary) + + boundary = np.zeros((len(boundary_x), 2)) + boundary[:, 0] = boundary_x[:] + boundary[:, 1] = boundary_y[:] + poly = Polygon(boundary) + + if shrink_boundary != 0.0: + nBounds = len(boundary_x) + for i in range(nBounds): + point = Point(boundary_x[i] + eps, boundary_y[i]) + if poly.contains(point) is True or poly.touches(point) is True: + boundary[i, 0] = boundary_x[i] + shrink_boundary + else: + boundary[i, 0] = boundary_x[i] - shrink_boundary + + point = Point(boundary_x[i], boundary_y[i] + eps) + if poly.contains(point) is True or poly.touches(point) is True: + boundary[i, 1] = boundary_y[i] + shrink_boundary + else: + boundary[i, 1] = boundary_y[i] - shrink_boundary + + poly = Polygon(boundary) + + # get rid of points outside of boundary + index = 0 + for i in range(len(grid_x)): + point = Point(grid_x[index], grid_y[index]) + if poly.contains(point) is False and poly.touches(point) is False: + grid_x = np.delete(grid_x, index) + grid_y = np.delete(grid_y, index) + else: + index += 1 + + return grid_x, grid_y + + def _discrete_grid( + self, + x_spacing, + y_spacing, + shear, + rotation, + center_x, + center_y, + boundary_setback, + boundary_poly + ): + """ + returns grid turbine layout. Assumes the turbines fill the entire plant area + + Args: + x_spacing (Float): grid spacing in the unrotated x direction (m) + y_spacing (Float): grid spacing in the unrotated y direction (m) + shear (Float): grid shear (rad) + rotation (Float): grid rotation (rad) + center_x (Float): the x coordinate of the grid center (m) + center_y (Float): the y coordinate of the grid center (m) + boundary_poly (Polygon): a shapely Polygon of the wind plant boundary + + Returns + return_x (Array(Float)): turbine x locations + return_y (Array(Float)): turbine y locations + """ + + shrunk_poly = boundary_poly.buffer(-boundary_setback) + if shrunk_poly.area <= 0: + return np.array([]), np.array([]) + # create grid + minx, miny, maxx, maxy = shrunk_poly.bounds + width = maxx-minx + height = maxy-miny + + center_point = Point((center_x,center_y)) + poly_to_center = center_point.distance(shrunk_poly.centroid) + + width = np.max([width,poly_to_center]) + height = np.max([height,poly_to_center]) + nrows = int(np.max([width,height])/np.min([x_spacing,y_spacing]))*2 + 1 + ncols = nrows + + xlocs = np.arange(0,ncols)*x_spacing + ylocs = np.arange(0,nrows)*y_spacing + row_number = np.arange(0,nrows) + + d = np.array([i for x in xlocs for i in row_number]) + layout_x = np.array([x for x in xlocs for y in ylocs]) + d*y_spacing*np.tan(shear) + layout_y = np.array([y for x in xlocs for y in ylocs]) + + # rotate + rotate_x = np.cos(rotation)*layout_x - np.sin(rotation)*layout_y + rotate_y = np.sin(rotation)*layout_x + np.cos(rotation)*layout_y + + # move center of grid + rotate_x = (rotate_x - np.mean(rotate_x)) + center_x + rotate_y = (rotate_y - np.mean(rotate_y)) + center_y + + # get rid of points outside of boundary polygon + meets_constraints = np.zeros(len(rotate_x),dtype=bool) + for i in range(len(rotate_x)): + pt = Point(rotate_x[i],rotate_y[i]) + if shrunk_poly.contains(pt) or shrunk_poly.touches(pt): + meets_constraints[i] = True + + # arrange final x,y points + return_x = rotate_x[meets_constraints] + return_y = rotate_y[meets_constraints] + + return return_x, return_y + + def find_lengths(self, x, y, npoints): + length = np.zeros(len(x) - 1) + for i in range(npoints): + length[i] = np.sqrt((x[i + 1] - x[i]) ** 2 + (y[i + 1] - y[i]) ** 2) + return length + + # def _place_boundary_turbines(self, n_boundary_turbs, start, boundary_x, boundary_y): + # """ + # Place turbines equally spaced traversing the perimiter if the wind farm along the boundary + + # Args: + # n_boundary_turbs (Int): number of turbines to be placed on the boundary + # start (Float): where the first turbine should be placed + # boundary_x (Array(Float)): x boundary points + # boundary_y (Array(Float)): y boundary points + + # Returns + # layout_x (Array(Float)): turbine x locations + # layout_y (Array(Float)): turbine y locations + # """ + + # # check if the boundary is closed, correct if not + # if boundary_x[-1] != boundary_x[0] or boundary_y[-1] != boundary_y[0]: + # boundary_x = np.append(boundary_x, boundary_x[0]) + # boundary_y = np.append(boundary_y, boundary_y[0]) + + # # make the boundary + # boundary = np.zeros((len(boundary_x), 2)) + # boundary[:, 0] = boundary_x[:] + # boundary[:, 1] = boundary_y[:] + # poly = Polygon(boundary) + # perimeter = poly.length + + # # get the flattened turbine locations + # spacing = perimeter / float(n_boundary_turbs) + # flattened_locs = np.linspace(start, perimeter + start - spacing, n_boundary_turbs) + + # # set all of the flattened values between 0 and the perimeter + # for i in range(n_boundary_turbs): + # while flattened_locs[i] < 0.0: + # flattened_locs[i] += perimeter + # if flattened_locs[i] > perimeter: + # flattened_locs[i] = flattened_locs[i] % perimeter + + # # place the turbines around the perimeter + # nBounds = len(boundary_x) + # layout_x = np.zeros(n_boundary_turbs) + # layout_y = np.zeros(n_boundary_turbs) + + # lenBound = np.zeros(nBounds - 1) + # for i in range(nBounds - 1): + # lenBound[i] = Point(boundary[i]).distance(Point(boundary[i + 1])) + # for i in range(n_boundary_turbs): + # for j in range(nBounds - 1): + # if flattened_locs[i] < sum(lenBound[0 : j + 1]): + # layout_x[i] = ( + # boundary_x[j] + # + (boundary_x[j + 1] - boundary_x[j]) + # * (flattened_locs[i] - sum(lenBound[0:j])) + # / lenBound[j] + # ) + # layout_y[i] = ( + # boundary_y[j] + # + (boundary_y[j + 1] - boundary_y[j]) + # * (flattened_locs[i] - sum(lenBound[0:j])) + # / lenBound[j] + # ) + # break + + # return layout_x, layout_y + + def _place_boundary_turbines(self, start, boundary_poly, nturbs=None, spacing=None): + xBounds, yBounds = boundary_poly.boundary.coords.xy + + if xBounds[-1] != xBounds[0]: + xBounds = np.append(xBounds, xBounds[0]) + yBounds = np.append(yBounds, yBounds[0]) + + nBounds = len(xBounds) + lenBound = self.find_lengths(xBounds, yBounds, len(xBounds) - 1) + circumference = sum(lenBound) + + if nturbs is not None and spacing is None: + # When the number of boundary turbines is specified + nturbs = int(nturbs) + bound_loc = np.linspace( + start, start + circumference - circumference / float(nturbs), nturbs + ) + elif spacing is not None and nturbs is None: + # When the spacing of boundary turbines is specified + nturbs = int(np.floor(circumference / spacing)) + bound_loc = np.linspace( + start, start + circumference - circumference / float(nturbs), nturbs + ) + else: + raise ValueError("Please specify either nturbs or spacing.") + + x = np.zeros(nturbs) + y = np.zeros(nturbs) + + if spacing is None: + # When the number of boundary turbines is specified + for i in range(nturbs): + if bound_loc[i] > circumference: + bound_loc[i] = bound_loc[i] % circumference + while bound_loc[i] < 0.0: + bound_loc[i] += circumference + for i in range(nturbs): + done = False + for j in range(nBounds): + if done is False: + if bound_loc[i] < sum(lenBound[0:j+1]): + point_x = ( + xBounds[j] + + (xBounds[j+1] - xBounds[j]) + * (bound_loc[i] - sum(lenBound[0:j])) + / lenBound[j] + ) + point_y = ( + yBounds[j] + + (yBounds[j+1] - yBounds[j]) + * (bound_loc[i] - sum(lenBound[0:j])) + / lenBound[j] + ) + done = True + x[i] = point_x + y[i] = point_y + else: + # When the spacing of boundary turbines is specified + additional_space = 0.0 + end_loop = False + for i in range(nturbs): + done = False + for j in range(nBounds): + while done is False: + dist = start + i*spacing + additional_space + if dist < sum(lenBound[0:j+1]): + point_x = ( + xBounds[j] + + (xBounds[j+1]-xBounds[j]) + * (dist -sum(lenBound[0:j])) + / lenBound[j] + ) + point_y = ( + yBounds[j] + + (yBounds[j+1]-yBounds[j]) + * (dist -sum(lenBound[0:j])) + / lenBound[j] + ) + + # Check if turbine is too close to previous turbine + if i > 0: + # Check if turbine just placed is to close to first turbine + min_dist = cdist([(point_x, point_y)], [(x[0], y[0])]) + if min_dist < spacing: + # TODO: make this more robust; + # pass is needed if 2nd turbine is too close to the first + if i == 1: + pass + else: + end_loop = True + ii = i + break + + min_dist = cdist([(point_x, point_y)], [(x[i-1], y[i-1])]) + if min_dist < spacing: + additional_space += 1.0 + else: + done = True + x[i] = point_x + y[i] = point_y + elif i == 0: + # If first turbine, just add initial turbine point + done = True + x[i] = point_x + y[i] = point_y + else: + pass + else: + break + if end_loop is True: + break + if end_loop is True: + x = x[:ii] + y = y[:ii] + break + return x, y + + def _place_boundary_turbines_with_specified_spacing( + self, + spacing, + start, + boundary_x, + boundary_y + ): + """ + Place turbines equally spaced traversing the perimiter if the wind farm along the boundary + + Args: + n_boundary_turbs (Int): number of turbines to be placed on the boundary + start (Float): where the first turbine should be placed + boundary_x (Array(Float)): x boundary points + boundary_y (Array(Float)): y boundary points + + Returns + layout_x (Array(Float)): turbine x locations + layout_y (Array(Float)): turbine y locations + """ + + # check if the boundary is closed, correct if not + if boundary_x[-1] != boundary_x[0] or boundary_y[-1] != boundary_y[0]: + boundary_x = np.append(boundary_x, boundary_x[0]) + boundary_y = np.append(boundary_y, boundary_y[0]) + + # make the boundary + boundary = np.zeros((len(boundary_x), 2)) + boundary[:, 0] = boundary_x[:] + boundary[:, 1] = boundary_y[:] + poly = Polygon(boundary) + perimeter = poly.length + + # get the flattened turbine locations + n_boundary_turbs = int(perimeter / float(spacing)) + flattened_locs = np.linspace(start, perimeter + start - spacing, n_boundary_turbs) + + # set all of the flattened values between 0 and the perimeter + for i in range(n_boundary_turbs): + while flattened_locs[i] < 0.0: + flattened_locs[i] += perimeter + if flattened_locs[i] > perimeter: + flattened_locs[i] = flattened_locs[i] % perimeter + + # place the turbines around the perimeter + nBounds = len(boundary_x) + layout_x = np.zeros(n_boundary_turbs) + layout_y = np.zeros(n_boundary_turbs) + + lenBound = np.zeros(nBounds - 1) + for i in range(nBounds - 1): + lenBound[i] = Point(boundary[i]).distance(Point(boundary[i + 1])) + for i in range(n_boundary_turbs): + for j in range(nBounds - 1): + if flattened_locs[i] < sum(lenBound[0 : j + 1]): + layout_x[i] = ( + boundary_x[j] + + (boundary_x[j + 1] - boundary_x[j]) + * (flattened_locs[i] - sum(lenBound[0:j])) + / lenBound[j] + ) + layout_y[i] = ( + boundary_y[j] + + (boundary_y[j + 1] - boundary_y[j]) + * (flattened_locs[i] - sum(lenBound[0:j])) + / lenBound[j] + ) + break + + return layout_x, layout_y + + def boundary_grid( + self, + start, + x_spacing, + y_spacing, + shear, + rotation, + center_x, + center_y, + boundary_setback, + n_boundary_turbines=None, + boundary_spacing=None, + ): + """ + Place turbines equally spaced traversing the perimiter if the wind farm along the boundary + + Args: + n_boundary_turbs,start: boundary variables + nrows,ncols,farm_width,farm_height,shear, + rotation,center_x,center_y,shrink_boundary,eps: grid variables + boundary_x,boundary_y: boundary points + + Returns + layout_x (Array(Float)): turbine x locations + layout_y (Array(Float)): turbine y locations + """ + + boundary_turbines_x, boundary_turbines_y = self._place_boundary_turbines( + start, self._boundary_polygon, nturbs=n_boundary_turbines, spacing=boundary_spacing + ) + # ( boundary_turbines_x, + # boundary_turbines_y ) = self._place_boundary_turbines_with_specified_spacing( + # spacing, start, boundary_x, boundary_y + # ) + + # grid_turbines_x, grid_turbines_y = self._discontinuous_grid( + # nrows, + # ncols, + # farm_width, + # farm_height, + # shear, + # rotation, + # center_x, + # center_y, + # shrink_boundary, + # boundary_x, + # boundary_y, + # eps=eps, + # ) + + grid_turbines_x, grid_turbines_y = self._discrete_grid( + x_spacing, + y_spacing, + shear, + rotation, + center_x, + center_y, + boundary_setback, + self._boundary_polygon, + ) + + layout_x = np.append(boundary_turbines_x, grid_turbines_x) + layout_y = np.append(boundary_turbines_y, grid_turbines_y) + + return layout_x, layout_y + + def reinitialize_bg( + self, + n_boundary_turbines=None, + start=None, + x_spacing=None, + y_spacing=None, + shear=None, + rotation=None, + center_x=None, + center_y=None, + boundary_setback=None, + boundary_x=None, + boundary_y=None, + boundary_spacing=None, + ): + + if n_boundary_turbines is not None: + self.n_boundary_turbines = n_boundary_turbines + if start is not None: + self.start = start + if x_spacing is not None: + self.x_spacing = x_spacing + if y_spacing is not None: + self.y_spacing = y_spacing + if shear is not None: + self.shear = shear + if rotation is not None: + self.rotation = rotation + if center_x is not None: + self.center_x = center_x + if center_y is not None: + self.center_y = center_y + if boundary_setback is not None: + self.boundary_setback = boundary_setback + if boundary_x is not None: + self.boundary_x = boundary_x + if boundary_y is not None: + self.boundary_y = boundary_y + if boundary_spacing is not None: + self.boundary_spacing = boundary_spacing + + def reinitialize_xy(self): + layout_x, layout_y = self.boundary_grid( + self.start, + self.x_spacing, + self.y_spacing, + self.shear, + self.rotation, + self.center_x, + self.center_y, + self.boundary_setback, + self.n_boundary_turbines, + self.boundary_spacing, + ) + + self.fmodel.set(layout=(layout_x, layout_y)) + + def plot_layout(self): + plt.figure(figsize=(9, 6)) + fontsize = 16 + + plt.plot(self.fmodel.layout_x, self.fmodel.layout_y, "ob") + # plt.plot(locsx, locsy, "or") + + plt.xlabel("x (m)", fontsize=fontsize) + plt.ylabel("y (m)", fontsize=fontsize) + plt.axis("equal") + plt.grid() + plt.tick_params(which="both", labelsize=fontsize) + + def space_constraint(self, x, y, min_dist, rho=500): + # Calculate distances between turbines + locs = np.vstack((x, y)).T + distances = cdist(locs, locs) + arange = np.arange(distances.shape[0]) + distances[arange, arange] = 1e10 + dist = np.min(distances, axis=0) + + g = 1 - np.array(dist) / min_dist + + # Following code copied from OpenMDAO KSComp(). + # Constraint is satisfied when KS_constraint <= 0 + g_max = np.max(np.atleast_2d(g), axis=-1)[:, np.newaxis] + g_diff = g - g_max + exponents = np.exp(rho * g_diff) + summation = np.sum(exponents, axis=-1)[:, np.newaxis] + KS_constraint = g_max + 1.0 / rho * np.log(summation) + + return KS_constraint[0][0], dist diff --git a/tests/layout_optimization_integration_test.py b/tests/layout_optimization_integration_test.py index 18353a8f5..f2e5de229 100644 --- a/tests/layout_optimization_integration_test.py +++ b/tests/layout_optimization_integration_test.py @@ -18,20 +18,22 @@ from floris.optimization.layout_optimization.layout_optimization_scipy import ( LayoutOptimizationScipy, ) +from floris.optimization.layout_optimization.layout_optimization_gridded import ( + LayoutOptimizationGridded, +) from floris.wind_data import WindDataBase TEST_DATA = Path(__file__).resolve().parent / "data" YAML_INPUT = TEST_DATA / "input_full.yaml" +test_boundaries = [(0.0, 0.0), (0.0, 1000.0), (1000.0, 1000.0), (1000.0, 0.0), (0.0, 0.0)] + def test_base_class(caplog): # Get a test fi fmodel = FlorisModel(configuration=YAML_INPUT) - # Set up a sample boundary - boundaries = [(0.0, 0.0), (0.0, 1000.0), (1000.0, 1000.0), (1000.0, 0.0), (0.0, 0.0)] - # Now initiate layout optimization with a frequency matrix passed in the 3rd position # (this should fail) freq = np.ones((5, 5)) @@ -39,12 +41,12 @@ def test_base_class(caplog): # Check that warning is raised if fmodel does not contain wind_data with caplog.at_level(logging.WARNING): - LayoutOptimization(fmodel, boundaries, 5) + LayoutOptimization(fmodel, test_boundaries, 5) assert caplog.text != "" # Checking not empty caplog.clear() with caplog.at_level(logging.WARNING): - LayoutOptimization(fmodel=fmodel, boundaries=boundaries, min_dist=5,) + LayoutOptimization(fmodel=fmodel, boundaries=test_boundaries, min_dist=5,) assert caplog.text != "" # Checking not empty time_series = TimeSeries( @@ -56,34 +58,31 @@ def test_base_class(caplog): caplog.clear() with caplog.at_level(logging.WARNING): - LayoutOptimization(fmodel, boundaries, 5) + LayoutOptimization(fmodel, test_boundaries, 5) assert caplog.text != "" # Not empty, because get_farm_AEP called on TimeSeries # Passing without keyword arguments should work, or with keyword arguments - LayoutOptimization(fmodel, boundaries, 5) - LayoutOptimization(fmodel=fmodel, boundaries=boundaries, min_dist=5) + LayoutOptimization(fmodel, test_boundaries, 5) + LayoutOptimization(fmodel=fmodel, boundaries=test_boundaries, min_dist=5) # Check with WindRose on fmodel fmodel.set(wind_data=time_series.to_WindRose()) caplog.clear() with caplog.at_level(logging.WARNING): - LayoutOptimization(fmodel, boundaries, 5) + LayoutOptimization(fmodel, test_boundaries, 5) assert caplog.text == "" # Empty - LayoutOptimization(fmodel, boundaries, 5) - LayoutOptimization(fmodel=fmodel, boundaries=boundaries, min_dist=5) + LayoutOptimization(fmodel, test_boundaries, 5) + LayoutOptimization(fmodel=fmodel, boundaries=test_boundaries, min_dist=5) def test_LayoutOptimizationRandomSearch(): fmodel = FlorisModel(configuration=YAML_INPUT) fmodel.set(layout_x=[0, 500], layout_y = [0, 0]) - # Set up a sample boundary - boundaries = [(0.0, 0.0), (0.0, 1000.0), (1000.0, 1000.0), (1000.0, 0.0), (0.0, 0.0)] - layout_opt = LayoutOptimizationRandomSearch( fmodel=fmodel, - boundaries=boundaries, + boundaries=test_boundaries, min_dist_D=5, seconds_per_iteration=1, total_optimization_seconds=1, @@ -92,3 +91,42 @@ def test_LayoutOptimizationRandomSearch(): # Check that the optimization runs layout_opt.optimize() + +def test_LayoutOptimizationGridded_initialization(caplog): + fmodel = FlorisModel(configuration=YAML_INPUT) + fmodel.set(layout_x=[0, 500], layout_y = [0, 0]) + + with pytest.raises(ValueError): + LayoutOptimizationGridded( + fmodel=fmodel, + boundaries=test_boundaries, + ) # No spacing specified + with pytest.raises(ValueError): + LayoutOptimizationGridded( + fmodel=fmodel, + boundaries=test_boundaries, + spacing=500, + spacing_D=5 + ) # Spacing specified in two ways + + fmodel.core.farm.rotor_diameters[1] = 100.0 + caplog.clear() + with caplog.at_level(logging.WARNING): + LayoutOptimizationGridded( + fmodel, + test_boundaries, + spacing_D=5 + ) + +def test_LayoutOptimizationGridded(): + fmodel = FlorisModel(configuration=YAML_INPUT) + fmodel.set(layout_x=[0, 500], layout_y = [0, 0]) + + # Set up a sample boundary + boundaries = [(0.0, 0.0), (0.0, 1000.0), (1000.0, 1000.0), (1000.0, 0.0), (0.0, 0.0)] + + layout_opt = LayoutOptimizationGridded( + fmodel=fmodel, + boundaries=boundaries, + spacing=5, + ) From f710d98b46545935da14146bf94c10e44d263986 Mon Sep 17 00:00:00 2001 From: misi9170 Date: Thu, 5 Sep 2024 13:07:03 -0600 Subject: [PATCH 02/15] Main running code drafted; adding tests. --- .../layout_optimization_base.py | 4 +- .../layout_optimization_gridded.py | 707 +++--------------- tests/layout_optimization_integration_test.py | 79 +- 3 files changed, 184 insertions(+), 606 deletions(-) diff --git a/floris/optimization/layout_optimization/layout_optimization_base.py b/floris/optimization/layout_optimization/layout_optimization_base.py index 99977c2f5..97b6c3b81 100644 --- a/floris/optimization/layout_optimization/layout_optimization_base.py +++ b/floris/optimization/layout_optimization/layout_optimization_base.py @@ -51,8 +51,8 @@ def __init__( b_depth = list_depth(boundaries) boundary_specification_error_msg = ( - "boundaries should be a list of coordinates (specifed as (x,y) "+\ - "tuples) or as a list of list of tuples (for seperable regions)." + "boundaries should be a list of coordinates (specified as (x,y) "+\ + "tuples) or as a list of list of tuples (for separable regions)." ) if b_depth == 1: diff --git a/floris/optimization/layout_optimization/layout_optimization_gridded.py b/floris/optimization/layout_optimization/layout_optimization_gridded.py index eea053b6b..9202c54b3 100644 --- a/floris/optimization/layout_optimization/layout_optimization_gridded.py +++ b/floris/optimization/layout_optimization/layout_optimization_gridded.py @@ -10,7 +10,7 @@ ) from .layout_optimization_base import LayoutOptimization - +from .layout_optimization_random_search import test_point_in_bounds class LayoutOptimizationGridded(LayoutOptimization): def __init__( @@ -18,9 +18,11 @@ def __init__( fmodel, boundaries, spacing: float | None=None, - spacing_D: float | None=None, + spacing_D: float | None=-1, rotation_step: float=5.0, rotation_range: tuple[float, float]=(0.0, 360.0), + translation_step: float | None=None, + translation_step_D: float | None=-1, hexagonal_packing: bool=False, enable_geometric_yaw: bool=False, use_value: bool=False, @@ -28,18 +30,38 @@ def __init__( ): # Save boundaries # Handle spacing information - if spacing is not None and spacing_D is not None: + if spacing is not None and spacing_D is not None and spacing_D >= 0: raise ValueError("Only one of spacing and spacing_D can be defined.") if spacing is None and spacing_D is None: raise ValueError("Either spacing or spacing_D must be defined.") - if spacing_D is not None: + if spacing_D is not None and spacing is None: + if spacing_D < 0: # Default to 5D + spacing_D = 5.0 spacing = spacing_D * fmodel.core.farm.rotor_diameters[0] if len(np.unique(fmodel.core.farm.rotor_diameters)) > 1: self.logger.warning(( "Found multiple turbine diameters. Using diameter of first turbine to set" - f" spacing to {spacing}m." + f" spacing to {spacing}m ({spacing_D} diameters)." )) + # Similar for translation step + if (translation_step is not None + and translation_step_D is not None + and translation_step_D >= 0 + ): + raise ValueError("Only one of translation_step and translation_step_D can be defined.") + if translation_step is None and translation_step_D is None: + raise ValueError("Either translation_step or translation_step_D must be defined.") + if translation_step_D is not None and translation_step is None: + if translation_step_D < 0: # Default to 1D + translation_step_D = 1.0 + translation_step = translation_step_D * fmodel.core.farm.rotor_diameters[0] + if len(np.unique(fmodel.core.farm.rotor_diameters)) > 1: + self.logger.warning(( + "Found multiple turbine diameters. Using diameter of first turbine to set" + f" translation step to {translation_step}m ({translation_step_D} diameters)." + )) + # Initialize the base class super().__init__( fmodel, @@ -52,606 +74,93 @@ def __init__( # Create the default grid # use spacing, hexagonal packing, and boundaries to create a grid. - - # Create test rotations - self.rotations = np.arange(rotation_range[0], rotation_range[1], rotation_step) - - def _discontinuous_grid( - self, - nrows, - ncols, - farm_width, - farm_height, - shear, - rotation, - center_x, - center_y, - shrink_boundary, - boundary_x, - boundary_y, - eps=1e-3, - ): - """ - Map from grid design variables to turbine x and y locations. - Includes integer design variables and the formulation - results in a discontinous design space. - - TODO: shrink_boundary doesn't work well with concave boundaries, - or with boundary angles less than 90 deg - - Args: - nrows (Int): number of rows in the grid. - ncols (Int): number of columns in the grid. - farm_width (Float): total grid width (before shear). - farm_height (Float): total grid height. - shear (Float): grid shear (rad). - rotation (Float): rotation about grid center (rad). - center_x (Float): location of grid x center. - center_y (Float): location of grid y center. - shrink_boundary (Float): how much to shrink the boundary that the grid can occupy. - boundary_x (Array(Float)): x boundary points. - boundary_y (Array(Float)): y boundary points. - - Returns: - grid_x (Array(Float)): turbine x locations. - grid_y (Array(Float)): turbine y locations. - """ - # create grid - nrows = int(nrows) - ncols = int(ncols) - xlocs = np.linspace(0.0, farm_width, ncols) - ylocs = np.linspace(0.0, farm_height, nrows) - y_spacing = ylocs[1] - ylocs[0] - nturbs = nrows * ncols - grid_x = np.zeros(nturbs) - grid_y = np.zeros(nturbs) - turb = 0 - for i in range(nrows): - for j in range(ncols): - grid_x[turb] = xlocs[j] + float(i) * y_spacing * np.tan(shear) - grid_y[turb] = ylocs[i] - turb += 1 - - # rotate - grid_x, grid_y = ( - np.cos(rotation) * grid_x - np.sin(rotation) * grid_y, - np.sin(rotation) * grid_x + np.cos(rotation) * grid_y, + d = 1.1 * np.sqrt((self.xmax**2 - self.xmin**2) + (self.ymax**2 - self.ymin**2)) + grid_1D = np.arange(0, d+spacing, spacing) + if hexagonal_packing: + raise NotImplementedError("Hexagonal packing not yet implemented.") + x_locs = [] + y_locs = [] + else: + square_x, square_y = np.meshgrid(grid_1D, grid_1D) + x_locs = square_x.flatten() + y_locs = square_y.flatten() + x_locs = x_locs - np.mean(x_locs) + 0.5*(self.xmax + self.xmin) + y_locs = y_locs - np.mean(y_locs) + 0.5*(self.ymax + self.ymin) + + # Trim to a circle to minimize wasted computation + x_locs_grid, y_locs_grid = self.trim_to_circle(x_locs, y_locs, d/2) + self.xy_grid = np.concatenate( + [x_locs_grid.reshape(-1,1), y_locs_grid.reshape(-1,1)], + axis=1 ) - # move center of grid - grid_x = (grid_x - np.mean(grid_x)) + center_x - grid_y = (grid_y - np.mean(grid_y)) + center_y - - # arrange the boundary - - # boundary = np.zeros((len(boundary_x),2)) - # boundary[:,0] = boundary_x[:] - # boundary[:,1] = boundary_y[:] - # poly = Polygon(boundary) - # centroid = poly.centroid - - # boundary[:,0] = (boundary_x[:]-centroid.x)*boundary_mult + centroid.x - # boundary[:,1] = (boundary_y[:]-centroid.y)*boundary_mult + centroid.y - # poly = Polygon(boundary) - - boundary = np.zeros((len(boundary_x), 2)) - boundary[:, 0] = boundary_x[:] - boundary[:, 1] = boundary_y[:] - poly = Polygon(boundary) - - if shrink_boundary != 0.0: - nBounds = len(boundary_x) - for i in range(nBounds): - point = Point(boundary_x[i] + eps, boundary_y[i]) - if poly.contains(point) is True or poly.touches(point) is True: - boundary[i, 0] = boundary_x[i] + shrink_boundary - else: - boundary[i, 0] = boundary_x[i] - shrink_boundary - - point = Point(boundary_x[i], boundary_y[i] + eps) - if poly.contains(point) is True or poly.touches(point) is True: - boundary[i, 1] = boundary_y[i] + shrink_boundary - else: - boundary[i, 1] = boundary_y[i] - shrink_boundary - - poly = Polygon(boundary) - - # get rid of points outside of boundary - index = 0 - for i in range(len(grid_x)): - point = Point(grid_x[index], grid_y[index]) - if poly.contains(point) is False and poly.touches(point) is False: - grid_x = np.delete(grid_x, index) - grid_y = np.delete(grid_y, index) - else: - index += 1 - - return grid_x, grid_y - - def _discrete_grid( - self, - x_spacing, - y_spacing, - shear, - rotation, - center_x, - center_y, - boundary_setback, - boundary_poly - ): - """ - returns grid turbine layout. Assumes the turbines fill the entire plant area - - Args: - x_spacing (Float): grid spacing in the unrotated x direction (m) - y_spacing (Float): grid spacing in the unrotated y direction (m) - shear (Float): grid shear (rad) - rotation (Float): grid rotation (rad) - center_x (Float): the x coordinate of the grid center (m) - center_y (Float): the y coordinate of the grid center (m) - boundary_poly (Polygon): a shapely Polygon of the wind plant boundary - - Returns - return_x (Array(Float)): turbine x locations - return_y (Array(Float)): turbine y locations - """ - - shrunk_poly = boundary_poly.buffer(-boundary_setback) - if shrunk_poly.area <= 0: - return np.array([]), np.array([]) - # create grid - minx, miny, maxx, maxy = shrunk_poly.bounds - width = maxx-minx - height = maxy-miny - - center_point = Point((center_x,center_y)) - poly_to_center = center_point.distance(shrunk_poly.centroid) - - width = np.max([width,poly_to_center]) - height = np.max([height,poly_to_center]) - nrows = int(np.max([width,height])/np.min([x_spacing,y_spacing]))*2 + 1 - ncols = nrows - - xlocs = np.arange(0,ncols)*x_spacing - ylocs = np.arange(0,nrows)*y_spacing - row_number = np.arange(0,nrows) - - d = np.array([i for x in xlocs for i in row_number]) - layout_x = np.array([x for x in xlocs for y in ylocs]) + d*y_spacing*np.tan(shear) - layout_y = np.array([y for x in xlocs for y in ylocs]) - - # rotate - rotate_x = np.cos(rotation)*layout_x - np.sin(rotation)*layout_y - rotate_y = np.sin(rotation)*layout_x + np.cos(rotation)*layout_y - - # move center of grid - rotate_x = (rotate_x - np.mean(rotate_x)) + center_x - rotate_y = (rotate_y - np.mean(rotate_y)) + center_y - - # get rid of points outside of boundary polygon - meets_constraints = np.zeros(len(rotate_x),dtype=bool) - for i in range(len(rotate_x)): - pt = Point(rotate_x[i],rotate_y[i]) - if shrunk_poly.contains(pt) or shrunk_poly.touches(pt): - meets_constraints[i] = True - - # arrange final x,y points - return_x = rotate_x[meets_constraints] - return_y = rotate_y[meets_constraints] - - return return_x, return_y - - def find_lengths(self, x, y, npoints): - length = np.zeros(len(x) - 1) - for i in range(npoints): - length[i] = np.sqrt((x[i + 1] - x[i]) ** 2 + (y[i + 1] - y[i]) ** 2) - return length - - # def _place_boundary_turbines(self, n_boundary_turbs, start, boundary_x, boundary_y): - # """ - # Place turbines equally spaced traversing the perimiter if the wind farm along the boundary - - # Args: - # n_boundary_turbs (Int): number of turbines to be placed on the boundary - # start (Float): where the first turbine should be placed - # boundary_x (Array(Float)): x boundary points - # boundary_y (Array(Float)): y boundary points - - # Returns - # layout_x (Array(Float)): turbine x locations - # layout_y (Array(Float)): turbine y locations - # """ - - # # check if the boundary is closed, correct if not - # if boundary_x[-1] != boundary_x[0] or boundary_y[-1] != boundary_y[0]: - # boundary_x = np.append(boundary_x, boundary_x[0]) - # boundary_y = np.append(boundary_y, boundary_y[0]) - - # # make the boundary - # boundary = np.zeros((len(boundary_x), 2)) - # boundary[:, 0] = boundary_x[:] - # boundary[:, 1] = boundary_y[:] - # poly = Polygon(boundary) - # perimeter = poly.length - - # # get the flattened turbine locations - # spacing = perimeter / float(n_boundary_turbs) - # flattened_locs = np.linspace(start, perimeter + start - spacing, n_boundary_turbs) - - # # set all of the flattened values between 0 and the perimeter - # for i in range(n_boundary_turbs): - # while flattened_locs[i] < 0.0: - # flattened_locs[i] += perimeter - # if flattened_locs[i] > perimeter: - # flattened_locs[i] = flattened_locs[i] % perimeter - - # # place the turbines around the perimeter - # nBounds = len(boundary_x) - # layout_x = np.zeros(n_boundary_turbs) - # layout_y = np.zeros(n_boundary_turbs) - - # lenBound = np.zeros(nBounds - 1) - # for i in range(nBounds - 1): - # lenBound[i] = Point(boundary[i]).distance(Point(boundary[i + 1])) - # for i in range(n_boundary_turbs): - # for j in range(nBounds - 1): - # if flattened_locs[i] < sum(lenBound[0 : j + 1]): - # layout_x[i] = ( - # boundary_x[j] - # + (boundary_x[j + 1] - boundary_x[j]) - # * (flattened_locs[i] - sum(lenBound[0:j])) - # / lenBound[j] - # ) - # layout_y[i] = ( - # boundary_y[j] - # + (boundary_y[j + 1] - boundary_y[j]) - # * (flattened_locs[i] - sum(lenBound[0:j])) - # / lenBound[j] - # ) - # break - - # return layout_x, layout_y - - def _place_boundary_turbines(self, start, boundary_poly, nturbs=None, spacing=None): - xBounds, yBounds = boundary_poly.boundary.coords.xy - - if xBounds[-1] != xBounds[0]: - xBounds = np.append(xBounds, xBounds[0]) - yBounds = np.append(yBounds, yBounds[0]) - - nBounds = len(xBounds) - lenBound = self.find_lengths(xBounds, yBounds, len(xBounds) - 1) - circumference = sum(lenBound) - - if nturbs is not None and spacing is None: - # When the number of boundary turbines is specified - nturbs = int(nturbs) - bound_loc = np.linspace( - start, start + circumference - circumference / float(nturbs), nturbs + # Limit the rotation range if grid has symmetry + if hexagonal_packing: + # Hexagonal packing has 60 degree symmetry + rotation_range = ( + rotation_range[0], + np.minimum(rotation_range[1], rotation_range[0]+60) ) - elif spacing is not None and nturbs is None: - # When the spacing of boundary turbines is specified - nturbs = int(np.floor(circumference / spacing)) - bound_loc = np.linspace( - start, start + circumference - circumference / float(nturbs), nturbs - ) - else: - raise ValueError("Please specify either nturbs or spacing.") - - x = np.zeros(nturbs) - y = np.zeros(nturbs) - - if spacing is None: - # When the number of boundary turbines is specified - for i in range(nturbs): - if bound_loc[i] > circumference: - bound_loc[i] = bound_loc[i] % circumference - while bound_loc[i] < 0.0: - bound_loc[i] += circumference - for i in range(nturbs): - done = False - for j in range(nBounds): - if done is False: - if bound_loc[i] < sum(lenBound[0:j+1]): - point_x = ( - xBounds[j] - + (xBounds[j+1] - xBounds[j]) - * (bound_loc[i] - sum(lenBound[0:j])) - / lenBound[j] - ) - point_y = ( - yBounds[j] - + (yBounds[j+1] - yBounds[j]) - * (bound_loc[i] - sum(lenBound[0:j])) - / lenBound[j] - ) - done = True - x[i] = point_x - y[i] = point_y else: - # When the spacing of boundary turbines is specified - additional_space = 0.0 - end_loop = False - for i in range(nturbs): - done = False - for j in range(nBounds): - while done is False: - dist = start + i*spacing + additional_space - if dist < sum(lenBound[0:j+1]): - point_x = ( - xBounds[j] - + (xBounds[j+1]-xBounds[j]) - * (dist -sum(lenBound[0:j])) - / lenBound[j] - ) - point_y = ( - yBounds[j] - + (yBounds[j+1]-yBounds[j]) - * (dist -sum(lenBound[0:j])) - / lenBound[j] - ) - - # Check if turbine is too close to previous turbine - if i > 0: - # Check if turbine just placed is to close to first turbine - min_dist = cdist([(point_x, point_y)], [(x[0], y[0])]) - if min_dist < spacing: - # TODO: make this more robust; - # pass is needed if 2nd turbine is too close to the first - if i == 1: - pass - else: - end_loop = True - ii = i - break - - min_dist = cdist([(point_x, point_y)], [(x[i-1], y[i-1])]) - if min_dist < spacing: - additional_space += 1.0 - else: - done = True - x[i] = point_x - y[i] = point_y - elif i == 0: - # If first turbine, just add initial turbine point - done = True - x[i] = point_x - y[i] = point_y - else: - pass - else: - break - if end_loop is True: - break - if end_loop is True: - x = x[:ii] - y = y[:ii] - break - return x, y - - def _place_boundary_turbines_with_specified_spacing( - self, - spacing, - start, - boundary_x, - boundary_y - ): - """ - Place turbines equally spaced traversing the perimiter if the wind farm along the boundary - - Args: - n_boundary_turbs (Int): number of turbines to be placed on the boundary - start (Float): where the first turbine should be placed - boundary_x (Array(Float)): x boundary points - boundary_y (Array(Float)): y boundary points - - Returns - layout_x (Array(Float)): turbine x locations - layout_y (Array(Float)): turbine y locations - """ - - # check if the boundary is closed, correct if not - if boundary_x[-1] != boundary_x[0] or boundary_y[-1] != boundary_y[0]: - boundary_x = np.append(boundary_x, boundary_x[0]) - boundary_y = np.append(boundary_y, boundary_y[0]) - - # make the boundary - boundary = np.zeros((len(boundary_x), 2)) - boundary[:, 0] = boundary_x[:] - boundary[:, 1] = boundary_y[:] - poly = Polygon(boundary) - perimeter = poly.length - - # get the flattened turbine locations - n_boundary_turbs = int(perimeter / float(spacing)) - flattened_locs = np.linspace(start, perimeter + start - spacing, n_boundary_turbs) - - # set all of the flattened values between 0 and the perimeter - for i in range(n_boundary_turbs): - while flattened_locs[i] < 0.0: - flattened_locs[i] += perimeter - if flattened_locs[i] > perimeter: - flattened_locs[i] = flattened_locs[i] % perimeter - - # place the turbines around the perimeter - nBounds = len(boundary_x) - layout_x = np.zeros(n_boundary_turbs) - layout_y = np.zeros(n_boundary_turbs) - - lenBound = np.zeros(nBounds - 1) - for i in range(nBounds - 1): - lenBound[i] = Point(boundary[i]).distance(Point(boundary[i + 1])) - for i in range(n_boundary_turbs): - for j in range(nBounds - 1): - if flattened_locs[i] < sum(lenBound[0 : j + 1]): - layout_x[i] = ( - boundary_x[j] - + (boundary_x[j + 1] - boundary_x[j]) - * (flattened_locs[i] - sum(lenBound[0:j])) - / lenBound[j] - ) - layout_y[i] = ( - boundary_y[j] - + (boundary_y[j + 1] - boundary_y[j]) - * (flattened_locs[i] - sum(lenBound[0:j])) - / lenBound[j] - ) - break - - return layout_x, layout_y - - def boundary_grid( - self, - start, - x_spacing, - y_spacing, - shear, - rotation, - center_x, - center_y, - boundary_setback, - n_boundary_turbines=None, - boundary_spacing=None, - ): - """ - Place turbines equally spaced traversing the perimiter if the wind farm along the boundary - - Args: - n_boundary_turbs,start: boundary variables - nrows,ncols,farm_width,farm_height,shear, - rotation,center_x,center_y,shrink_boundary,eps: grid variables - boundary_x,boundary_y: boundary points - - Returns - layout_x (Array(Float)): turbine x locations - layout_y (Array(Float)): turbine y locations - """ - - boundary_turbines_x, boundary_turbines_y = self._place_boundary_turbines( - start, self._boundary_polygon, nturbs=n_boundary_turbines, spacing=boundary_spacing - ) - # ( boundary_turbines_x, - # boundary_turbines_y ) = self._place_boundary_turbines_with_specified_spacing( - # spacing, start, boundary_x, boundary_y - # ) - - # grid_turbines_x, grid_turbines_y = self._discontinuous_grid( - # nrows, - # ncols, - # farm_width, - # farm_height, - # shear, - # rotation, - # center_x, - # center_y, - # shrink_boundary, - # boundary_x, - # boundary_y, - # eps=eps, - # ) - - grid_turbines_x, grid_turbines_y = self._discrete_grid( - x_spacing, - y_spacing, - shear, - rotation, - center_x, - center_y, - boundary_setback, - self._boundary_polygon, - ) - - layout_x = np.append(boundary_turbines_x, grid_turbines_x) - layout_y = np.append(boundary_turbines_y, grid_turbines_y) - - return layout_x, layout_y - - def reinitialize_bg( - self, - n_boundary_turbines=None, - start=None, - x_spacing=None, - y_spacing=None, - shear=None, - rotation=None, - center_x=None, - center_y=None, - boundary_setback=None, - boundary_x=None, - boundary_y=None, - boundary_spacing=None, - ): - - if n_boundary_turbines is not None: - self.n_boundary_turbines = n_boundary_turbines - if start is not None: - self.start = start - if x_spacing is not None: - self.x_spacing = x_spacing - if y_spacing is not None: - self.y_spacing = y_spacing - if shear is not None: - self.shear = shear - if rotation is not None: - self.rotation = rotation - if center_x is not None: - self.center_x = center_x - if center_y is not None: - self.center_y = center_y - if boundary_setback is not None: - self.boundary_setback = boundary_setback - if boundary_x is not None: - self.boundary_x = boundary_x - if boundary_y is not None: - self.boundary_y = boundary_y - if boundary_spacing is not None: - self.boundary_spacing = boundary_spacing + # Square grid has 90 degree symmetry + rotation_range = ( + rotation_range[0], + np.minimum(rotation_range[1], rotation_range[0]+90) + ) - def reinitialize_xy(self): - layout_x, layout_y = self.boundary_grid( - self.start, - self.x_spacing, - self.y_spacing, - self.shear, - self.rotation, - self.center_x, - self.center_y, - self.boundary_setback, - self.n_boundary_turbines, - self.boundary_spacing, + # Create test rotations and translations + self.rotations = np.arange(rotation_range[0], rotation_range[1], rotation_step) + self.translations = np.arange(0, spacing, translation_step) + + def optimize(self): + + # Sweep over rotations and translations to find the best layout + n_rots = len(self.rotations) + n_trans = len(self.translations) + n_tot = n_rots * n_trans**2 + + # There are a total of n_rots x n_trans x n_trans layouts to test + rots_rad = np.radians(self.rotations) + rotation_matrices = np.array( + [ + [np.cos(rots_rad), -np.sin(rots_rad)], + [np.sin(rots_rad), np.cos(rots_rad)] + ] + ).transpose(2,0,1) + + translations_x, translations_y = np.meshgrid(self.translations, self.translations) + translation_matrices = np.concatenate( + [translations_x.reshape(-1,1), translations_y.reshape(-1,1)], + axis=1 ) - self.fmodel.set(layout=(layout_x, layout_y)) + rotations_all = np.tile(rotation_matrices, (n_trans**2, 1, 1)) + translations_all = np.repeat(translation_matrices, n_rots, axis=0)[:,None,:] - def plot_layout(self): - plt.figure(figsize=(9, 6)) - fontsize = 16 + # Create candidate layouts [(n_rots x n_trans x n_trans) x n_turbines x 2] + candidate_layouts = np.einsum('ijk,lk->ilj', rotations_all, self.xy_grid) + translations_all - plt.plot(self.fmodel.layout_x, self.fmodel.layout_y, "ob") - # plt.plot(locsx, locsy, "or") - - plt.xlabel("x (m)", fontsize=fontsize) - plt.ylabel("y (m)", fontsize=fontsize) - plt.axis("equal") - plt.grid() - plt.tick_params(which="both", labelsize=fontsize) - - def space_constraint(self, x, y, min_dist, rho=500): - # Calculate distances between turbines - locs = np.vstack((x, y)).T - distances = cdist(locs, locs) - arange = np.arange(distances.shape[0]) - distances[arange, arange] = 1e10 - dist = np.min(distances, axis=0) - - g = 1 - np.array(dist) / min_dist - - # Following code copied from OpenMDAO KSComp(). - # Constraint is satisfied when KS_constraint <= 0 - g_max = np.max(np.atleast_2d(g), axis=-1)[:, np.newaxis] - g_diff = g - g_max - exponents = np.exp(rho * g_diff) - summation = np.sum(exponents, axis=-1)[:, np.newaxis] - KS_constraint = g_max + 1.0 / rho * np.log(summation) - - return KS_constraint[0][0], dist + # For each candidate layout, check how many turbines are in bounds + turbines_in_bounds = np.zeros(n_tot) + for i in range(n_tot): + turbines_in_bounds[i] = np.sum( + [test_point_in_bounds(xy[0], xy[1], self._boundary_polygon) for + xy in candidate_layouts[i, :, :]] + ) + idx_max = np.argmax(turbines_in_bounds) # FIRST maximizing index returned + + # Get the best layout + x_opt_all = candidate_layouts[idx_max, :, 0] + y_opt_all = candidate_layouts[idx_max, :, 1] + mask_in_bounds = [test_point_in_bounds(x, y, self._boundary_polygon) for + x, y in zip(x_opt_all, y_opt_all)] + + # Return the best layout, along with the number of turbines in bounds + return turbines_in_bounds[idx_max], x_opt_all[mask_in_bounds], y_opt_all[mask_in_bounds] + + @staticmethod + def trim_to_circle(x_locs, y_locs, radius): + center = np.array([0.5*(x_locs.max() + x_locs.min()), 0.5*(y_locs.max() + y_locs.min())]) + xy = np.concatenate([x_locs.reshape(-1,1), y_locs.reshape(-1,1)], axis=1) + mask = np.linalg.norm(xy - center, axis=1) <= radius + return x_locs[mask], y_locs[mask] diff --git a/tests/layout_optimization_integration_test.py b/tests/layout_optimization_integration_test.py index f2e5de229..a9a56048b 100644 --- a/tests/layout_optimization_integration_test.py +++ b/tests/layout_optimization_integration_test.py @@ -78,7 +78,7 @@ def test_base_class(caplog): def test_LayoutOptimizationRandomSearch(): fmodel = FlorisModel(configuration=YAML_INPUT) - fmodel.set(layout_x=[0, 500], layout_y = [0, 0]) + fmodel.set(layout_x=[0, 500], layout_y=[0, 0]) layout_opt = LayoutOptimizationRandomSearch( fmodel=fmodel, @@ -94,12 +94,14 @@ def test_LayoutOptimizationRandomSearch(): def test_LayoutOptimizationGridded_initialization(caplog): fmodel = FlorisModel(configuration=YAML_INPUT) - fmodel.set(layout_x=[0, 500], layout_y = [0, 0]) + fmodel.set(layout_x=[0, 500], layout_y=[0, 0]) with pytest.raises(ValueError): LayoutOptimizationGridded( fmodel=fmodel, boundaries=test_boundaries, + spacing=None, + spacing_D=None, ) # No spacing specified with pytest.raises(ValueError): LayoutOptimizationGridded( @@ -118,9 +120,8 @@ def test_LayoutOptimizationGridded_initialization(caplog): spacing_D=5 ) -def test_LayoutOptimizationGridded(): +def test_LayoutOptimizationGridded_default_grid(): fmodel = FlorisModel(configuration=YAML_INPUT) - fmodel.set(layout_x=[0, 500], layout_y = [0, 0]) # Set up a sample boundary boundaries = [(0.0, 0.0), (0.0, 1000.0), (1000.0, 1000.0), (1000.0, 0.0), (0.0, 0.0)] @@ -128,5 +129,73 @@ def test_LayoutOptimizationGridded(): layout_opt = LayoutOptimizationGridded( fmodel=fmodel, boundaries=boundaries, - spacing=5, + spacing=50, ) + + # Test it worked... + +def test_LayoutOptimizationGridded_basic(): + fmodel = FlorisModel(configuration=YAML_INPUT) + + spacing = 60 + + layout_opt = LayoutOptimizationGridded( + fmodel=fmodel, + boundaries=test_boundaries, + spacing=spacing, + rotation_step=5, + rotation_range=(0, 360), + translation_step=50, + hexagonal_packing=False, + enable_geometric_yaw=False, + use_value=False, + ) + + n_turbs_opt, x_opt, y_opt = layout_opt.optimize() + + # Check that the number of turbines is correct + assert n_turbs_opt == len(x_opt) + + + # Check all are indeed in bounds + assert (np.all(x_opt > 0.0) & np.all(x_opt < 1000.0) + & np.all(y_opt > 0.0) & np.all(y_opt < 1000.0)) + + # Check that the layout is at least as good as the basic rectangular fill + n_turbs_subopt = (1000 // spacing + 1) ** 2 + + assert n_turbs_opt >= n_turbs_subopt + +def test_LayoutOptimizationGridded_diagonal(): + fmodel = FlorisModel(configuration=YAML_INPUT) + + turbine_spacing = 1000.0 + corner = 2*turbine_spacing / np.sqrt(2) + + # Create a "thin" boundary area at a 45 degree angle + boundaries_diag = [ + (0.0, 0.0), + (0.0, 10.0), + (corner, corner+10), + (corner+10, corner+10), + (0.0, 0.0) + ] + + layout_opt = LayoutOptimizationGridded( + fmodel=fmodel, + boundaries=boundaries_diag, + spacing=turbine_spacing, + rotation_step=5, + rotation_range=(0, 360), + translation_step=1, + hexagonal_packing=False, + enable_geometric_yaw=False, + use_value=False, + ) + + n_turbs_opt, _, _ = layout_opt.optimize() + assert n_turbs_opt == 3 # 3 should fit in the diagonal + + # Also test a limited rotation; should be worse. + # Also test a very coarse rotation step; should be worse. + # Also test a very coarse translation step; should be worse. From 32b7dbd85937d4e11f6d36caf4756a25c891cb35 Mon Sep 17 00:00:00 2001 From: misi9170 Date: Thu, 5 Sep 2024 15:03:08 -0600 Subject: [PATCH 03/15] Tests passing, although runs a little slow. --- .../layout_optimization_gridded.py | 32 ++++++--- tests/layout_optimization_integration_test.py | 71 ++++++++++++++++--- 2 files changed, 84 insertions(+), 19 deletions(-) diff --git a/floris/optimization/layout_optimization/layout_optimization_gridded.py b/floris/optimization/layout_optimization/layout_optimization_gridded.py index 9202c54b3..9966b7c8d 100644 --- a/floris/optimization/layout_optimization/layout_optimization_gridded.py +++ b/floris/optimization/layout_optimization/layout_optimization_gridded.py @@ -17,14 +17,15 @@ def __init__( self, fmodel, boundaries, - spacing: float | None=None, - spacing_D: float | None=-1, - rotation_step: float=5.0, - rotation_range: tuple[float, float]=(0.0, 360.0), - translation_step: float | None=None, - translation_step_D: float | None=-1, - hexagonal_packing: bool=False, - enable_geometric_yaw: bool=False, + spacing: float | None = None, + spacing_D: float | None = -1, + rotation_step: float = 5.0, + rotation_range: tuple[float, float] = (0.0, 360.0), + translation_step: float | None = None, + translation_step_D: float | None = -1, + translation_range: tuple[float, float] | None = None, + hexagonal_packing: bool = False, + enable_geometric_yaw: bool = False, use_value: bool=False, ): @@ -86,9 +87,13 @@ def __init__( y_locs = square_y.flatten() x_locs = x_locs - np.mean(x_locs) + 0.5*(self.xmax + self.xmin) y_locs = y_locs - np.mean(y_locs) + 0.5*(self.ymax + self.ymin) - + # Trim to a circle to minimize wasted computation - x_locs_grid, y_locs_grid = self.trim_to_circle(x_locs, y_locs, d/2) + x_locs_grid, y_locs_grid = self.trim_to_circle( + x_locs, + y_locs, + (grid_1D.max()-grid_1D.min()+spacing)/2 + ) self.xy_grid = np.concatenate( [x_locs_grid.reshape(-1,1), y_locs_grid.reshape(-1,1)], axis=1 @@ -108,9 +113,14 @@ def __init__( np.minimum(rotation_range[1], rotation_range[0]+90) ) + # Deal with None translation_range + if translation_range is None: + translation_range = (0.0, spacing) + # Create test rotations and translations self.rotations = np.arange(rotation_range[0], rotation_range[1], rotation_step) - self.translations = np.arange(0, spacing, translation_step) + self.translations = np.arange(translation_range[0], translation_range[1], translation_step) + self.translations = np.concatenate([-np.flip(self.translations), self.translations]) def optimize(self): diff --git a/tests/layout_optimization_integration_test.py b/tests/layout_optimization_integration_test.py index a9a56048b..0503d6941 100644 --- a/tests/layout_optimization_integration_test.py +++ b/tests/layout_optimization_integration_test.py @@ -156,6 +156,12 @@ def test_LayoutOptimizationGridded_basic(): # Check that the number of turbines is correct assert n_turbs_opt == len(x_opt) + # Check that spacing is respected + xx_diff = x_opt.reshape(-1,1) - x_opt.reshape(1,-1) + yy_diff = y_opt.reshape(-1,1) - y_opt.reshape(1,-1) + dists = np.sqrt(xx_diff**2 + yy_diff**2) + dists[np.arange(0, len(dists), 1), np.arange(0, len(dists), 1)] = np.inf + assert (dists > spacing - 1e-6).all() # Check all are indeed in bounds assert (np.all(x_opt > 0.0) & np.all(x_opt < 1000.0) @@ -175,9 +181,9 @@ def test_LayoutOptimizationGridded_diagonal(): # Create a "thin" boundary area at a 45 degree angle boundaries_diag = [ (0.0, 0.0), - (0.0, 10.0), - (corner, corner+10), - (corner+10, corner+10), + (0.0, 100.0), + (corner, corner+100.0), + (corner+100.0, corner+100.0), (0.0, 0.0) ] @@ -187,15 +193,64 @@ def test_LayoutOptimizationGridded_diagonal(): spacing=turbine_spacing, rotation_step=5, rotation_range=(0, 360), - translation_step=1, + translation_step=20, hexagonal_packing=False, enable_geometric_yaw=False, use_value=False, ) - n_turbs_opt, _, _ = layout_opt.optimize() + n_turbs_opt, x_opt, y_opt = layout_opt.optimize() + + # Confirm that spacing is respected + xx_diff = x_opt.reshape(-1,1) - x_opt.reshape(1,-1) + yy_diff = y_opt.reshape(-1,1) - y_opt.reshape(1,-1) + dists = np.sqrt(xx_diff**2 + yy_diff**2) + dists[np.arange(0, len(dists), 1), np.arange(0, len(dists), 1)] = np.inf + assert (dists > turbine_spacing - 1e-6).all() + assert n_turbs_opt == 3 # 3 should fit in the diagonal - # Also test a limited rotation; should be worse. - # Also test a very coarse rotation step; should be worse. - # Also test a very coarse translation step; should be worse. + # Test a limited range of rotation + layout_opt = LayoutOptimizationGridded( + fmodel=fmodel, + boundaries=boundaries_diag, + spacing=turbine_spacing, + rotation_step=5, + rotation_range=(0, 10), + translation_step=20, + hexagonal_packing=False, + enable_geometric_yaw=False, + use_value=False, + ) + n_turbs_opt, x_opt, y_opt = layout_opt.optimize() + assert n_turbs_opt < 3 + + # Test a coarse rotation + layout_opt = LayoutOptimizationGridded( + fmodel=fmodel, + boundaries=boundaries_diag, + spacing=turbine_spacing, + rotation_step=60, + rotation_range=(0, 360), + translation_step=20, + hexagonal_packing=False, + enable_geometric_yaw=False, + use_value=False, + ) + n_turbs_opt, x_opt, y_opt = layout_opt.optimize() + assert n_turbs_opt < 3 + + # Test a coarse translation + layout_opt = LayoutOptimizationGridded( + fmodel=fmodel, + boundaries=boundaries_diag, + spacing=turbine_spacing, + rotation_step=5, + rotation_range=(0, 10), + translation_step=300, + hexagonal_packing=False, + enable_geometric_yaw=False, + use_value=False, + ) + n_turbs_opt, x_opt, y_opt = layout_opt.optimize() + assert n_turbs_opt < 3 From 4fc4e5c6bb235d3617df1cbfeb7a11172296244a Mon Sep 17 00:00:00 2001 From: misi9170 Date: Thu, 5 Sep 2024 15:15:44 -0600 Subject: [PATCH 04/15] Test for separate boundaries. --- tests/layout_optimization_integration_test.py | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/layout_optimization_integration_test.py b/tests/layout_optimization_integration_test.py index 0503d6941..f3df31858 100644 --- a/tests/layout_optimization_integration_test.py +++ b/tests/layout_optimization_integration_test.py @@ -254,3 +254,66 @@ def test_LayoutOptimizationGridded_diagonal(): ) n_turbs_opt, x_opt, y_opt = layout_opt.optimize() assert n_turbs_opt < 3 + +def test_LayoutOptimizationGridded_separate_boundaries(): + fmodel = FlorisModel(configuration=YAML_INPUT) + separate_boundaries = [ + [(0.0, 0.0), (0.0, 100.0), (100.0, 100.0), (100.0, 0.0), (0.0, 0.0)], + [(1000.0, 0.0), (1000.0, 100.0), (1100.0, 100.0), (1100.0, 0.0), (1000.0, 0.0)] + ] + + layout_opt = LayoutOptimizationGridded( + fmodel=fmodel, + boundaries=separate_boundaries, + spacing=150, + rotation_step=5, + rotation_range=(0, 360), + translation_step=50, + hexagonal_packing=False, + enable_geometric_yaw=False, + use_value=False, + ) + + n_turbs_opt, x_opt, y_opt = layout_opt.optimize() + assert n_turbs_opt == 2 # One in each of the boundary areas + + # Check they're inside as expected + assert ((0.0 <= y_opt) & (y_opt <= 100.0)).all() + assert (((0.0 <= x_opt) & (x_opt <= 100.0)) | ((1000.0 <= x_opt) & (x_opt <= 1100.0))).all() + + +def test_LayoutOptimizationGridded_hexagonal(): + fmodel = FlorisModel(configuration=YAML_INPUT) + + spacing = 200 + + # First, run a square layout + layout_opt = LayoutOptimizationGridded( + fmodel=fmodel, + boundaries=test_boundaries, + spacing=spacing, + rotation_step=5, + rotation_range=(0, 360), + translation_step=50, + hexagonal_packing=False, + enable_geometric_yaw=False, + use_value=False, + ) + n_turbs_opt_square = layout_opt.optimize()[0] + + # Now, run a hexagonal layout + layout_opt = LayoutOptimizationGridded( + fmodel=fmodel, + boundaries=test_boundaries, + spacing=spacing, + rotation_step=5, + rotation_range=(0, 360), + translation_step=50, + hexagonal_packing=True, + enable_geometric_yaw=False, + use_value=False, + ) + n_turbs_opt_hex = layout_opt.optimize()[0] + + # Check that the hexagonal layout is better + assert n_turbs_opt_hex >= n_turbs_opt_square From 7eb3dad93126bce5e036bbd717ca7a2f76da2171 Mon Sep 17 00:00:00 2001 From: misi9170 Date: Thu, 5 Sep 2024 15:40:25 -0600 Subject: [PATCH 05/15] Add handling for hexagonal grid. --- .../layout_optimization_gridded.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/floris/optimization/layout_optimization/layout_optimization_gridded.py b/floris/optimization/layout_optimization/layout_optimization_gridded.py index 9966b7c8d..0afbc0110 100644 --- a/floris/optimization/layout_optimization/layout_optimization_gridded.py +++ b/floris/optimization/layout_optimization/layout_optimization_gridded.py @@ -78,15 +78,13 @@ def __init__( d = 1.1 * np.sqrt((self.xmax**2 - self.xmin**2) + (self.ymax**2 - self.ymin**2)) grid_1D = np.arange(0, d+spacing, spacing) if hexagonal_packing: - raise NotImplementedError("Hexagonal packing not yet implemented.") - x_locs = [] - y_locs = [] + x_locs = np.tile(grid_1D.reshape(1,-1), (len(grid_1D), 1)) + x_locs[np.arange(1, len(grid_1D), 2), :] += 0.5 * spacing + y_locs = np.tile(np.sqrt(3) / 2 * grid_1D.reshape(-1,1), (1, len(grid_1D))) else: - square_x, square_y = np.meshgrid(grid_1D, grid_1D) - x_locs = square_x.flatten() - y_locs = square_y.flatten() - x_locs = x_locs - np.mean(x_locs) + 0.5*(self.xmax + self.xmin) - y_locs = y_locs - np.mean(y_locs) + 0.5*(self.ymax + self.ymin) + x_locs, y_locs = np.meshgrid(grid_1D, grid_1D) + x_locs = x_locs.flatten() - np.mean(x_locs) + 0.5*(self.xmax + self.xmin) + y_locs = y_locs.flatten() - np.mean(y_locs) + 0.5*(self.ymax + self.ymin) # Trim to a circle to minimize wasted computation x_locs_grid, y_locs_grid = self.trim_to_circle( From b3283d4fec8c67fd37099f643780ac337b1e0ea5 Mon Sep 17 00:00:00 2001 From: misi9170 Date: Thu, 5 Sep 2024 15:44:44 -0600 Subject: [PATCH 06/15] Formatting fixes. --- .../layout_optimization/layout_optimization_gridded.py | 3 ++- tests/layout_optimization_integration_test.py | 10 +++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/floris/optimization/layout_optimization/layout_optimization_gridded.py b/floris/optimization/layout_optimization/layout_optimization_gridded.py index 0afbc0110..cdc279faa 100644 --- a/floris/optimization/layout_optimization/layout_optimization_gridded.py +++ b/floris/optimization/layout_optimization/layout_optimization_gridded.py @@ -12,6 +12,7 @@ from .layout_optimization_base import LayoutOptimization from .layout_optimization_random_search import test_point_in_bounds + class LayoutOptimizationGridded(LayoutOptimization): def __init__( self, @@ -61,7 +62,7 @@ def __init__( self.logger.warning(( "Found multiple turbine diameters. Using diameter of first turbine to set" f" translation step to {translation_step}m ({translation_step_D} diameters)." - )) + )) # Initialize the base class super().__init__( diff --git a/tests/layout_optimization_integration_test.py b/tests/layout_optimization_integration_test.py index f3df31858..f5ece853b 100644 --- a/tests/layout_optimization_integration_test.py +++ b/tests/layout_optimization_integration_test.py @@ -12,15 +12,15 @@ from floris.optimization.layout_optimization.layout_optimization_base import ( LayoutOptimization, ) +from floris.optimization.layout_optimization.layout_optimization_gridded import ( + LayoutOptimizationGridded, +) from floris.optimization.layout_optimization.layout_optimization_random_search import ( LayoutOptimizationRandomSearch, ) from floris.optimization.layout_optimization.layout_optimization_scipy import ( LayoutOptimizationScipy, ) -from floris.optimization.layout_optimization.layout_optimization_gridded import ( - LayoutOptimizationGridded, -) from floris.wind_data import WindDataBase @@ -110,7 +110,7 @@ def test_LayoutOptimizationGridded_initialization(caplog): spacing=500, spacing_D=5 ) # Spacing specified in two ways - + fmodel.core.farm.rotor_diameters[1] = 100.0 caplog.clear() with caplog.at_level(logging.WARNING): @@ -126,7 +126,7 @@ def test_LayoutOptimizationGridded_default_grid(): # Set up a sample boundary boundaries = [(0.0, 0.0), (0.0, 1000.0), (1000.0, 1000.0), (1000.0, 0.0), (0.0, 0.0)] - layout_opt = LayoutOptimizationGridded( + LayoutOptimizationGridded( fmodel=fmodel, boundaries=boundaries, spacing=50, From 9e19d60db777aa80f8ab8da39d8fad108a5e21b4 Mon Sep 17 00:00:00 2001 From: misi9170 Date: Tue, 10 Sep 2024 09:03:53 -0600 Subject: [PATCH 07/15] Changing spacing to min_dist for consistency with other optimizers. --- .../layout_optimization_gridded.py | 34 ++++++++-------- tests/layout_optimization_integration_test.py | 40 +++++++++---------- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/floris/optimization/layout_optimization/layout_optimization_gridded.py b/floris/optimization/layout_optimization/layout_optimization_gridded.py index cdc279faa..27c5b765d 100644 --- a/floris/optimization/layout_optimization/layout_optimization_gridded.py +++ b/floris/optimization/layout_optimization/layout_optimization_gridded.py @@ -18,8 +18,8 @@ def __init__( self, fmodel, boundaries, - spacing: float | None = None, - spacing_D: float | None = -1, + min_dist: float | None = None, + min_dist_D: float | None = -1, rotation_step: float = 5.0, rotation_range: tuple[float, float] = (0.0, 360.0), translation_step: float | None = None, @@ -32,18 +32,18 @@ def __init__( ): # Save boundaries # Handle spacing information - if spacing is not None and spacing_D is not None and spacing_D >= 0: - raise ValueError("Only one of spacing and spacing_D can be defined.") - if spacing is None and spacing_D is None: - raise ValueError("Either spacing or spacing_D must be defined.") - if spacing_D is not None and spacing is None: - if spacing_D < 0: # Default to 5D - spacing_D = 5.0 - spacing = spacing_D * fmodel.core.farm.rotor_diameters[0] + if min_dist is not None and min_dist_D is not None and min_dist_D >= 0: + raise ValueError("Only one of min_dist and min_dist_D can be defined.") + if min_dist is None and min_dist_D is None: + raise ValueError("Either min_dist or min_dist_D must be defined.") + if min_dist_D is not None and min_dist is None: + if min_dist_D < 0: # Default to 5D + min_dist_D = 5.0 + min_dist = min_dist_D * fmodel.core.farm.rotor_diameters[0] if len(np.unique(fmodel.core.farm.rotor_diameters)) > 1: self.logger.warning(( "Found multiple turbine diameters. Using diameter of first turbine to set" - f" spacing to {spacing}m ({spacing_D} diameters)." + f" min_dist to {min_dist}m ({min_dist_D} diameters)." )) # Similar for translation step @@ -68,19 +68,19 @@ def __init__( super().__init__( fmodel, boundaries, - min_dist=spacing, + min_dist=min_dist, enable_geometric_yaw=enable_geometric_yaw, use_value=use_value, ) # Create the default grid - # use spacing, hexagonal packing, and boundaries to create a grid. + # use min_dist, hexagonal packing, and boundaries to create a grid. d = 1.1 * np.sqrt((self.xmax**2 - self.xmin**2) + (self.ymax**2 - self.ymin**2)) - grid_1D = np.arange(0, d+spacing, spacing) + grid_1D = np.arange(0, d+min_dist, min_dist) if hexagonal_packing: x_locs = np.tile(grid_1D.reshape(1,-1), (len(grid_1D), 1)) - x_locs[np.arange(1, len(grid_1D), 2), :] += 0.5 * spacing + x_locs[np.arange(1, len(grid_1D), 2), :] += 0.5 * min_dist y_locs = np.tile(np.sqrt(3) / 2 * grid_1D.reshape(-1,1), (1, len(grid_1D))) else: x_locs, y_locs = np.meshgrid(grid_1D, grid_1D) @@ -91,7 +91,7 @@ def __init__( x_locs_grid, y_locs_grid = self.trim_to_circle( x_locs, y_locs, - (grid_1D.max()-grid_1D.min()+spacing)/2 + (grid_1D.max()-grid_1D.min()+min_dist)/2 ) self.xy_grid = np.concatenate( [x_locs_grid.reshape(-1,1), y_locs_grid.reshape(-1,1)], @@ -114,7 +114,7 @@ def __init__( # Deal with None translation_range if translation_range is None: - translation_range = (0.0, spacing) + translation_range = (0.0, min_dist) # Create test rotations and translations self.rotations = np.arange(rotation_range[0], rotation_range[1], rotation_step) diff --git a/tests/layout_optimization_integration_test.py b/tests/layout_optimization_integration_test.py index f5ece853b..ab631c866 100644 --- a/tests/layout_optimization_integration_test.py +++ b/tests/layout_optimization_integration_test.py @@ -100,16 +100,16 @@ def test_LayoutOptimizationGridded_initialization(caplog): LayoutOptimizationGridded( fmodel=fmodel, boundaries=test_boundaries, - spacing=None, - spacing_D=None, - ) # No spacing specified + min_dist=None, + min_dist_D=None, + ) # No min_dist specified with pytest.raises(ValueError): LayoutOptimizationGridded( fmodel=fmodel, boundaries=test_boundaries, - spacing=500, - spacing_D=5 - ) # Spacing specified in two ways + min_dist=500, + min_dist_D=5 + ) # min_dist specified in two ways fmodel.core.farm.rotor_diameters[1] = 100.0 caplog.clear() @@ -117,7 +117,7 @@ def test_LayoutOptimizationGridded_initialization(caplog): LayoutOptimizationGridded( fmodel, test_boundaries, - spacing_D=5 + min_dist_D=5 ) def test_LayoutOptimizationGridded_default_grid(): @@ -129,7 +129,7 @@ def test_LayoutOptimizationGridded_default_grid(): LayoutOptimizationGridded( fmodel=fmodel, boundaries=boundaries, - spacing=50, + min_dist=50, ) # Test it worked... @@ -137,12 +137,12 @@ def test_LayoutOptimizationGridded_default_grid(): def test_LayoutOptimizationGridded_basic(): fmodel = FlorisModel(configuration=YAML_INPUT) - spacing = 60 + min_dist = 60 layout_opt = LayoutOptimizationGridded( fmodel=fmodel, boundaries=test_boundaries, - spacing=spacing, + min_dist=min_dist, rotation_step=5, rotation_range=(0, 360), translation_step=50, @@ -156,19 +156,19 @@ def test_LayoutOptimizationGridded_basic(): # Check that the number of turbines is correct assert n_turbs_opt == len(x_opt) - # Check that spacing is respected + # Check that min_dist is respected xx_diff = x_opt.reshape(-1,1) - x_opt.reshape(1,-1) yy_diff = y_opt.reshape(-1,1) - y_opt.reshape(1,-1) dists = np.sqrt(xx_diff**2 + yy_diff**2) dists[np.arange(0, len(dists), 1), np.arange(0, len(dists), 1)] = np.inf - assert (dists > spacing - 1e-6).all() + assert (dists > min_dist - 1e-6).all() # Check all are indeed in bounds assert (np.all(x_opt > 0.0) & np.all(x_opt < 1000.0) & np.all(y_opt > 0.0) & np.all(y_opt < 1000.0)) # Check that the layout is at least as good as the basic rectangular fill - n_turbs_subopt = (1000 // spacing + 1) ** 2 + n_turbs_subopt = (1000 // min_dist + 1) ** 2 assert n_turbs_opt >= n_turbs_subopt @@ -190,7 +190,7 @@ def test_LayoutOptimizationGridded_diagonal(): layout_opt = LayoutOptimizationGridded( fmodel=fmodel, boundaries=boundaries_diag, - spacing=turbine_spacing, + min_dist=turbine_spacing, rotation_step=5, rotation_range=(0, 360), translation_step=20, @@ -214,7 +214,7 @@ def test_LayoutOptimizationGridded_diagonal(): layout_opt = LayoutOptimizationGridded( fmodel=fmodel, boundaries=boundaries_diag, - spacing=turbine_spacing, + min_dist=turbine_spacing, rotation_step=5, rotation_range=(0, 10), translation_step=20, @@ -229,7 +229,7 @@ def test_LayoutOptimizationGridded_diagonal(): layout_opt = LayoutOptimizationGridded( fmodel=fmodel, boundaries=boundaries_diag, - spacing=turbine_spacing, + min_dist=turbine_spacing, rotation_step=60, rotation_range=(0, 360), translation_step=20, @@ -244,7 +244,7 @@ def test_LayoutOptimizationGridded_diagonal(): layout_opt = LayoutOptimizationGridded( fmodel=fmodel, boundaries=boundaries_diag, - spacing=turbine_spacing, + min_dist=turbine_spacing, rotation_step=5, rotation_range=(0, 10), translation_step=300, @@ -265,7 +265,7 @@ def test_LayoutOptimizationGridded_separate_boundaries(): layout_opt = LayoutOptimizationGridded( fmodel=fmodel, boundaries=separate_boundaries, - spacing=150, + min_dist=150, rotation_step=5, rotation_range=(0, 360), translation_step=50, @@ -291,7 +291,7 @@ def test_LayoutOptimizationGridded_hexagonal(): layout_opt = LayoutOptimizationGridded( fmodel=fmodel, boundaries=test_boundaries, - spacing=spacing, + min_dist=spacing, rotation_step=5, rotation_range=(0, 360), translation_step=50, @@ -305,7 +305,7 @@ def test_LayoutOptimizationGridded_hexagonal(): layout_opt = LayoutOptimizationGridded( fmodel=fmodel, boundaries=test_boundaries, - spacing=spacing, + min_dist=spacing, rotation_step=5, rotation_range=(0, 360), translation_step=50, From 5d4c11b0f71808dcf3ed765500651ef29fbab2a4 Mon Sep 17 00:00:00 2001 From: misi9170 Date: Tue, 10 Sep 2024 09:21:50 -0600 Subject: [PATCH 08/15] Save optimization results; provide simple example. --- .../004_generate_gridded_layout.py | 60 +++++++++++++++++++ .../layout_optimization_gridded.py | 15 ++++- 2 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 examples/examples_layout_optimization/004_generate_gridded_layout.py diff --git a/examples/examples_layout_optimization/004_generate_gridded_layout.py b/examples/examples_layout_optimization/004_generate_gridded_layout.py new file mode 100644 index 000000000..021feafcb --- /dev/null +++ b/examples/examples_layout_optimization/004_generate_gridded_layout.py @@ -0,0 +1,60 @@ +"""Example: Gridded layout design +This example shows a layout optimization that places as many turbines as +possible into a given boundary using a gridded layout pattern. +""" + +import matplotlib.pyplot as plt +import numpy as np + +from floris import FlorisModel, WindRose +from floris.optimization.layout_optimization.layout_optimization_gridded import ( + LayoutOptimizationGridded, +) + + +if __name__ == '__main__': + # Set up FLORIS + fmodel = FlorisModel('../inputs/gch.yaml') + + + # Setup 72 wind directions with a random wind speed and frequency distribution + wind_directions = np.arange(0, 360.0, 5.0) + np.random.seed(1) + wind_speeds = 8.0 + np.random.randn(1) * 0.0 + # Shape frequency distribution to match number of wind directions and wind speeds + freq = ( + np.abs( + np.sort( + np.random.randn(len(wind_directions)) + ) + ) + .reshape( ( len(wind_directions), len(wind_speeds) ) ) + ) + freq = freq / freq.sum() + fmodel.set( + wind_data=WindRose( + wind_directions=wind_directions, + wind_speeds=wind_speeds, + freq_table=freq, + ti_table=0.06 + ) + ) + + # Set the boundaries + # The boundaries for the turbines, specified as vertices + boundaries = [(0.0, 0.0), (0.0, 1000.0), (1000.0, 1000.0), (1000.0, 0.0), (0.0, 0.0)] + + layout_opt = LayoutOptimizationGridded( + fmodel, + boundaries, + min_dist_D=5., # results in spacing of 5*125.88 = 629.4 m + min_dist=None, # Alternatively, can specify spacing directly in meters + ) + + layout_opt.optimize() + + # Note that the "initial" layout that is provided with the fmodel is + # not used by the layout optimization. + layout_opt.plot_layout_opt_results() + + plt.show() diff --git a/floris/optimization/layout_optimization/layout_optimization_gridded.py b/floris/optimization/layout_optimization/layout_optimization_gridded.py index 27c5b765d..edca030c0 100644 --- a/floris/optimization/layout_optimization/layout_optimization_gridded.py +++ b/floris/optimization/layout_optimization/layout_optimization_gridded.py @@ -73,6 +73,11 @@ def __init__( use_value=use_value, ) + # Initial locations not used for optimization, but may be useful + # for comparison + self.x0 = fmodel.layout_x + self.y0 = fmodel.layout_y + # Create the default grid # use min_dist, hexagonal packing, and boundaries to create a grid. @@ -164,8 +169,14 @@ def optimize(self): mask_in_bounds = [test_point_in_bounds(x, y, self._boundary_polygon) for x, y in zip(x_opt_all, y_opt_all)] - # Return the best layout, along with the number of turbines in bounds - return turbines_in_bounds[idx_max], x_opt_all[mask_in_bounds], y_opt_all[mask_in_bounds] + # Save best layout, along with the number of turbines in bounds, and return + self.n_turbines_max = turbines_in_bounds[idx_max] + self.x_opt = x_opt_all[mask_in_bounds] + self.y_opt = y_opt_all[mask_in_bounds] + return self.n_turbines_max, self.x_opt, self.y_opt + + def _get_initial_and_final_locs(self): + return self.x0, self.y0, self.x_opt, self.y_opt @staticmethod def trim_to_circle(x_locs, y_locs, radius): From ec4e3c051825e0c8d66e31d01b2f8856244238da Mon Sep 17 00:00:00 2001 From: misi9170 Date: Tue, 10 Sep 2024 09:25:33 -0600 Subject: [PATCH 09/15] Simplify example, since wind data isnt used for gridded layout. --- .../004_generate_gridded_layout.py | 27 ++----------------- .../layout_optimization_gridded.py | 2 +- 2 files changed, 3 insertions(+), 26 deletions(-) diff --git a/examples/examples_layout_optimization/004_generate_gridded_layout.py b/examples/examples_layout_optimization/004_generate_gridded_layout.py index 021feafcb..649c474fa 100644 --- a/examples/examples_layout_optimization/004_generate_gridded_layout.py +++ b/examples/examples_layout_optimization/004_generate_gridded_layout.py @@ -13,37 +13,14 @@ if __name__ == '__main__': - # Set up FLORIS + # Load the Floris model fmodel = FlorisModel('../inputs/gch.yaml') - - # Setup 72 wind directions with a random wind speed and frequency distribution - wind_directions = np.arange(0, 360.0, 5.0) - np.random.seed(1) - wind_speeds = 8.0 + np.random.randn(1) * 0.0 - # Shape frequency distribution to match number of wind directions and wind speeds - freq = ( - np.abs( - np.sort( - np.random.randn(len(wind_directions)) - ) - ) - .reshape( ( len(wind_directions), len(wind_speeds) ) ) - ) - freq = freq / freq.sum() - fmodel.set( - wind_data=WindRose( - wind_directions=wind_directions, - wind_speeds=wind_speeds, - freq_table=freq, - ti_table=0.06 - ) - ) - # Set the boundaries # The boundaries for the turbines, specified as vertices boundaries = [(0.0, 0.0), (0.0, 1000.0), (1000.0, 1000.0), (1000.0, 0.0), (0.0, 0.0)] + # Set up the optimization object with 5D spacing layout_opt = LayoutOptimizationGridded( fmodel, boundaries, diff --git a/floris/optimization/layout_optimization/layout_optimization_gridded.py b/floris/optimization/layout_optimization/layout_optimization_gridded.py index edca030c0..b8d2d4bc8 100644 --- a/floris/optimization/layout_optimization/layout_optimization_gridded.py +++ b/floris/optimization/layout_optimization/layout_optimization_gridded.py @@ -174,7 +174,7 @@ def optimize(self): self.x_opt = x_opt_all[mask_in_bounds] self.y_opt = y_opt_all[mask_in_bounds] return self.n_turbines_max, self.x_opt, self.y_opt - + def _get_initial_and_final_locs(self): return self.x0, self.y0, self.x_opt, self.y_opt From 667101bbaadd0df9b84a4867cd21080bd90f3103 Mon Sep 17 00:00:00 2001 From: misi9170 Date: Tue, 10 Sep 2024 09:57:24 -0600 Subject: [PATCH 10/15] New example for complex boundaries; increased flexibility in plotting optimal layout. --- ...05_layout_optimization_complex_boundary.py | 83 +++++++++++++++++++ .../layout_optimization_base.py | 35 +++++++- 2 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 examples/examples_layout_optimization/005_layout_optimization_complex_boundary.py diff --git a/examples/examples_layout_optimization/005_layout_optimization_complex_boundary.py b/examples/examples_layout_optimization/005_layout_optimization_complex_boundary.py new file mode 100644 index 000000000..79bfcf2e7 --- /dev/null +++ b/examples/examples_layout_optimization/005_layout_optimization_complex_boundary.py @@ -0,0 +1,83 @@ +"""Example: Separated boundaries layout optimization +Demonstrates the capabilities of LayoutOptimizationGridded and +LayoutOptimizationRandomSearch to optimize turbine layouts with complex +boundaries. +""" + +import matplotlib.pyplot as plt +import numpy as np + +from floris import FlorisModel, WindRose +from floris.optimization.layout_optimization.layout_optimization_gridded import ( + LayoutOptimizationGridded, +) +from floris.optimization.layout_optimization.layout_optimization_random_search import ( + LayoutOptimizationRandomSearch, +) + + +if __name__ == '__main__': + # Load the Floris model + fmodel = FlorisModel('../inputs/gch.yaml') + + # Set the boundaries + # The boundaries for the turbines, specified as vertices + boundaries = [ + [(0.0, 0.0), (0.0, 1000.0), (1000.0, 1000.0), (1000.0, 0.0), (0.0, 0.0)], + [(1500.0, 0.0), (1500.0, 1000.0), (2500.0, 0.0), (1500.0, 0.0)], + ] + + # Set up the wind data information + wind_directions = np.arange(0, 360.0, 5.0) + np.random.seed(1) + wind_speeds = 8.0 + np.random.randn(1) * 0.0 + # Shape frequency distribution to match number of wind directions and wind speeds + freq = ( + np.abs( + np.sort( + np.random.randn(len(wind_directions)) + ) + ) + .reshape( ( len(wind_directions), len(wind_speeds) ) ) + ) + freq = freq / freq.sum() + # Set wind data in the FlorisModel + fmodel.set( + wind_data=WindRose( + wind_directions=wind_directions, + wind_speeds=wind_speeds, + freq_table=freq, + ti_table=0.06 + ) + ) + + # Begin by placing as many turbines as possible using a gridded layout at 6D spacing + layout_opt_gridded = LayoutOptimizationGridded( + fmodel, + boundaries, + min_dist_D=6., + min_dist=None, + ) + layout_opt_gridded.optimize() + print("Gridded layout complete.") + + # Set the layout on the fmodel + fmodel.set(layout_x=layout_opt_gridded.x_opt, layout_y=layout_opt_gridded.y_opt) + + # Update the layout using a random search optimization with 5D minimum spacing + layout_opt_rs = LayoutOptimizationRandomSearch( + fmodel, + boundaries, + min_dist_D=5., + seconds_per_iteration=10, + total_optimization_seconds=60., + use_dist_based_init=False, + ) + layout_opt_rs.optimize() + + layout_opt_rs.plot_layout_opt_results( + initial_locs_plotting_dict={"label": "Gridded initial layout"}, + final_locs_plotting_dict={"label": "Random search optimized layout"}, + ) + + plt.show() diff --git a/floris/optimization/layout_optimization/layout_optimization_base.py b/floris/optimization/layout_optimization/layout_optimization_base.py index 97b6c3b81..608c75c71 100644 --- a/floris/optimization/layout_optimization/layout_optimization_base.py +++ b/floris/optimization/layout_optimization/layout_optimization_base.py @@ -130,7 +130,14 @@ def optimize(self): sol = self._optimize() return sol - def plot_layout_opt_results(self, plot_boundary_dict={}, ax=None, fontsize=16): + def plot_layout_opt_results( + self, + plot_boundary_dict={}, + initial_locs_plotting_dict={}, + final_locs_plotting_dict={}, + ax=None, + fontsize=16 + ): x_initial, y_initial, x_opt, y_opt = self._get_initial_and_final_locs() @@ -140,6 +147,7 @@ def plot_layout_opt_results(self, plot_boundary_dict={}, ax=None, fontsize=16): ax = fig.add_subplot(111) ax.set_aspect("equal") + # Handle default boundary plotting default_plot_boundary_dict = { "color":"None", "alpha":1, @@ -148,9 +156,30 @@ def plot_layout_opt_results(self, plot_boundary_dict={}, ax=None, fontsize=16): } plot_boundary_dict = {**default_plot_boundary_dict, **plot_boundary_dict} + # Handle default initial location plotting + default_initial_locs_plotting_dict = { + "marker":"o", + "color":"b", + "linestyle":"None", + "label":"Initial locations", + } + initial_locs_plotting_dict = { + **default_initial_locs_plotting_dict, + **initial_locs_plotting_dict + } + + # Handle default final location plotting + default_final_locs_plotting_dict = { + "marker":"o", + "color":"r", + "linestyle":"None", + "label":"New locations", + } + final_locs_plotting_dict = {**default_final_locs_plotting_dict, **final_locs_plotting_dict} + self.plot_layout_opt_boundary(plot_boundary_dict, ax=ax) - ax.plot(x_initial, y_initial, "ob", label="Initial locations") - ax.plot(x_opt, y_opt, "or", label="New locations") + ax.plot(x_initial, y_initial, **initial_locs_plotting_dict) + ax.plot(x_opt, y_opt, **final_locs_plotting_dict) ax.set_xlabel("x (m)", fontsize=fontsize) ax.set_ylabel("y (m)", fontsize=fontsize) ax.grid(True) From e1d18aef77eaeb7977b5bae6363ab2d89affe197 Mon Sep 17 00:00:00 2001 From: misi9170 Date: Tue, 10 Sep 2024 13:36:04 -0600 Subject: [PATCH 11/15] speed up tests; clean up. --- .../layout_optimization_gridded.py | 9 +----- tests/layout_optimization_integration_test.py | 30 +++++-------------- 2 files changed, 9 insertions(+), 30 deletions(-) diff --git a/floris/optimization/layout_optimization/layout_optimization_gridded.py b/floris/optimization/layout_optimization/layout_optimization_gridded.py index b8d2d4bc8..b175d763c 100644 --- a/floris/optimization/layout_optimization/layout_optimization_gridded.py +++ b/floris/optimization/layout_optimization/layout_optimization_gridded.py @@ -1,13 +1,6 @@ from __future__ import annotations -import matplotlib.pyplot as plt import numpy as np -from scipy.spatial.distance import cdist -from shapely.geometry import ( - LineString, - Point, - Polygon, -) from .layout_optimization_base import LayoutOptimization from .layout_optimization_random_search import test_point_in_bounds @@ -161,7 +154,7 @@ def optimize(self): [test_point_in_bounds(xy[0], xy[1], self._boundary_polygon) for xy in candidate_layouts[i, :, :]] ) - idx_max = np.argmax(turbines_in_bounds) # FIRST maximizing index returned + idx_max = np.argmax(turbines_in_bounds) # First maximizing index returned # Get the best layout x_opt_all = candidate_layouts[idx_max, :, 0] diff --git a/tests/layout_optimization_integration_test.py b/tests/layout_optimization_integration_test.py index ab631c866..ff988f507 100644 --- a/tests/layout_optimization_integration_test.py +++ b/tests/layout_optimization_integration_test.py @@ -120,20 +120,6 @@ def test_LayoutOptimizationGridded_initialization(caplog): min_dist_D=5 ) -def test_LayoutOptimizationGridded_default_grid(): - fmodel = FlorisModel(configuration=YAML_INPUT) - - # Set up a sample boundary - boundaries = [(0.0, 0.0), (0.0, 1000.0), (1000.0, 1000.0), (1000.0, 0.0), (0.0, 0.0)] - - LayoutOptimizationGridded( - fmodel=fmodel, - boundaries=boundaries, - min_dist=50, - ) - - # Test it worked... - def test_LayoutOptimizationGridded_basic(): fmodel = FlorisModel(configuration=YAML_INPUT) @@ -191,9 +177,9 @@ def test_LayoutOptimizationGridded_diagonal(): fmodel=fmodel, boundaries=boundaries_diag, min_dist=turbine_spacing, - rotation_step=5, + rotation_step=45, # To speed up test rotation_range=(0, 360), - translation_step=20, + translation_step=50, hexagonal_packing=False, enable_geometric_yaw=False, use_value=False, @@ -217,7 +203,7 @@ def test_LayoutOptimizationGridded_diagonal(): min_dist=turbine_spacing, rotation_step=5, rotation_range=(0, 10), - translation_step=20, + translation_step=50, hexagonal_packing=False, enable_geometric_yaw=False, use_value=False, @@ -230,9 +216,9 @@ def test_LayoutOptimizationGridded_diagonal(): fmodel=fmodel, boundaries=boundaries_diag, min_dist=turbine_spacing, - rotation_step=60, + rotation_step=60, # Not fine enough to find ideal 45 deg rotation rotation_range=(0, 360), - translation_step=20, + translation_step=50, hexagonal_packing=False, enable_geometric_yaw=False, use_value=False, @@ -245,7 +231,7 @@ def test_LayoutOptimizationGridded_diagonal(): fmodel=fmodel, boundaries=boundaries_diag, min_dist=turbine_spacing, - rotation_step=5, + rotation_step=45, rotation_range=(0, 10), translation_step=300, hexagonal_packing=False, @@ -259,7 +245,7 @@ def test_LayoutOptimizationGridded_separate_boundaries(): fmodel = FlorisModel(configuration=YAML_INPUT) separate_boundaries = [ [(0.0, 0.0), (0.0, 100.0), (100.0, 100.0), (100.0, 0.0), (0.0, 0.0)], - [(1000.0, 0.0), (1000.0, 100.0), (1100.0, 100.0), (1100.0, 0.0), (1000.0, 0.0)] + [(200.0, 0.0), (200.0, 100.0), (300.0, 100.0), (300.0, 0.0), (200.0, 0.0)] ] layout_opt = LayoutOptimizationGridded( @@ -279,7 +265,7 @@ def test_LayoutOptimizationGridded_separate_boundaries(): # Check they're inside as expected assert ((0.0 <= y_opt) & (y_opt <= 100.0)).all() - assert (((0.0 <= x_opt) & (x_opt <= 100.0)) | ((1000.0 <= x_opt) & (x_opt <= 1100.0))).all() + assert (((0.0 <= x_opt) & (x_opt <= 100.0)) | ((200.0 <= x_opt) & (x_opt <= 300.0))).all() def test_LayoutOptimizationGridded_hexagonal(): From 9631415e87269d2895c8cf52f2f1e72d235e83bb Mon Sep 17 00:00:00 2001 From: misi9170 Date: Tue, 10 Sep 2024 13:49:44 -0600 Subject: [PATCH 12/15] Add documentation. --- docs/layout_optimization.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/layout_optimization.md b/docs/layout_optimization.md index aa835fdee..f935bee65 100644 --- a/docs/layout_optimization.md +++ b/docs/layout_optimization.md @@ -79,3 +79,26 @@ shading indicating wind speed heterogeneity (lighter shade is lower wind speed, higher wind speed). The progress of each of the genetic individuals in the optimization process is shown in the right-hand plot. ![](plot_complex_docs.png) + +## Gridded layout optimization +The `LayoutOptimizationGridded` class allows users to quickly find a layout that fits the most +turbines possible into boundary area, given that the turbines are arranged in a gridded layout. +To do so, a range of different rotations and translations of a generic gridded arrangement are +tried, and the one that fits the most turbines into the boundary area is selected. No AEP +evaluations are performed; rather, the cost function $f$ to be maximized is simply $N$, the number +of turbines, and there is an additional constraint that the turbines are arranged in a gridded +fashion. Note that in other layout optimizers, $N$ is fixed. + +We envisage that this will be useful for users that quickly want to generate a layout to adequately +"fill" a boundary region in a gridded manner. By default, the gridded arrangement is a square grid +with spacing of `min_dist` (or `min_dist_D`); however, instantiating with the `hexagonal_packing` +keyword argument set to `True` will provide a grid that offsets the rows to enable tighter packing +of turbines while still satisfying the `min_dist`. + +As with the `LayoutOptimizationRandomSearch` class, the boundaries specified can be complex (and +may contain separate areas). +User settings include `rotation_step`, which specifies the step size for rotating the grid +(in degrees); `rotation_range`, which specifies the range of rotation angles; `translation_step` or +`translation_step_D`, which specifies the step size for translating the grid in meters or rotor +diameters, respectively; abd `translation_range` specifies the range of possible translations. +All come with default values, which we expect to be suitable for many or most users. From 97394107a524c4e783e8b2aaa670651402cdc686 Mon Sep 17 00:00:00 2001 From: misi9170 Date: Tue, 10 Sep 2024 19:59:56 -0600 Subject: [PATCH 13/15] Improve documentation; remove enable_geometric_yaw and use_value options, which are not relevant to this optimization procedure. --- .../layout_optimization_gridded.py | 51 +++++++++++++++---- tests/layout_optimization_integration_test.py | 16 ------ 2 files changed, 42 insertions(+), 25 deletions(-) diff --git a/floris/optimization/layout_optimization/layout_optimization_gridded.py b/floris/optimization/layout_optimization/layout_optimization_gridded.py index b175d763c..34329e873 100644 --- a/floris/optimization/layout_optimization/layout_optimization_gridded.py +++ b/floris/optimization/layout_optimization/layout_optimization_gridded.py @@ -2,15 +2,26 @@ import numpy as np +from floris import FlorisModel + from .layout_optimization_base import LayoutOptimization from .layout_optimization_random_search import test_point_in_bounds class LayoutOptimizationGridded(LayoutOptimization): + """ + Generates layouts that fit the most turbines arranged in a gridded + pattern into the given boundaries. The grid can be square (default) + or hexagonal. The layout is optimized by rotating and translating + the grid to maximize the number of turbines that fit within the + boundaries. Note that no wake or AEP calculations are performed in + determining the maximum number of turbines that fit within the + boundary. + """ def __init__( self, - fmodel, - boundaries, + fmodel: FlorisModel, + boundaries: list[tuple[float, float] | list[tuple[float, float]]], min_dist: float | None = None, min_dist_D: float | None = -1, rotation_step: float = 5.0, @@ -19,11 +30,33 @@ def __init__( translation_step_D: float | None = -1, translation_range: tuple[float, float] | None = None, hexagonal_packing: bool = False, - enable_geometric_yaw: bool = False, - use_value: bool=False, - ): - # Save boundaries + """ + Initialize the LayoutOptimizationGridded object. + + Args: + fmodel: FlorisModel, mostly used to obtain rotor diameter for spacing + boundaries: List of boundary vertices. Specified as a list of two-tuples (x,y), + or a list of lists of two-tuples if there are multiple separate boundary areas. + min_dist: Minimum distance between turbines in meters. Defaults to None, which results + in 5D spacing if min_dist_D is not defined. + min_dist_D: Minimum distance between turbines in terms of rotor diameters. If specified + as a negative number, will result in 5D spacing using the first turbine diameter + found on the fmodel. Defaults to -1, which results in 5D spacing if min_dist is not + defined. + rotation_step: Step size for grid rotations in degrees. Defaults to 5.0. + rotation_range: Range of possible rotation in degrees. Defaults to (0.0, 360.0). + translation_step: Step size for translation in meters. Defaults to None, which results + in 1D translations if translation_step_D is not defined. + translation_step_D: Step size for translation in terms of rotor diameters. If specified + as a negative number, will result in 1D translation steps using the first turbine + diameter found on the fmodel. Defaults to -1, which results in 1D steps if + translation_step is not defined. + translation_range: Range of translation in meters. Defaults to None, which results in + a range of (0, min_dist). + hexagonal_packing: Use hexagonal packing instead of square grid. Defaults to False. + """ + # Handle spacing information if min_dist is not None and min_dist_D is not None and min_dist_D >= 0: raise ValueError("Only one of min_dist and min_dist_D can be defined.") @@ -62,8 +95,8 @@ def __init__( fmodel, boundaries, min_dist=min_dist, - enable_geometric_yaw=enable_geometric_yaw, - use_value=use_value, + enable_geometric_yaw=False, + use_value=False, ) # Initial locations not used for optimization, but may be useful @@ -85,7 +118,7 @@ def __init__( x_locs = x_locs.flatten() - np.mean(x_locs) + 0.5*(self.xmax + self.xmin) y_locs = y_locs.flatten() - np.mean(y_locs) + 0.5*(self.ymax + self.ymin) - # Trim to a circle to minimize wasted computation + # Trim to a circle to avoid wasted computation x_locs_grid, y_locs_grid = self.trim_to_circle( x_locs, y_locs, diff --git a/tests/layout_optimization_integration_test.py b/tests/layout_optimization_integration_test.py index ff988f507..cf848f7bc 100644 --- a/tests/layout_optimization_integration_test.py +++ b/tests/layout_optimization_integration_test.py @@ -133,8 +133,6 @@ def test_LayoutOptimizationGridded_basic(): rotation_range=(0, 360), translation_step=50, hexagonal_packing=False, - enable_geometric_yaw=False, - use_value=False, ) n_turbs_opt, x_opt, y_opt = layout_opt.optimize() @@ -181,8 +179,6 @@ def test_LayoutOptimizationGridded_diagonal(): rotation_range=(0, 360), translation_step=50, hexagonal_packing=False, - enable_geometric_yaw=False, - use_value=False, ) n_turbs_opt, x_opt, y_opt = layout_opt.optimize() @@ -205,8 +201,6 @@ def test_LayoutOptimizationGridded_diagonal(): rotation_range=(0, 10), translation_step=50, hexagonal_packing=False, - enable_geometric_yaw=False, - use_value=False, ) n_turbs_opt, x_opt, y_opt = layout_opt.optimize() assert n_turbs_opt < 3 @@ -220,8 +214,6 @@ def test_LayoutOptimizationGridded_diagonal(): rotation_range=(0, 360), translation_step=50, hexagonal_packing=False, - enable_geometric_yaw=False, - use_value=False, ) n_turbs_opt, x_opt, y_opt = layout_opt.optimize() assert n_turbs_opt < 3 @@ -235,8 +227,6 @@ def test_LayoutOptimizationGridded_diagonal(): rotation_range=(0, 10), translation_step=300, hexagonal_packing=False, - enable_geometric_yaw=False, - use_value=False, ) n_turbs_opt, x_opt, y_opt = layout_opt.optimize() assert n_turbs_opt < 3 @@ -256,8 +246,6 @@ def test_LayoutOptimizationGridded_separate_boundaries(): rotation_range=(0, 360), translation_step=50, hexagonal_packing=False, - enable_geometric_yaw=False, - use_value=False, ) n_turbs_opt, x_opt, y_opt = layout_opt.optimize() @@ -282,8 +270,6 @@ def test_LayoutOptimizationGridded_hexagonal(): rotation_range=(0, 360), translation_step=50, hexagonal_packing=False, - enable_geometric_yaw=False, - use_value=False, ) n_turbs_opt_square = layout_opt.optimize()[0] @@ -296,8 +282,6 @@ def test_LayoutOptimizationGridded_hexagonal(): rotation_range=(0, 360), translation_step=50, hexagonal_packing=True, - enable_geometric_yaw=False, - use_value=False, ) n_turbs_opt_hex = layout_opt.optimize()[0] From e32618c1ccac8653cb7ff43717b0bf68647113c7 Mon Sep 17 00:00:00 2001 From: misi9170 Date: Tue, 10 Sep 2024 20:00:33 -0600 Subject: [PATCH 14/15] ruff formatting. --- .../layout_optimization/layout_optimization_gridded.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/floris/optimization/layout_optimization/layout_optimization_gridded.py b/floris/optimization/layout_optimization/layout_optimization_gridded.py index 34329e873..76a85c6db 100644 --- a/floris/optimization/layout_optimization/layout_optimization_gridded.py +++ b/floris/optimization/layout_optimization/layout_optimization_gridded.py @@ -15,7 +15,7 @@ class LayoutOptimizationGridded(LayoutOptimization): or hexagonal. The layout is optimized by rotating and translating the grid to maximize the number of turbines that fit within the boundaries. Note that no wake or AEP calculations are performed in - determining the maximum number of turbines that fit within the + determining the maximum number of turbines that fit within the boundary. """ def __init__( @@ -43,7 +43,7 @@ def __init__( min_dist_D: Minimum distance between turbines in terms of rotor diameters. If specified as a negative number, will result in 5D spacing using the first turbine diameter found on the fmodel. Defaults to -1, which results in 5D spacing if min_dist is not - defined. + defined. rotation_step: Step size for grid rotations in degrees. Defaults to 5.0. rotation_range: Range of possible rotation in degrees. Defaults to (0.0, 360.0). translation_step: Step size for translation in meters. Defaults to None, which results @@ -51,7 +51,7 @@ def __init__( translation_step_D: Step size for translation in terms of rotor diameters. If specified as a negative number, will result in 1D translation steps using the first turbine diameter found on the fmodel. Defaults to -1, which results in 1D steps if - translation_step is not defined. + translation_step is not defined. translation_range: Range of translation in meters. Defaults to None, which results in a range of (0, min_dist). hexagonal_packing: Use hexagonal packing instead of square grid. Defaults to False. From 08db66f1925fc1b0ddeacf9607e134467422c981 Mon Sep 17 00:00:00 2001 From: misi9170 Date: Tue, 10 Sep 2024 20:04:21 -0600 Subject: [PATCH 15/15] Small typo fixes in docs. --- docs/layout_optimization.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/layout_optimization.md b/docs/layout_optimization.md index f935bee65..f249ae9b1 100644 --- a/docs/layout_optimization.md +++ b/docs/layout_optimization.md @@ -82,14 +82,15 @@ shown in the right-hand plot. ## Gridded layout optimization The `LayoutOptimizationGridded` class allows users to quickly find a layout that fits the most -turbines possible into boundary area, given that the turbines are arranged in a gridded layout. +turbines possible into the specified boundary area, given that the turbines are arranged in a +gridded layout. To do so, a range of different rotations and translations of a generic gridded arrangement are tried, and the one that fits the most turbines into the boundary area is selected. No AEP evaluations are performed; rather, the cost function $f$ to be maximized is simply $N$, the number of turbines, and there is an additional constraint that the turbines are arranged in a gridded fashion. Note that in other layout optimizers, $N$ is fixed. -We envisage that this will be useful for users that quickly want to generate a layout to adequately +We envisage that this will be useful for users that want to quickly generate a layout to "fill" a boundary region in a gridded manner. By default, the gridded arrangement is a square grid with spacing of `min_dist` (or `min_dist_D`); however, instantiating with the `hexagonal_packing` keyword argument set to `True` will provide a grid that offsets the rows to enable tighter packing @@ -100,5 +101,5 @@ may contain separate areas). User settings include `rotation_step`, which specifies the step size for rotating the grid (in degrees); `rotation_range`, which specifies the range of rotation angles; `translation_step` or `translation_step_D`, which specifies the step size for translating the grid in meters or rotor -diameters, respectively; abd `translation_range` specifies the range of possible translations. -All come with default values, which we expect to be suitable for many or most users. +diameters, respectively; and `translation_range`, which specifies the range of possible +translations. All come with default values, which we expect to be suitable for many or most users.