Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enabling layout optimization for value #862

Merged
merged 13 commits into from
Apr 4, 2024
Merged
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,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,
Expand All @@ -19,9 +41,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 +71,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
ejsimley marked this conversation as resolved.
Show resolved Hide resolved
self.optProb = pyoptsparse.Optimization('layout', self._obj_func)

self.optProb = self.add_var_group(self.optProb)
Expand Down Expand Up @@ -98,7 +127,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
Loading