Skip to content

Commit

Permalink
Enabling layout optimization for value (#862)
Browse files Browse the repository at this point in the history
* adding value functions to wind_data

* adding functions to get expected and annual value in floris_model

* including value objective in layout optimization

* updating floris model integration tests for AVP

* reg test for scipy layout opt with value

* Fix docstring and a few spelling errors

* Update docstring

* updating scipy layout opt reg test results

* typo fix

* updating pyOptSparse layout optimization docstring

---------

Co-authored-by: Paul <[email protected]>
  • Loading branch information
ejsimley and paulf81 authored Apr 4, 2024
1 parent 397d93c commit 27fe153
Show file tree
Hide file tree
Showing 8 changed files with 742 additions and 106 deletions.
142 changes: 142 additions & 0 deletions floris/floris_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,39 @@


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 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
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,
Expand All @@ -19,9 +52,16 @@ 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)

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)
Expand All @@ -42,7 +82,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 pyOptSparse optimization object with name and objective function
self.optProb = pyoptsparse.Optimization('layout', self._obj_func)

self.optProb = self.add_var_group(self.optProb)
Expand Down Expand Up @@ -98,7 +138,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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,29 +44,15 @@ def __init__(
solver='SLSQP',
optOptions=None,
enable_geometric_yaw=False,
use_value=False,
):
"""
_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.
"""

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 = [
Expand Down Expand Up @@ -101,7 +113,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):
Expand Down Expand Up @@ -205,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:
Expand Down
Loading

0 comments on commit 27fe153

Please sign in to comment.