diff --git a/floris/floris_model.py b/floris/floris_model.py index 8ca0c1a96..2b0f6cb9a 100644 --- a/floris/floris_model.py +++ b/floris/floris_model.py @@ -3,6 +3,11 @@ import inspect from pathlib import Path +from typing import ( + Any, + List, + Optional, +) import numpy as np import pandas as pd @@ -25,6 +30,11 @@ NDArrayBool, NDArrayFloat, ) +from floris.utilities import ( + nested_get, + nested_set, + print_nested_dict, +) from floris.wind_data import WindDataBase @@ -94,118 +104,8 @@ def __init__(self, configuration: dict | str | Path): ) raise ValueError("turbine_grid_points must be less than or equal to 3.") - def assign_hub_height_to_ref_height(self): - - # Confirm can do this operation - unique_heights = np.unique(self.core.farm.hub_heights) - if len(unique_heights) > 1: - raise ValueError( - "To assign hub heights to reference height, can not have more than one " - "specified height. " - f"Current length is {unique_heights}." - ) - - self.core.flow_field.reference_wind_height = unique_heights[0] - - def copy(self): - """Create an independent copy of the current FlorisModel object""" - return FlorisModel(self.core.as_dict()) - - def set( - self, - wind_speeds: list[float] | NDArrayFloat | None = None, - wind_directions: list[float] | NDArrayFloat | None = None, - wind_shear: float | None = None, - wind_veer: float | None = None, - reference_wind_height: float | None = None, - turbulence_intensities: list[float] | NDArrayFloat | None = None, - air_density: float | None = None, - layout_x: list[float] | NDArrayFloat | None = None, - layout_y: list[float] | NDArrayFloat | None = None, - turbine_type: list | None = None, - turbine_library_path: str | Path | None = None, - solver_settings: dict | None = None, - heterogenous_inflow_config=None, - wind_data: type[WindDataBase] | None = None, - yaw_angles: NDArrayFloat | list[float] | None = None, - power_setpoints: NDArrayFloat | list[float] | list[float, None] | None = None, - disable_turbines: NDArrayBool | list[bool] | None = None, - ): - """ - Set the wind conditions and operation setpoints for the wind farm. - - Args: - wind_speeds (NDArrayFloat | list[float] | None, optional): Wind speeds at each findex. - Defaults to None. - wind_directions (NDArrayFloat | list[float] | None, optional): Wind directions at each - findex. Defaults to None. - wind_shear (float | None, optional): Wind shear exponent. Defaults to None. - wind_veer (float | None, optional): Wind veer. Defaults to None. - reference_wind_height (float | None, optional): Reference wind height. Defaults to None. - turbulence_intensities (NDArrayFloat | list[float] | None, optional): Turbulence - intensities at each findex. Defaults to None. - air_density (float | None, optional): Air density. Defaults to None. - layout_x (NDArrayFloat | list[float] | None, optional): X-coordinates of the turbines. - Defaults to None. - layout_y (NDArrayFloat | list[float] | None, optional): Y-coordinates of the turbines. - Defaults to None. - turbine_type (list | None, optional): Turbine type. Defaults to None. - turbine_library_path (str | Path | None, optional): Path to the turbine library. - Defaults to None. - solver_settings (dict | None, optional): Solver settings. Defaults to None. - heterogenous_inflow_config (None, optional): Heterogenous inflow configuration. Defaults - to None. - wind_data (type[WindDataBase] | None, optional): Wind data. Defaults to None. - yaw_angles (NDArrayFloat | list[float] | None, optional): Turbine yaw angles. - Defaults to None. - power_setpoints (NDArrayFloat | list[float] | list[float, None] | None, optional): - Turbine power setpoints. - disable_turbines (NDArrayBool | list[bool] | None, optional): NDArray with dimensions - n_findex x n_turbines. True values indicate the turbine is disabled at that findex - and the power setpoint at that position is set to 0. Defaults to None. - """ - # Initialize a new Floris object after saving the setpoints - _yaw_angles = self.core.farm.yaw_angles - _power_setpoints = self.core.farm.power_setpoints - self._reinitialize( - wind_speeds=wind_speeds, - wind_directions=wind_directions, - wind_shear=wind_shear, - wind_veer=wind_veer, - reference_wind_height=reference_wind_height, - turbulence_intensities=turbulence_intensities, - air_density=air_density, - layout_x=layout_x, - layout_y=layout_y, - turbine_type=turbine_type, - turbine_library_path=turbine_library_path, - solver_settings=solver_settings, - heterogenous_inflow_config=heterogenous_inflow_config, - wind_data=wind_data, - ) - - # If the yaw angles or power setpoints are not the default, set them back to the - # previous setting - if not (_yaw_angles == 0).all(): - self.core.farm.set_yaw_angles(_yaw_angles) - if not ( - (_power_setpoints == POWER_SETPOINT_DEFAULT) - | (_power_setpoints == POWER_SETPOINT_DISABLED) - ).all(): - self.core.farm.set_power_setpoints(_power_setpoints) - - # Set the operation - self._set_operation( - yaw_angles=yaw_angles, - power_setpoints=power_setpoints, - disable_turbines=disable_turbines, - ) - def reset_operation(self): - """ - Instantiate a new Floris object to set all operation setpoints to their default values. - """ - self._reinitialize() + ### Methods for setting and running the FlorisModel def _reinitialize( self, @@ -227,6 +127,9 @@ def _reinitialize( """ Instantiate a new Floris object with updated conditions set by arguments. Any parameters in Floris that aren't changed by arguments to this function retain their values. + Note that, although it's name is similar to the reinitialize() method from Floris v3, + this function is not meant to be called directly by the user---users should instead call + the set() method. Args: wind_speeds (NDArrayFloat | list[float] | None, optional): Wind speeds at each findex. @@ -377,6 +280,102 @@ def _set_operation( self.core.farm.yaw_angles[disable_turbines] = 0.0 self.core.farm.power_setpoints[disable_turbines] = POWER_SETPOINT_DISABLED + def set( + self, + wind_speeds: list[float] | NDArrayFloat | None = None, + wind_directions: list[float] | NDArrayFloat | None = None, + wind_shear: float | None = None, + wind_veer: float | None = None, + reference_wind_height: float | None = None, + turbulence_intensities: list[float] | NDArrayFloat | None = None, + air_density: float | None = None, + layout_x: list[float] | NDArrayFloat | None = None, + layout_y: list[float] | NDArrayFloat | None = None, + turbine_type: list | None = None, + turbine_library_path: str | Path | None = None, + solver_settings: dict | None = None, + heterogenous_inflow_config=None, + wind_data: type[WindDataBase] | None = None, + yaw_angles: NDArrayFloat | list[float] | None = None, + power_setpoints: NDArrayFloat | list[float] | list[float, None] | None = None, + disable_turbines: NDArrayBool | list[bool] | None = None, + ): + """ + Set the wind conditions and operation setpoints for the wind farm. + + Args: + wind_speeds (NDArrayFloat | list[float] | None, optional): Wind speeds at each findex. + Defaults to None. + wind_directions (NDArrayFloat | list[float] | None, optional): Wind directions at each + findex. Defaults to None. + wind_shear (float | None, optional): Wind shear exponent. Defaults to None. + wind_veer (float | None, optional): Wind veer. Defaults to None. + reference_wind_height (float | None, optional): Reference wind height. Defaults to None. + turbulence_intensities (NDArrayFloat | list[float] | None, optional): Turbulence + intensities at each findex. Defaults to None. + air_density (float | None, optional): Air density. Defaults to None. + layout_x (NDArrayFloat | list[float] | None, optional): X-coordinates of the turbines. + Defaults to None. + layout_y (NDArrayFloat | list[float] | None, optional): Y-coordinates of the turbines. + Defaults to None. + turbine_type (list | None, optional): Turbine type. Defaults to None. + turbine_library_path (str | Path | None, optional): Path to the turbine library. + Defaults to None. + solver_settings (dict | None, optional): Solver settings. Defaults to None. + heterogenous_inflow_config (None, optional): Heterogenous inflow configuration. Defaults + to None. + wind_data (type[WindDataBase] | None, optional): Wind data. Defaults to None. + yaw_angles (NDArrayFloat | list[float] | None, optional): Turbine yaw angles. + Defaults to None. + power_setpoints (NDArrayFloat | list[float] | list[float, None] | None, optional): + Turbine power setpoints. + disable_turbines (NDArrayBool | list[bool] | None, optional): NDArray with dimensions + n_findex x n_turbines. True values indicate the turbine is disabled at that findex + and the power setpoint at that position is set to 0. Defaults to None. + """ + # Initialize a new Floris object after saving the setpoints + _yaw_angles = self.core.farm.yaw_angles + _power_setpoints = self.core.farm.power_setpoints + self._reinitialize( + wind_speeds=wind_speeds, + wind_directions=wind_directions, + wind_shear=wind_shear, + wind_veer=wind_veer, + reference_wind_height=reference_wind_height, + turbulence_intensities=turbulence_intensities, + air_density=air_density, + layout_x=layout_x, + layout_y=layout_y, + turbine_type=turbine_type, + turbine_library_path=turbine_library_path, + solver_settings=solver_settings, + heterogenous_inflow_config=heterogenous_inflow_config, + wind_data=wind_data, + ) + + # If the yaw angles or power setpoints are not the default, set them back to the + # previous setting + if not (_yaw_angles == 0).all(): + self.core.farm.set_yaw_angles(_yaw_angles) + if not ( + (_power_setpoints == POWER_SETPOINT_DEFAULT) + | (_power_setpoints == POWER_SETPOINT_DISABLED) + ).all(): + self.core.farm.set_power_setpoints(_power_setpoints) + + # Set the operation + self._set_operation( + yaw_angles=yaw_angles, + power_setpoints=power_setpoints, + disable_turbines=disable_turbines, + ) + + def reset_operation(self): + """ + Instantiate a new Floris object to set all operation setpoints to their default values. + """ + self._reinitialize() + def run(self) -> None: """ Run the FLORIS solve to compute the velocity field and wake effects. @@ -401,94 +400,330 @@ def run_no_wake(self) -> None: # Finalize values to user-supplied order self.core.finalize() - def get_plane_of_points( - self, - normal_vector="z", - planar_coordinate=None, - ): - """ - Calculates velocity values through the - :py:meth:`FlorisModel.calculate_wake` method at points in plane - specified by inputs. - Args: - normal_vector (string, optional): Vector normal to plane. - Defaults to z. - planar_coordinate (float, optional): Value of normal vector - to slice through. Defaults to None. + ### Methods for extracting turbine performance after running + + def get_turbine_powers(self) -> NDArrayFloat: + """Calculates the power at each turbine in the wind farm. Returns: - :py:class:`pandas.DataFrame`: containing values of x1, x2, x3, u, v, w + NDArrayFloat: Powers at each turbine. """ - # Get results vectors - if normal_vector == "z": - x_flat = self.core.grid.x_sorted_inertial_frame[0].flatten() - y_flat = self.core.grid.y_sorted_inertial_frame[0].flatten() - z_flat = self.core.grid.z_sorted_inertial_frame[0].flatten() - else: - x_flat = self.core.grid.x_sorted[0].flatten() - y_flat = self.core.grid.y_sorted[0].flatten() - z_flat = self.core.grid.z_sorted[0].flatten() - u_flat = self.core.flow_field.u_sorted[0].flatten() - v_flat = self.core.flow_field.v_sorted[0].flatten() - w_flat = self.core.flow_field.w_sorted[0].flatten() - # Create a df of these - if normal_vector == "z": - df = pd.DataFrame( - { - "x1": x_flat, - "x2": y_flat, - "x3": z_flat, - "u": u_flat, - "v": v_flat, - "w": w_flat, - } + # Confirm calculate wake has been run + if self.core.state is not State.USED: + raise RuntimeError( + "Can't run function `FlorisModel.get_turbine_powers` without " + "first running `FlorisModel.run`." ) - if normal_vector == "x": - df = pd.DataFrame( - { - "x1": y_flat, - "x2": z_flat, - "x3": x_flat, - "u": u_flat, - "v": v_flat, - "w": w_flat, - } + # Check for negative velocities, which could indicate bad model + # parameters or turbines very closely spaced. + if (self.core.flow_field.u < 0.0).any(): + self.logger.warning("Some velocities at the rotor are negative.") + + turbine_powers = power( + velocities=self.core.flow_field.u, + air_density=self.core.flow_field.air_density, + power_functions=self.core.farm.turbine_power_functions, + yaw_angles=self.core.farm.yaw_angles, + tilt_angles=self.core.farm.tilt_angles, + power_setpoints=self.core.farm.power_setpoints, + tilt_interps=self.core.farm.turbine_tilt_interps, + turbine_type_map=self.core.farm.turbine_type_map, + turbine_power_thrust_tables=self.core.farm.turbine_power_thrust_tables, + correct_cp_ct_for_tilt=self.core.farm.correct_cp_ct_for_tilt, + multidim_condition=self.core.flow_field.multidim_conditions, + ) + return turbine_powers + + def get_farm_power( + self, + turbine_weights=None, + use_turbulence_correction=False, + ): + """ + Report wind plant power from instance of floris. Optionally includes + uncertainty in wind direction and yaw position when determining power. + Uncertainty is included by computing the mean wind farm power for a + distribution of wind direction and yaw position deviations from the + original wind direction and yaw angles. + + Args: + turbine_weights (NDArrayFloat | list[float] | None, optional): + weighing terms that allow the user to emphasize power at + particular turbines and/or completely ignore the power + from other turbines. This is useful when, for example, you are + modeling multiple wind farms in a single floris object. If you + only want to calculate the power production for one of those + farms and include the wake effects of the neighboring farms, + you can set the turbine_weights for the neighboring farms' + turbines to 0.0. The array of turbine powers from floris + is multiplied with this array in the calculation of the + objective function. If None, this is an array with all values + 1.0 and with shape equal to (n_findex, n_turbines). + Defaults to None. + use_turbulence_correction: (bool, optional): When *True* uses a + turbulence parameter to adjust power output calculations. + Defaults to *False*. + + Returns: + float: Sum of wind turbine powers in W. + """ + # TODO: Turbulence correction used in the power calculation, but may not be in + # the model yet + # TODO: Turbines need a switch for using turbulence correction + # TODO: Uncomment out the following two lines once the above are resolved + # for turbine in self.core.farm.turbines: + # turbine.use_turbulence_correction = use_turbulence_correction + + # Confirm calculate wake has been run + if self.core.state is not State.USED: + raise RuntimeError( + "Can't run function `FlorisModel.get_turbine_powers` without " + "first running `FlorisModel.calculate_wake`." ) - if normal_vector == "y": - df = pd.DataFrame( - { - "x1": x_flat, - "x2": z_flat, - "x3": y_flat, - "u": u_flat, - "v": v_flat, - "w": w_flat, - } + + if turbine_weights is None: + # Default to equal weighing of all turbines when turbine_weights is None + turbine_weights = np.ones( + ( + self.core.flow_field.n_findex, + self.core.farm.n_turbines, + ) + ) + elif len(np.shape(turbine_weights)) == 1: + # Deal with situation when 1D array is provided + turbine_weights = np.tile( + turbine_weights, + (self.core.flow_field.n_findex, 1), ) - # Subset to plane - # TODO: Seems sloppy as need more than one plane in the z-direction for GCH - if planar_coordinate is not None: - df = df[np.isclose(df.x3, planar_coordinate)] # , atol=0.1, rtol=0.0)] + # Calculate all turbine powers and apply weights + turbine_powers = self.get_turbine_powers() + turbine_powers = np.multiply(turbine_weights, turbine_powers) - # Drop duplicates - # TODO is this still needed now that we setup a grid for just this plane? - df = df.drop_duplicates() + return np.sum(turbine_powers, axis=1) - # Sort values of df to make sure plotting is acceptable - df = df.sort_values(["x2", "x1"]).reset_index(drop=True) + def get_farm_AEP( + self, + freq, + cut_in_wind_speed=0.001, + cut_out_wind_speed=None, + turbine_weights=None, + no_wake=False, + ) -> float: + """ + Estimate annual energy production (AEP) for distributions of wind speed, wind + direction, frequency of occurrence, and yaw offset. - return df + Args: + freq (NDArrayFloat): NumPy array with shape (n_findex) + with the frequencies of each wind direction and + wind speed combination. These frequencies should typically sum + up to 1.0 and are used to weigh the wind farm power for every + condition in calculating the wind farm's AEP. + cut_in_wind_speed (float, optional): Wind speed in m/s below which + any calculations are ignored and the wind farm is known to + produce 0.0 W of power. Note that to prevent problems with the + wake models at negative / zero wind speeds, this variable must + always have a positive value. Defaults to 0.001 [m/s]. + cut_out_wind_speed (float, optional): Wind speed above which the + wind farm is known to produce 0.0 W of power. If None is + specified, will assume that the wind farm does not cut out + at high wind speeds. Defaults to None. + turbine_weights (NDArrayFloat | list[float] | None, optional): + weighing terms that allow the user to emphasize power at + particular turbines and/or completely ignore the power + from other turbines. This is useful when, for example, you are + modeling multiple wind farms in a single floris object. If you + only want to calculate the power production for one of those + farms and include the wake effects of the neighboring farms, + you can set the turbine_weights for the neighboring farms' + turbines to 0.0. The array of turbine powers from floris + is multiplied with this array in the calculation of the + objective function. If None, this is an array with all values + 1.0 and with shape equal to (n_findex, + n_turbines). Defaults to None. + no_wake: (bool, optional): When *True* updates the turbine + quantities without calculating the wake or adding the wake to + the flow field. This can be useful when quantifying the loss + in AEP due to wakes. Defaults to *False*. - def calculate_horizontal_plane( + + Returns: + float: + The Annual Energy Production (AEP) for the wind farm in + watt-hours. + """ + + # Verify dimensions of the variable "freq" + if np.shape(freq)[0] != self.core.flow_field.n_findex: + raise UserWarning( + "'freq' should be a one-dimensional array with dimensions (n_findex). " + f"Given shape is {np.shape(freq)}" + ) + + # Check if frequency vector sums to 1.0. If not, raise a warning + if np.abs(np.sum(freq) - 1.0) > 0.001: + self.logger.warning( + "WARNING: The frequency array provided to get_farm_AEP() does not sum to 1.0." + ) + + # Copy the full wind speed array from the floris object and initialize + # the the farm_power variable as an empty array. + wind_speeds = np.array(self.core.flow_field.wind_speeds, copy=True) + wind_directions = np.array(self.core.flow_field.wind_directions, copy=True) + turbulence_intensities = np.array(self.core.flow_field.turbulence_intensities, copy=True) + farm_power = np.zeros(self.core.flow_field.n_findex) + + # Determine which wind speeds we must evaluate + conditions_to_evaluate = wind_speeds >= cut_in_wind_speed + if cut_out_wind_speed is not None: + conditions_to_evaluate = conditions_to_evaluate & (wind_speeds < cut_out_wind_speed) + + # Evaluate the conditions in floris + if np.any(conditions_to_evaluate): + wind_speeds_subset = wind_speeds[conditions_to_evaluate] + wind_directions_subset = wind_directions[conditions_to_evaluate] + turbulence_intensities_subset = turbulence_intensities[conditions_to_evaluate] + self.set( + wind_speeds=wind_speeds_subset, + wind_directions=wind_directions_subset, + turbulence_intensities=turbulence_intensities_subset, + ) + if no_wake: + self.run_no_wake() + else: + self.run() + farm_power[conditions_to_evaluate] = self.get_farm_power( + turbine_weights=turbine_weights + ) + + # Finally, calculate AEP in GWh + aep = np.sum(np.multiply(freq, farm_power) * 365 * 24) + + # Reset the FLORIS object to the full wind speed array + self.set( + wind_speeds=wind_speeds, + wind_directions=wind_directions, + turbulence_intensities=turbulence_intensities + ) + + return aep + + def get_farm_AEP_with_wind_data( self, - height, - x_resolution=200, + wind_data, + cut_in_wind_speed=0.001, + cut_out_wind_speed=None, + turbine_weights=None, + no_wake=False, + ) -> float: + """ + Estimate annual energy production (AEP) for distributions of wind speed, wind + direction, frequency of occurrence, and yaw offset. + + Args: + wind_data: (type(WindDataBase)): TimeSeries or WindRose object containing + the wind conditions over which to calculate the AEP. Should match the wind_data + object passed to reinitialize(). + cut_in_wind_speed (float, optional): Wind speed in m/s below which + any calculations are ignored and the wind farm is known to + produce 0.0 W of power. Note that to prevent problems with the + wake models at negative / zero wind speeds, this variable must + always have a positive value. Defaults to 0.001 [m/s]. + cut_out_wind_speed (float, optional): Wind speed above which the + wind farm is known to produce 0.0 W of power. If None is + specified, will assume that the wind farm does not cut out + at high wind speeds. Defaults to None. + turbine_weights (NDArrayFloat | list[float] | None, optional): + weighing terms that allow the user to emphasize power at + particular turbines and/or completely ignore the power + from other turbines. This is useful when, for example, you are + modeling multiple wind farms in a single floris object. If you + only want to calculate the power production for one of those + farms and include the wake effects of the neighboring farms, + you can set the turbine_weights for the neighboring farms' + turbines to 0.0. The array of turbine powers from floris + is multiplied with this array in the calculation of the + objective function. If None, this is an array with all values + 1.0 and with shape equal to (n_findex, + n_turbines). Defaults to None. + no_wake: (bool, optional): When *True* updates the turbine + quantities without calculating the wake or adding the wake to + the flow field. This can be useful when quantifying the loss + in AEP due to wakes. Defaults to *False*. + + Returns: + float: + The Annual Energy Production (AEP) for the wind farm in + watt-hours. + """ + + # Verify the wind_data object matches FLORIS' initialization + if wind_data.n_findex != self.core.flow_field.n_findex: + raise ValueError("WindData object and floris do not have same findex") + + # Get freq directly from wind_data + freq = wind_data.unpack_freq() + + return self.get_farm_AEP( + freq, + cut_in_wind_speed=cut_in_wind_speed, + cut_out_wind_speed=cut_out_wind_speed, + turbine_weights=turbine_weights, + no_wake=no_wake, + ) + + def get_turbine_ais(self) -> NDArrayFloat: + turbine_ais = axial_induction( + velocities=self.core.flow_field.u, + air_density=self.core.flow_field.air_density, + yaw_angles=self.core.farm.yaw_angles, + tilt_angles=self.core.farm.tilt_angles, + power_setpoints=self.core.farm.power_setpoints, + axial_induction_functions=self.core.farm.turbine_axial_induction_functions, + tilt_interps=self.core.farm.turbine_tilt_interps, + correct_cp_ct_for_tilt=self.core.farm.correct_cp_ct_for_tilt, + turbine_type_map=self.core.farm.turbine_type_map, + turbine_power_thrust_tables=self.core.farm.turbine_power_thrust_tables, + average_method=self.core.grid.average_method, + cubature_weights=self.core.grid.cubature_weights, + multidim_condition=self.core.flow_field.multidim_conditions, + ) + return turbine_ais + + def get_turbine_thrust_coefficients(self) -> NDArrayFloat: + turbine_thrust_coefficients = thrust_coefficient( + velocities=self.core.flow_field.u, + air_density=self.core.flow_field.air_density, + yaw_angles=self.core.farm.yaw_angles, + tilt_angles=self.core.farm.tilt_angles, + power_setpoints=self.core.farm.power_setpoints, + thrust_coefficient_functions=self.core.farm.turbine_thrust_coefficient_functions, + tilt_interps=self.core.farm.turbine_tilt_interps, + correct_cp_ct_for_tilt=self.core.farm.correct_cp_ct_for_tilt, + turbine_type_map=self.core.farm.turbine_type_map, + turbine_power_thrust_tables=self.core.farm.turbine_power_thrust_tables, + average_method=self.core.grid.average_method, + cubature_weights=self.core.grid.cubature_weights, + multidim_condition=self.core.flow_field.multidim_conditions, + ) + return turbine_thrust_coefficients + + def get_turbine_TIs(self) -> NDArrayFloat: + return self.core.flow_field.turbulence_intensity_field + + + ### Methods for sampling and visualization + + def calculate_cross_plane( + self, + downstream_dist, y_resolution=200, - x_bounds=None, + z_resolution=200, y_bounds=None, + z_bounds=None, wd=None, ws=None, ti=None, @@ -511,15 +746,6 @@ def calculate_horizontal_plane( Defaults to None. y_bounds (tuple, optional): Limits of output array (in m). Defaults to None. - wd (float, optional): Wind direction. Defaults to None. - ws (float, optional): Wind speed. Defaults to None. - ti (float, optional): Turbulence intensity. Defaults to None. - yaw_angles (NDArrayFloat, optional): Turbine yaw angles. Defaults - to None. - power_setpoints (NDArrayFloat, optional): - Turbine power setpoints. Defaults to None. - disable_turbines (NDArrayBool, optional): Boolean array on whether - to disable turbines. Defaults to None. Returns: :py:class:`~.tools.cut_plane.CutPlane`: containing values @@ -536,13 +762,14 @@ def calculate_horizontal_plane( # Store the current state for reinitialization floris_dict = self.core.as_dict() + # Set the solver to a flow field planar grid solver_settings = { "type": "flow_field_planar_grid", - "normal_vector": "z", - "planar_coordinate": height, - "flow_field_grid_points": [x_resolution, y_resolution], - "flow_field_bounds": [x_bounds, y_bounds], + "normal_vector": "x", + "planar_coordinate": downstream_dist, + "flow_field_grid_points": [y_resolution, z_resolution], + "flow_field_bounds": [y_bounds, z_bounds], } self.set( wind_directions=wd, @@ -561,17 +788,12 @@ def calculate_horizontal_plane( # TODO this just seems to be flattening and storing the data in a df; is this necessary? # It seems the biggest depenedcy is on CutPlane and the subsequent visualization tools. df = self.get_plane_of_points( - normal_vector="z", - planar_coordinate=height, + normal_vector="x", + planar_coordinate=downstream_dist, ) # Compute the cutplane - horizontal_plane = CutPlane( - df, - self.core.grid.grid_resolution[0], - self.core.grid.grid_resolution[1], - "z", - ) + cross_plane = CutPlane(df, y_resolution, z_resolution, "x") # Reset the fmodel object back to the turbine grid configuration self.core = Core.from_dict(floris_dict) @@ -579,15 +801,15 @@ def calculate_horizontal_plane( # Run the simulation again for futher postprocessing (i.e. now we can get farm power) self.run() - return horizontal_plane + return cross_plane - def calculate_cross_plane( + def calculate_horizontal_plane( self, - downstream_dist, + height, + x_resolution=200, y_resolution=200, - z_resolution=200, + x_bounds=None, y_bounds=None, - z_bounds=None, wd=None, ws=None, ti=None, @@ -610,6 +832,15 @@ def calculate_cross_plane( Defaults to None. y_bounds (tuple, optional): Limits of output array (in m). Defaults to None. + wd (float, optional): Wind direction. Defaults to None. + ws (float, optional): Wind speed. Defaults to None. + ti (float, optional): Turbulence intensity. Defaults to None. + yaw_angles (NDArrayFloat, optional): Turbine yaw angles. Defaults + to None. + power_setpoints (NDArrayFloat, optional): + Turbine power setpoints. Defaults to None. + disable_turbines (NDArrayBool, optional): Boolean array on whether + to disable turbines. Defaults to None. Returns: :py:class:`~.tools.cut_plane.CutPlane`: containing values @@ -626,14 +857,13 @@ def calculate_cross_plane( # Store the current state for reinitialization floris_dict = self.core.as_dict() - # Set the solver to a flow field planar grid solver_settings = { "type": "flow_field_planar_grid", - "normal_vector": "x", - "planar_coordinate": downstream_dist, - "flow_field_grid_points": [y_resolution, z_resolution], - "flow_field_bounds": [y_bounds, z_bounds], + "normal_vector": "z", + "planar_coordinate": height, + "flow_field_grid_points": [x_resolution, y_resolution], + "flow_field_bounds": [x_bounds, y_bounds], } self.set( wind_directions=wd, @@ -652,12 +882,17 @@ def calculate_cross_plane( # TODO this just seems to be flattening and storing the data in a df; is this necessary? # It seems the biggest depenedcy is on CutPlane and the subsequent visualization tools. df = self.get_plane_of_points( - normal_vector="x", - planar_coordinate=downstream_dist, + normal_vector="z", + planar_coordinate=height, ) # Compute the cutplane - cross_plane = CutPlane(df, y_resolution, z_resolution, "x") + horizontal_plane = CutPlane( + df, + self.core.grid.grid_resolution[0], + self.core.grid.grid_resolution[1], + "z", + ) # Reset the fmodel object back to the turbine grid configuration self.core = Core.from_dict(floris_dict) @@ -665,7 +900,7 @@ def calculate_cross_plane( # Run the simulation again for futher postprocessing (i.e. now we can get farm power) self.run() - return cross_plane + return horizontal_plane def calculate_y_plane( self, @@ -758,351 +993,113 @@ def calculate_y_plane( # Compute the cutplane y_plane = CutPlane(df, x_resolution, z_resolution, "y") - # Reset the fmodel object back to the turbine grid configuration - self.core = Core.from_dict(floris_dict) - - # Run the simulation again for futher postprocessing (i.e. now we can get farm power) - self.run() - - return y_plane - - def check_wind_condition_for_viz(self, wd=None, ws=None, ti=None): - if len(wd) > 1 or len(wd) < 1: - raise ValueError( - "Wind direction input must be of length 1 for visualization. " - f"Current length is {len(wd)}." - ) - - if len(ws) > 1 or len(ws) < 1: - raise ValueError( - "Wind speed input must be of length 1 for visualization. " - f"Current length is {len(ws)}." - ) - - if len(ti) != 1: - raise ValueError( - "Turbulence intensity input must be of length 1 for visualization. " - f"Current length is {len(ti)}." - ) - - def get_turbine_powers(self) -> NDArrayFloat: - """Calculates the power at each turbine in the wind farm. - - Returns: - NDArrayFloat: Powers at each turbine. - """ - - # Confirm calculate wake has been run - if self.core.state is not State.USED: - raise RuntimeError( - "Can't run function `FlorisModel.get_turbine_powers` without " - "first running `FlorisModel.run`." - ) - # Check for negative velocities, which could indicate bad model - # parameters or turbines very closely spaced. - if (self.core.flow_field.u < 0.0).any(): - self.logger.warning("Some velocities at the rotor are negative.") - - turbine_powers = power( - velocities=self.core.flow_field.u, - air_density=self.core.flow_field.air_density, - power_functions=self.core.farm.turbine_power_functions, - yaw_angles=self.core.farm.yaw_angles, - tilt_angles=self.core.farm.tilt_angles, - power_setpoints=self.core.farm.power_setpoints, - tilt_interps=self.core.farm.turbine_tilt_interps, - turbine_type_map=self.core.farm.turbine_type_map, - turbine_power_thrust_tables=self.core.farm.turbine_power_thrust_tables, - correct_cp_ct_for_tilt=self.core.farm.correct_cp_ct_for_tilt, - multidim_condition=self.core.flow_field.multidim_conditions, - ) - return turbine_powers - - def get_turbine_thrust_coefficients(self) -> NDArrayFloat: - turbine_thrust_coefficients = thrust_coefficient( - velocities=self.core.flow_field.u, - air_density=self.core.flow_field.air_density, - yaw_angles=self.core.farm.yaw_angles, - tilt_angles=self.core.farm.tilt_angles, - power_setpoints=self.core.farm.power_setpoints, - thrust_coefficient_functions=self.core.farm.turbine_thrust_coefficient_functions, - tilt_interps=self.core.farm.turbine_tilt_interps, - correct_cp_ct_for_tilt=self.core.farm.correct_cp_ct_for_tilt, - turbine_type_map=self.core.farm.turbine_type_map, - turbine_power_thrust_tables=self.core.farm.turbine_power_thrust_tables, - average_method=self.core.grid.average_method, - cubature_weights=self.core.grid.cubature_weights, - multidim_condition=self.core.flow_field.multidim_conditions, - ) - return turbine_thrust_coefficients - - def get_turbine_ais(self) -> NDArrayFloat: - turbine_ais = axial_induction( - velocities=self.core.flow_field.u, - air_density=self.core.flow_field.air_density, - yaw_angles=self.core.farm.yaw_angles, - tilt_angles=self.core.farm.tilt_angles, - power_setpoints=self.core.farm.power_setpoints, - axial_induction_functions=self.core.farm.turbine_axial_induction_functions, - tilt_interps=self.core.farm.turbine_tilt_interps, - correct_cp_ct_for_tilt=self.core.farm.correct_cp_ct_for_tilt, - turbine_type_map=self.core.farm.turbine_type_map, - turbine_power_thrust_tables=self.core.farm.turbine_power_thrust_tables, - average_method=self.core.grid.average_method, - cubature_weights=self.core.grid.cubature_weights, - multidim_condition=self.core.flow_field.multidim_conditions, - ) - return turbine_ais - - @property - def turbine_average_velocities(self) -> NDArrayFloat: - return average_velocity( - velocities=self.core.flow_field.u, - method=self.core.grid.average_method, - cubature_weights=self.core.grid.cubature_weights, - ) - - def get_turbine_TIs(self) -> NDArrayFloat: - return self.core.flow_field.turbulence_intensity_field - - def get_farm_power( - self, - turbine_weights=None, - use_turbulence_correction=False, - ): - """ - Report wind plant power from instance of floris. Optionally includes - uncertainty in wind direction and yaw position when determining power. - Uncertainty is included by computing the mean wind farm power for a - distribution of wind direction and yaw position deviations from the - original wind direction and yaw angles. - - Args: - turbine_weights (NDArrayFloat | list[float] | None, optional): - weighing terms that allow the user to emphasize power at - particular turbines and/or completely ignore the power - from other turbines. This is useful when, for example, you are - modeling multiple wind farms in a single floris object. If you - only want to calculate the power production for one of those - farms and include the wake effects of the neighboring farms, - you can set the turbine_weights for the neighboring farms' - turbines to 0.0. The array of turbine powers from floris - is multiplied with this array in the calculation of the - objective function. If None, this is an array with all values - 1.0 and with shape equal to (n_findex, n_turbines). - Defaults to None. - use_turbulence_correction: (bool, optional): When *True* uses a - turbulence parameter to adjust power output calculations. - Defaults to *False*. - - Returns: - float: Sum of wind turbine powers in W. - """ - # TODO: Turbulence correction used in the power calculation, but may not be in - # the model yet - # TODO: Turbines need a switch for using turbulence correction - # TODO: Uncomment out the following two lines once the above are resolved - # for turbine in self.core.farm.turbines: - # turbine.use_turbulence_correction = use_turbulence_correction - - # Confirm calculate wake has been run - if self.core.state is not State.USED: - raise RuntimeError( - "Can't run function `FlorisModel.get_turbine_powers` without " - "first running `FlorisModel.calculate_wake`." - ) - - if turbine_weights is None: - # Default to equal weighing of all turbines when turbine_weights is None - turbine_weights = np.ones( - ( - self.core.flow_field.n_findex, - self.core.farm.n_turbines, - ) - ) - elif len(np.shape(turbine_weights)) == 1: - # Deal with situation when 1D array is provided - turbine_weights = np.tile( - turbine_weights, - (self.core.flow_field.n_findex, 1), - ) - - # Calculate all turbine powers and apply weights - turbine_powers = self.get_turbine_powers() - turbine_powers = np.multiply(turbine_weights, turbine_powers) - - return np.sum(turbine_powers, axis=1) - - def get_farm_AEP( - self, - freq, - cut_in_wind_speed=0.001, - cut_out_wind_speed=None, - turbine_weights=None, - no_wake=False, - ) -> float: - """ - Estimate annual energy production (AEP) for distributions of wind speed, wind - direction, frequency of occurrence, and yaw offset. - - Args: - freq (NDArrayFloat): NumPy array with shape (n_findex) - with the frequencies of each wind direction and - wind speed combination. These frequencies should typically sum - up to 1.0 and are used to weigh the wind farm power for every - condition in calculating the wind farm's AEP. - cut_in_wind_speed (float, optional): Wind speed in m/s below which - any calculations are ignored and the wind farm is known to - produce 0.0 W of power. Note that to prevent problems with the - wake models at negative / zero wind speeds, this variable must - always have a positive value. Defaults to 0.001 [m/s]. - cut_out_wind_speed (float, optional): Wind speed above which the - wind farm is known to produce 0.0 W of power. If None is - specified, will assume that the wind farm does not cut out - at high wind speeds. Defaults to None. - turbine_weights (NDArrayFloat | list[float] | None, optional): - weighing terms that allow the user to emphasize power at - particular turbines and/or completely ignore the power - from other turbines. This is useful when, for example, you are - modeling multiple wind farms in a single floris object. If you - only want to calculate the power production for one of those - farms and include the wake effects of the neighboring farms, - you can set the turbine_weights for the neighboring farms' - turbines to 0.0. The array of turbine powers from floris - is multiplied with this array in the calculation of the - objective function. If None, this is an array with all values - 1.0 and with shape equal to (n_findex, - n_turbines). Defaults to None. - no_wake: (bool, optional): When *True* updates the turbine - quantities without calculating the wake or adding the wake to - the flow field. This can be useful when quantifying the loss - in AEP due to wakes. Defaults to *False*. - - - Returns: - float: - The Annual Energy Production (AEP) for the wind farm in - watt-hours. - """ - - # Verify dimensions of the variable "freq" - if np.shape(freq)[0] != self.core.flow_field.n_findex: - raise UserWarning( - "'freq' should be a one-dimensional array with dimensions (n_findex). " - f"Given shape is {np.shape(freq)}" - ) - - # Check if frequency vector sums to 1.0. If not, raise a warning - if np.abs(np.sum(freq) - 1.0) > 0.001: - self.logger.warning( - "WARNING: The frequency array provided to get_farm_AEP() does not sum to 1.0." - ) + # Reset the fmodel object back to the turbine grid configuration + self.core = Core.from_dict(floris_dict) - # Copy the full wind speed array from the floris object and initialize - # the the farm_power variable as an empty array. - wind_speeds = np.array(self.core.flow_field.wind_speeds, copy=True) - wind_directions = np.array(self.core.flow_field.wind_directions, copy=True) - turbulence_intensities = np.array(self.core.flow_field.turbulence_intensities, copy=True) - farm_power = np.zeros(self.core.flow_field.n_findex) + # Run the simulation again for futher postprocessing (i.e. now we can get farm power) + self.run() - # Determine which wind speeds we must evaluate - conditions_to_evaluate = wind_speeds >= cut_in_wind_speed - if cut_out_wind_speed is not None: - conditions_to_evaluate = conditions_to_evaluate & (wind_speeds < cut_out_wind_speed) + return y_plane - # Evaluate the conditions in floris - if np.any(conditions_to_evaluate): - wind_speeds_subset = wind_speeds[conditions_to_evaluate] - wind_directions_subset = wind_directions[conditions_to_evaluate] - turbulence_intensities_subset = turbulence_intensities[conditions_to_evaluate] - self.set( - wind_speeds=wind_speeds_subset, - wind_directions=wind_directions_subset, - turbulence_intensities=turbulence_intensities_subset, - ) - if no_wake: - self.run_no_wake() - else: - self.run() - farm_power[conditions_to_evaluate] = self.get_farm_power( - turbine_weights=turbine_weights + def check_wind_condition_for_viz(self, wd=None, ws=None, ti=None): + if len(wd) > 1 or len(wd) < 1: + raise ValueError( + "Wind direction input must be of length 1 for visualization. " + f"Current length is {len(wd)}." ) - # Finally, calculate AEP in GWh - aep = np.sum(np.multiply(freq, farm_power) * 365 * 24) - - # Reset the FLORIS object to the full wind speed array - self.set( - wind_speeds=wind_speeds, - wind_directions=wind_directions, - turbulence_intensities=turbulence_intensities - ) + if len(ws) > 1 or len(ws) < 1: + raise ValueError( + "Wind speed input must be of length 1 for visualization. " + f"Current length is {len(ws)}." + ) - return aep + if len(ti) != 1: + raise ValueError( + "Turbulence intensity input must be of length 1 for visualization. " + f"Current length is {len(ti)}." + ) - def get_farm_AEP_with_wind_data( + def get_plane_of_points( self, - wind_data, - cut_in_wind_speed=0.001, - cut_out_wind_speed=None, - turbine_weights=None, - no_wake=False, - ) -> float: + normal_vector="z", + planar_coordinate=None, + ): """ - Estimate annual energy production (AEP) for distributions of wind speed, wind - direction, frequency of occurrence, and yaw offset. + Calculates velocity values through the + :py:meth:`FlorisModel.calculate_wake` method at points in plane + specified by inputs. Args: - wind_data: (type(WindDataBase)): TimeSeries or WindRose object containing - the wind conditions over which to calculate the AEP. Should match the wind_data - object passed to reinitialize(). - cut_in_wind_speed (float, optional): Wind speed in m/s below which - any calculations are ignored and the wind farm is known to - produce 0.0 W of power. Note that to prevent problems with the - wake models at negative / zero wind speeds, this variable must - always have a positive value. Defaults to 0.001 [m/s]. - cut_out_wind_speed (float, optional): Wind speed above which the - wind farm is known to produce 0.0 W of power. If None is - specified, will assume that the wind farm does not cut out - at high wind speeds. Defaults to None. - turbine_weights (NDArrayFloat | list[float] | None, optional): - weighing terms that allow the user to emphasize power at - particular turbines and/or completely ignore the power - from other turbines. This is useful when, for example, you are - modeling multiple wind farms in a single floris object. If you - only want to calculate the power production for one of those - farms and include the wake effects of the neighboring farms, - you can set the turbine_weights for the neighboring farms' - turbines to 0.0. The array of turbine powers from floris - is multiplied with this array in the calculation of the - objective function. If None, this is an array with all values - 1.0 and with shape equal to (n_findex, - n_turbines). Defaults to None. - no_wake: (bool, optional): When *True* updates the turbine - quantities without calculating the wake or adding the wake to - the flow field. This can be useful when quantifying the loss - in AEP due to wakes. Defaults to *False*. + normal_vector (string, optional): Vector normal to plane. + Defaults to z. + planar_coordinate (float, optional): Value of normal vector + to slice through. Defaults to None. Returns: - float: - The Annual Energy Production (AEP) for the wind farm in - watt-hours. + :py:class:`pandas.DataFrame`: containing values of x1, x2, x3, u, v, w """ + # Get results vectors + if normal_vector == "z": + x_flat = self.core.grid.x_sorted_inertial_frame[0].flatten() + y_flat = self.core.grid.y_sorted_inertial_frame[0].flatten() + z_flat = self.core.grid.z_sorted_inertial_frame[0].flatten() + else: + x_flat = self.core.grid.x_sorted[0].flatten() + y_flat = self.core.grid.y_sorted[0].flatten() + z_flat = self.core.grid.z_sorted[0].flatten() + u_flat = self.core.flow_field.u_sorted[0].flatten() + v_flat = self.core.flow_field.v_sorted[0].flatten() + w_flat = self.core.flow_field.w_sorted[0].flatten() - # Verify the wind_data object matches FLORIS' initialization - if wind_data.n_findex != self.core.flow_field.n_findex: - raise ValueError("WindData object and floris do not have same findex") + # Create a df of these + if normal_vector == "z": + df = pd.DataFrame( + { + "x1": x_flat, + "x2": y_flat, + "x3": z_flat, + "u": u_flat, + "v": v_flat, + "w": w_flat, + } + ) + if normal_vector == "x": + df = pd.DataFrame( + { + "x1": y_flat, + "x2": z_flat, + "x3": x_flat, + "u": u_flat, + "v": v_flat, + "w": w_flat, + } + ) + if normal_vector == "y": + df = pd.DataFrame( + { + "x1": x_flat, + "x2": z_flat, + "x3": y_flat, + "u": u_flat, + "v": v_flat, + "w": w_flat, + } + ) - # Get freq directly from wind_data - freq = wind_data.unpack_freq() + # Subset to plane + # TODO: Seems sloppy as need more than one plane in the z-direction for GCH + if planar_coordinate is not None: + df = df[np.isclose(df.x3, planar_coordinate)] # , atol=0.1, rtol=0.0)] - return self.get_farm_AEP( - freq, - cut_in_wind_speed=cut_in_wind_speed, - cut_out_wind_speed=cut_out_wind_speed, - turbine_weights=turbine_weights, - no_wake=no_wake, - ) + # Drop duplicates + # TODO is this still needed now that we setup a grid for just this plane? + df = df.drop_duplicates() + + # Sort values of df to make sure plotting is acceptable + df = df.sort_values(["x2", "x1"]).reset_index(drop=True) + + return df def sample_flow_at_points(self, x: NDArrayFloat, y: NDArrayFloat, z: NDArrayFloat): """ @@ -1248,25 +1245,82 @@ def sample_velocity_deficit_profiles( return velocity_deficit_profiles - @property - def layout_x(self): - """ - Wind turbine coordinate information. + + ### Utility methods + + def assign_hub_height_to_ref_height(self): + + # Confirm can do this operation + unique_heights = np.unique(self.core.farm.hub_heights) + if len(unique_heights) > 1: + raise ValueError( + "To assign hub heights to reference height, can not have more than one " + "specified height. " + f"Current length is {unique_heights}." + ) + + self.core.flow_field.reference_wind_height = unique_heights[0] + + def get_power_thrust_model(self) -> str: + """Get the power thrust model of a FlorisModel. Returns: - np.array: Wind turbine x-coordinate. + str: The power_thrust_model. """ - return self.core.farm.layout_x + return self.core.farm.turbine_definitions[0]["power_thrust_model"] - @property - def layout_y(self): + def set_power_thrust_model(self, power_thrust_model: str): + """Set the power thrust model of a FlorisModel. + + Args: + power_thrust_model (str): The power thrust model to set. """ - Wind turbine coordinate information. + turbine_type = self.core.farm.turbine_definitions[0] + turbine_type["power_thrust_model"] = power_thrust_model + self.set(turbine_type=[turbine_type]) + + def copy(self): + """Create an independent copy of the current FlorisModel object""" + return FlorisModel(self.core.as_dict()) + + def get_param( + self, + param: List[str], + param_idx: Optional[int] = None + ) -> Any: + """Get a parameter from a FlorisModel object. + + Args: + param (List[str]): A list of keys to traverse the FlorisModel dictionary. + param_idx (Optional[int], optional): The index to get the value at. Defaults to None. + If None, the entire parameter is returned. Returns: - np.array: Wind turbine y-coordinate. + Any: The value of the parameter. """ - return self.core.farm.layout_y + fm_dict = self.core.as_dict() + + if param_idx is None: + return nested_get(fm_dict, param) + else: + return nested_get(fm_dict, param)[param_idx] + + def set_param( + self, + param: List[str], + value: Any, + param_idx: Optional[int] = None + ): + """Set a parameter in a FlorisModel object. + + Args: + param (List[str]): A list of keys to traverse the FlorisModel dictionary. + value (Any): The value to set. + param_idx (Optional[int], optional): The index to set the value at. Defaults to None. + """ + fm_dict_mod = self.core.as_dict() + nested_set(fm_dict_mod, param, value, param_idx) + self.__init__(fm_dict_mod) def get_turbine_layout(self, z=False): """ @@ -1286,6 +1340,43 @@ def get_turbine_layout(self, z=False): else: return xcoords, ycoords + def print_dict(self) -> None: + """Print the FlorisModel dictionary. + """ + print_nested_dict(self.core.as_dict()) + + + ### Properties + + @property + def layout_x(self): + """ + Wind turbine coordinate information. + + Returns: + np.array: Wind turbine x-coordinate. + """ + return self.core.farm.layout_x + + @property + def layout_y(self): + """ + Wind turbine coordinate information. + + Returns: + np.array: Wind turbine y-coordinate. + """ + return self.core.farm.layout_y + + @property + def turbine_average_velocities(self) -> NDArrayFloat: + return average_velocity( + velocities=self.core.flow_field.u, + method=self.core.grid.average_method, + cubature_weights=self.core.grid.cubature_weights, + ) + + ### v3 functions that are removed - raise an error if used def calculate_wake(self, **_): diff --git a/floris/utilities.py b/floris/utilities.py index 117726362..074d9a1b3 100644 --- a/floris/utilities.py +++ b/floris/utilities.py @@ -3,7 +3,13 @@ import os from math import ceil -from typing import Tuple +from typing import ( + Any, + Dict, + List, + Optional, + Tuple, +) import numpy as np import yaml @@ -266,3 +272,69 @@ def round_nearest(x: int | float, base: int = 5) -> int: int: The rounded number. """ return base * ceil((x + 0.5) / base) + + +def nested_get( + d: Dict[str, Any], + keys: List[str] +) -> Any: + """Get a value from a nested dictionary using a list of keys. + Based on: + https://stackoverflow.com/questions/14692690/access-nested-dictionary-items-via-a-list-of-keys + + Args: + d (Dict[str, Any]): The dictionary to get the value from. + keys (List[str]): A list of keys to traverse the dictionary. + + Returns: + Any: The value at the end of the key traversal. + """ + for key in keys: + d = d[key] + return d + +def nested_set( + d: Dict[str, Any], + keys: List[str], + value: Any, + idx: Optional[int] = None +) -> None: + """Set a value in a nested dictionary using a list of keys. + Based on: + https://stackoverflow.com/questions/14692690/access-nested-dictionary-items-via-a-list-of-keys + + Args: + dic (Dict[str, Any]): The dictionary to set the value in. + keys (List[str]): A list of keys to traverse the dictionary. + value (Any): The value to set. + idx (Optional[int], optional): If the value is an list, the index to change. + Defaults to None. + """ + d_in = d.copy() + + for key in keys[:-1]: + d = d.setdefault(key, {}) + if idx is None: + # Parameter is a scalar, set directly + d[keys[-1]] = value + else: + # Parameter is a list, need to first get the list, change the values at idx + + # # Get the underlying list + par_list = nested_get(d_in, keys) + par_list[idx] = value + d[keys[-1]] = par_list + +def print_nested_dict(dictionary: Dict[str, Any], indent: int = 0) -> None: + """Print a nested dictionary with indentation. + + Args: + dictionary (Dict[str, Any]): The dictionary to print. + indent (int, optional): The number of spaces to indent. Defaults to 0. + """ + for key, value in dictionary.items(): + print(" " * indent + str(key)) + if isinstance(value, dict): + print_nested_dict(value, indent + 4) + else: + print(" " * (indent + 4) + str(value)) diff --git a/tests/floris_model_integration_test.py b/tests/floris_model_integration_test.py index 397cbef9d..3bb210cda 100644 --- a/tests/floris_model_integration_test.py +++ b/tests/floris_model_integration_test.py @@ -493,3 +493,30 @@ def test_calculate_planes(): fmodel.calculate_y_plane(0.0, ws=[wind_speeds[0]], wd=[wind_directions[0]]) with pytest.raises(ValueError): fmodel.calculate_cross_plane(500.0, ws=[wind_speeds[0]], wd=[wind_directions[0]]) + +def test_get_and_set_param(): + fmodel = FlorisModel(configuration=YAML_INPUT) + + # Get the wind speed + wind_speeds = fmodel.get_param(['flow_field', 'wind_speeds']) + assert wind_speeds[0] == 8.0 + + # Set the wind speed + fmodel.set_param(['flow_field', 'wind_speeds'], 10.0, param_idx=0) + wind_speed = fmodel.get_param(['flow_field', 'wind_speeds'], param_idx=0 ) + assert wind_speed == 10.0 + + # Repeat with wake parameter + fmodel.set_param(['wake', 'wake_velocity_parameters', 'gauss', 'alpha'], 0.1) + alpha = fmodel.get_param(['wake', 'wake_velocity_parameters', 'gauss', 'alpha']) + assert alpha == 0.1 + +def test_get_power_thrust_model(): + fmodel = FlorisModel(configuration=YAML_INPUT) + assert fmodel.get_power_thrust_model() == "cosine-loss" + +def test_set_power_thrust_model(): + + fmodel = FlorisModel(configuration=YAML_INPUT) + fmodel.set_power_thrust_model("simple-derating") + assert fmodel.get_power_thrust_model() == "simple-derating" diff --git a/tests/utilities_unit_test.py b/tests/utilities_unit_test.py index 3048e7fb0..f58ca5c64 100644 --- a/tests/utilities_unit_test.py +++ b/tests/utilities_unit_test.py @@ -1,10 +1,14 @@ +from pathlib import Path + import attr import numpy as np import pytest from floris.utilities import ( cosd, + nested_get, + nested_set, reverse_rotate_coordinates_rel_west, rotate_coordinates_rel_west, sind, @@ -20,6 +24,10 @@ ) +TEST_DATA = Path(__file__).resolve().parent / "data" +YAML_INPUT = TEST_DATA / "input_full.yaml" + + def test_cosd(): assert pytest.approx(cosd(0.0)) == 1.0 assert pytest.approx(cosd(90.0)) == 0.0 @@ -154,3 +162,28 @@ def test_reverse_rotate_coordinates_rel_west(): np.testing.assert_almost_equal(grid_x_reversed.squeeze(), coordinates[:,0].squeeze()) np.testing.assert_almost_equal(grid_y_reversed.squeeze(), coordinates[:,1].squeeze()) np.testing.assert_almost_equal(grid_z_reversed.squeeze(), coordinates[:,2].squeeze()) + + +def test_nested_get(): + example_dict = { + 'a': { + 'b': { + 'c': 10 + } + } + } + + assert nested_get(example_dict, ['a', 'b', 'c']) == 10 + + +def test_nested_set(): + example_dict = { + 'a': { + 'b': { + 'c': 10 + } + } + } + + nested_set(example_dict, ['a', 'b', 'c'], 20) + assert nested_get(example_dict, ['a', 'b', 'c']) == 20