Source code for floris.optimization.layout_optimization.layout_optimization_gridded

from __future__ import annotations

import numpy as np

from floris import FlorisModel

from .layout_optimization_base import LayoutOptimization
from .layout_optimization_random_search import test_point_in_bounds


[docs] 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: 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, 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, ): """ 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.") 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.flat[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" min_dist to {min_dist}m ({min_dist_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.flat[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, boundaries, min_dist=min_dist, enable_geometric_yaw=False, use_value=False, ) # 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. d = 1.1 * np.sqrt((self.xmax - self.xmin)**2 + (self.ymax - self.ymin)**2) 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 * 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) 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 avoid wasted computation x_locs_grid, y_locs_grid = self.trim_to_circle( x_locs, y_locs, (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)], axis=1 ) # 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) ) else: # Square grid has 90 degree symmetry rotation_range = ( rotation_range[0], np.minimum(rotation_range[1], rotation_range[0]+90) ) # Deal with None translation_range if translation_range is None: translation_range = (0.0, min_dist) # Create test rotations and translations self.rotations = np.arange(rotation_range[0], rotation_range[1], rotation_step) self.translations = np.arange(translation_range[0], translation_range[1], translation_step) self.translations = np.concatenate([-np.flip(self.translations), self.translations])
[docs] 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 ) rotations_all = np.tile(rotation_matrices, (n_trans**2, 1, 1)) translations_all = np.repeat(translation_matrices, n_rots, axis=0)[:,None,:] # 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 # 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)] # Save best layout, along with the number of turbines in bounds, and return self.n_turbines_max = round(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
[docs] @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]