import math
from typing import (
Any,
Dict,
List,
Tuple,
)
import matplotlib.lines
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from scipy.spatial.distance import pdist, squareform
from floris import FlorisModel
from floris.utilities import rotate_coordinates_rel_west, wind_delta
[docs]
def plot_turbine_points(
fmodel: FlorisModel,
ax: plt.Axes = None,
turbine_indices: List[int] = None,
plotting_dict: Dict[str, Any] = {},
) -> plt.Axes:
"""
Plots turbine layout from a FlorisModel object.
Args:
fmodel (FlorisModel): The FlorisModel object containing layout data.
ax (plt.Axes, optional): An existing axes object to plot on. If None,
a new figure and axes will be created. Defaults to None.
turbine_indices (List[int], optional): A list of turbine indices to plot.
If None, all turbines will be plotted. Defaults to None.
plotting_dict (Dict[str, Any], optional): A dictionary to customize plot
appearance. Valid keys include:
* 'color' (str): Turbine marker color. Defaults to 'black'.
* 'marker' (str): Turbine marker style. Defaults to '.'.
* 'markersize' (int): Turbine marker size. Defaults to 10.
* 'label' (str): Label for the legend. Defaults to None.
Returns:
plt.Axes: The axes object used for the plot.
Raises:
IndexError: If any value in `turbine_indices` is an invalid turbine index.
"""
# Generate axis, if needed
if ax is None:
_, ax = plt.subplots()
# If turbine_indices is not none, make sure all elements correspond to real indices
if turbine_indices is not None:
try:
fmodel.layout_x[turbine_indices]
except IndexError:
raise IndexError("turbine_indices does not correspond to turbine indices in fi")
else:
turbine_indices = list(range(len(fmodel.layout_x)))
# Generate plotting dictionary
default_plotting_dict = {
"color": "black",
"marker": ".",
"markersize": 10,
"label": None,
}
plotting_dict = {**default_plotting_dict, **plotting_dict}
# Plot
ax.plot(
fmodel.layout_x[turbine_indices],
fmodel.layout_y[turbine_indices],
linestyle="None",
**plotting_dict,
)
# Make sure axis set to equal
ax.axis("equal")
return ax
[docs]
def plot_turbine_labels(
fmodel: FlorisModel,
ax: plt.Axes = None,
turbine_names: List[str] = None,
turbine_indices: List[int] = None,
label_offset: float = None,
show_bbox: bool = False,
bbox_dict: Dict[str, Any] = {},
plotting_dict: Dict[str, Any] = {},
) -> plt.Axes:
"""
Adds turbine labels to a turbine layout plot.
Args:
fmodel (FlorisModel): The FlorisModel object containing layout data.
ax (plt.Axes, optional): An existing axes object to plot on. If None,
a new figure and axes will be created. Defaults to None.
turbine_names (List[str], optional): Custom turbine labels. If None,
defaults to turbine indices (e.g., '000', '001'). Defaults to None.
turbine_indices (List[int], optional): Indices of turbines to label.
If None, all turbines will be labeled. Defaults to None.
label_offset (float, optional): Distance to offset labels from turbine
points (in meters). If None, defaults to rotor_diameter/8.
Defaults to None.
show_bbox (bool, optional): If True, adds a bounding box around each label.
Defaults to False.
bbox_dict (Dict[str, Any], optional): Dictionary to customize the appearance
of bounding boxes (if show_bbox is True). Valid keys include:
* 'facecolor' (str): Box background color. Defaults to 'gray'.
* 'alpha' (float): Opacity of box. Defaults to 0.5.
* 'pad' (float): Padding around text. Defaults to 0.1.
* 'boxstyle' (str): Box style (e.g., 'round'). Defaults to 'round'.
plotting_dict (Dict[str, Any], optional): Dictionary to control text
appearance. Valid keys include:
* 'color' (str): Text color. Defaults to 'black'.
Returns:
plt.Axes: The axes object used for the plot.
Raises:
IndexError: If any value in `turbine_indices` is an invalid turbine index.
ValueError: If the length of `turbine_names` does not match the number of turbines.
"""
# Generate axis, if needed
if ax is None:
_, ax = plt.subplots()
# If turbine names not none, confirm has correct number of turbines
if turbine_names is not None:
if len(turbine_names) != len(fmodel.layout_x):
raise ValueError(
"Length of turbine_names not equal to number turbines in fmodel object"
)
else:
# Assign simple default numbering
turbine_names = [f"{i:03d}" for i in range(len(fmodel.layout_x))]
# If label_offset is None, use default value of r/8
if label_offset is None:
rotor_diameters = fmodel.core.farm.rotor_diameters.flatten()
r = rotor_diameters[0] / 2.0
label_offset = r / 8.0
# If turbine_indices is not none, make sure all elements correspond to real indices
if turbine_indices is not None:
try:
fmodel.layout_x[turbine_indices]
except IndexError:
raise IndexError("turbine_indices does not correspond to turbine indices in fi")
else:
turbine_indices = list(range(len(fmodel.layout_x)))
# Generate plotting dictionary
default_plotting_dict = {
"color": "black",
"label": None,
}
plotting_dict = {**default_plotting_dict, **plotting_dict}
# If showing bbox is true, if bbox_dict is None, use a default
default_bbox_dict = {"facecolor": "gray", "alpha": 0.5, "pad": 0.1, "boxstyle": "round"}
bbox_dict = {**default_bbox_dict, **bbox_dict}
for ti in turbine_indices:
if not show_bbox:
ax.text(
fmodel.layout_x[ti] + label_offset,
fmodel.layout_y[ti] + label_offset,
turbine_names[ti],
**plotting_dict,
)
else:
ax.text(
fmodel.layout_x[ti] + label_offset,
fmodel.layout_y[ti] + label_offset,
turbine_names[ti],
bbox=bbox_dict,
**plotting_dict,
)
# Plot labels and aesthetics
ax.axis("equal")
return ax
[docs]
def plot_turbine_rotors(
fmodel: FlorisModel,
ax: plt.Axes = None,
color: str = "k",
wd: float = None,
yaw_angles: np.ndarray = None,
) -> plt.Axes:
"""
Plots wind turbine rotors on an existing axes, visually representing their yaw angles.
Args:
fmodel (FlorisModel): The FlorisModel object containing layout and turbine data.
ax (plt.Axes, optional): An existing axes object to plot on. If None,
a new figure and axes will be created. Defaults to None.
color (str, optional): Color of the turbine rotor lines. Defaults to 'k' (black).
wd (float, optional): Wind direction (in degrees) relative to global reference.
If None, the first wind direction in `fmodel.core.flow_field.wind_directions` is used.
Defaults to None.
yaw_angles (np.ndarray, optional): Array of turbine yaw angles (in degrees). If None,
the values from `fmodel.core.farm.yaw_angles` are used. Defaults to None.
Returns:
plt.Axes: The axes object used for the plot.
"""
if not ax:
_, ax = plt.subplots()
if yaw_angles is None:
yaw_angles = fmodel.core.farm.yaw_angles
if wd is None:
wd = fmodel.core.flow_field.wind_directions[0]
# Rotate yaw angles to inertial frame for plotting turbines relative to wind direction
yaw_angles = yaw_angles - wind_delta(np.array(wd))
if color is None:
color = "k"
# If yaw angles is not 1D, assume we want first findex
yaw_angles = np.array(yaw_angles)
if yaw_angles.ndim == 2:
yaw_angles = yaw_angles[0, :]
rotor_diameters = fmodel.core.farm.rotor_diameters.flatten()
for x, y, yaw, d in zip(fmodel.layout_x, fmodel.layout_y, yaw_angles, rotor_diameters):
R = d / 2.0
x_0 = x + np.sin(np.deg2rad(yaw)) * R
x_1 = x - np.sin(np.deg2rad(yaw)) * R
y_0 = y - np.cos(np.deg2rad(yaw)) * R
y_1 = y + np.cos(np.deg2rad(yaw)) * R
ax.plot([x_0, x_1], [y_0, y_1], color=color)
return ax
[docs]
def get_wake_direction(x_i: float, y_i: float, x_j: float, y_j: float) -> float:
"""
Calculates the wind direction at which the wake of turbine i would impact turbine j.
Args:
x_i (float): X-coordinate of turbine i (the upstream turbine).
y_i (float): Y-coordinate of turbine i.
x_j (float): X-coordinate of turbine j (the downstream turbine).
y_j (float): Y-coordinate of turbine j.
Returns:
float: Wind direction in degrees (0-360) where 0 degrees represents wind
blowing from the north, and the angle increases clockwise.
"""
dx = x_j - x_i
dy = y_j - y_i
angle_rad = np.arctan2(dy, dx)
# Adjust for "from" direction (add 180 degrees) and wrap within 0-360
angle_deg = 270 - np.rad2deg(angle_rad)
wind_direction = angle_deg % 360
return wind_direction
[docs]
def label_line(
line: matplotlib.lines.Line2D,
label_text: str,
ax: plt.Axes,
near_i: int = None,
near_x: float = None,
near_y: float = None,
rotation_offset: float = 0.0,
offset: Tuple[float, float] = (0, 0),
size: int = 7,
) -> None:
"""
Adds a text label to a matplotlib line, with options to specify label placement.
Args:
line (matplotlib.lines.Line2D): The line object to label.
label_text (str): The text of the label.
ax (plt.Axes): The axes object where the line is plotted.
near_i (int, optional): Index near which to place the label. Defaults to None.
near_x (float, optional): X-coordinate near which to place the label. Defaults to None.
near_y (float, optional): Y-coordinate near which to place the label. Defaults to None.
rotation_offset (float, optional): Additional rotation for the label (in degrees).
Defaults to 0.0.
offset (Tuple[float, float], optional): X and Y offset from the label position.
Defaults to (0, 0).
size (int, optional): Font size of the label. Defaults to 7.
Raises:
ValueError: If none of `near_i`, `near_x`, or `near_y`
are provided to determine label placement.
"""
def put_label(i: int) -> None:
"""
Adds a label to a line segment within a plot (used internally by the 'label_line' function).
Args:
i (int): The index of the line segment where the label should be placed.
The label will be positioned between points i and i+1.
"""
i = min(i, len(x) - 2)
dx = sx[i + 1] - sx[i]
dy = sy[i + 1] - sy[i]
rotation = np.rad2deg(np.arctan2(dy, dx)) + rotation_offset
pos = [(x[i] + x[i + 1]) / 2.0 + offset[0], (y[i] + y[i + 1]) / 2 + offset[1]]
ax.text(
pos[0],
pos[1],
label_text,
size=size,
rotation=rotation,
color=line.get_color(),
ha="center",
va="center",
bbox={"ec": "1", "fc": "1", "alpha": 0.8},
)
# extract line data
x = line.get_xdata()
y = line.get_ydata()
# define screen spacing
if ax.get_xscale() == "log":
sx = np.log10(x)
else:
sx = x
if ax.get_yscale() == "log":
sy = np.log10(y)
else:
sy = y
# find index
if near_i is not None:
i = near_i
if i < 0: # sanitize negative i
i = len(x) + i
put_label(i)
elif near_x is not None:
for i in range(len(x) - 2):
if (x[i] < near_x and x[i + 1] >= near_x) or (x[i + 1] < near_x and x[i] >= near_x):
put_label(i)
elif near_y is not None:
for i in range(len(y) - 2):
if (y[i] < near_y and y[i + 1] >= near_y) or (y[i + 1] < near_y and y[i] >= near_y):
put_label(i)
else:
raise ValueError("Need one of near_i, near_x, near_y")
[docs]
def plot_waking_directions(
fmodel: FlorisModel,
ax: plt.Axes = None,
turbine_indices: List[int] = None,
wake_plotting_dict: Dict[str, Any] = {},
D: float = None,
limit_dist_D: float = None,
limit_dist_m: float = None,
limit_num: int = None,
wake_label_size: int = 7,
) -> plt.Axes:
"""
Plots lines representing potential waking directions between wind turbines in a layout.
Args:
fmodel (FlorisModel): Instantiated FlorisModel object containing layout data.
ax (plt.Axes, optional): An existing axes object to plot on. If None, a new
figure and axes will be created. Defaults to None.
turbine_indices (List[int], optional): Indices of turbines to include in the plot.
If None, all turbines are plotted. Defaults to None.
wake_plotting_dict (Dict[str, Any], optional): Dictionary to customize the appearance
of waking direction lines. Valid keys include:
* 'color' (str): Line color. Defaults to 'black'.
* 'linestyle' (str): Line style (e.g., 'solid', 'dashed'). Defaults to 'solid'.
* 'linewidth' (float): Line width. Defaults to 0.5.
D (float, optional): Rotor diameter. Used for distance calculations if `limit_dist_D`
is provided. If None, defaults to the first turbine's rotor diameter.
limit_dist_D (float, optional): Maximum distance between turbines (in rotor diameters)
to plot waking lines. Defaults to None (no limit).
limit_dist_m (float, optional): Maximum distance (in meters) between turbines to plot
waking lines. Overrides `limit_dist_D` if provided. Defaults to None (no limit).
limit_num (int, optional): Limits the number of waking lines plotted from each turbine
to the `limit_num` closest neighbors. Defaults to None (no limit).
wake_label_size (int, optional): Font size for labels showing wake distance and direction.
Defaults to 7.
Returns:
plt.Axes: The axes object used for the plot.
Raises:
IndexError: If any value in `turbine_indices` is an invalid turbine index.
"""
if not ax:
_, ax = plt.subplots()
# If turbine_indices is not none, make sure all elements correspond to real indices
if turbine_indices is not None:
try:
fmodel.layout_x[turbine_indices]
except IndexError:
raise IndexError("turbine_indices does not correspond to turbine indices in fi")
else:
turbine_indices = list(range(len(fmodel.layout_x)))
layout_x = fmodel.layout_x[turbine_indices]
layout_y = fmodel.layout_y[turbine_indices]
N_turbs = len(layout_x)
# Combine default plotting options
def_wake_plotting_dict = {
"color": "black",
"linestyle": "solid",
"linewidth": 0.5,
}
wake_plotting_dict = {**def_wake_plotting_dict, **wake_plotting_dict}
# N_turbs = len(fmodel.core.farm.turbine_definitions)
if D is None:
D = fmodel.core.farm.turbine_definitions[0]["rotor_diameter"]
# TODO: build out capability to use multiple diameters, if of interest.
# D = np.array([turb['rotor_diameter'] for turb in
# fmodel.core.farm.turbine_definitions])
# else:
# D = D*np.ones(N_turbs)
dists_m = np.zeros((N_turbs, N_turbs))
angles_d = np.zeros((N_turbs, N_turbs))
for i in range(N_turbs):
for j in range(N_turbs):
dists_m[i, j] = np.linalg.norm([layout_x[i] - layout_x[j], layout_y[i] - layout_y[j]])
angles_d[i, j] = get_wake_direction(layout_x[i], layout_y[i], layout_x[j], layout_y[j])
# Mask based on the limit distance (assumed to be in measurement D)
if limit_dist_D is not None and limit_dist_m is None:
limit_dist_m = limit_dist_D * D
if limit_dist_m is not None:
mask = dists_m > limit_dist_m
dists_m[mask] = np.nan
angles_d[mask] = np.nan
# Handle default limit number case
if limit_num is None:
limit_num = -1
# Loop over pairs, plot
label_exists = np.full((N_turbs, N_turbs), False)
for i in range(N_turbs):
for j in range(N_turbs):
# import ipdb; ipdb.set_trace()
if (
~np.isnan(dists_m[i, j])
and dists_m[i, j] != 0.0
and ~(dists_m[i, j] > np.sort(dists_m[i, :])[limit_num])
# and i in layout_plotting_dict["turbine_indices"]
# and j in layout_plotting_dict["turbine_indices"]
):
(h,) = ax.plot(
layout_x[[i, j]],
layout_y[[i, j]],
**wake_plotting_dict
)
# Only label in one direction
if ~label_exists[i, j]:
linetext = "{0:.1f} D --- {1:.0f}/{2:.0f}".format(
dists_m[i, j] / D,
angles_d[i, j],
angles_d[j, i],
)
label_line(
h,
linetext,
ax,
near_i=1,
near_x=None,
near_y=None,
rotation_offset=0,
size=wake_label_size,
)
label_exists[i, j] = True
label_exists[j, i] = True
return ax
[docs]
def plot_farm_terrain(fmodel: FlorisModel, ax: plt.Axes = None) -> None:
"""
Creates a filled contour plot visualizing terrain-corrected wind turbine hub heights.
Args:
fmodel (FlorisModel): The FlorisModel object containing layout data.
ax (plt.Axes, optional): An existing axes object to plot on. If None, a new
figure and axes will be created. Defaults to None.
"""
if not ax:
_, ax = plt.subplots()
hub_heights = fmodel.core.farm.hub_heights.flatten()
cntr = ax.tricontourf(fmodel.layout_x, fmodel.layout_y, hub_heights, levels=14, cmap="RdBu_r")
ax.get_figure().colorbar(
cntr,
ax=ax,
label="Terrain-corrected hub height (m)",
ticks=np.linspace(
np.min(hub_heights) - 10.0,
np.max(hub_heights) + 10.0,
15,
),
)
return ax
[docs]
def shade_region(
points: np.ndarray,
show_points: bool = False,
plotting_dict_region: Dict[str, Any] = {},
plotting_dict_points: Dict[str, Any] = {},
ax: plt.Axes = None,
) -> plt.Axes:
"""
Shades a region defined by a set of vertices and optionally plots the vertices.
Args:
points (np.ndarray): A 2D array where each row represents (x, y) coordinates of a vertex.
show_points (bool, optional): If True, plots markers at the specified vertices.
Defaults to False.
plotting_dict_region (Dict[str, Any], optional): Customization options for shaded region.
Valid keys include:
* 'color' (str): Fill color. Defaults to 'black'.
* 'edgecolor' (str): Edge color. Defaults to None (no edge).
* 'alpha' (float): Opacity (transparency) of the fill. Defaults to 0.3.
* 'label' (str): Optional label for legend.
plotting_dict_points (Dict[str, Any], optional): Customization options for vertex markers.
Valid keys include:
* 'color' (str): Marker color. Defaults to 'black'.
* 'marker' (str): Marker style (e.g., '.', 'o', 'x'). Defaults to None (no marker).
* 's' (float): Marker size. Defaults to 10.
* 'label' (str): Optional label for legend.
ax (plt.Axes, optional): An existing axes object for plotting. If None, creates a new figure
and axes. Defaults to None.
Returns:
plt.Axes: The axes object used for the plot.
"""
# Generate axis, if needed
if ax is None:
fig = plt.figure(figsize=(8, 8))
ax = fig.add_subplot(111)
# Generate plotting dictionary
default_plotting_dict_region = {
"color": "black",
"edgecolor": None,
"alpha": 0.3,
"label": None,
}
plotting_dict_region = {**default_plotting_dict_region, **plotting_dict_region}
ax.fill(points[:, 0], points[:, 1], **plotting_dict_region)
if show_points:
default_plotting_dict_points = {"color": "black", "marker": ".", "s": 10, "label": None}
plotting_dict_points = {**default_plotting_dict_points, **plotting_dict_points}
ax.scatter(points[:, 0], points[:, 1], **plotting_dict_points)
# Plot labels and aesthetics
ax.axis("equal")
return ax