diff --git a/docs/user_defined_operation_models.ipynb b/docs/user_defined_operation_models.ipynb new file mode 100644 index 000000000..f814e2897 --- /dev/null +++ b/docs/user_defined_operation_models.ipynb @@ -0,0 +1,394 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "ba9ae6ce", + "metadata": {}, + "source": [ + "# User-defined Turbine Operation Models\n", + "\n", + "Beginning in v4.x, FLORIS supports user-defined turbine operation models that can be passed directly into FLORIS using `fmodel.set_operation_model`. A user-defined operation model may be a dynamic or static class. If the operation model is a dynamic class, it must conform to the `attrs` package for declaring attributes. Additionally all user-defined operation models should inherit from the abstract parent class `BaseOperationModel`, available in FLORIS.\n", + "\n", + "All operation models must implement the following \"fundamental\" methods:\n", + "- `power`: computes the power output of the turbine in Watts\n", + "- `thrust_coefficient`: computes the dimensionless thrust coefficient of the turbine\n", + "- `axial_induction`: computes the dimensionless axial induction factor of the turbine\n", + "\n", + "Operation models may then implement additional methods as needed.\n", + "\n", + "The following arguments are passed to the operation model fundamental methods at runtime:\n", + "\n", + "| Argument | Data type | Description |\n", + "|----------|-----------|----------|\n", + "| `power_thrust_table` | `dict` | Dictionary of model parameters defined on the turbine input yaml |\n", + "| `velocities` | `NDArrayFloat` | Array of inflow velocities (in m/s) to each turbine grid point, dimensions `(n_findex, n_turbines, n_grid, n_grid)` |\n", + "| `turbulence_intensities` | `NDArrayFloat` | Array of inflow turbulence intensities (as decimal values) to each turbine, dimensions `(n_findex, n_turbines, 1, 1)` |\n", + "| `air_density` | `float` | Ambient air density in kg/m^3 |\n", + "| `yaw_angles` | `NDArrayFloat` | Array of turbine yaw angles (in degrees, as misalignments from the inflow wind direction), dimensions `(n_findex, n_turbines)` |\n", + "| `tilt_angles` | `NDArrayFloat` | Array of turbine absolute [CHECK] tilt angles (in degrees, positive means tilted backwards), dimensions `(n_findex, n_turbines)` |\n", + "| `power_setpoints` | `NDArrayFloat` | Array of turbine power setpoints (in Watts), dimensions `(n_findex, n_turbines)` |\n", + "| `awc_modes` | `NDArrayStr` | Array of strings specifying the AWC mode for each turbine, dimensions `(n_findex, n_turbines)` |\n", + "| `awc_amplitudes` | `NDArrayFloat` | Array of AWC amplitudes (in degrees) for each turbine, dimensions `(n_findex, n_turbines)` |\n", + "| `tilt_interp` | `interpolator` | Scipy 1D interpolator to find the (floating) tilt angle as a function of wind speed |\n", + "| `average_method` | `string` | Averaging method for combining velocities over the turbine grid points |\n", + "| `cubature_weights` | `NDArrayFloat` | Weights for cubature grid computation of rotor-effective velocity, dimensions `(1, n_grid x n_grid)`|\n", + "| `correct_cp_ct_for_tilt` | `NDArrayInt` | Flag for correcting power and thrust curves to account for platform tilt, dimensions `(n_findex, n_turbines)` |\n", + "| `**_` | -- | Catch-all for unused arguments |\n", + "\n", + "Not all of these arguments must be used or defined as arguments by the user, as long as the final argument be `**_` to allow for unused arguments.\n", + "\n", + "Each of the fundamental methods must return an array of floats (`NDArrayFloat`) with dimensions `(n_findex, n_turbines)`, representing the compute power, thrust coefficient, or axial induction factor for each turbine at each flow condition index." + ] + }, + { + "cell_type": "markdown", + "id": "aefe4f59", + "metadata": {}, + "source": [ + "### Static example\n", + "\n", + "We begin with a very simple example that will produce a constant power, thrust coefficient, and axial induction factor regardless of the inputs. We are using a static class for this example; this class does not need to be instantiated and has no attributes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d751aa3c", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "from attrs import define, field\n", + "from floris.type_dec import floris_float_type, NDArrayFloat\n", + "from floris.core.turbine.operation_models import BaseOperationModel\n", + "\n", + "@define\n", + "class ConstantValueTurbine(BaseOperationModel):\n", + " \"\"\"\n", + " A simple turbine operation model that returns constant values for power,\n", + " thrust coefficient, and axial induction factor regardless of input conditions.\n", + " \"\"\"\n", + " @staticmethod\n", + " def power(\n", + " velocities: NDArrayFloat,\n", + " **_\n", + " ) -> NDArrayFloat:\n", + " # Constant power of 500 kW, in correct shape (n_findex, n_turbines)\n", + " return 500000.0 * np.ones(velocities.shape[0:2], dtype=floris_float_type)\n", + "\n", + " @staticmethod\n", + " def thrust_coefficient(\n", + " velocities: NDArrayFloat,\n", + " **_\n", + " ) -> NDArrayFloat:\n", + " # Return thrust coefficient based on actuator disk theory\n", + " # Because the class is static, we can call the axial_induction method directly\n", + " a = ConstantValueTurbine.axial_induction(velocities)\n", + " return 4 * a * (1 - a)\n", + "\n", + " @staticmethod\n", + " def axial_induction(\n", + " velocities: NDArrayFloat,\n", + " **_\n", + " ) -> NDArrayFloat:\n", + " # Constant axial induction factor of 0.3\n", + " return 0.3 * np.ones(velocities.shape[0:2], dtype=floris_float_type)" + ] + }, + { + "cell_type": "markdown", + "id": "29ea6830", + "metadata": {}, + "source": [ + "Let's now use this constant operation model in FLORIS." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b74802c2", + "metadata": {}, + "outputs": [], + "source": [ + "from floris import FlorisModel, TimeSeries\n", + "\n", + "fmodel = FlorisModel(\"defaults\")\n", + "time_series = TimeSeries(\n", + " wind_directions=np.array([270.0, 270.0, 280.0]),\n", + " wind_speeds=np.array([8.0, 10.0, 12.0]),\n", + " turbulence_intensities=np.array([0.06, 0.06, 0.06]),\n", + ")\n", + "fmodel.set(\n", + " layout_x = [0.0, 500.0],\n", + " layout_y = [0.0, 0.0],\n", + " wind_data=time_series,\n", + ")\n", + "fmodel.set_operation_model(ConstantValueTurbine)\n", + "\n", + "fmodel.run()\n", + "\n", + "print(\"Powers [W]:\\n\", fmodel.get_turbine_powers(), \"\\n\")\n", + "print(\"Thrust coefficients [-]:\\n\", fmodel.get_turbine_thrust_coefficients(), \"\\n\")\n", + "print(\"Axial induction factors [-]:\\n\", fmodel.get_turbine_axial_induction_factors(), \"\\n\")" + ] + }, + { + "cell_type": "markdown", + "id": "aa2b73b0", + "metadata": {}, + "source": [ + "## Dynamic example\n", + "\n", + "Now, we will create an operation model that allows the user to set attributes at instantiation. In this example, we will create an operation model that allows the user to set constant power, thrust coefficient, and axial induction factor values at instantiation. These values will then be returned by the fundamental methods regardless of the inputs. We use the `attrs` package to define attributes of the class." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a7dbaf71", + "metadata": {}, + "outputs": [], + "source": [ + "@define\n", + "class DynamicValueTurbine(BaseOperationModel):\n", + " \"\"\"\n", + " A simple turbine operation model that returns constant values for power,\n", + " thrust coefficient, and axial induction factor regardless of input conditions,\n", + " based on user-defined attributes.\n", + " \"\"\"\n", + " power_value = field(init=True, default=600000.0, type=floris_float_type)\n", + " axial_induction_value = field(init=True, default=0.2, type=floris_float_type)\n", + " def power(\n", + " self,\n", + " velocities: NDArrayFloat,\n", + " **_\n", + " ) -> NDArrayFloat:\n", + " # Constant power of 500 kW, in correct shape (n_findex, n_turbines)\n", + " return self.power_value * np.ones(velocities.shape[0:2], dtype=floris_float_type)\n", + "\n", + " def thrust_coefficient(\n", + " self,\n", + " velocities: NDArrayFloat,\n", + " **_\n", + " ) -> NDArrayFloat:\n", + " # Return thrust coefficient based on actuator disk theory\n", + " # Because the class is static, we can call the axial_induction method directly\n", + " a = self.axial_induction(velocities)\n", + " return 4 * a * (1 - a)\n", + "\n", + " def axial_induction(\n", + " self,\n", + " velocities: NDArrayFloat,\n", + " **_\n", + " ) -> NDArrayFloat:\n", + " # Constant axial induction factor of 0.3\n", + " return self.axial_induction_value * np.ones(velocities.shape[0:2], dtype=floris_float_type)" + ] + }, + { + "cell_type": "markdown", + "id": "f9ef3c9a", + "metadata": {}, + "source": [ + "To use this class, we must first instantiate it. If we instantiate it without any arguments, the default values will be used. Otherwise, we can pass in our desired constant values." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "93f1e99f", + "metadata": {}, + "outputs": [], + "source": [ + "turbine_operation_model = DynamicValueTurbine()\n", + "fmodel.set_operation_model(turbine_operation_model)\n", + "fmodel.run()\n", + "\n", + "print(\"Powers [W]:\\n\", fmodel.get_turbine_powers(), \"\\n\")\n", + "print(\"Thrust coefficients [-]:\\n\", fmodel.get_turbine_thrust_coefficients(), \"\\n\")\n", + "print(\"Axial induction factors [-]:\\n\", fmodel.get_turbine_axial_induction_factors(), \"\\n\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "eeb38ea7", + "metadata": {}, + "outputs": [], + "source": [ + "turbine_operation_model = DynamicValueTurbine(power_value=750000.0, axial_induction_value=0.25)\n", + "fmodel.set_operation_model(turbine_operation_model)\n", + "fmodel.run()\n", + "\n", + "print(\"Powers [W]:\\n\", fmodel.get_turbine_powers(), \"\\n\")\n", + "print(\"Thrust coefficients [-]:\\n\", fmodel.get_turbine_thrust_coefficients(), \"\\n\")\n", + "print(\"Axial induction factors [-]:\\n\", fmodel.get_turbine_axial_induction_factors(), \"\\n\")" + ] + }, + { + "cell_type": "markdown", + "id": "08be8e42", + "metadata": {}, + "source": [ + "## More complex example\n", + "\n", + "Now, let's use an example where some parameters are defined on the `power_thrust_table` on the turbine input yaml, and some parameters are set upon instantiation of the class." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "38731e16", + "metadata": {}, + "outputs": [], + "source": [ + "from floris.core.turbine import SimpleTurbine\n", + "\n", + "@define\n", + "class ScaledTurbine(BaseOperationModel):\n", + " \"\"\"\n", + " A turbine operation model that scales power and thrust coefficient\n", + " based on a user-defined scaling factor. This will use methods from the\n", + " prepackaged SimpleTurbine model, leaving some values as default.\n", + "\n", + " Scaling only applies to power, not to thrust_coefficient or\n", + " axial_induction. We also demonstrate that other \"nonfundamental\" methods\n", + " can be used on the class.\n", + " \"\"\"\n", + " scaling_factor = field(init=True, default=1.0, type=floris_float_type)\n", + "\n", + " def power(\n", + " self,\n", + " power_thrust_table: dict,\n", + " velocities: NDArrayFloat,\n", + " air_density: float,\n", + " **_\n", + " ) -> NDArrayFloat:\n", + " unscaled_power = SimpleTurbine.power(\n", + " power_thrust_table=power_thrust_table,\n", + " velocities=velocities,\n", + " air_density=air_density,\n", + " )\n", + " scaled_power = self._compute_scaled_power(unscaled_power)\n", + " return scaled_power\n", + "\n", + " def _compute_scaled_power(self, power: NDArrayFloat) -> NDArrayFloat:\n", + " return self.scaling_factor * power\n", + "\n", + " def thrust_coefficient(\n", + " self,\n", + " power_thrust_table: dict,\n", + " velocities: NDArrayFloat,\n", + " **_\n", + " ) -> NDArrayFloat:\n", + " unscaled_thrust_coefficient = SimpleTurbine.thrust_coefficient(\n", + " power_thrust_table=power_thrust_table,\n", + " velocities=velocities,\n", + " )\n", + " return unscaled_thrust_coefficient\n", + "\n", + " def axial_induction(\n", + " self,\n", + " power_thrust_table: dict,\n", + " velocities: NDArrayFloat,\n", + " **_\n", + " ) -> NDArrayFloat:\n", + " unscaled_axial_induction = SimpleTurbine.axial_induction(\n", + " power_thrust_table=power_thrust_table,\n", + " velocities=velocities,\n", + " )\n", + " return unscaled_axial_induction" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "72ce900e", + "metadata": {}, + "outputs": [], + "source": [ + "# First, run with the unscaled SimpleTurbine model for comparison\n", + "fmodel.set_operation_model(SimpleTurbine)\n", + "fmodel.run()\n", + "initial_powers = fmodel.get_turbine_powers()\n", + "print(\"Unscaled Powers [W]:\\n\", initial_powers, \"\\n\")\n", + "\n", + "# Then, run with the scaled model\n", + "fmodel.set_operation_model(ScaledTurbine(scaling_factor=1.2))\n", + "fmodel.run()\n", + "\n", + "print(\"ScaledTurbine powers [W]:\\n\", fmodel.get_turbine_powers(), \"\\n\")" + ] + }, + { + "cell_type": "markdown", + "id": "0f3409fc", + "metadata": {}, + "source": [ + "## Prepackaged operation models\n", + "\n", + "Naturally, prepackaged operation models can also be used in this way. In fact, we just did that with the `SimpleTurbine` model! Let's take a look at using the `CosineLossTurbine` operation model from FLORIS, either as one of the preset defaults or by passing the class in directly." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1dd9ed49", + "metadata": {}, + "outputs": [], + "source": [ + "from floris.core.turbine import CosineLossTurbine\n", + "\n", + "fmodel.set_operation_model(\"simple\")\n", + "fmodel.set(\n", + " yaw_angles=np.array([[0.0, 20.0], [0.0, 20.0], [0.0, 20.0]]),\n", + ")\n", + "fmodel.run()\n", + "\n", + "# Simple model does not respond to yaw angles, so powers are unaffected\n", + "print(\"Powers under simple model [W]:\\n\", fmodel.get_turbine_powers(), \"\\n\")\n", + "\n", + "# Now, switch to the cosine loss model as a built-in option\n", + "fmodel.set_operation_model(\"cosine-loss\")\n", + "fmodel.run()\n", + "\n", + "print(\"Powers under cosine-loss model [W]:\\n\", fmodel.get_turbine_powers(), \"\\n\")\n", + "\n", + "# Instead, we can pass in the class directly\n", + "fmodel.set_operation_model(CosineLossTurbine)\n", + "fmodel.run()\n", + "\n", + "print(\"Powers under cosine-loss model (class) [W]:\\n\", fmodel.get_turbine_powers(), \"\\n\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "814df049", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "floris", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/floris/core/__init__.py b/floris/core/__init__.py index e37f9c113..18e0ad524 100644 --- a/floris/core/__init__.py +++ b/floris/core/__init__.py @@ -22,7 +22,7 @@ import floris.logging_manager -from .base import BaseClass, BaseModel, State +from .base import BaseClass, BaseLibrary, BaseModel, State from .turbine.turbine import ( axial_induction, power, diff --git a/floris/core/base.py b/floris/core/base.py index 76c131597..aa666477d 100644 --- a/floris/core/base.py +++ b/floris/core/base.py @@ -1,4 +1,5 @@ +import importlib from abc import abstractmethod from enum import Enum from typing import ( @@ -63,3 +64,33 @@ def prepare_function() -> dict: @abstractmethod def function() -> None: raise NotImplementedError("BaseModel.function") + +@define +class BaseLibrary(BaseClass): + """ + Base class that writes the name and module of the class into the attrs dictionary. + """ + __classinfo__: dict = {"module": "", "name": ""} + def __attrs_post_init__(self) -> None: + #import ipdb; ipdb.set_trace() + self.__classinfo__ = { + "module": type(self).__module__, + "name": type(self).__name__ + } + + @staticmethod + def from_dict(data_dict): + """Recreate instance from dictionary with class information""" + if "__classinfo__" not in data_dict: + raise ValueError( + "Dictionary does not contain class information. ", + "Insure inheritance from BaseLibrary." + ) + data_noinfo = data_dict.copy() + class_info = data_noinfo.pop("__classinfo__") + + # Import the module and get the class + module = importlib.import_module(class_info["module"]) + cls = getattr(module, class_info["name"]) + + return cls(**data_noinfo) diff --git a/floris/core/turbine/__init__.py b/floris/core/turbine/__init__.py index ada6073c9..8f5839088 100644 --- a/floris/core/turbine/__init__.py +++ b/floris/core/turbine/__init__.py @@ -1,5 +1,4 @@ - -from floris.core.turbine.controller_dependent_operation_model import ControllerDependentTurbine +from floris.core.turbine.operation_model_base import BaseOperationModel from floris.core.turbine.operation_models import ( AWCTurbine, CosineLossTurbine, @@ -8,4 +7,5 @@ SimpleDeratingTurbine, SimpleTurbine, ) +from floris.core.turbine.controller_dependent_operation_model import ControllerDependentTurbine from floris.core.turbine.unified_momentum_model import UnifiedMomentumModelTurbine diff --git a/floris/core/turbine/controller_dependent_operation_model.py b/floris/core/turbine/controller_dependent_operation_model.py index 7ced79f1d..01114195f 100644 --- a/floris/core/turbine/controller_dependent_operation_model.py +++ b/floris/core/turbine/controller_dependent_operation_model.py @@ -10,7 +10,7 @@ compute_tilt_angles_for_floating_turbines, rotor_velocity_air_density_correction, ) -from floris.core.turbine.operation_models import BaseOperationModel +from floris.core.turbine import BaseOperationModel from floris.type_dec import ( NDArrayFloat, NDArrayObject, diff --git a/floris/core/turbine/operation_model_base.py b/floris/core/turbine/operation_model_base.py new file mode 100644 index 000000000..d1a8a0f53 --- /dev/null +++ b/floris/core/turbine/operation_model_base.py @@ -0,0 +1,38 @@ +from abc import abstractmethod + +from attrs import define + +from floris.core import BaseLibrary + + +@define +class BaseOperationModel(BaseLibrary): + """ + Base class for turbine operation models. All turbine operation models must implement static + power(), thrust_coefficient(), and axial_induction() methods, which are called by power() and + thrust_coefficient() through the interface in the turbine.py module. + + Args: + BaseClass (_type_): _description_ + + Raises: + NotImplementedError: _description_ + NotImplementedError: _description_ + """ + @staticmethod + @abstractmethod + def power() -> None: + raise NotImplementedError("BaseOperationModel.power") + + @staticmethod + @abstractmethod + def thrust_coefficient() -> None: + raise NotImplementedError("BaseOperationModel.thrust_coefficient") + + @staticmethod + @abstractmethod + def axial_induction() -> None: + # TODO: Consider whether we can make a generic axial_induction method + # based purely on thrust_coefficient so that we don't need to implement + # axial_induction() in individual operation models. + raise NotImplementedError("BaseOperationModel.axial_induction") diff --git a/floris/core/turbine/operation_models.py b/floris/core/turbine/operation_models.py index 6c093bf13..bd30e5c34 100644 --- a/floris/core/turbine/operation_models.py +++ b/floris/core/turbine/operation_models.py @@ -18,6 +18,7 @@ rotor_velocity_tilt_cosine_correction, rotor_velocity_yaw_cosine_correction, ) +from floris.core.turbine import BaseOperationModel from floris.type_dec import ( NDArrayFloat, NDArrayObject, @@ -28,39 +29,6 @@ POWER_SETPOINT_DEFAULT = 1e12 POWER_SETPOINT_DISABLED = 0.001 - -@define -class BaseOperationModel(BaseClass): - """ - Base class for turbine operation models. All turbine operation models must implement static - power(), thrust_coefficient(), and axial_induction() methods, which are called by power() and - thrust_coefficient() through the interface in the turbine.py module. - - Args: - BaseClass (_type_): _description_ - - Raises: - NotImplementedError: _description_ - NotImplementedError: _description_ - """ - @staticmethod - @abstractmethod - def power() -> None: - raise NotImplementedError("BaseOperationModel.power") - - @staticmethod - @abstractmethod - def thrust_coefficient() -> None: - raise NotImplementedError("BaseOperationModel.thrust_coefficient") - - @staticmethod - @abstractmethod - def axial_induction() -> None: - # TODO: Consider whether we can make a generic axial_induction method - # based purely on thrust_coefficient so that we don't need to implement - # axial_induciton() in individual operation models. - raise NotImplementedError("BaseOperationModel.axial_induction") - @define class SimpleTurbine(BaseOperationModel): """ diff --git a/floris/core/turbine/turbine.py b/floris/core/turbine/turbine.py index d9900037e..52fbd3797 100644 --- a/floris/core/turbine/turbine.py +++ b/floris/core/turbine/turbine.py @@ -10,7 +10,7 @@ from attrs import define, field from scipy.interpolate import interp1d -from floris.core import BaseClass +from floris.core import BaseClass, BaseLibrary from floris.core.turbine import ( AWCTurbine, ControllerDependentTurbine, @@ -620,7 +620,12 @@ def __post_init__(self) -> None: self.power_thrust_table = floris_numeric_dict_converter(self.power_thrust_table) def _initialize_power_thrust_functions(self) -> None: - turbine_function_model = TURBINE_MODEL_MAP["operation_model"][self.operation_model] + if isinstance(self.operation_model, str): + turbine_function_model = TURBINE_MODEL_MAP["operation_model"][self.operation_model] + elif isinstance(self.operation_model, dict): + turbine_function_model = BaseLibrary.from_dict(self.operation_model) + else: + turbine_function_model = self.operation_model self.thrust_coefficient_function = turbine_function_model.thrust_coefficient self.axial_induction_function = turbine_function_model.axial_induction self.power_function = turbine_function_model.power diff --git a/floris/core/turbine/unified_momentum_model.py b/floris/core/turbine/unified_momentum_model.py index 251748178..bb2a748a9 100644 --- a/floris/core/turbine/unified_momentum_model.py +++ b/floris/core/turbine/unified_momentum_model.py @@ -17,7 +17,7 @@ average_velocity, rotor_velocity_air_density_correction, ) -from floris.core.turbine.operation_models import BaseOperationModel +from floris.core.turbine import BaseOperationModel from floris.type_dec import NDArrayFloat diff --git a/floris/floris_model.py b/floris/floris_model.py index 12a26cf9d..a8917e991 100644 --- a/floris/floris_model.py +++ b/floris/floris_model.py @@ -1009,7 +1009,7 @@ def get_farm_AVP( turbine_weights=turbine_weights ) * hours_per_year - def get_turbine_ais(self) -> NDArrayFloat: + def get_turbine_axial_induction_factors(self) -> NDArrayFloat: turbine_ais = axial_induction( velocities=self.core.flow_field.u, turbulence_intensities=self.core.flow_field.turbulence_intensity_field[:,:,None,None], @@ -1030,6 +1030,13 @@ def get_turbine_ais(self) -> NDArrayFloat: ) return turbine_ais + def get_turbine_ais(self) -> NDArrayFloat: + self.logger.warning( + "Computing axial inductions with get_turbine_ais is now deprecated. Please use" + " the more explicit get_turbine_axial_induction_factors method instead." + ) + return self.get_turbine_axial_induction_factors() + def get_turbine_thrust_coefficients(self) -> NDArrayFloat: turbine_thrust_coefficients = thrust_coefficient( velocities=self.core.flow_field.u, @@ -1578,7 +1585,7 @@ def set_operation_model(self, operation_model: str | List[str]): Args: operation_model (str): The operation model to set. """ - if isinstance(operation_model, str): + if (not isinstance(operation_model, (list, np.ndarray))): if len(self.core.farm.turbine_type) == 1: # Set a single one here, then, and return turbine_type = self.core.farm.turbine_definitions[0] @@ -1597,11 +1604,12 @@ def set_operation_model(self, operation_model: str | List[str]): "equal to the number of turbines." ) + # Proceed to update turbine definitions turbine_type_list = self.core.farm.turbine_definitions for tindex in range(self.core.farm.n_turbines): turbine_type_list[tindex]["turbine_type"] = ( - turbine_type_list[tindex]["turbine_type"]+"_"+operation_model[tindex] + turbine_type_list[tindex]["turbine_type"]+"_"+str(operation_model[tindex]) ) turbine_type_list[tindex]["operation_model"] = operation_model[tindex] diff --git a/pyproject.toml b/pyproject.toml index ec6516eb1..55044f101 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -234,6 +234,7 @@ lines_after_imports = 2 line_length = 100 order_by_type = false split_on_trailing_comma = true +skip = ["floris/core/turbine/__init__.py"] # length_sort = true # case_sensitive: False diff --git a/tests/turbine_operation_models_integration_test.py b/tests/turbine_operation_models_integration_test.py new file mode 100644 index 000000000..971217934 --- /dev/null +++ b/tests/turbine_operation_models_integration_test.py @@ -0,0 +1,111 @@ +import numpy as np +import pytest +from attrs import define, field + +from floris import FlorisModel +from floris.core.turbine import BaseOperationModel +from floris.type_dec import floris_float_type + + +# Establish a static class +@define +class UserDefinedStatic(BaseOperationModel): + def power(velocities, **_): + return 1000*np.ones(velocities.shape[:2]) + def thrust_coefficient(velocities, **_): + return 0.8*np.ones(velocities.shape[:2]) + def axial_induction(velocities, **_): + return 1/3*np.ones(velocities.shape[:2]) + +# Establish a dynamic class +@define +class UserDefinedDynamic(BaseOperationModel): + flat_power: floris_float_type = field(init=True, default=500.0) + flat_thrust_coefficient: floris_float_type = field(init=True, default=0.7) + flat_axial_induction: floris_float_type = field(init=True, default=0.3) + def power(self, velocities, **_): + return self.flat_power*np.ones(velocities.shape[:2]) + def thrust_coefficient(self, velocities, **_): + return self.flat_thrust_coefficient*np.ones(velocities.shape[:2]) + def axial_induction(self, velocities, **_): + return self.flat_axial_induction*np.ones(velocities.shape[:2]) + + +def test_static_user_defined_op_model(): + fmodel = FlorisModel("defaults") + fmodel.set( + layout_x=[0.0, 500.0, 1000.0], + layout_y=[0.0, 0.0, 0.0], + wind_speeds=[8.0, 9.0], + wind_directions=[270.0, 280.0], + turbulence_intensities=[0.06, 0.06] + ) + fmodel.set_operation_model(UserDefinedStatic) + fmodel.run() + power = fmodel.get_turbine_powers() + thrust_coefficients = fmodel.get_turbine_thrust_coefficients() + axial_inductions = fmodel.get_turbine_axial_induction_factors() + + assert np.all(power.shape == (2, 3)) + assert np.all(thrust_coefficients.shape == (2, 3)) + assert np.all(axial_inductions.shape == (2, 3)) + + assert np.allclose(power, 1000.0) + assert np.allclose(thrust_coefficients, 0.8) + assert np.allclose(axial_inductions, 1/3) + +def test_dynamic_user_defined_op_model(): + + fmodel = FlorisModel("defaults") + fmodel.set( + layout_x=[0.0, 500.0, 1000.0], + layout_y=[0.0, 0.0, 0.0], + wind_speeds=[8.0, 9.0], + wind_directions=[270.0, 280.0], + turbulence_intensities=[0.06, 0.06] + ) + # Try without instantiating (TODO: create more helpful error?) + with pytest.raises(TypeError): + fmodel.set_operation_model(UserDefinedDynamic) + fmodel.run() + # Now instantiate and try again + instantiated_operation_model = UserDefinedDynamic() + fmodel.set_operation_model(instantiated_operation_model) + fmodel.run() + power = fmodel.get_turbine_powers() + thrust_coefficients = fmodel.get_turbine_thrust_coefficients() + axial_inductions = fmodel.get_turbine_axial_induction_factors() + + assert np.all(power.shape == (2, 3)) + assert np.all(thrust_coefficients.shape == (2, 3)) + assert np.all(axial_inductions.shape == (2, 3)) + + assert np.allclose(power, 500.0) + assert np.allclose(thrust_coefficients, 0.7) + assert np.allclose(axial_inductions, 0.3) + +def test_set_run_ordering(): + fmodel = FlorisModel("defaults") + fmodel.set_operation_model(UserDefinedStatic) + fmodel.set( + layout_x=[0.0, 500.0, 1000.0], + layout_y=[0.0, 0.0, 0.0], + wind_speeds=[8.0, 9.0], + wind_directions=[270.0, 280.0], + turbulence_intensities=[0.06, 0.06] + ) + fmodel.run() + + # Reset, rerun + fmodel.set( + wind_directions=[300.0, 310.0], + ) + fmodel.run() + + # Now, try a dynamic model + fmodel.set_operation_model(UserDefinedDynamic(flat_power=850.0)) + fmodel.run() + fmodel.set( + wind_directions=[240.0, 250.0], + ) + fmodel.run()