From fc134ce7d78e5564d0a9ff4c5b6fee23f39fc0d8 Mon Sep 17 00:00:00 2001 From: Eric Simley Date: Thu, 21 Mar 2024 14:00:24 -0600 Subject: [PATCH 01/10] adding value functions to wind_data --- floris/wind_data.py | 306 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 294 insertions(+), 12 deletions(-) diff --git a/floris/wind_data.py b/floris/wind_data.py index 2b8952e9f..e36e0d169 100644 --- a/floris/wind_data.py +++ b/floris/wind_data.py @@ -511,15 +511,19 @@ def plot_ti_over_ws( Args: ax (:py:class:`matplotlib.pyplot.axes`, optional): The figure axes - on which the wind rose is plotted. Defaults to None. - plot_kwargs (dict, optional): Keyword arguments to be passed to - ax.plot(). + on which the turbulence intensity is plotted. Defaults to None. + marker (str, optional): Scatter plot marker style. Defaults to ".". + ls (str, optional): Scatter plot line style. Defaults to "None". + color (str, optional): Scatter plot color. Defaults to "k". Returns: :py:class:`matplotlib.pyplot.axes`: A figure axes object containing - the plotted wind rose. + the plotted turbulence intensities as a function of wind speed. """ + # TODO: Plot mean and std. devs. of TI in each ws bin in addition to + # individual points + # Set up figure if ax is None: _, ax = plt.subplots() @@ -529,6 +533,110 @@ def plot_ti_over_ws( ax.set_ylabel("Turbulence Intensity (%)") ax.grid(True) + def assign_value_using_wd_ws_function(self, func, normalize=False): + """ + Use the passed in function to assign new values to the value table. + + Args: + func (function): Function which accepts wind_directions as its + first argument and wind_speeds as second argument and returns + values. + normalize (bool, optional): If True, the value array will be + normalized by the mean value. Defaults to False. + + """ + self.value_table = func(self.wd_grid, self.ws_grid) + + if normalize: + self.value_table /= np.sum(self.freq_table * self.value_table) + + self._build_gridded_and_flattened_version() + + def assign_value_piecewise_linear( + self, + value_zero_ws=1.425, + ws_knee=4.5, + slope_1=0.0, + slope_2=-0.135, + limit_to_zero=False, + normalize=False, + ): + """ + Define value as a continuous piecewise linear function of wind speed + with two line segments. The default parameters yield a value function + that approximates the normalized mean electricity price vs. wind speed + curve for the SPP market in the U.S. for years 2018-2020 from figure 7 + in Simley et al. "The value of wake steering wind farm flow control in + US energy markets," Wind Energy Science, 2024. + https://doi.org/10.5194/wes-9-219-2024. This default value function is + constant at low wind speeds, then linearly decreases above 4.5 m/s. + + Args: + value_zero_ws (float, optional): The value when wind speed is zero. + Defaults to 1.425. + ws_knee (float, optional): The wind speed separating line segments + 1 and 2. Default = 4.5 m/s. + slope_1 (float, optional): The slope of the first line segment + (unit of value per m/s). Defaults to zero. + slope_2 (float, optional): The slope of the second line segment + (unit of value per m/s). Defaults to -0.135. + limit_to_zero (bool, optional): If True, negative values will be + set to zero. Defaults to False. + normalize (bool, optional): If True, the value array will be + normalized by the mean value. Defaults to False. + """ + + def piecewise_linear_value_func(wind_directions, wind_speeds): + value = np.zeros_like(wind_speeds) + value[wind_speeds < ws_knee] = ( + slope_1 * wind_speeds[wind_speeds < ws_knee] + value_zero_ws + ) + + offset_2 = (slope_1 - slope_2) * ws_knee + value_zero_ws + + value[wind_speeds >= ws_knee] = slope_2 * wind_speeds[wind_speeds >= ws_knee] + offset_2 + + if limit_to_zero: + value[value < 0] = 0.0 + + return value + + self.assign_value_using_wd_ws_function(piecewise_linear_value_func, normalize) + + def plot_value_over_ws( + self, + ax=None, + marker=".", + ls="None", + color="k", + ): + """ + Scatter plot the value of the energy generated against wind speed. + + Args: + ax (:py:class:`matplotlib.pyplot.axes`, optional): The figure axes + on which the value is plotted. Defaults to None. + marker (str, optional): Scatter plot marker style. Defaults to ".". + ls (str, optional): Scatter plot line style. Defaults to "None". + color (str, optional): Scatter plot color. Defaults to "k". + + Returns: + :py:class:`matplotlib.pyplot.axes`: A figure axes object containing + the plotted value as a function of wind speed. + """ + + # TODO: Plot mean and std. devs. of value in each ws bin in addition to + # individual points + + # Set up figure + if ax is None: + _, ax = plt.subplots() + + ax.plot(self.ws_flat, self.value_table_flat, marker=marker, ls=ls, color=color) + ax.set_xlabel("Wind Speed (m/s)") + ax.set_ylabel("Value") + ax.grid(True) + @staticmethod def read_csv_long(file_path: str, ws_col: str = 'wind_speeds', @@ -952,16 +1060,18 @@ def plot_ti_over_ws( Args: ax (:py:class:`matplotlib.pyplot.axes`, optional): The figure axes - on which the wind rose is plotted. Defaults to None. - plot_kwargs (dict, optional): Keyword arguments to be passed to - ax.plot(). + on which the mean turbulence intensity is plotted. Defaults to None. + marker (str, optional): Scatter plot marker style. Defaults to ".". + ls (str, optional): Scatter plot line style. Defaults to "None". + color (str, optional): Scatter plot color. Defaults to "k". Returns: :py:class:`matplotlib.pyplot.axes`: A figure axes object containing - the plotted wind rose. + the plotted mean turbulence intensities as a function of wind speed. """ - # TODO: Plot std. devs. of TI in addition to mean values + # TODO: Plot individual points and std. devs. of TI in addition to mean + # values # Set up figure if ax is None: @@ -976,6 +1086,111 @@ def plot_ti_over_ws( ax.set_ylabel("Mean Turbulence Intensity (%)") ax.grid(True) + def assign_value_using_wd_ws_ti_function(self, func, normalize=False): + """ + Use the passed in function to assign new values to the value table. + + Args: + func (function): Function which accepts wind_directions as its + first argument, wind_speeds as its second argument, and + turbulence_intensities as its third argument and returns + values. + normalize (bool, optional): If True, the value array will be + normalized by the mean value. Defaults to False. + + """ + self.value_table = func(self.wd_grid, self.ws_grid, self.ti_grid) + + if normalize: + self.value_table /= np.sum(self.freq_table * self.value_table) + + self._build_gridded_and_flattened_version() + + def assign_value_piecewise_linear( + self, + value_zero_ws=1.425, + ws_knee=4.5, + slope_1=0.0, + slope_2=-0.135, + limit_to_zero=False, + normalize=False, + ): + """ + Define value as a continuous piecewise linear function of wind speed + with two line segments. The default parameters yield a value function + that approximates the normalized mean electricity price vs. wind speed + curve for the SPP market in the U.S. for years 2018-2020 from figure 7 + in Simley et al. "The value of wake steering wind farm flow control in + US energy markets," Wind Energy Science, 2024. + https://doi.org/10.5194/wes-9-219-2024. This default value function is + constant at low wind speeds, then linearly decreases above 4.5 m/s. + + Args: + value_zero_ws (float, optional): The value when wind speed is zero. + Defaults to 1.425. + ws_knee (float, optional): The wind speed separating line segments + 1 and 2. Default = 4.5 m/s. + slope_1 (float, optional): The slope of the first line segment + (unit of value per m/s). Defaults to zero. + slope_2 (float, optional): The slope of the second line segment + (unit of value per m/s). Defaults to -0.135. + limit_to_zero (bool, optional): If True, negative values will be + set to zero. Defaults to False. + normalize (bool, optional): If True, the value array will be + normalized by the mean value. Defaults to False. + """ + + def piecewise_linear_value_func(wind_directions, wind_speeds, turbulence_intensities): + value = np.zeros_like(wind_speeds) + value[wind_speeds < ws_knee] = ( + slope_1 * wind_speeds[wind_speeds < ws_knee] + value_zero_ws + ) + + offset_2 = (slope_1 - slope_2) * ws_knee + value_zero_ws + + value[wind_speeds >= ws_knee] = slope_2 * wind_speeds[wind_speeds >= ws_knee] + offset_2 + + if limit_to_zero: + value[value < 0] = 0.0 + + return value + + self.assign_value_using_wd_ws_ti_function(piecewise_linear_value_func, normalize) + + def plot_value_over_ws( + self, + ax=None, + marker=".", + ls="None", + color="k", + ): + """ + Scatter plot the value of the energy generated against wind speed. + + Args: + ax (:py:class:`matplotlib.pyplot.axes`, optional): The figure axes + on which the value is plotted. Defaults to None. + marker (str, optional): Scatter plot marker style. Defaults to ".". + ls (str, optional): Scatter plot line style. Defaults to "None". + color (str, optional): Scatter plot color. Defaults to "k". + + Returns: + :py:class:`matplotlib.pyplot.axes`: A figure axes object containing + the plotted value as a function of wind speed. + """ + + # TODO: Plot mean and std. devs. of value in each ws bin in addition to + # individual points + + # Set up figure + if ax is None: + _, ax = plt.subplots() + + ax.plot(self.ws_flat, self.value_table_flat, marker=marker, ls=ls, color=color) + ax.set_xlabel("Wind Speed (m/s)") + ax.set_ylabel("Value") + ax.grid(True) + @staticmethod def read_csv_long(file_path: str, ws_col: str = 'wind_speeds', @@ -1118,9 +1333,8 @@ def __init__( "wind_directions and wind_speeds must be the same length if provided as arrays" ) - if ( - isinstance(wind_directions, np.ndarray) - and isinstance(turbulence_intensities, np.ndarray) + if isinstance(wind_directions, np.ndarray) and isinstance( + turbulence_intensities, np.ndarray ): if len(wind_directions) != len(turbulence_intensities): raise ValueError( @@ -1276,6 +1490,74 @@ def iref_func(wind_directions, wind_speeds): self.assign_ti_using_wd_ws_function(iref_func) + def assign_value_using_wd_ws_function(self, func, normalize=False): + """ + Use the passed in function to assign new values to the value table. + + Args: + func (function): Function which accepts wind_directions as its + first argument and wind_speeds as second argument and returns + values. + normalize (bool, optional): If True, the value array will be + normalized by the mean value. Defaults to False. + + """ + self.values = func(self.wind_directions, self.wind_speeds) + + if normalize: + self.values /= np.mean(self.values) + + def assign_value_piecewise_linear( + self, + value_zero_ws=1.425, + ws_knee=4.5, + slope_1=0.0, + slope_2=-0.135, + limit_to_zero=False, + normalize=False, + ): + """ + Define value as a continuous piecewise linear function of wind speed + with two line segments. The default parameters yield a value function + that approximates the normalized mean electricity price vs. wind speed + curve for the SPP market in the U.S. for years 2018-2020 from figure 7 + in Simley et al. "The value of wake steering wind farm flow control in + US energy markets," Wind Energy Science, 2024. + https://doi.org/10.5194/wes-9-219-2024. This default value function is + constant at low wind speeds, then linearly decreases above 4.5 m/s. + + Args: + value_zero_ws (float, optional): The value when wind speed is zero. + Defaults to 1.425. + ws_knee (float, optional): The wind speed separating line segments + 1 and 2. Default = 4.5 m/s. + slope_1 (float, optional): The slope of the first line segment + (unit of value per m/s). Defaults to zero. + slope_2 (float, optional): The slope of the second line segment + (unit of value per m/s). Defaults to -0.135. + limit_to_zero (bool, optional): If True, negative values will be + set to zero. Defaults to False. + normalize (bool, optional): If True, the value array will be + normalized by the mean value. Defaults to False. + """ + + def piecewise_linear_value_func(wind_directions, wind_speeds): + value = np.zeros_like(wind_speeds) + value[wind_speeds < ws_knee] = ( + slope_1 * wind_speeds[wind_speeds < ws_knee] + value_zero_ws + ) + + offset_2 = (slope_1 - slope_2) * ws_knee + value_zero_ws + + value[wind_speeds >= ws_knee] = slope_2 * wind_speeds[wind_speeds >= ws_knee] + offset_2 + + if limit_to_zero: + value[value < 0] = 0.0 + + return value + + self.assign_value_using_wd_ws_function(piecewise_linear_value_func, normalize) + def to_WindRose( self, wd_step=2.0, ws_step=1.0, wd_edges=None, ws_edges=None, bin_weights=None ): From 2ba2ef4a4273ebf907977b523a37ca8364dbf438 Mon Sep 17 00:00:00 2001 From: Eric Simley Date: Tue, 2 Apr 2024 12:07:53 -0600 Subject: [PATCH 02/10] adding functions to get expected and annual value in floris_model --- floris/floris_model.py | 142 +++++++++++++++++++++++++++++++++++++++++ floris/wind_data.py | 5 ++ 2 files changed, 147 insertions(+) diff --git a/floris/floris_model.py b/floris/floris_model.py index 548f2e9f6..10c4025c0 100644 --- a/floris/floris_model.py +++ b/floris/floris_model.py @@ -701,6 +701,148 @@ def get_farm_AEP( turbine_weights=turbine_weights ) * hours_per_year + def get_expected_farm_value( + self, + freq=None, + values=None, + turbine_weights=None, + ) -> float: + """ + Compute the expected (mean) value produced by the wind farm. This is + computed by multiplying the wind farm power for each wind condition by + the corresponding value of the power generated (e.g., electricity + market price per unit of energy), then weighting by frequency and + summing over all conditions. + + Args: + freq (NDArrayFloat): NumPy array with shape (n_findex) + with the frequencies of each wind condition combination. + These frequencies should typically sum up to 1.0 and are used + to weigh the wind farm value for every condition in calculating + the wind farm's expected value. Defaults to None. If None and a + WindData object is supplied, the WindData object's frequencies + will be used. Otherwise, uniform frequencies are assumed (i.e., + a simple mean over the findices is computed). + values (NDArrayFloat): NumPy array with shape (n_findex) + with the values corresponding to the power generated for each + wind condition combination. The wind farm power is multiplied + by the value for every condition in calculating the wind farm's + expected value. Defaults to None. If None and a WindData object + is supplied, the WindData object's values will be used. + Otherwise, a value of 1 for all conditions is assumed (i.e., + the expected farm value will be equivalent to the expected farm + power). + 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 value 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 + expected value. If None, this is an array with all values 1.0 + and with shape equal to (n_findex, n_turbines). Defaults to None. + + Returns: + float: + The expected value produced by the wind farm in units of value. + """ + + farm_power = self._get_farm_power(turbine_weights=turbine_weights) + + if freq is None: + if self.wind_data is None: + freq = np.array([1.0/self.core.flow_field.n_findex]) + else: + freq = self.wind_data.unpack_freq() + + if values is None: + if self.wind_data is None: + values = np.array([1.0]) + else: + values = self.wind_data.unpack_value() + + farm_value = np.multiply(values, farm_power) + + return np.nansum(np.multiply(freq, farm_value)) + + def get_farm_AVP( + self, + freq=None, + values=None, + turbine_weights=None, + hours_per_year=8760, + ) -> float: + """ + Estimate annual value production (AVP) for distribution of wind + conditions, frequencies of occurrence, and corresponding values of + power generated (e.g., electricity price per unit of energy). + + Args: + freq (NDArrayFloat): NumPy array with shape (n_findex) + with the frequencies of each wind condition combination. + These frequencies should typically sum up to 1.0 and are used + to weigh the wind farm value for every condition in calculating + the wind farm's AVP. Defaults to None. If None and a + WindData object is supplied, the WindData object's frequencies + will be used. Otherwise, uniform frequencies are assumed (i.e., + a simple mean over the findices is computed). + values (NDArrayFloat): NumPy array with shape (n_findex) + with the values corresponding to the power generated for each + wind condition combination. The wind farm power is multiplied + by the value for every condition in calculating the wind farm's + AVP. Defaults to None. If None and a WindData object is + supplied, the WindData object's values will be used. Otherwise, + a value of 1 for all conditions is assumed (i.e., the AVP will + be equivalent to the AEP). + 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 value 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 AVP. If + None, this is an array with all values 1.0 and with shape equal + to (n_findex, n_turbines). Defaults to None. + hours_per_year (float, optional): Number of hours in a year. + Defaults to 365 * 24. + + Returns: + float: + The Annual Value Production (AVP) for the wind farm in units + of value. + """ + if ( + freq is None + and not isinstance(self.wind_data, WindRose) + and not isinstance(self.wind_data, WindTIRose) + ): + self.logger.warning( + "Computing AVP with uniform frequencies. Results results may not reflect annual " + "operation." + ) + + if ( + values is None + and not isinstance(self.wind_data, WindRose) + and not isinstance(self.wind_data, WindTIRose) + ): + self.logger.warning( + "Computing AVP with uniform value equal to 1. Results will be equivalent to " + "annual energy production." + ) + + return self.get_expected_farm_value( + freq=freq, + values=values, + turbine_weights=turbine_weights + ) * hours_per_year + def get_turbine_ais(self) -> NDArrayFloat: turbine_ais = axial_induction( velocities=self.core.flow_field.u, diff --git a/floris/wind_data.py b/floris/wind_data.py index e36e0d169..808edc1ee 100644 --- a/floris/wind_data.py +++ b/floris/wind_data.py @@ -51,6 +51,11 @@ def unpack_freq(self): return self.unpack()[3] + def unpack_value(self): + """Unpack values of power generated""" + + return self.unpack()[4] + def check_heterogenous_inflow_config_by_wd(self, heterogenous_inflow_config_by_wd): """ Check that the heterogenous_inflow_config_by_wd dictionary is properly formatted From df2acf1da88b439dc17b963809f759de0947e30c Mon Sep 17 00:00:00 2001 From: Eric Simley Date: Tue, 2 Apr 2024 14:06:35 -0600 Subject: [PATCH 03/10] including value objective in layout optimization --- .../layout_optimization_base.py | 32 +++++++++++++--- .../layout_optimization_pyoptsparse.py | 37 +++++++++++++++++-- .../layout_optimization_scipy.py | 17 +++++++-- 3 files changed, 73 insertions(+), 13 deletions(-) diff --git a/floris/optimization/layout_optimization/layout_optimization_base.py b/floris/optimization/layout_optimization/layout_optimization_base.py index d52e6b1f2..99016d902 100644 --- a/floris/optimization/layout_optimization/layout_optimization_base.py +++ b/floris/optimization/layout_optimization/layout_optimization_base.py @@ -26,12 +26,24 @@ class LayoutOptimization(LoggingManager): initializes to 2 rotor diameters. Defaults to None. enable_geometric_yaw (bool, optional): If True, enables geometric yaw optimization. Defaults to False. + use_value (bool, optional): If True, the layout optimization objective + is to maximize annual value production using the value array in the + FLORIS model's WindData object. If False, the optimization + objective is to maximize AEP. Defaults to False. """ - def __init__(self, fmodel, boundaries, min_dist=None, enable_geometric_yaw=False): + def __init__( + self, + fmodel, + boundaries, + min_dist=None, + enable_geometric_yaw=False, + use_value=False, + ): self.fmodel = fmodel.copy() # Does not copy over the wind_data object self.fmodel.set(wind_data=fmodel.wind_data) self.boundaries = boundaries self.enable_geometric_yaw = enable_geometric_yaw + self.use_value = use_value self._boundary_polygon = Polygon(self.boundaries) self._boundary_line = LineString(self.boundaries) @@ -41,7 +53,7 @@ def __init__(self, fmodel, boundaries, min_dist=None, enable_geometric_yaw=False self.ymin = np.min([tup[1] for tup in boundaries]) self.ymax = np.max([tup[1] for tup in boundaries]) - # If no minimum distance is provided, assume a value of 2 rotor diamters + # If no minimum distance is provided, assume a value of 2 rotor diameters if min_dist is None: self.min_dist = 2 * self.rotor_diameter else: @@ -53,9 +65,13 @@ def __init__(self, fmodel, boundaries, min_dist=None, enable_geometric_yaw=False # a WindData object, but it is still recommended. self.logger.warning( "Running layout optimization without a WindData object (e.g. TimeSeries, WindRose, " - "WindTIRose). We suggest that the user set the wind conditions on the FlorisModel " - " using the wind_data keyword argument for layout optimizations to capture " - "frequencies accurately." + "WindTIRose). We suggest that the user set the wind conditions (and if applicable, " + "frequencies and values) on the FlorisModel using the wind_data keyword argument " + "for layout optimizations to capture frequencies and the value of the energy " + "production accurately. If a WindData object is not defined, uniform frequencies " + "will be assumed. If use_value is True and a WindData object is not defined, a " + "value of 1 will be used for each wind condition and layout optimization will " + "simply be performed to maximize AEP." ) # Establish geometric yaw class @@ -67,7 +83,11 @@ def __init__(self, fmodel, boundaries, min_dist=None, enable_geometric_yaw=False ) # TODO: is this being used? fmodel.run() - self.initial_AEP = fmodel.get_farm_AEP() + + if self.use_value: + self.initial_AEP_or_AVP = fmodel.get_farm_AVP() + else: + self.initial_AEP_or_AVP = fmodel.get_farm_AEP() def __str__(self): return "layout" diff --git a/floris/optimization/layout_optimization/layout_optimization_pyoptsparse.py b/floris/optimization/layout_optimization/layout_optimization_pyoptsparse.py index 959b152a3..41b3d9a0c 100644 --- a/floris/optimization/layout_optimization/layout_optimization_pyoptsparse.py +++ b/floris/optimization/layout_optimization/layout_optimization_pyoptsparse.py @@ -19,9 +19,35 @@ def __init__( storeHistory='hist.hist', hotStart=None, enable_geometric_yaw=False, + use_value=False, ): - super().__init__(fmodel, boundaries, min_dist=min_dist, - enable_geometric_yaw=enable_geometric_yaw) + """ + _summary_ TODO: write summary and describe the remaining arguments + + Args: + fmodel (FlorisModel): A FlorisModel object. + boundaries (iterable(float, float)): Pairs of x- and y-coordinates + that represent the boundary's vertices (m). + min_dist (float, optional): The minimum distance to be maintained + between turbines during the optimization (m). If not specified, + initializes to 2 rotor diameters. Defaults to None. + solver (str, optional): Sets the solver used by Scipy. Defaults to 'SLSQP'. + optOptions (dict, optional): Dictionary for setting the + optimization options. Defaults to None. + enable_geometric_yaw (bool, optional): If True, enables geometric yaw + optimization. Defaults to False. + use_value (bool, optional): If True, the layout optimization objective + is to maximize annual value production using the value array in the + FLORIS model's WindData object. If False, the optimization + objective is to maximize AEP. Defaults to False. + """ + super().__init__( + fmodel, + boundaries, + min_dist=min_dist, + enable_geometric_yaw=enable_geometric_yaw, + use_value=use_value + ) self.x0 = self._norm(self.fmodel.layout_x, self.xmin, self.xmax) self.y0 = self._norm(self.fmodel.layout_y, self.ymin, self.ymax) @@ -42,7 +68,7 @@ def __init__( self.logger.error(err_msg, stack_info=True) raise ImportError(err_msg) - # Insantiate ptOptSparse optimization object with name and objective function + # Instantiate ptOptSparse optimization object with name and objective function self.optProb = pyoptsparse.Optimization('layout', self._obj_func) self.optProb = self.add_var_group(self.optProb) @@ -98,7 +124,10 @@ def _obj_func(self, varDict): # Compute the objective function funcs = {} - funcs["obj"] = -1 * self.fmodel.get_farm_AEP() / self.initial_AEP + if self.use_value: + funcs["obj"] = -1 * self.fmodel.get_farm_AVP() / self.initial_AEP_or_AVP + else: + funcs["obj"] = -1 * self.fmodel.get_farm_AEP() / self.initial_AEP_or_AVP # Compute constraints, if any are defined for the optimization funcs = self.compute_cons(funcs, self.x, self.y) diff --git a/floris/optimization/layout_optimization/layout_optimization_scipy.py b/floris/optimization/layout_optimization/layout_optimization_scipy.py index ff3048cae..82be374fb 100644 --- a/floris/optimization/layout_optimization/layout_optimization_scipy.py +++ b/floris/optimization/layout_optimization/layout_optimization_scipy.py @@ -18,9 +18,10 @@ def __init__( solver='SLSQP', optOptions=None, enable_geometric_yaw=False, + use_value=False, ): """ - _summary_ + _summary_ TODO: write summary Args: fmodel (FlorisModel): A FlorisModel object. @@ -35,12 +36,19 @@ def __init__( solver (str, optional): Sets the solver used by Scipy. Defaults to 'SLSQP'. optOptions (dict, optional): Dicitonary for setting the optimization options. Defaults to None. + enable_geometric_yaw (bool, optional): If True, enables geometric yaw + optimization. Defaults to False. + use_value (bool, optional): If True, the layout optimization objective + is to maximize annual value production using the value array in the + FLORIS model's WindData object. If False, the optimization + objective is to maximize AEP. Defaults to False. """ super().__init__( fmodel, boundaries, min_dist=min_dist, - enable_geometric_yaw=enable_geometric_yaw + enable_geometric_yaw=enable_geometric_yaw, + use_value=use_value ) self.boundaries_norm = [ @@ -101,7 +109,10 @@ def _obj_func(self, locs): self.fmodel.set(yaw_angles=yaw_angles) self.fmodel.run() - return -1 * self.fmodel.get_farm_AEP() / self.initial_AEP + if self.use_value: + return -1 * self.fmodel.get_farm_AVP() / self.initial_AEP_or_AVP + else: + return -1 * self.fmodel.get_farm_AEP() / self.initial_AEP_or_AVP def _change_coordinates(self, locs): From d5c98c4082d38168bf9b15c8a0137292ec908533 Mon Sep 17 00:00:00 2001 From: Eric Simley Date: Tue, 2 Apr 2024 14:51:00 -0600 Subject: [PATCH 04/10] updating floris model integration tests for AVP --- tests/floris_model_integration_test.py | 56 ++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/floris_model_integration_test.py b/tests/floris_model_integration_test.py index ae5f07558..94ed44c98 100644 --- a/tests/floris_model_integration_test.py +++ b/tests/floris_model_integration_test.py @@ -390,6 +390,62 @@ def test_get_farm_aep(caplog): expected_farm_power = fmodel.get_expected_farm_power(freq=freq) np.testing.assert_allclose(expected_farm_power, aep / (365 * 24)) +def test_get_farm_avp(caplog): + fmodel = FlorisModel(configuration=YAML_INPUT) + + wind_speeds = np.array([7.0, 8.0, 9.0]) + wind_directions = np.array([260.0, 270.0, 280.0]) + turbulence_intensities = np.array([0.07, 0.06, 0.05]) + + layout_x = np.array([0, 0]) + layout_y = np.array([0, 1000]) + # n_turbines = len(layout_x) + + fmodel.set( + wind_speeds=wind_speeds, + wind_directions=wind_directions, + turbulence_intensities=turbulence_intensities, + layout_x=layout_x, + layout_y=layout_y, + ) + + fmodel.run() + + farm_powers = fmodel.get_farm_power() + + # Define frequencies + freq = np.array([0.25, 0.5, 0.25]) + + # Define values of energy produced (e.g., price per MWh) + values = np.array([30.0, 20.0, 10.0]) + + # Check warning raised if values not passed; no warning if values passed + with caplog.at_level(logging.WARNING): + fmodel.get_farm_AVP(freq=freq) + assert caplog.text != "" # Checking not empty + caplog.clear() + with caplog.at_level(logging.WARNING): + fmodel.get_farm_AVP(freq=freq, values=values) + assert caplog.text == "" # Checking empty + + # Check that AVP is equivalent to AEP when values not passed + farm_aep = fmodel.get_farm_AEP(freq=freq) + farm_avp = fmodel.get_farm_AVP(freq=freq) + + np.testing.assert_allclose(farm_avp, farm_aep) + + # Now check that AVP is what we expect when values passed + farm_avp = fmodel.get_farm_AVP(freq=freq,values=values) + + farm_values = np.multiply(values, farm_powers) + avp = np.sum(np.multiply(freq, farm_values) * 365 * 24) + + np.testing.assert_allclose(farm_avp, avp) + + # Also check get_expected_farm_value + expected_farm_power = fmodel.get_expected_farm_value(freq=freq, values=values) + np.testing.assert_allclose(expected_farm_power, avp / (365 * 24)) + def test_set_ti(): fmodel = FlorisModel(configuration=YAML_INPUT) From 6f23f6a58cba56ad9d2a0a300e244446e90f5177 Mon Sep 17 00:00:00 2001 From: Eric Simley Date: Wed, 3 Apr 2024 11:08:11 -0600 Subject: [PATCH 05/10] reg test for scipy layout opt with value --- .../reg_tests/scipy_layout_opt_regression.py | 64 -------- .../scipy_layout_opt_regression_test.py | 137 ++++++++++++++++++ 2 files changed, 137 insertions(+), 64 deletions(-) delete mode 100644 tests/reg_tests/scipy_layout_opt_regression.py create mode 100644 tests/reg_tests/scipy_layout_opt_regression_test.py diff --git a/tests/reg_tests/scipy_layout_opt_regression.py b/tests/reg_tests/scipy_layout_opt_regression.py deleted file mode 100644 index 049b1b841..000000000 --- a/tests/reg_tests/scipy_layout_opt_regression.py +++ /dev/null @@ -1,64 +0,0 @@ - -import numpy as np -import pandas as pd - -from floris import FlorisModel -from floris.optimization.layout_optimization.layout_optimization_scipy import ( - LayoutOptimizationScipy, -) -from tests.conftest import ( - assert_results_arrays, -) - - -DEBUG = False -VELOCITY_MODEL = "gauss" -DEFLECTION_MODEL = "gauss" - -baseline = np.array( - [ - [0.00000000e+00, 4.96470529e+02, 1.00000000e+03], - [4.58108861e-15, 1.09603647e+01, 2.47721427e+01], - ] -) - - -def test_scipy_layout_opt(sample_inputs_fixture): - """ - The SciPy optimization method optimizes turbine layout using SciPy's minimize method. This test - compares the optimization results from the SciPy layout optimizaiton for a simple farm with a - simple wind rose to stored baseline results. - """ - sample_inputs_fixture.core["wake"]["model_strings"]["velocity_model"] = VELOCITY_MODEL - sample_inputs_fixture.core["wake"]["model_strings"]["deflection_model"] = DEFLECTION_MODEL - - opt_options = { - "maxiter": 5, - "disp": True, - "iprint": 2, - "ftol": 1e-12, - "eps": 0.01, - } - - boundaries = [(0.0, 0.0), (0.0, 1000.0), (1000.0, 1000.0), (1000.0, 0.0), (0.0, 0.0)] - - fmodel = FlorisModel(sample_inputs_fixture.core) - wd_array = np.arange(0, 360.0, 5.0) - ws_array = 8.0 * np.ones_like(wd_array) - D = 126.0 # Rotor diameter for the NREL 5 MW - fmodel.reinitialize( - layout_x=[0.0, 5 * D, 10 * D], - layout_y=[0.0, 0.0, 0.0], - wind_directions=wd_array, - wind_speeds=ws_array, - ) - - layout_opt = LayoutOptimizationScipy(fmodel, boundaries, optOptions=opt_options) - sol = layout_opt.optimize() - locations_opt = np.array([sol[0], sol[1]]) - - if DEBUG: - print(baseline) - print(locations_opt) - - assert_results_arrays(locations_opt, baseline) diff --git a/tests/reg_tests/scipy_layout_opt_regression_test.py b/tests/reg_tests/scipy_layout_opt_regression_test.py new file mode 100644 index 000000000..b26c96c3d --- /dev/null +++ b/tests/reg_tests/scipy_layout_opt_regression_test.py @@ -0,0 +1,137 @@ + +import numpy as np +import pandas as pd + +from floris import FlorisModel, WindRose +from floris.optimization.layout_optimization.layout_optimization_scipy import ( + LayoutOptimizationScipy, +) +from tests.conftest import ( + assert_results_arrays, +) + + +DEBUG = False +VELOCITY_MODEL = "gauss" +DEFLECTION_MODEL = "gauss" + +baseline = np.array( + [ + [0.00000000e+00, 4.96470529e+02, 1.00000000e+03], + [4.58108861e-15, 1.09603647e+01, 2.47721427e+01], + ] +) + +baseline_value = np.array( + [ + [8.68262334e+01, 1.04360964e-12, 4.00000000e+02, 2.36100415e+02], + [1.69954798e-14, 4.00000000e+02, 0.00000000e+00, 4.00000000e+02], + ] +) + + +def test_scipy_layout_opt(sample_inputs_fixture): + """ + The SciPy optimization method optimizes turbine layout using SciPy's minimize method. This test + compares the optimization results from the SciPy layout optimization for a simple farm with a + simple wind rose to stored baseline results. + """ + sample_inputs_fixture.core["wake"]["model_strings"]["velocity_model"] = VELOCITY_MODEL + sample_inputs_fixture.core["wake"]["model_strings"]["deflection_model"] = DEFLECTION_MODEL + + opt_options = { + "maxiter": 5, + "disp": True, + "iprint": 2, + "ftol": 1e-12, + "eps": 0.01, + } + + boundaries = [(0.0, 0.0), (0.0, 1000.0), (1000.0, 1000.0), (1000.0, 0.0), (0.0, 0.0)] + + fmodel = FlorisModel(sample_inputs_fixture.core) + wd_array = np.arange(0, 360.0, 5.0) + ws_array = 8.0 * np.ones_like(wd_array) + ti_array = 0.1 * np.ones_like(wd_array) + D = 126.0 # Rotor diameter for the NREL 5 MW + fmodel.set( + layout_x=[0.0, 5 * D, 10 * D], + layout_y=[0.0, 0.0, 0.0], + wind_directions=wd_array, + wind_speeds=ws_array, + turbulence_intensities=ti_array, + ) + + layout_opt = LayoutOptimizationScipy(fmodel, boundaries, optOptions=opt_options) + sol = layout_opt.optimize() + locations_opt = np.array([sol[0], sol[1]]) + + if DEBUG: + print(baseline) + print(locations_opt) + + assert_results_arrays(locations_opt, baseline) + +def test_scipy_layout_opt_value(sample_inputs_fixture): + """ + This test compares the optimization results from the SciPy layout optimization for a simple + farm with a simple wind rose to stored baseline results, optimizing for annual value production + instead of AEP. The value of the energy produced depends on the wind direction, causing the + optimal layout to differ from the case where the objective is maximum AEP. In this case, because + the value is much higher when the wind is from the north or south, the turbines are staggered to + avoid wake interactions for northerly and southerly winds. + """ + sample_inputs_fixture.core["wake"]["model_strings"]["velocity_model"] = VELOCITY_MODEL + sample_inputs_fixture.core["wake"]["model_strings"]["deflection_model"] = DEFLECTION_MODEL + + opt_options = { + "maxiter": 5, + "disp": True, + "iprint": 2, + "ftol": 1e-12, + "eps": 0.1, + } + + boundaries = [(0.0, 0.0), (0.0, 400.0), (400.0, 400.0), (400.0, 0.0), (0.0, 0.0)] + + fmodel = FlorisModel(sample_inputs_fixture.core) + + # set wind conditions and values using a WindData object with the default uniform frequency + wd_array = np.arange(0, 360.0, 5.0) + ws_array = np.array([8.0]) + + # Define the value table such that the value of the energy produced is + # significantly higher when the wind direction is close to the north or + # south, and zero when the wind is from the east or west. + value_table = (0.5 + 0.5*np.cos(2*np.radians(wd_array)))**10 + value_table = value_table.reshape((len(wd_array),1)) + + wind_rose = WindRose( + wind_directions=wd_array, + wind_speeds=ws_array, + ti_table=0.1, + value_table=value_table + ) + + # Start with a rectangular 4-turbine array with 2D spacing + D = 126.0 # Rotor diameter for the NREL 5 MW + fmodel.set( + layout_x=200 + np.array([-1 * D, -1 * D, 1 * D, 1 * D]), + layout_y=200 + np.array([-1* D, 1 * D, -1 * D, 1 * D]), + wind_data=wind_rose, + ) + + layout_opt = LayoutOptimizationScipy( + fmodel, + boundaries, + optOptions=opt_options, + use_value=True + ) + sol = layout_opt.optimize() + locations_opt = np.array([sol[0], sol[1]]) + + if DEBUG: + print(baseline) + print(locations_opt) + + assert_results_arrays(locations_opt, baseline_value) From 453d86f41b307093b5a72ff629a0d41a4827f9f6 Mon Sep 17 00:00:00 2001 From: Paul Date: Wed, 3 Apr 2024 14:50:24 -0600 Subject: [PATCH 06/10] Fix docstring and a few spelling errors --- .../layout_optimization_scipy.py | 52 ++++++++++--------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/floris/optimization/layout_optimization/layout_optimization_scipy.py b/floris/optimization/layout_optimization/layout_optimization_scipy.py index 82be374fb..5cb3a816e 100644 --- a/floris/optimization/layout_optimization/layout_optimization_scipy.py +++ b/floris/optimization/layout_optimization/layout_optimization_scipy.py @@ -9,6 +9,32 @@ class LayoutOptimizationScipy(LayoutOptimization): + """ + This class provides an interface for optimizing the layout of wind turbines + using the Scipy optimization library. The optimization objective is to + maximize annual energy production (AEP) or annual value production (AVP). + + + Args: + fmodel (FlorisModel): A FlorisModel object. + boundaries (iterable(float, float)): Pairs of x- and y-coordinates + that represent the boundary's vertices (m). + bnds (iterable, optional): Bounds for the optimization + variables (pairs of min/max values for each variable (m)). If + none are specified, they are set to 0 and 1. Defaults to None. + min_dist (float, optional): The minimum distance to be maintained + between turbines during the optimization (m). If not specified, + initializes to 2 rotor diameters. Defaults to None. + solver (str, optional): Sets the solver used by Scipy. Defaults to 'SLSQP'. + optOptions (dict, optional): Dictionary for setting the + optimization options. Defaults to None. + enable_geometric_yaw (bool, optional): If True, enables geometric yaw + optimization. Defaults to False. + use_value (bool, optional): If True, the layout optimization objective + is to maximize annual value production using the value array in the + FLORIS model's WindData object. If False, the optimization + objective is to maximize AEP. Defaults to False. + """ def __init__( self, fmodel, @@ -20,29 +46,7 @@ def __init__( enable_geometric_yaw=False, use_value=False, ): - """ - _summary_ TODO: write summary - - Args: - fmodel (FlorisModel): A FlorisModel object. - boundaries (iterable(float, float)): Pairs of x- and y-coordinates - that represent the boundary's vertices (m). - bnds (iterable, optional): Bounds for the optimization - variables (pairs of min/max values for each variable (m)). If - none are specified, they are set to 0 and 1. Defaults to None. - min_dist (float, optional): The minimum distance to be maintained - between turbines during the optimization (m). If not specified, - initializes to 2 rotor diameters. Defaults to None. - solver (str, optional): Sets the solver used by Scipy. Defaults to 'SLSQP'. - optOptions (dict, optional): Dicitonary for setting the - optimization options. Defaults to None. - enable_geometric_yaw (bool, optional): If True, enables geometric yaw - optimization. Defaults to False. - use_value (bool, optional): If True, the layout optimization objective - is to maximize annual value production using the value array in the - FLORIS model's WindData object. If False, the optimization - objective is to maximize AEP. Defaults to False. - """ + super().__init__( fmodel, boundaries, @@ -216,7 +220,7 @@ def _get_initial_and_final_locs(self): def optimize(self): """ This method finds the optimized layout of wind turbines for power - production given the provided frequencies of occurance of wind + production given the provided frequencies of occurrence of wind conditions (wind speed, direction). Returns: From acb69c8fd390b0f9f675fe678fb3235c738cbff7 Mon Sep 17 00:00:00 2001 From: Paul Date: Wed, 3 Apr 2024 14:51:24 -0600 Subject: [PATCH 07/10] Update docstring --- .../layout_optimization_pyoptsparse.py | 43 ++++++++++--------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/floris/optimization/layout_optimization/layout_optimization_pyoptsparse.py b/floris/optimization/layout_optimization/layout_optimization_pyoptsparse.py index 41b3d9a0c..095944a72 100644 --- a/floris/optimization/layout_optimization/layout_optimization_pyoptsparse.py +++ b/floris/optimization/layout_optimization/layout_optimization_pyoptsparse.py @@ -8,6 +8,28 @@ class LayoutOptimizationPyOptSparse(LayoutOptimization): + """ + This class provides an interface for optimizing the layout of wind turbines + using the pyOptSparse optimization library. The optimization objective is to + maximize annual energy production (AEP) or annual value production (AVP). + + Args: + fmodel (FlorisModel): A FlorisModel object. + boundaries (iterable(float, float)): Pairs of x- and y-coordinates + that represent the boundary's vertices (m). + min_dist (float, optional): The minimum distance to be maintained + between turbines during the optimization (m). If not specified, + initializes to 2 rotor diameters. Defaults to None. + solver (str, optional): Sets the solver used by Scipy. Defaults to 'SLSQP'. + optOptions (dict, optional): Dictionary for setting the + optimization options. Defaults to None. + enable_geometric_yaw (bool, optional): If True, enables geometric yaw + optimization. Defaults to False. + use_value (bool, optional): If True, the layout optimization objective + is to maximize annual value production using the value array in the + FLORIS model's WindData object. If False, the optimization + objective is to maximize AEP. Defaults to False. + """ def __init__( self, fmodel, @@ -21,26 +43,7 @@ def __init__( enable_geometric_yaw=False, use_value=False, ): - """ - _summary_ TODO: write summary and describe the remaining arguments - - Args: - fmodel (FlorisModel): A FlorisModel object. - boundaries (iterable(float, float)): Pairs of x- and y-coordinates - that represent the boundary's vertices (m). - min_dist (float, optional): The minimum distance to be maintained - between turbines during the optimization (m). If not specified, - initializes to 2 rotor diameters. Defaults to None. - solver (str, optional): Sets the solver used by Scipy. Defaults to 'SLSQP'. - optOptions (dict, optional): Dictionary for setting the - optimization options. Defaults to None. - enable_geometric_yaw (bool, optional): If True, enables geometric yaw - optimization. Defaults to False. - use_value (bool, optional): If True, the layout optimization objective - is to maximize annual value production using the value array in the - FLORIS model's WindData object. If False, the optimization - objective is to maximize AEP. Defaults to False. - """ + super().__init__( fmodel, boundaries, From e5b96a651a02a4841a2346078770c1deae7a546d Mon Sep 17 00:00:00 2001 From: Eric Simley Date: Wed, 3 Apr 2024 14:55:03 -0600 Subject: [PATCH 08/10] updating scipy layout opt reg test results --- tests/reg_tests/scipy_layout_opt_regression_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/reg_tests/scipy_layout_opt_regression_test.py b/tests/reg_tests/scipy_layout_opt_regression_test.py index b26c96c3d..1029dfd76 100644 --- a/tests/reg_tests/scipy_layout_opt_regression_test.py +++ b/tests/reg_tests/scipy_layout_opt_regression_test.py @@ -17,8 +17,8 @@ baseline = np.array( [ - [0.00000000e+00, 4.96470529e+02, 1.00000000e+03], - [4.58108861e-15, 1.09603647e+01, 2.47721427e+01], + [0.0, 495.37587653, 1000.0], + [5.0, 11.40800868, 24.93196392], ] ) From a5dd4ae89111590f85aa5a308b1dc17b32078cdb Mon Sep 17 00:00:00 2001 From: ejsimley <40040961+ejsimley@users.noreply.github.com> Date: Wed, 3 Apr 2024 16:38:42 -0600 Subject: [PATCH 09/10] typo fix --- .../layout_optimization/layout_optimization_pyoptsparse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/floris/optimization/layout_optimization/layout_optimization_pyoptsparse.py b/floris/optimization/layout_optimization/layout_optimization_pyoptsparse.py index 095944a72..912f9b602 100644 --- a/floris/optimization/layout_optimization/layout_optimization_pyoptsparse.py +++ b/floris/optimization/layout_optimization/layout_optimization_pyoptsparse.py @@ -71,7 +71,7 @@ def __init__( self.logger.error(err_msg, stack_info=True) raise ImportError(err_msg) - # Instantiate ptOptSparse optimization object with name and objective function + # Instantiate pyOptSparse optimization object with name and objective function self.optProb = pyoptsparse.Optimization('layout', self._obj_func) self.optProb = self.add_var_group(self.optProb) From 975cbe7b41c6459e0fd8b12112fcbf27b8053bd2 Mon Sep 17 00:00:00 2001 From: Eric Simley Date: Thu, 4 Apr 2024 11:00:20 -0600 Subject: [PATCH 10/10] updating pyOptSparse layout optimization docstring --- .../layout_optimization_pyoptsparse.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/floris/optimization/layout_optimization/layout_optimization_pyoptsparse.py b/floris/optimization/layout_optimization/layout_optimization_pyoptsparse.py index 912f9b602..3a87dff70 100644 --- a/floris/optimization/layout_optimization/layout_optimization_pyoptsparse.py +++ b/floris/optimization/layout_optimization/layout_optimization_pyoptsparse.py @@ -20,9 +20,20 @@ class LayoutOptimizationPyOptSparse(LayoutOptimization): min_dist (float, optional): The minimum distance to be maintained between turbines during the optimization (m). If not specified, initializes to 2 rotor diameters. Defaults to None. - solver (str, optional): Sets the solver used by Scipy. Defaults to 'SLSQP'. + solver (str, optional): Sets the solver used by pyOptSparse. Defaults + to 'SLSQP'. optOptions (dict, optional): Dictionary for setting the optimization options. Defaults to None. + timeLimit (float, optional): Variable passed to pyOptSparse optimizer. + The maximum amount of time for optimizer to run (seconds). If None, + no time limit is imposed. Defaults to None. + storeHistory (str, optional): Variable passed to pyOptSparse optimizer. + File name of the history file into which the history of the + pyOptSparse optimization will be stored. Defaults to "hist.hist". + hotStart (str, optional): Variable passed to pyOptSparse optimizer. + File name of the history file to “replay” for the optimization. + If None, pyOptSparse initializes the optimization from scratch. + Defaults to None. enable_geometric_yaw (bool, optional): If True, enables geometric yaw optimization. Defaults to False. use_value (bool, optional): If True, the layout optimization objective