from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Iterable
import attrs
import numpy as np
from attrs import define, field
from floris.core import BaseClass
from floris.type_dec import (
floris_array_converter,
floris_float_type,
NDArrayFloat,
NDArrayInt,
)
from floris.utilities import (
reverse_rotate_coordinates_rel_west,
rotate_coordinates_rel_west,
)
[docs]
@define
class Grid(ABC, BaseClass):
"""
Grid should establish domain bounds based on given criteria,
and develop three arrays to contain components of the grid
locations in space. This could be generalized to any number
of dimensions to be used by perhaps a turbulence field.
The grid will have to be reestablished for each wind direction since the planform
area of the farm will be different.
x are the locations in space in the primary direction (typically the direction of the wind)
y are the locations in space in the lateral direction
z are the locations in space in the vertical direction
u are the velocity components at each point in space
v are the velocity components at each point in space
w are the velocity components at each point in space
all of these arrays are the same size
Args:
turbine_coordinates (:py:obj:`NDArrayFloat`): The arrays of turbine coordinates as Numpy
arrays with shape (N coordinates, 3).
turbine_diameters (:py:obj:`NDArrayFloat`): The rotor diameters of each turbine.
wind_directions (:py:obj:`NDArrayFloat`): Wind directions supplied by the user.
grid_resolution (:py:obj:`int` | :py:obj:`Iterable(int,)`): Grid resolution with values
specific to each grid type.
"""
turbine_coordinates: NDArrayFloat = field(converter=floris_array_converter)
turbine_diameters: NDArrayFloat = field(converter=floris_array_converter)
wind_directions: NDArrayFloat = field(converter=floris_array_converter)
grid_resolution: int | Iterable = field()
n_turbines: int = field(init=False)
n_findex: int = field(init=False)
x_sorted: NDArrayFloat = field(init=False)
y_sorted: NDArrayFloat = field(init=False)
z_sorted: NDArrayFloat = field(init=False)
x_sorted_inertial_frame: NDArrayFloat = field(init=False)
y_sorted_inertial_frame: NDArrayFloat = field(init=False)
z_sorted_inertial_frame: NDArrayFloat = field(init=False)
cubature_weights: NDArrayFloat = field(init=False, default=None)
[docs]
@turbine_coordinates.validator
def check_coordinates(self, instance: attrs.Attribute, value: np.ndarray) -> None:
"""
Ensures all elements are Numpy arrays and keeps the `n_turbines`
attribute up to date.
"""
types = np.unique([isinstance(c, np.ndarray) for c in value])
if not all(types):
raise TypeError(
"'turbine_coordinates' must be `np.array` objects "
"with three components of type `float`."
)
self.n_turbines = len(value)
[docs]
@wind_directions.validator
def wind_directions_validator(self, instance: attrs.Attribute, value: NDArrayFloat) -> None:
"""Using the validator method to keep the `n_findex` attribute up to date."""
self.n_findex = value.size
[docs]
@grid_resolution.validator
def grid_resolution_validator(self, instance: attrs.Attribute, value: int | Iterable) -> None:
# TODO move this to the grid types and off of the base class
"""Check that grid resolution is given as appropriate for the chosen Grid-type."""
if isinstance(value, int) and \
isinstance(self, (TurbineGrid, TurbineCubatureGrid, PointsGrid)):
return
elif isinstance(value, Iterable) and isinstance(self, FlowFieldPlanarGrid):
assert type(value[0]) is int
assert type(value[1]) is int
elif isinstance(value, Iterable) and isinstance(self, FlowFieldGrid):
assert type(value[0]) is int
assert type(value[1]) is int
assert type(value[2]) is int
else:
raise TypeError("`grid_resolution` must be of type int or Iterable(int,)")
[docs]
@abstractmethod
def set_grid(self) -> None:
raise NotImplementedError("Grid.set_grid")
[docs]
@define
class TurbineGrid(Grid):
"""See `Grid` for more details.
Args:
turbine_coordinates (:py:obj:`NDArrayFloat`): The arrays of turbine coordinates as Numpy
arrays with shape (N coordinates, 3).
turbine_diameters (:py:obj:`NDArrayFloat`): The rotor diameters of each turbine.
wind_directions (:py:obj:`NDArrayFloat`): Wind directions supplied by the user.
grid_resolution (:py:obj:`int`): The number of points in each
direction of the square grid on the rotor plane. For example, grid_resolution=3
creates a 3x3 grid within the rotor swept area.
"""
# TODO: describe these and the differences between `sorted_indices` and `sorted_coord_indices`
sorted_indices: NDArrayInt = field(init=False)
sorted_coord_indices: NDArrayInt = field(init=False)
unsorted_indices: NDArrayInt = field(init=False)
x_center_of_rotation: NDArrayFloat = field(init=False)
y_center_of_rotation: NDArrayFloat = field(init=False)
average_method = "cubic-mean"
def __attrs_post_init__(self) -> None:
self.set_grid()
[docs]
def set_grid(self) -> None:
"""
Create grid points at each turbine for each wind direction and wind speed in the simulation.
This creates the underlying data structure for the calculation.
arrays have shape
(n wind directions, n wind speeds, n turbines, m grid spanwise, m grid vertically)
- dimension 1: each wind direction
- dimension 2: each wind speed
- dimension 3: each turbine
- dimension 4: number of points in the spanwise direction (ngrid)
- dimension 5: number of points in the vertical dimension (ngrid)
For example
- x is [
n wind direction,
n wind speeds,
n turbines,
x-component of the points in the spanwise direction,
x-component of the points in the vertical direction
]
- y is [
n wind direction,
n wind speeds,
n turbines,
y-component of the points in the spanwise direction,
y-component of the points in the vertical direction
]
The x,y,z arrays contain the actual locations in that direction.
# - **self.grid_resolution** (*int*, optional): The square root of the number
# of points to use on the turbine grid. This number will be
# squared so that the points can be evenly distributed.
# Defaults to 5.
If the grid conforms to the sequential solver interface,
it must be sorted from upstream to downstream
In a y-z plane on the rotor swept area, the -2 dimension is a column of
points and the -1 dimension is the row number.
So the following line prints the 0'th column of the the 0'th turbine's grid:
print(grid.y_sorted[0,0,0,0,:])
print(grid.z_sorted[0,0,0,0,:])
And this line prints a single point
print(grid.y_sorted[0,0,0,0,0])
print(grid.z_sorted[0,0,0,0,0])
Note that the x coordinates are all the same for the rotor plane.
"""
# TODO: Where should we locate the coordinate system? Currently, its at
# the foot of the turbine where the tower meets the ground.
# These are the rotated coordinates of the wind turbines based on the wind direction
x, y, z, self.x_center_of_rotation, self.y_center_of_rotation = rotate_coordinates_rel_west(
self.wind_directions,
self.turbine_coordinates,
)
# - **rloc** (*float, optional): A value, from 0 to 1, that determines
# the width/height of the grid of points on the rotor as a ratio of
# the rotor radius.
# Defaults to 0.5.
# Create the data for the turbine grids
radius_ratio = 0.5
disc_area_radius = radius_ratio * self.turbine_diameters / 2
template_grid = np.ones(
(
self.n_findex,
self.n_turbines,
self.grid_resolution,
self.grid_resolution,
),
dtype=floris_float_type
)
# Calculate the radial distance from the center of the turbine rotor.
# If a grid resolution of 1 is selected, create a disc_grid of zeros, as
# np.linspace would just return the starting value of -1 * disc_area_radius
# which would place the point below the center of the rotor.
if self.grid_resolution == 1:
disc_grid = np.zeros((np.shape(disc_area_radius)[0], 1 ))
else:
disc_grid = np.linspace(
-1 * disc_area_radius,
disc_area_radius,
self.grid_resolution,
dtype=floris_float_type,
axis=1
)
# Construct the turbine grids
# Here, they are already rotated to the correct orientation for each wind direction
_x = x[:, :, None, None] * template_grid
ones_grid = np.ones(
(self.n_turbines, self.grid_resolution, self.grid_resolution),
dtype=floris_float_type
)
_y = y[:, :, None, None] + template_grid * ( disc_grid[None, :, :, None])
_z = z[:, :, None, None] + template_grid * ( disc_grid[:, None, :] * ones_grid )
# Sort the turbines at each wind direction
# Get the sorted indices for the x coordinates. These are the indices
# to sort the turbines from upstream to downstream for all wind directions.
# Also, store the indices to sort them back for when the calculation finishes.
self.sorted_indices = _x.argsort(axis=1)
self.sorted_coord_indices = x.argsort(axis=1)
self.unsorted_indices = self.sorted_indices.argsort(axis=1)
# Put the turbine coordinates into the final arrays in their sorted order
# These are the coordinates that should be used within the internal calculations
# such as the wake models and the solvers.
self.x_sorted = np.take_along_axis(_x, self.sorted_indices, axis=1)
self.y_sorted = np.take_along_axis(_y, self.sorted_indices, axis=1)
self.z_sorted = np.take_along_axis(_z, self.sorted_indices, axis=1)
# Now calculate grid coordinates in original frame (from 270 deg perspective)
self.x_sorted_inertial_frame, self.y_sorted_inertial_frame, self.z_sorted_inertial_frame = \
reverse_rotate_coordinates_rel_west(
wind_directions=self.wind_directions,
grid_x=self.x_sorted,
grid_y=self.y_sorted,
grid_z=self.z_sorted,
x_center_of_rotation=self.x_center_of_rotation,
y_center_of_rotation=self.y_center_of_rotation,
)
[docs]
@define
class TurbineCubatureGrid(Grid):
"""
This grid type arranges points throughout the swept area of the rotor based on the cubature
of a unit circle. The number of points is set by the user, and then the location of the
points and their weighting in integration is automatically set. This type of grid
enables a better approximation of the total incoming velocities on the rotor and therefore
a more accurate average velocity, thrust coefficient, and axial induction.
Args:
turbine_coordinates (:py:obj:`NDArrayFloat`): The arrays of turbine coordinates as Numpy
arrays with shape (N coordinates, 3).
turbine_diameters (:py:obj:`NDArrayFloat`): The rotor diameters of each turbine.
wind_directions (:py:obj:`NDArrayFloat`): Wind directions supplied by the user.
grid_resolution (:py:obj:`int`): The number of points to
include in the cubature method. This value must be in the range [1, 10], and the
corresponding cubature weights are set automatically.
"""
sorted_indices: NDArrayInt = field(init=False)
sorted_coord_indices: NDArrayInt = field(init=False)
unsorted_indices: NDArrayInt = field(init=False)
x_center_of_rotation: NDArrayFloat = field(init=False)
y_center_of_rotation: NDArrayFloat = field(init=False)
average_method = "simple-cubature"
def __attrs_post_init__(self) -> None:
self.set_grid()
[docs]
def set_grid(self) -> None:
"""
"""
# These are the rotated coordinates of the wind turbines based on the wind direction
x, y, z, self.x_center_of_rotation, self.y_center_of_rotation = rotate_coordinates_rel_west(
self.wind_directions,
self.turbine_coordinates
)
# Coefficients
cubature_coefficients = TurbineCubatureGrid.get_cubature_coefficients(self.grid_resolution)
# Generate grid points
yv = np.kron(cubature_coefficients["r"], cubature_coefficients["q"])
zv = np.kron(cubature_coefficients["r"], cubature_coefficients["t"])
# Calculate weighting terms for the grid points
self.cubature_weights = (
np.kron(cubature_coefficients["A"], np.ones((1, self.grid_resolution)))
* cubature_coefficients["B"] / np.pi
)
# Here, the coordinates are already rotated to the correct orientation for each
# wind direction
template_grid = np.ones(
(
self.n_findex,
self.n_turbines,
len(yv), # Number of coordinates
1,
),
dtype=floris_float_type
)
_x = x[:, :, None, None] * template_grid
_y = y[:, :, None, None] * template_grid
_z = z[:, :, None, None] * template_grid
n_coordinates = len(yv)
yv = np.broadcast_to(yv, (self.n_findex, self.n_turbines, n_coordinates))
yv = np.expand_dims(yv, axis=-1)
zv = np.broadcast_to(zv, (self.n_findex, self.n_turbines, n_coordinates))
zv = np.expand_dims(zv, axis=-1)
for ti in range(self.n_turbines):
_y[:, ti, :, :] += yv[:, ti] * self.turbine_diameters[ti] / 2.0
_z[:, ti, :, :] += zv[:, ti] * self.turbine_diameters[ti] / 2.0
# Sort the turbines at each wind direction
# Get the sorted indices for the x coordinates. These are the indices
# to sort the turbines from upstream to downstream for all wind directions.
# Also, store the indices to sort them back for when the calculation finishes.
self.sorted_indices = _x.argsort(axis=1)
self.sorted_coord_indices = x.argsort(axis=1)
self.unsorted_indices = self.sorted_indices.argsort(axis=1)
# Put the turbine coordinates into the final arrays in their sorted order
# These are the coordinates that should be used within the internal calculations
# such as the wake models and the solvers.
self.x_sorted = np.take_along_axis(_x, self.sorted_indices, axis=1)
self.y_sorted = np.take_along_axis(_y, self.sorted_indices, axis=1)
self.z_sorted = np.take_along_axis(_z, self.sorted_indices, axis=1)
# Now calculate grid coordinates in original frame (from 270 deg perspective)
self.x_sorted_inertial_frame, self.y_sorted_inertial_frame, self.z_sorted_inertial_frame = \
reverse_rotate_coordinates_rel_west(
wind_directions=self.wind_directions,
grid_x=self.x_sorted,
grid_y=self.y_sorted,
grid_z=self.z_sorted,
x_center_of_rotation=self.x_center_of_rotation,
y_center_of_rotation=self.y_center_of_rotation,
)
[docs]
@classmethod
def get_cubature_coefficients(cls, N: int):
"""
Retrieve cubature integration coefficients. This is a class-method, and therefore
the coefficients can be accessed without creating an instance of the class.
Args:
N (int): Order of the cubature integration. The total
number of rotor points will be N^2. Must be an integer in the range [1, 10].
Returns:
cubature_coefficients (dict): A dictionary containing the cubature
integration coefficients, "r", "t", "q", "A" and "B".
"""
if N < 1 or N > 10:
raise ValueError(
f"Order of cubature integration must be between '1' and '10', given {N}."
)
elif N == 1:
r = [0.0000000000000000000000000]
t = [0.0000000000000000000000000]
q = [1.0000000000000000000000000]
A = [1.0000000000000000000000000]
elif N == 2:
r = [-0.7071067811865475244008444, 0.7071067811865475244008444]
t = [-0.7071067811865475244008444, 0.7071067811865475244008444]
q = [ 0.7071067811865475244008444, 0.7071067811865475244008444]
A = [ 0.5000000000000000000000000, 0.5000000000000000000000000]
elif N == 3:
r = [-0.8164965809277260327324280, 0.0000000000000000000000000, 0.8164965809277260327324280] # noqa: E501
t = [-0.8660254037844386467637232, 0.0000000000000000000000000, 0.8660254037844386467637232] # noqa: E501
q = [ 0.5000000000000000000000000, 1.0000000000000000000000000, 0.5000000000000000000000000] # noqa: E501
A = [ 0.3750000000000000000000000, 0.2500000000000000000000000, 0.3750000000000000000000000] # noqa: E501
elif N == 4:
r = [-0.8880738339771152621607646,-0.4597008433809830609776340, 0.4597008433809830609776340, 0.8880738339771152621607646] # noqa: E501
t = [-0.9238795325112867561281832,-0.3826834323650897717284600, 0.3826834323650897717284600, 0.9238795325112867561281832] # noqa: E501
q = [ 0.3826834323650897717284600, 0.9238795325112867561281832, 0.9238795325112867561281832, 0.3826834323650897717284600] # noqa: E501
A = [ 0.2500000000000000000000000, 0.2500000000000000000000000, 0.2500000000000000000000000, 0.2500000000000000000000000] # noqa: E501
elif N == 5:
r = [-0.9192110607898045793726291,-0.5958615826865180525340234, 0.0000000000000000000000000, 0.5958615826865180525340234, 0.9192110607898045793726291] # noqa: E501
t = [-0.9510565162951535721164393,-0.5877852522924731291687060, 0.0000000000000000000000000, 0.5877852522924731291687060, 0.9510565162951535721164393] # noqa: E501
q = [ 0.3090169943749474241022934, 0.8090169943749474241022934, 1.0000000000000000000000000, 0.8090169943749474241022934, 0.3090169943749474241022934] # noqa: E501
A = [ 0.1882015313502336375250377, 0.2562429130942108069194067, 0.1111111111111111111111111, 0.2562429130942108069194067, 0.1882015313502336375250377] # noqa: E501
elif N == 6:
r = [-0.9419651451198933233901941,-0.7071067811865475244008444,-0.3357106870197288066698994, 0.3357106870197288066698994, 0.7071067811865475244008444, 0.9419651451198933233901941] # noqa: E501
t = [-0.9659258262890682867497432,-0.7071067811865475244008444,-0.2588190451025207623488988, 0.2588190451025207623488988, 0.7071067811865475244008444, 0.9659258262890682867497432] # noqa: E501
q = [ 0.2588190451025207623488988, 0.7071067811865475244008444, 0.9659258262890682867497432, 0.9659258262890682867497432, 0.7071067811865475244008444, 0.2588190451025207623488988] # noqa: E501
A = [ 0.1388888888888888888888889, 0.2222222222222222222222222, 0.1388888888888888888888889, 0.1388888888888888888888889, 0.2222222222222222222222222, 0.1388888888888888888888889] # noqa: E501
elif N == 7:
r = [-0.9546790248493448767148503,-0.7684615381131740734708478,-0.4608042298407784190147371, 0.0000000000000000000000000, 0.4608042298407784190147371, 0.7684615381131740734708478, 0.9546790248493448767148503] # noqa: E501
t = [-0.9749279121818236070181317,-0.7818314824680298087084445,-0.4338837391175581204757683, 0.0000000000000000000000000, 0.4338837391175581204757683, 0.7818314824680298087084445, 0.9749279121818236070181317] # noqa: E501
q = [ 0.2225209339563144042889026, 0.6234898018587335305250049, 0.9009688679024191262361023, 1.0000000000000000000000000, 0.9009688679024191262361023, 0.6234898018587335305250049, 0.2225209339563144042889026] # noqa: E501
A = [ 0.1102311055883841876377392, 0.1940967344215859403901162, 0.1644221599900298719721446, 0.0625000000000000000000000, 0.1644221599900298719721446, 0.1940967344215859403901162, 0.1102311055883841876377392] # noqa: E501
elif N == 8:
r = [-0.9646596061808674528345806,-0.8185294874300058668603761,-0.5744645143153507855310459,-0.2634992299855422962484895, 0.2634992299855422962484895, 0.5744645143153507855310459, 0.8185294874300058668603761, 0.9646596061808674528345806] # noqa: E501
t = [-0.9807852804032304491261822,-0.8314696123025452370787884,-0.5555702330196022247428308,-0.1950903220161282678482849, 0.1950903220161282678482849, 0.5555702330196022247428308, 0.8314696123025452370787884, 0.9807852804032304491261822] # noqa: E501
q = [ 0.1950903220161282678482849, 0.5555702330196022247428308, 0.8314696123025452370787884, 0.9807852804032304491261822, 0.9807852804032304491261822, 0.8314696123025452370787884, 0.5555702330196022247428308, 0.1950903220161282678482849] # noqa: E501
A = [ 0.0869637112843634643432660, 0.1630362887156365356567340, 0.1630362887156365356567340, 0.0869637112843634643432660, 0.0869637112843634643432660, 0.1630362887156365356567340, 0.1630362887156365356567340, 0.0869637112843634643432660] # noqa: E501
elif N == 9:
r = [-0.9710282199223060261836893,-0.8503863747508400503582112,-0.6452980455813291706201889,-0.3738447061866471744516959, 0.0000000000000000000000000, 0.3738447061866471744516959, 0.6452980455813291706201889, 0.8503863747508400503582112, 0.9710282199223060261836893] # noqa: E501
t = [-0.9848077530122080593667430,-0.8660254037844386467637232,-0.6427876096865393263226434,-0.3420201433256687330440996, 0.0000000000000000000000000, 0.3420201433256687330440996, 0.6427876096865393263226434, 0.8660254037844386467637232, 0.9848077530122080593667430] # noqa: E501
q = [ 0.1736481776669303488517166, 0.5000000000000000000000000, 0.7660444431189780352023927, 0.9396926207859083840541093, 1.0000000000000000000000000, 0.9396926207859083840541093, 0.7660444431189780352023927, 0.5000000000000000000000000, 0.1736481776669303488517166] # noqa: E501
A = [ 0.0718567803956129706617061, 0.1406780075747310300960863, 0.1559132614878706270409275, 0.1115519505417853722012801, 0.0400000000000000000000000, 0.1115519505417853722012801, 0.1559132614878706270409275, 0.1406780075747310300960863, 0.0718567803956129706617061] # noqa: E501
elif N == 10:
r = [-0.9762632447087885713212574,-0.8770602345636481685478274,-0.7071067811865475244008444,-0.4803804169063914437972190,-0.2165873427295972057980989, 0.2165873427295972057980989, 0.4803804169063914437972190, 0.7071067811865475244008444, 0.8770602345636481685478274, 0.9762632447087885713212574] # noqa: E501
t = [-0.9876883405951377261900402,-0.8910065241883678623597096,-0.7071067811865475244008444,-0.4539904997395467915604084,-0.1564344650402308690101053, 0.1564344650402308690101053, 0.4539904997395467915604084, 0.7071067811865475244008444, 0.8910065241883678623597096, 0.9876883405951377261900402] # noqa: E501
q = [ 0.1564344650402308690101053, 0.4539904997395467915604084, 0.7071067811865475244008444, 0.8910065241883678623597096, 0.9876883405951377261900402, 0.9876883405951377261900402, 0.8910065241883678623597096, 0.7071067811865475244008444, 0.4539904997395467915604084, 0.1564344650402308690101053] # noqa: E501
A = [ 0.0592317212640472718785660, 0.1196571676248416170103229, 0.1422222222222222222222222, 0.1196571676248416170103229, 0.0592317212640472718785660, 0.0592317212640472718785660, 0.1196571676248416170103229, 0.1422222222222222222222222, 0.1196571676248416170103229, 0.0592317212640472718785660] # noqa: E501
return {
"r": np.array(r, dtype=float),
"t": np.array(t, dtype=float),
"q": np.array(q, dtype=float),
"A": np.array(A, dtype=float),
"B": np.pi/N,
}
[docs]
@define
class FlowFieldGrid(Grid):
"""
Args:
turbine_coordinates (:py:obj:`NDArrayFloat`): The arrays of turbine coordinates as Numpy
arrays with shape (N coordinates, 3).
turbine_diameters (:py:obj:`NDArrayFloat`): The rotor diameters of each turbine.
wind_directions (:py:obj:`NDArrayFloat`): Wind directions supplied by the user.
grid_resolution (:py:obj:`Iterable(int,)`): The number of grid points to create in each
planar direction. Must be 3 components for resolution in the x, y, and z directions.
"""
x_center_of_rotation: NDArrayFloat = field(init=False)
y_center_of_rotation: NDArrayFloat = field(init=False)
def __attrs_post_init__(self) -> None:
self.set_grid()
[docs]
def set_grid(self) -> None:
"""
Create a structured grid for the entire flow field domain.
Calculates the domain bounds for the current wake model. The bounds
are calculated based on preset extents from the
given layout. The bounds consist of the minimum and maximum values
in the x-, y-, and z-directions.
If the Curl model is used, the predefined bounds are always set.
First, sort the turbines so that we know the bounds in the correct orientation.
Then, create the grid based on this wind-from-left orientation
"""
# These are the rotated coordinates of the wind turbines based on the wind direction
x, y, z, self.x_center_of_rotation, self.y_center_of_rotation = rotate_coordinates_rel_west(
self.wind_directions,
self.turbine_coordinates
)
# Construct the arrays storing the grid points
eps = 0.01
xmin = min(x[0,0]) - 2 * self.turbine_diameters
xmax = max(x[0,0]) + 10 * self.turbine_diameters
ymin = min(y[0,0]) - 2 * self.turbine_diameters
ymax = max(y[0,0]) + 2 * self.turbine_diameters
zmin = 0 + eps
zmax = 6 * max(z[0,0])
x_points, y_points, z_points = np.meshgrid(
np.linspace(xmin, xmax, int(self.grid_resolution[0])),
np.linspace(ymin, ymax, int(self.grid_resolution[1])),
np.linspace(zmin, zmax, int(self.grid_resolution[2])),
indexing="ij"
)
self.x_sorted = x_points[None, None, :, :, :]
self.y_sorted = y_points[None, None, :, :, :]
self.z_sorted = z_points[None, None, :, :, :]
# Now calculate grid coordinates in original frame (from 270 deg perspective)
self.x_sorted_inertial_frame, self.y_sorted_inertial_frame, self.z_sorted_inertial_frame = \
reverse_rotate_coordinates_rel_west(
wind_directions=self.wind_directions,
grid_x=self.x_sorted,
grid_y=self.y_sorted,
grid_z=self.z_sorted,
x_center_of_rotation=self.x_center_of_rotation,
y_center_of_rotation=self.y_center_of_rotation,
)
[docs]
@define
class FlowFieldPlanarGrid(Grid):
"""
Args:
turbine_coordinates (:py:obj:`NDArrayFloat`): The arrays of turbine coordinates as Numpy
arrays with shape (N coordinates, 3).
turbine_diameters (:py:obj:`NDArrayFloat`): The rotor diameters of each turbine.
wind_directions (:py:obj:`NDArrayFloat`): Wind directions supplied by the user.
grid_resolution (:py:obj:`Iterable(int,)`): The number of grid points to create in each
planar direction. Must be 2 components for resolution in the x and y directions.
The z direction is set to 3 planes at -10.0, 0.0, and +10.0 relative to the
`planar_coordinate`.
"""
normal_vector: str = field()
planar_coordinate: float = field()
x1_bounds: tuple = field(default=None)
x2_bounds: tuple = field(default=None)
x_center_of_rotation: NDArrayFloat = field(init=False)
y_center_of_rotation: NDArrayFloat = field(init=False)
sorted_indices: NDArrayInt = field(init=False)
unsorted_indices: NDArrayInt = field(init=False)
def __attrs_post_init__(self) -> None:
self.set_grid()
[docs]
def set_grid(self) -> None:
"""
Create a structured grid for the entire flow field domain.
Calculates the domain bounds for the current wake model. The bounds
are calculated based on preset extents from the
given layout. The bounds consist of the minimum and maximum values
in the x-, y-, and z-directions.
First, sort the turbines so that we know the bounds in the correct orientation.
Then, create the grid based on this wind-from-left orientation
"""
# These are the rotated coordinates of the wind turbines based on the wind direction
x, y, z, self.x_center_of_rotation, self.y_center_of_rotation = rotate_coordinates_rel_west(
self.wind_directions,
self.turbine_coordinates
)
max_diameter = np.max(self.turbine_diameters)
if self.normal_vector == "z": # Rules of thumb for horizontal plane
if self.x1_bounds is None:
self.x1_bounds = (np.min(x) - 2 * max_diameter, np.max(x) + 10 * max_diameter)
if self.x2_bounds is None:
self.x2_bounds = (np.min(y) - 2 * max_diameter, np.max(y) + 2 * max_diameter)
# TODO figure out proper z spacing for GCH, currently set to +/- 10.0
x_points, y_points, z_points = np.meshgrid(
np.linspace(self.x1_bounds[0], self.x1_bounds[1], int(self.grid_resolution[0])),
np.linspace(self.x2_bounds[0], self.x2_bounds[1], int(self.grid_resolution[1])),
np.array([
float(self.planar_coordinate) - 10.0,
float(self.planar_coordinate),
float(self.planar_coordinate) + 10.0
]),
indexing="ij"
)
self.x_sorted = x_points[None, :, :, :]
self.y_sorted = y_points[None, :, :, :]
self.z_sorted = z_points[None, :, :, :]
elif self.normal_vector == "x": # Rules of thumb for cross plane
if self.x1_bounds is None:
self.x1_bounds = (np.min(y) - 2 * max_diameter, np.max(y) + 2 * max_diameter)
if self.x2_bounds is None:
self.x2_bounds = (0.001, 6 * np.max(z))
x_points, y_points, z_points = np.meshgrid(
np.array([float(self.planar_coordinate)]),
np.linspace(self.x1_bounds[0], self.x1_bounds[1], int(self.grid_resolution[0])),
np.linspace(self.x2_bounds[0], self.x2_bounds[1], int(self.grid_resolution[1])),
indexing="ij"
)
self.x_sorted = x_points[None, :, :, :]
self.y_sorted = y_points[None, :, :, :]
self.z_sorted = z_points[None, :, :, :]
elif self.normal_vector == "y": # Rules of thumb for y plane
if self.x1_bounds is None:
self.x1_bounds = (np.min(x) - 2 * max_diameter, np.max(x) + 10 * max_diameter)
if self.x2_bounds is None:
self.x2_bounds = (0.001, 6 * np.max(z))
x_points, y_points, z_points = np.meshgrid(
np.linspace(self.x1_bounds[0], self.x1_bounds[1], int(self.grid_resolution[0])),
np.array([float(self.planar_coordinate)]),
np.linspace(self.x2_bounds[0], self.x2_bounds[1], int(self.grid_resolution[1])),
indexing="ij"
)
self.x_sorted = x_points[None, :, :, :]
self.y_sorted = y_points[None, :, :, :]
self.z_sorted = z_points[None, :, :, :]
# Now calculate grid coordinates in original frame (from 270 deg perspective)
self.x_sorted_inertial_frame, self.y_sorted_inertial_frame, self.z_sorted_inertial_frame = \
reverse_rotate_coordinates_rel_west(
wind_directions=self.wind_directions,
grid_x=self.x_sorted,
grid_y=self.y_sorted,
grid_z=self.z_sorted,
x_center_of_rotation=self.x_center_of_rotation,
y_center_of_rotation=self.y_center_of_rotation,
)
[docs]
@define
class PointsGrid(Grid):
"""
Args:
turbine_coordinates (:py:obj:`NDArrayFloat`): Not used for PointsGrid, but
required for the `Grid` super-class.
turbine_diameters (:py:obj:`NDArrayFloat`): Not used for PointsGrid, but
required for the `Grid` super-class.
wind_directions (:py:obj:`NDArrayFloat`): Wind directions supplied by the user.
grid_resolution (:py:obj:`int` | :py:obj:`Iterable(int,)`): Not used for PointsGrid, but
required for the `Grid` super-class.
points_x (:py:obj:`NDArrayFloat`): Array of x-components for the points in the grid.
points_y (:py:obj:`NDArrayFloat`): Array of y-components for the points in the grid.
points_z (:py:obj:`NDArrayFloat`): Array of z-components for the points in the grid.
x_center_of_rotation (:py:obj:`float`, optional): Component of the centroid of the
farm or area of interest. The PointsGrid will be rotated around this center
of rotation to account for wind direction changes. If not supplied, the center
of rotation will be the centroid of the points in the PointsGrid.
y_center_of_rotation (:py:obj:`float`, optional): Component of the centroid of the
farm or area of interest. The PointsGrid will be rotated around this center
of rotation to account for wind direction changes. If not supplied, the center
of rotation will be the centroid of the points in the PointsGrid.
"""
points_x: NDArrayFloat = field(converter=floris_array_converter)
points_y: NDArrayFloat = field(converter=floris_array_converter)
points_z: NDArrayFloat = field(converter=floris_array_converter)
x_center_of_rotation: float | None = field(default=None)
y_center_of_rotation: float | None = field(default=None)
def __attrs_post_init__(self) -> None:
self.set_grid()
[docs]
def set_grid(self) -> None:
"""
Set points for calculation based on a series of user-supplied coordinates.
"""
point_coordinates = np.array(list(zip(self.points_x, self.points_y, self.points_z)))
# These are the rotated coordinates of the wind turbines based on the wind direction
x, y, z, _, _ = rotate_coordinates_rel_west(
self.wind_directions,
point_coordinates,
x_center_of_rotation=self.x_center_of_rotation,
y_center_of_rotation=self.y_center_of_rotation
)
self.x_sorted = x[:,:,None,None]
self.y_sorted = y[:,:,None,None]
self.z_sorted = z[:,:,None,None]