diff --git a/examples/01_opening_floris_computing_power.py b/examples/01_opening_floris_computing_power.py index b006dfe4d..a7b8296ef 100644 --- a/examples/01_opening_floris_computing_power.py +++ b/examples/01_opening_floris_computing_power.py @@ -70,7 +70,7 @@ wind_directions = np.array([260., 270., 280.]) wind_speeds = np.array([8.0, 9.0, 10.0]) fi.reinitialize(wind_directions=wind_directions, wind_speeds=wind_speeds) -yaw_angles = np.zeros([1,3,2]) # 1 wind direction, 3 wind speeds, 2 turbines +yaw_angles = np.zeros([3,3,2]) # 1 wind direction, 3 wind speeds, 2 turbines fi.calculate_wake(yaw_angles=yaw_angles) turbine_powers = fi.get_turbine_powers()/1000. print('The turbine power matrix should be of dimensions 3 WD X 3 WS X 2 Turbines') diff --git a/examples/30_multi_dimensional_cp_ct.py b/examples/30_multi_dimensional_cp_ct.py index 2d2303018..29c5070f8 100644 --- a/examples/30_multi_dimensional_cp_ct.py +++ b/examples/30_multi_dimensional_cp_ct.py @@ -96,7 +96,7 @@ wind_directions = np.array([260., 270., 280.]) wind_speeds = np.array([8.0, 9.0, 10.0]) fi.reinitialize(wind_directions=wind_directions, wind_speeds=wind_speeds) -yaw_angles = np.zeros([1,3,2]) # 1 wind direction, 3 wind speeds, 2 turbines +yaw_angles = np.zeros([3,3,2]) # 1 wind direction, 3 wind speeds, 2 turbines fi.calculate_wake(yaw_angles=yaw_angles) turbine_powers = fi.get_turbine_powers_multidim()/1000. print('The turbine power matrix should be of dimensions 3 WD X 3 WS X 2 Turbines') diff --git a/floris/simulation/base.py b/floris/simulation/base.py index eb26364ab..5f5ea2427 100644 --- a/floris/simulation/base.py +++ b/floris/simulation/base.py @@ -33,7 +33,7 @@ ) from floris.logging_manager import LoggingManager -from floris.type_dec import FromDictMixin +from floris.type_dec import FromDictMixin, ValidateMixin class State(Enum): @@ -43,7 +43,7 @@ class State(Enum): @define -class BaseClass(FromDictMixin): +class BaseClass(FromDictMixin, ValidateMixin): """ BaseClass object class. This class does the logging and MixIn class inheritance. """ diff --git a/floris/simulation/farm.py b/floris/simulation/farm.py index 1bfddf695..26b307078 100644 --- a/floris/simulation/farm.py +++ b/floris/simulation/farm.py @@ -33,6 +33,7 @@ ) from floris.simulation.turbine import compute_tilt_angles_for_floating_turbines from floris.type_dec import ( + array_3D_field, convert_to_path, floris_array_converter, iter_validator, @@ -79,50 +80,53 @@ class Farm(BaseClass): default=default_turbine_library_path, converter=convert_to_path ) + n_wind_directions: int = field(init=False) + n_wind_speeds: int = field(init=False) + turbine_definitions: list = field(init=False, validator=iter_validator(list, dict)) turbine_fCts: Dict[str, interp1d] | List[interp1d] = field(init=False, factory=list) - turbine_fCts_sorted: NDArrayFloat = field(init=False, factory=list) + turbine_fCts_sorted: NDArrayFloat = array_3D_field turbine_fTilts: list = field(init=False, factory=list) - yaw_angles: NDArrayFloat = field(init=False) - yaw_angles_sorted: NDArrayFloat = field(init=False) + yaw_angles: NDArrayFloat = array_3D_field + yaw_angles_sorted: NDArrayFloat = array_3D_field - tilt_angles: NDArrayFloat = field(init=False) - tilt_angles_sorted: NDArrayFloat = field(init=False) + tilt_angles: NDArrayFloat = array_3D_field + tilt_angles_sorted: NDArrayFloat = array_3D_field - hub_heights: NDArrayFloat = field(init=False) - hub_heights_sorted: NDArrayFloat = field(init=False, factory=list) + hub_heights: NDArrayFloat = array_3D_field + hub_heights_sorted: NDArrayFloat = array_3D_field turbine_map: List[Turbine | TurbineMultiDimensional] = field(init=False, factory=list) - turbine_type_map: NDArrayObject = field(init=False, factory=list) - turbine_type_map_sorted: NDArrayObject = field(init=False, factory=list) + turbine_type_map: NDArrayObject = array_3D_field + turbine_type_map_sorted: NDArrayObject = array_3D_field turbine_power_interps: Dict[str, interp1d] | List[interp1d] = field(init=False, factory=list) - turbine_power_interps_sorted: NDArrayFloat = field(init=False, factory=list) + turbine_power_interps_sorted: NDArrayFloat = array_3D_field - rotor_diameters: NDArrayFloat = field(init=False, factory=list) - rotor_diameters_sorted: NDArrayFloat = field(init=False, factory=list) + rotor_diameters: NDArrayFloat = array_3D_field + rotor_diameters_sorted: NDArrayFloat = array_3D_field - TSRs: NDArrayFloat = field(init=False, factory=list) - TSRs_sorted: NDArrayFloat = field(init=False, factory=list) + TSRs: NDArrayFloat = array_3D_field + TSRs_sorted: NDArrayFloat = array_3D_field - pPs: NDArrayFloat = field(init=False, factory=list) - pPs_sorted: NDArrayFloat = field(init=False, factory=list) + pPs: NDArrayFloat = array_3D_field + pPs_sorted: NDArrayFloat = array_3D_field - pTs: NDArrayFloat = field(init=False, factory=list) - pTs_sorted: NDArrayFloat = field(init=False, factory=list) + pTs: NDArrayFloat = array_3D_field + pTs_sorted: NDArrayFloat = array_3D_field - ref_density_cp_cts: NDArrayFloat = field(init=False, factory=list) - ref_density_cp_cts_sorted: NDArrayFloat = field(init=False, factory=list) + ref_density_cp_cts: NDArrayFloat = array_3D_field + ref_density_cp_cts_sorted: NDArrayFloat = array_3D_field - ref_tilt_cp_cts: NDArrayFloat = field(init=False, factory=list) - ref_tilt_cp_cts_sorted: NDArrayFloat = field(init=False, factory=list) + ref_tilt_cp_cts: NDArrayFloat = array_3D_field + ref_tilt_cp_cts_sorted: NDArrayFloat = array_3D_field - correct_cp_ct_for_tilt: NDArrayFloat = field(init=False, factory=list) - correct_cp_ct_for_tilt_sorted: NDArrayFloat = field(init=False, factory=list) + correct_cp_ct_for_tilt: NDArrayFloat = array_3D_field + correct_cp_ct_for_tilt_sorted: NDArrayFloat = array_3D_field internal_turbine_library: Path = field(init=False, default=default_turbine_library_path) @@ -405,6 +409,9 @@ def expand_farm_properties( ) def set_yaw_angles(self, n_wind_directions: int, n_wind_speeds: int): + self.n_wind_directions = n_wind_directions + self.n_wind_speeds = n_wind_speeds + # TODO Is this just for initializing yaw angles to zero? self.yaw_angles = np.zeros((n_wind_directions, n_wind_speeds, self.n_turbines)) self.yaw_angles_sorted = np.zeros((n_wind_directions, n_wind_speeds, self.n_turbines)) diff --git a/floris/simulation/flow_field.py b/floris/simulation/flow_field.py index f6baf49c4..687210551 100644 --- a/floris/simulation/flow_field.py +++ b/floris/simulation/flow_field.py @@ -27,6 +27,8 @@ Grid, ) from floris.type_dec import ( + array_5D_field, + array_mixed_dim_field, floris_array_converter, NDArrayFloat, ) @@ -47,26 +49,24 @@ class FlowField(BaseClass): n_wind_speeds: int = field(init=False) n_wind_directions: int = field(init=False) - - u_initial_sorted: NDArrayFloat = field(init=False, factory=lambda: np.array([])) - v_initial_sorted: NDArrayFloat = field(init=False, factory=lambda: np.array([])) - w_initial_sorted: NDArrayFloat = field(init=False, factory=lambda: np.array([])) - u_sorted: NDArrayFloat = field(init=False, factory=lambda: np.array([])) - v_sorted: NDArrayFloat = field(init=False, factory=lambda: np.array([])) - w_sorted: NDArrayFloat = field(init=False, factory=lambda: np.array([])) - u: NDArrayFloat = field(init=False, factory=lambda: np.array([])) - v: NDArrayFloat = field(init=False, factory=lambda: np.array([])) - w: NDArrayFloat = field(init=False, factory=lambda: np.array([])) + n_turbines: int = field(init=False, default=0) + grid_shape: tuple[int, int, int, int, int] = field(init=False, default=(0, 0, 0, 0, 0)) + + u_initial_sorted: NDArrayFloat = array_5D_field + v_initial_sorted: NDArrayFloat = array_5D_field + w_initial_sorted: NDArrayFloat = array_5D_field + u_sorted: NDArrayFloat = array_5D_field + v_sorted: NDArrayFloat = array_5D_field + w_sorted: NDArrayFloat = array_5D_field + u: NDArrayFloat = array_5D_field + v: NDArrayFloat = array_5D_field + w: NDArrayFloat = array_5D_field het_map: list = field(init=False, default=None) - dudz_initial_sorted: NDArrayFloat = field(init=False, factory=lambda: np.array([])) + dudz_initial_sorted: NDArrayFloat = array_5D_field - turbulence_intensity_field: NDArrayFloat = field(init=False, factory=lambda: np.array([])) - turbulence_intensity_field_sorted: NDArrayFloat = field( - init=False, factory=lambda: np.array([]) - ) - turbulence_intensity_field_sorted_avg: NDArrayFloat = field( - init=False, factory=lambda: np.array([]) - ) + turbulence_intensity_field: NDArrayFloat = array_mixed_dim_field + turbulence_intensity_field_sorted: NDArrayFloat = array_5D_field + turbulence_intensity_field_sorted_avg: NDArrayFloat = array_mixed_dim_field @wind_speeds.validator def wind_speeds_validator(self, instance: attrs.Attribute, value: NDArrayFloat) -> None: @@ -113,6 +113,18 @@ def het_map_validator(self, instance: attrs.Attribute, value: list | None) -> No "The het_map's wind direction dimension not equal to number of wind directions." ) + @grid_shape.validator + def grid_shape_validator(self, attribute: attrs.Attribute, value: tuple) -> None: + """Validates that ``grid_shape`` is length-5 tuple of integers. + + Args: + attribute (attrs.Attribute): The attrs Attribute data. + value (tuple): A length-5 tuple of integers. + """ + if len(value) != 5: + raise ValueError("`grid_shape` must be a tuple of 5 integer values.") + if not all(isinstance(v, int) for v in value): + raise TypeError("`grid_shape` must be a tuple of 5 integer values.") def __attrs_post_init__(self) -> None: if self.heterogenous_inflow_config is not None: @@ -131,6 +143,9 @@ def initialize_velocity_field(self, grid: Grid) -> None: # determined by this line. Since the right-most dimension on grid.z is storing the values # for height, using it here to apply the shear law makes that dimension store the vertical # wind profile. + self.n_turbines = grid.n_turbines + self.grid_shape = grid.grid_shape + wind_profile_plane = (grid.z_sorted / self.reference_wind_height) ** self.wind_shear dwind_profile_plane = ( self.wind_shear diff --git a/floris/simulation/grid.py b/floris/simulation/grid.py index 3786fc873..4b505773d 100644 --- a/floris/simulation/grid.py +++ b/floris/simulation/grid.py @@ -28,6 +28,8 @@ floris_float_type, NDArrayFloat, NDArrayInt, + validate_3DArray_shape, + validate_5DArray_shape, ) from floris.utilities import ( reverse_rotate_coordinates_rel_west, @@ -74,15 +76,16 @@ class Grid(ABC, BaseClass): time_series: bool = field() grid_resolution: int | Iterable = field() - n_turbines: int = field(init=False) - n_wind_speeds: int = field(init=False) n_wind_directions: 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) + n_wind_speeds: int = field(init=False) + n_turbines: int = field(init=False) + grid_shape: tuple[int, int, int, int, int] = field(init=False) + x_sorted: NDArrayFloat = field(init=False, validator=validate_5DArray_shape) + y_sorted: NDArrayFloat = field(init=False, validator=validate_5DArray_shape) + z_sorted: NDArrayFloat = field(init=False, validator=validate_5DArray_shape) + x_sorted_inertial_frame: NDArrayFloat = field(init=False, validator=validate_5DArray_shape) + y_sorted_inertial_frame: NDArrayFloat = field(init=False, validator=validate_5DArray_shape) + z_sorted_inertial_frame: NDArrayFloat = field(init=False, validator=validate_5DArray_shape) cubature_weights: NDArrayFloat = field(init=False, default=None) @turbine_coordinates.validator @@ -121,12 +124,16 @@ def grid_resolution_validator(self, instance: attrs.Attribute, value: int | Iter 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 + if not (len(value) == 2 and all(isinstance(v, int) for v in value)): + raise TypeError( + "`FlowFieldPlanarGrid` must have `grid_resolution` as an iterable of 2 `int`s.", + value + ) 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 + if len(value) != 3 or all(isinstance(v, int) for v in value): + raise TypeError( + "'FlowFieldGrid` must have `grid_resolution` as an iterable of 3 `int`s.", value + ) else: raise TypeError("`grid_resolution` must be of type int or Iterable(int,)") @@ -151,11 +158,15 @@ class TurbineGrid(Grid): 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) + sorted_indices: NDArrayInt = field(init=False, validator=validate_5DArray_shape) + sorted_coord_indices: NDArrayInt = field(init=False, validator=validate_3DArray_shape) + unsorted_indices: NDArrayInt = field(init=False, validator=validate_5DArray_shape) + x_center_of_rotation: floris_float_type = field( + init=False, validator=attrs.validators.instance_of(floris_float_type) + ) + y_center_of_rotation: floris_float_type = field( + init=False, validator=attrs.validators.instance_of(floris_float_type) + ) average_method = "cubic-mean" def __attrs_post_init__(self) -> None: @@ -238,6 +249,7 @@ def set_grid(self) -> None: ), dtype=floris_float_type ) + self.grid_shape = template_grid.shape # 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 @@ -311,11 +323,15 @@ class TurbineCubatureGrid(Grid): 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) + sorted_indices: NDArrayInt = field(init=False, validator=validate_5DArray_shape) + sorted_coord_indices: NDArrayInt = field(init=False, validator=validate_3DArray_shape) + unsorted_indices: NDArrayInt = field(init=False, validator=validate_5DArray_shape) + x_center_of_rotation: floris_float_type = field( + init=False, validator=attrs.validators.instance_of(floris_float_type) + ) + y_center_of_rotation: floris_float_type = field( + init=False, validator=attrs.validators.instance_of(floris_float_type) + ) average_method = "simple-cubature" def __attrs_post_init__(self) -> None: @@ -355,6 +371,7 @@ def set_grid(self) -> None: ), dtype=floris_float_type ) + self.grid_shape = template_grid.shape _x = x[:, :, :, None, None] * template_grid _y = y[:, :, :, None, None] * template_grid _z = z[:, :, :, None, None] * template_grid @@ -475,8 +492,12 @@ class FlowFieldGrid(Grid): 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) + x_center_of_rotation: floris_float_type = field( + init=False, validator=attrs.validators.instance_of(floris_float_type) + ) + y_center_of_rotation: floris_float_type = field( + init=False, validator=attrs.validators.instance_of(floris_float_type) + ) def __attrs_post_init__(self) -> None: self.set_grid() @@ -495,6 +516,7 @@ def set_grid(self) -> None: 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 """ + self.grid_shape = (self.n_wind_directions, self.n_wind_speeds, *self.grid_resolution) # 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( @@ -553,10 +575,14 @@ class FlowFieldPlanarGrid(Grid): 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) + x_center_of_rotation: floris_float_type = field( + init=False, validator=attrs.validators.instance_of(floris_float_type) + ) + y_center_of_rotation: floris_float_type = field( + init=False, validator=attrs.validators.instance_of(floris_float_type) + ) + sorted_indices: NDArrayInt = field(init=False, validator=validate_3DArray_shape) + unsorted_indices: NDArrayInt = field(init=False, validator=validate_3DArray_shape) def __attrs_post_init__(self) -> None: self.set_grid() @@ -587,6 +613,9 @@ def set_grid(self) -> None: if self.x2_bounds is None: self.x2_bounds = (np.min(y) - 2 * max_diameter, np.max(y) + 2 * max_diameter) + grid_resolution = (self.grid_resolution[0], self.grid_resolution[1], 3) + self.grid_shape = (self.n_wind_directions, self.n_wind_speeds, *grid_resolution) + # 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])), @@ -610,6 +639,9 @@ def set_grid(self) -> None: if self.x2_bounds is None: self.x2_bounds = (0.001, 6 * np.max(z)) + grid_resolution = (1, self.grid_resolution[0], self.grid_resolution[1]) + self.grid_shape = (self.n_wind_directions, self.n_wind_speeds, *grid_resolution) + 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])), @@ -628,6 +660,9 @@ def set_grid(self) -> None: if self.x2_bounds is None: self.x2_bounds = (0.001, 6 * np.max(z)) + grid_resolution = (self.grid_resolution[0], 1, self.grid_resolution[1]) + self.grid_shape = (self.n_wind_directions, self.n_wind_speeds, *grid_resolution) + 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)]), @@ -681,8 +716,12 @@ class PointsGrid(Grid): 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) + x_center_of_rotation: floris_float_type | None = field( + default=None, validator=attrs.validators.instance_of(floris_float_type) + ) + y_center_of_rotation: floris_float_type | None = field( + default=None, validator=attrs.validators.instance_of(floris_float_type) + ) def __attrs_post_init__(self) -> None: self.set_grid() @@ -692,6 +731,7 @@ 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))) + self.grid_shape = (self.n_wind_directions, self.n_wind_speeds, self.points_x.shape[0], 1, 1) # These are the rotated coordinates of the wind turbines based on the wind direction x, y, z, _, _ = rotate_coordinates_rel_west( diff --git a/floris/type_dec.py b/floris/type_dec.py index 892739035..5c8d3842d 100644 --- a/floris/type_dec.py +++ b/floris/type_dec.py @@ -28,7 +28,11 @@ import attrs import numpy as np import numpy.typing as npt -from attrs import Attribute, define +from attrs import ( + Attribute, + define, + field, +) ### Define general data types used throughout @@ -144,6 +148,89 @@ def convert_to_path(fn: str | Path) -> Path: raise TypeError(f"The passed input: {fn} could not be converted to a pathlib.Path object") +def validate_3DArray_shape(instance, attribute: Attribute, value: np.ndarray) -> None: + """Validator that checks if the array's shape is N wind directions x N wind speeds x N turbines. + + Args: + instance (cls): The class instance. + attribute (Attribute): The ``attrs.Attribute`` data. + value (np.ndarray): The input or updated NumPy array. + + Raises: + TypeError: raised if :py:attr:`value` is not a NumPy array. + ValueError: raised if the shape of :py:attr:`value` is not + N wind directions x N wind speeds x N turbines. + """ + if not isinstance(value, np.ndarray): + raise TypeError(f"`{attribute.name}` is not a valid NumPy array type.") + + # Don't fail on the initialized empty array or initialized 1-D array + if value.size == 0 or value.ndim == 1: + return + + shape = (instance.n_wind_directions, instance.n_wind_speeds, instance.n_turbines) + if value.shape != shape: + # The grid sorted_coord_indices are broadcast along the wind speed dimension + broadcast_shape = (instance.n_wind_directions, 1, instance.n_turbines) + if value.shape != broadcast_shape: + raise ValueError( + f"`{attribute.name}` should have shape: {shape}; not shape: {value.shape}" + ) + + +def validate_5DArray_shape(instance, attribute: Attribute, value: np.ndarray) -> None: + """Validator that checks if the array's shape is + N wind directions x N wind speeds x N turbines x N grid points x N grid points. + + Args: + instance (cls): The class instance. + attribute (Attribute): The ``attrs.Attribute`` data. + value (np.ndarray): The input or updated NumPy array. + + Raises: + TypeError: raised if :py:attr:`value` is not a NumPy array. + ValueError: raised if the shape of :py:attr:`value` is not + N wind directions x N wind speeds x N turbines x N grid points x N grid points. + """ + if not isinstance(value, np.ndarray): + raise TypeError(f"`{attribute.name}` is not a valid NumPy array type.") + + # Don't fail on the initialized empty array + if value.size == 0: + return + + if value.shape != instance.grid_shape: + broadcast_shape = ( + instance.n_wind_directions, instance.n_wind_speeds, instance.n_turbines, 1, 1 + ) + if value.shape != broadcast_shape: + raise ValueError( + f"`{attribute.name}` should have shape: {instance.grid_shape}; not shape: " + f"{value.shape}" + ) + + +def validate_mixed_dim(instance, attribute: Attribute, value: np.ndarray) -> None: + """Validator that checks if the array's shape is N wind directions x N wind speeds x N turbines + or N wind directions x N wind speeds x N turbines x N grid points x N grid points. + + Args: + instance (cls): The class instance. + attribute (Attribute): The ``attrs.Attribute`` data. + value (np.ndarray): The input or updated NumPy array. + + Raises: + TypeError: raised if :py:attr:`value` is not a NumPy array. + ValueError: raised if the shape of :py:attr:`value` is not a valid 5D or 3D array. + """ + try: + validate_5DArray_shape(instance, attribute, value) + except ValueError: + try: + validate_3DArray_shape(instance, attribute, value) + except ValueError: + raise ValueError(f"`{attribute.name}` could not be validated as a 5-D or 3-D array.") + @define class FromDictMixin: """ @@ -205,7 +292,25 @@ def as_dict(self) -> dict: return attrs.asdict(self, filter=_attr_floris_filter, value_serializer=_attr_serializer) -# Avoids constant redefinition of the same attr.ib properties for model attributes +@define +class ValidateMixin: + """ + A Mixin class to wraps the ``attrs.validate()`` method to provide ``self.validate()`` so that + all class attributes with validators can be run at once. + """ + def validate(self) -> None: + """Runs ``attrs.validate(self)``.""" + attrs.validate(self) + + +# Avoids constant redefinition of the same field properties for model attributes + +array_3D_field = field(init=False, factory=lambda: np.array([]), validator=validate_3DArray_shape) +array_5D_field = field(init=False, factory=lambda: np.array([]), validator=validate_5DArray_shape) +array_mixed_dim_field = field( + init=False, factory=lambda: np.array([]), validator=validate_mixed_dim +) + # from functools import partial, update_wrapper diff --git a/tests/floris_unit_test.py b/tests/floris_unit_test.py index 05c01f022..fceeb10f7 100644 --- a/tests/floris_unit_test.py +++ b/tests/floris_unit_test.py @@ -12,6 +12,7 @@ # See https://floris.readthedocs.io for documentation +from copy import deepcopy from pathlib import Path import yaml @@ -49,7 +50,11 @@ def test_init(): def test_asdict(turbine_grid_fixture: TurbineGrid): - floris = Floris.from_dict(DICT_INPUT) + grid_dict = deepcopy(DICT_INPUT) + grid_dict["flow_field"]["wind_speeds"] = turbine_grid_fixture.wind_speeds + grid_dict["flow_field"]["wind_directions"] = turbine_grid_fixture.wind_directions + + floris = Floris.from_dict(grid_dict) floris.flow_field.initialize_velocity_field(turbine_grid_fixture) dict1 = floris.as_dict() diff --git a/tests/type_dec_unit_test.py b/tests/type_dec_unit_test.py index 641f207dc..5b31b46c9 100644 --- a/tests/type_dec_unit_test.py +++ b/tests/type_dec_unit_test.py @@ -12,28 +12,38 @@ # See https://floris.readthedocs.io for documentation +from __future__ import annotations + from pathlib import Path from typing import List +import attrs import numpy as np import pytest from attrs import define, field from floris.type_dec import ( + array_3D_field, + array_5D_field, + array_mixed_dim_field, convert_to_path, floris_array_converter, FromDictMixin, iter_validator, + NDArrayFloat, + validate_3DArray_shape, + validate_5DArray_shape, + ValidateMixin, ) @define -class AttrsDemoClass(FromDictMixin): - w: int +class AttrsDemoClass(FromDictMixin, ValidateMixin): + w: int = field(validator=attrs.validators.instance_of(int)) x: int = field(converter=int) y: float = field(converter=float, default=2.1) z: str = field(converter=str, default="z") - non_initd: float = field(init=False) + non_initd: float = field(init=False, validator=attrs.validators.instance_of(float)) def __attrs_post_init__(self): self.non_initd = 1.1 @@ -49,6 +59,29 @@ def __attrs_post_init__(self): ) +@define +class ArrayValidatorDemoClass(ValidateMixin): + n_wind_directions: int = field(default=3) + n_wind_speeds: int = field(default=4) + n_turbines: int = field(default=10) + grid_shape: tuple[int, int, int, int, int] = field(init=False) + three_dim_starts_as_one: NDArrayFloat = field( + factory=lambda: np.array([]), validator=validate_3DArray_shape + ) + five_dimensions_provided: NDArrayFloat = array_5D_field + three_dimensions_provided: NDArrayFloat = array_3D_field + mixed_dimensions_provided: NDArrayFloat = array_mixed_dim_field + + def set_grid(self, grid_resolution: int): + self.grid_shape = ( + self.n_wind_directions, + self.n_wind_speeds, + self.n_turbines, + grid_resolution, + grid_resolution + ) + + def test_as_dict(): # Non-initialized attributes should not be exported cls = AttrsDemoClass(w=0, x=1, liststr=["a", "b"]) @@ -98,6 +131,75 @@ def test_FromDictMixin_custom(): AttrsDemoClass.from_dict(inputs) +def test_ValidateMixin(): + inputs = { + "w": 0, + "x": 1, + "y": 2.3, + "z": "asdf", + } + demo = AttrsDemoClass.from_dict(inputs) + + # Disable the validators to set attributes with a failing value, then + # manually validate with the mixin functionality to check it's working + with attrs.validators.disabled(): + demo.w = "string" + + with pytest.raises(TypeError): + demo.validate() + + with attrs.validators.disabled(): + demo.non_initd = "2.2" + + with pytest.raises(TypeError): + demo.validate() + + with attrs.validators.disabled(): + demo.liststr = (3, 2) + + with pytest.raises(TypeError): + demo.validate() + +def test_array_validators(): + # NOTE: demo.validate() is called in case attrs.on_setattrs is ever disabled, but is unnecessary + + # Check initialization works + demo = ArrayValidatorDemoClass() + demo.set_grid(3) + demo.validate() + + # Check assignment with correct shape: 3 x 4 x 10 (x 3 x 3) + demo.five_dimensions_provided = np.random.random((3, 4, 10, 3, 3)) + demo.three_dimensions_provided = np.random.random((3, 4, 10)) + demo.mixed_dimensions_provided = np.random.random((3, 4, 10, 3, 3)) + demo.validate() + + # Check assignment with correct broadcatable shape: 3 x 4 x 10 x 1 x 1 or 3 x 1 x 10 + demo.five_dimensions_provided = np.random.random((3, 4, 10, 1, 1)) + demo.three_dimensions_provided = np.random.random((3, 1, 10)) + demo.mixed_dimensions_provided = np.random.random((3, 1, 10)) + demo.validate() + + # Check for correct number of dimensions, but wrong shape + with pytest.raises(ValueError): + demo.five_dimensions_provided = np.random.random((3, 3, 3, 3, 3)) + + with pytest.raises(ValueError): + demo.mixed_dimensions_provided = np.random.random((4, 3, 10, 1, 1)) + + with pytest.raises(ValueError): + demo.mixed_dimensions_provided = np.random.random((4, 3, 10)) + + with pytest.raises(ValueError): + demo.three_dimensions_provided = np.random.random((3, 5, 10)) + + # Check that the 3D that starts as a 1-D shape is working + demo.three_dim_starts_as_one = np.ones(demo.n_turbines) + demo.validate() + demo.three_dim_starts_as_one = np.ones((3, 4, demo.n_turbines)) + demo.validate() + + def test_iter_validator(): # Check the correct values work