"""
This module provides helpful functions for creating various plots
"""
from __future__ import annotations
import datetime
import numpy as np
import pandas as pd
import matplotlib as mpl
import numpy.typing as npt
import matplotlib.pyplot as plt
from pyproj import Transformer
from bokeh.models import WMTSTileSource, ColumnDataSource
from bokeh.palettes import Category10, viridis
from bokeh.plotting import figure
from matplotlib.ticker import StrMethodFormatter
from openoa import PlantData
NDArrayFloat = npt.NDArray[np.float64]
plt.close("all")
[docs]
def set_styling() -> None:
"""Sets some of the matplotlib plotting styling to be consistent throughout any module where
plotting is implemented.
"""
font = {"family": "serif", "size": 14}
mpl.rc("font", **font)
mpl.rc("text", usetex=False)
mpl.rcParams["figure.figsize"] = (15, 6)
mpl.rcParams["axes.grid"] = True
mpl.rcParams["axes.axisbelow"] = True
set_styling()
[docs]
def map_wgs84_to_cartesian(
longitude_origin: NDArrayFloat | float,
latitude_origin: NDArrayFloat | float,
longitude_points: NDArrayFloat | pd.Series | float,
latitude_points: NDArrayFloat | pd.Series | float,
) -> tuple[NDArrayFloat, NDArrayFloat] | tuple[pd.Series, pd.Series] | tuple[float, float]:
"""Maps WGS-84 latitude and longitude to local cartesian coordinates using an origin coordinate
pair.
Args:
longitude_origin(:obj:`numpy array of shape (1, ) | float`): longitude of cartesian
coordinate system origin.
latitude_origin(:obj:`numpy array of shape (1, ) | float`): latitude of cartesian
coordinate system origin.
longitude_points(:obj:`numpy array of shape (n, ) | pd.Series | float`): longitude(s) of
points of interest.
latitude_points(:obj:`numpy array of shape (n, ) | pd.Series | float`): latitude(s) of
points of interest.
Returns:
Tuple representing cartesian coordinates (x, y); returned as a tuple of numpy arrays,
pandas Series, or scalars, dependent upon the originally passed data.
"""
R = 6371e3 # Earth radius, in meters
delta_phi = np.radians(latitude_points - latitude_origin)
delta_lambda = np.radians(longitude_points - longitude_origin)
phi_origin = np.radians(latitude_origin)
phi_points = np.radians(latitude_points)
lambda_origin = np.radians(longitude_origin)
lambda_points = np.radians(longitude_points)
a = (
np.sin(delta_phi / 2) ** 2
+ np.cos(phi_origin) * np.cos(phi_points) * np.sin(delta_lambda / 2) ** 2
)
c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1 - a))
rho = R * c
a = np.sin(lambda_points - lambda_origin) * np.cos(phi_points)
b = np.cos(phi_origin) * np.sin(phi_points) - np.sin(phi_origin) * np.cos(phi_points) * np.cos(
lambda_points - lambda_origin
)
theta = -1 * np.arctan2(a, b) + np.pi / 2
x = rho * np.cos(theta)
y = rho * np.sin(theta)
return x, y
[docs]
def luminance(rgb: tuple[int, int, int]):
"""Calculates the brightness of an rgb 255 color. See https://en.wikipedia.org/wiki/Relative_luminance
Args:
rgb(:obj:`tuple`): Tuple of red, gree, and blue values in the range of 0-255.
Returns:
luminance(:obj:`int`): relative luminance.
Example:
.. code-block:: python
>>> rgb = (255,127,0)
>>> luminance(rgb)
0.5687976470588235
>>> luminance((0,50,255))
0.21243529411764706
"""
luminance = (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) / 255
return luminance
[docs]
def color_to_rgb(color: str | tuple[int, int, int]):
"""Converts named colors, hex and normalised RGB to 255 RGB values
Args:
color(:obj:`color`): RGB, HEX or named color.
Returns:
rgb(:obj:`tuple`): 255 RGB values.
Example:
.. code-block:: python
>>> color_to_rgb("Red")
(255, 0, 0)
>>> color_to_rgb((1,1,0))
(255,255,0)
>>> color_to_rgb("#ff00ff")
(255,0,255)
"""
if isinstance(color, tuple):
if max(color) > 1:
color = tuple([i / 255 for i in color])
rgb = mpl.colors.to_rgb(color)
rgb = tuple([int(i * 255) for i in rgb])
return rgb
[docs]
def plot_windfarm(
asset_df,
tile_name="OpenMap",
plot_width=800,
plot_height=800,
marker_size=14,
figure_kwargs={},
marker_kwargs={},
):
"""Plot the windfarm spatially on a map using the Bokeh plotting libaray.
Args:
asset_df(:obj:`pd.DataFrame`): PlantData.asset object containing the asset metadata.
tile_name(:obj:`str`): tile set to be used for the underlay, e.g. OpenMap, ESRI, OpenTopoMap
plot_width(:obj:`int`): width of plot
plot_height(:obj:`int`): height of plot
marker_size(:obj:`int`): size of markers
figure_kwargs(:obj:`dict`): additional figure options for advanced users, see Bokeh docs
marker_kwargs(:obj:`dict`): additional marker options for advanced users, see Bokeh docs.
We have some custom behavior around the "fill_color" attribute. If "fill_color" is not
defined, OpenOA will use an internally defined color pallete. If "fill_color" is the
name of a column in the asset table, OpenOA will use the value of that column as the
marker color. Otherwise, "fill_color" is passed through to Bokeh.
Returns:
Bokeh_plot(:obj:`axes handle`): windfarm map
Example:
.. bokeh-plot::
import pandas as pd
from bokeh.plotting import figure, output_file, show
from openoa.utils.plot import plot_windfarm
from examples import project_ENGIE
# Load plant object
project = project_ENGIE.prepare("../examples/data/la_haute_borne")
# Create the bokeh wind farm plot
show(plot_windfarm(project.asset, tile_name="ESRI", plot_width=600, plot_height=600))
"""
# See https://wiki.openstreetmap.org/wiki/Tile_servers for various tile services
MAP_TILES = {
"OpenMap": WMTSTileSource(url="http://c.tile.openstreetmap.org/{Z}/{X}/{Y}.png"),
"ESRI": WMTSTileSource(
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{Z}/{Y}/{X}.jpg"
),
"OpenTopoMap": WMTSTileSource(url="https://tile.opentopomap.org/{Z}/{X}/{Y}.png"),
}
# Use pyproj to transform longitude and latitude into web-mercator and add to a copy of the asset dataframe
TRANSFORM_4326_TO_3857 = Transformer.from_crs("EPSG:4326", "EPSG:3857")
asset_df["x"], asset_df["y"] = TRANSFORM_4326_TO_3857.transform(
asset_df["latitude"], asset_df["longitude"]
)
asset_df["coordinates"] = tuple(zip(asset_df["latitude"], asset_df["longitude"]))
# Define default and then update figure and marker options based on kwargs
figure_options = {
"tools": "save,hover,pan,wheel_zoom,reset,help",
"x_axis_label": "Longitude",
"y_axis_label": "Latitude",
"match_aspect": True,
"tooltips": [("asset_id", "@asset_id"), ("type", "@type"), ("(Lat,Lon)", "@coordinates")],
}
figure_options.update(figure_kwargs)
marker_options = {
"marker": "circle_y",
"line_width": 1,
"alpha": 0.8,
"fill_color": "auto_fill_color",
"line_color": "auto_line_color",
"legend_group": "type",
}
marker_options.update(marker_kwargs)
# Create an appropriate fill color map and contrasting line color
if marker_options["fill_color"] == "auto_fill_color":
color_grouping = marker_options["legend_group"]
asset_df = asset_df.sort_values(color_grouping)
if len(set(asset_df[color_grouping])) <= 10:
color_palette = list(Category10[10])
else:
color_palette = viridis(len(set(asset_df[color_grouping])))
color_mapping = dict(zip(set(asset_df[color_grouping]), color_palette))
asset_df["auto_fill_color"] = asset_df[color_grouping].map(color_mapping)
asset_df["auto_fill_color"] = asset_df["auto_fill_color"].apply(color_to_rgb)
asset_df["auto_line_color"] = [
"black" if luminance(color) > 0.5 else "white" for color in asset_df["auto_fill_color"]
]
else:
if marker_options["fill_color"] in asset_df.columns:
asset_df[marker_options["fill_color"]] = asset_df[marker_options["fill_color"]].apply(
color_to_rgb
)
asset_df["auto_line_color"] = [
"black" if luminance(color) > 0.5 else "white"
for color in asset_df[marker_options["fill_color"]]
]
else:
asset_df["auto_line_color"] = "black"
# Create the bokeh data source without the "geometry" that isn't compatible with bokeh
source = ColumnDataSource(asset_df.drop(columns=["geometry"]))
# Create a bokeh figure with tiles
plot_map = figure(
width=plot_width,
height=plot_height,
x_axis_type="mercator",
y_axis_type="mercator",
**figure_options,
)
plot_map.add_tile(MAP_TILES[tile_name])
# Plot the asset devices
plot_map.scatter(x="x", y="y", source=source, size=marker_size, **marker_options)
return plot_map
[docs]
def plot_by_id(
df: pd.DataFrame,
id_col: str,
x_axis: str,
y_axis: str,
max_cols: int = 4,
xlim: tuple[float, float] = (None, None),
ylim: tuple[float, float] = (None, None),
xlabel: str | None = None,
ylabel: str | None = None,
return_fig: bool = False,
figure_kwargs: dict = {},
plot_kwargs: dict = {},
) -> None:
"""Function to plot any two fields against each other in a dataframe with unique plots for each
asset_id.
Args:
df(:obj:`pd.DataFrame`): The dataframe for comparing values.
id_col(:obj:`str`): The asset_id column (or index column) in `df`.
x_axis(:obj:`str`): Independent variable to plot, should align with a column in :py:attr:`df`.
y_axis(:obj:`str`): Dependent variable to plot, should align with a column in :py:attr:`df`.
max_cols(:obj:`int`, optional): The maximum number of columns in the plot. Defaults to 4.
xlim(:obj:`tuple[float, float]`, optional): A tuple of the x-axis (min, max) values.
Defaults to (None, None).
ylim(:obj:`tuple[float, float]`, optional): A tuple of the y-axis (min, max) values.
Defaults to (None, None).
xlabel(:obj:`str` | None): The x-axis label, if None, then :py:attr:`x_axis` will be used.
Defaults to None.
ylabel(:obj:`str` | None): The y-axis label, if None, then :py:attr:`x_axis` will be used.
Defaults to None.
return_fig(:obj:`bool`, optional): Set to True to return the figure and axes objects,
otherwise set to False. Defaults to False.
figure_kwargs(:obj:`dict`, optional): Additional keyword arguments that should be passed to
`plt.figure()`. Defaults to {}.
plot_kwargs(:obj:`dict`, optional): Additional keyword arguments that should be passed
to `ax.scatter`. Defaults to {}.
Returns:
(:obj:`None`)
"""
# Operate on a totally new copy of the data so that transofrmations don't carry through
df = df.copy()
# Get the id_col as the first index in the multi index or ensure it is the primary index
if not isinstance(df.index, pd.MultiIndex):
if id_col != df.index.name:
df = df.set_index(id_col, append=True)
elif id_col not in df.index.names:
df = df.set_index(id_col, append=True)
if isinstance(df.index, pd.MultiIndex):
df = df.swaplevel(id_col, 0)
# Check that the columns are valid
if x_axis not in df.columns:
raise ValueError(f"'{x_axis}' is not a valid column")
if y_axis not in df.columns:
raise ValueError(f"'{x_axis}' is not a valid column")
# Create the plotting parameters
id_arrary = df.index.get_level_values(id_col).unique()
num_id = id_arrary.size
num_rows = int(np.ceil(num_id / max_cols))
# Set the plotting defaults, if None are set
xlabel = x_axis if xlabel is None else xlabel
ylabel = y_axis if ylabel is None else ylabel
figure_kwargs.setdefault("figsize", (15, num_rows * 5))
plot_kwargs.setdefault("s", 5)
# Create the plot
fig, axes_list = plt.subplots(num_rows, max_cols, sharex=True, sharey=True, **figure_kwargs)
for i, (t_id, ax) in enumerate(zip(id_arrary, axes_list.flatten())):
scada = df.loc[t_id]
ax.scatter(scada[x_axis], scada[y_axis], **plot_kwargs)
ax.set_title(t_id)
# Only add axis labels for the bottom row and leftmost column
if np.floor(i / max_cols) + 1 == num_rows:
ax.set_xlabel(xlabel)
if i % max_cols == 0:
ax.set_ylabel(ylabel)
ax.set_xlim(xlim)
ax.set_ylim(ylim)
# Delete the extra axes
num_axes = axes_list.size
if i < num_axes - 1:
for j in range(i + 1, num_axes):
fig.delaxes(axes_list.flatten()[j])
fig.tight_layout()
plt.show()
if return_fig:
return fig, axes_list
[docs]
def column_histograms(df: pd.DataFrame, columns: list = None, return_fig: bool = False):
"""Produces a histogram plot for each numeric column in :py:attr:`df`.
Args:
df(:obj:`pd.DataFrame`): The dataframe for plotting.
return_fig(:obj:`bool`): Indicator for if the figure and axes objects should be returned,
by default False.
Returns:
(None)
"""
df = df.select_dtypes((int, float)).copy()
columns = df.columns.tolist() if columns is None else columns
num_cols = len(columns)
max_cols = 3
num_rows = int(np.ceil(num_cols / max_cols))
fig, axes_list = plt.subplots(num_rows, max_cols, figsize=(15, num_rows * 5))
for i, (col, ax) in enumerate(zip(columns, axes_list.flatten())):
data = df.loc[:, col].dropna().values
ax.hist(data, 40)
ax.set_title(col)
# Only add axis labels for the bottom row
if i % 4 == 0:
ax.set_ylabel("Count of Occurrences")
# Delete the extra axes
num_axes = axes_list.size
if i < num_axes - 1:
for j in range(i + 1, num_axes):
fig.delaxes(axes_list.flatten()[j])
fig.tight_layout()
plt.show()
if return_fig:
return fig, axes_list
[docs]
def plot_power_curve(
wind_speed: pd.Series,
power: pd.Series,
flag: np.ndarray | pd.Series,
flag_labels: tuple[str, str] = ("Flagged Readings", "Power Curve"),
xlim: tuple[float, float] = (None, None),
ylim: tuple[float, float] = (None, None),
legend: bool = False,
return_fig: bool = False,
figure_kwargs: dict = {},
legend_kwargs: dict = {},
scatter_kwargs: dict = {},
) -> None | tuple[plt.Figure, plt.Axes]:
"""Plots the individual points on a power curve, with an optional :py:attr:`flag` filtering for
singling out readings in the figure. If `flag` is all false values then no overlaid flagge
scatter points will be created.
Args:
wind_speed (:obj:`pandas.Series`): A pandas Series or numpy array of the recorded wind
speeds, in m/s.
power (:obj:`pandas.Series` | `np.ndarray`): A pandas Series or numpy array of
the recorded power, in kW.
flag (:obj:`numpy.ndarray` | `pd.Series`): A pandas Series or numpy array of booleans for
which points to flag in the windspeed and power data.
flag_labels (:obj:`tuple[str, str]`, optional): The labels to give to the scatter points,
corresponding to the flagged points and raw points, respectively. Defaults to
("Flagged Readings", "Power Curve").
xlim (:obj:`tuple[float, float]`, optional): A tuple of the x-axis (min, max) values.
Defaults to (None, None).
ylim (:obj:`tuple[float, float]`, optional): A tuple of the y-axis (min, max) values.
Defaults to (None, None).
legend (:obj:`bool`, optional): Set to True to place a legend in the figure, otherwise set
to False. Defaults to False.
return_fig (:obj:`bool`, optional): Set to True to return the figure and axes objects,
otherwise set to False. Defaults to False.
figure_kwargs (:obj:`dict`, optional): Additional keyword arguments that should be passed to
``plt.figure()``. Defaults to {}.
scatter_kwargs (:obj:`dict`, optional): Additional keyword arguments that should be passed
to ``ax.scatter()``. Defaults to {}.
legend_kwargs (:obj:`dict`, optional): Additional keyword arguments that should be passed to
``ax.legend()``. Defaults to {}.
Returns:
None | tuple[plt.Figure, plt.Axes]: _description_
"""
figure_kwargs.setdefault("dpi", 200)
fig = plt.figure(**figure_kwargs)
ax = fig.add_subplot(111)
if ~np.any(flag):
pc_label = "Power Curve" if flag_labels is None else flag_labels[1]
ax.scatter(wind_speed, power, label=pc_label, **scatter_kwargs)
else:
pc_label = "Power Curve" if flag_labels is None else flag_labels[1]
flagged_label = "Flagged Readings" if flag_labels is None else flag_labels[0]
ax.scatter(wind_speed, power, label=pc_label, **scatter_kwargs)
ax.scatter(wind_speed[flag], power[flag], label=flagged_label, **scatter_kwargs)
ax.yaxis.set_major_formatter(StrMethodFormatter("{x:,.0f}"))
if legend:
ax.legend(**legend_kwargs)
ax.set_xlabel("Wind Speed (m/s)")
ax.set_ylabel("Power (kW)")
ax.set_xlim(xlim)
ax.set_ylim(ylim)
if return_fig:
return fig, ax
fig.tight_layout()
plt.show()
[docs]
def plot_monthly_reanalysis_windspeed(
data: dict[str, pd.DataFrame],
windspeed_col: str,
plant_por: tuple[datetime.datetime, datetime.datetime],
normalize: bool = True,
xlim: tuple[datetime.datetime, datetime.datetime] = (None, None),
ylim: tuple[float, float] = (None, None),
return_fig: bool = False,
figure_kwargs: dict = {},
plot_kwargs: dict = {},
legend_kwargs: dict = {},
) -> None | tuple[plt.Figure, plt.Axes]:
"""Make a plot of the normalized annual average wind speeds from reanalysis data to show general
trends for each, and highlighting the period of record for the plant data.
Args:
data(:obj:`dict[pandas.DataFrame]`): The dictionary of reanalysis dataframes.
windspeed_col(:obj:`str`): The name of the column for the windspeed data to be plot.
plot_por(:obj:`tuple[datetime.datetime, datetime.datetime]`): The start and end datetimes
for a plant's period of record (POR).
normalize(:obj:`bool`): Indicator of if the windspeeds shoudld be normalized (True), or not
(False). Defaults to True.
xlim (:obj:`tuple[datetime.datetime, datetime.datetime]`, optional): A tuple of datetimes
representing the x-axis plotting display limits. Defaults to (None, None).
ylim (:obj:`tuple[float, float]`, optional): A tuple of the y-axis plotting display limits.
Defaults to (None, None).
return_fig (:obj:`bool`, optional): Flag to return the figure and axes objects. Defaults to False.
figure_kwargs (:obj:`dict`, optional): Additional figure instantiation keyword arguments
that are passed to ``plt.figure()``. Defaults to {}.
plot_kwargs (:obj:`dict`, optional): Additional plotting keyword arguments that are passed to
``ax.plot()``. Defaults to {}.
legend_kwargs (:obj:`dict`, optional): Additional legend keyword arguments that are passed to
``ax.legend()``. Defaults to {}.
Returns:
None | tuple[matplotlib.pyplot.Figure, matplotlib.pyplot.Axes]: If :py:attr:`return_fig` is
True, then the figure and axes objects are returned for further tinkering/saving.
"""
# Define parameters needed for plotting
min_val, max_val = (np.inf, -np.inf) if ylim == (None, None) else ylim
figure_kwargs.setdefault("figsize", (14, 6))
figure_kwargs.setdefault("dpi", 200)
fig = plt.figure(**figure_kwargs)
ax = fig.add_subplot(111)
for name, df in data.items():
# Compute the rolling mean and normalize it over a 12 month average
ws = df.resample("MS")[windspeed_col].mean().to_frame().rolling(12).mean()
if normalize:
ws = ws[windspeed_col] / ws[windspeed_col].mean()
# Update the min and max values
min_val = min(min_val, ws.min())
max_val = max(max_val, ws.max())
ax.plot(ws, label=name, **plot_kwargs)
# Plot a vertical line at y = 1
_xlims = (ws.index[0], ws.index[-1]) if xlim is None else xlim
ax.hlines(1, *_xlims, colors="k", linestyles="--")
# Fill in the period of record
ax.fill_between(
plant_por,
[min_val, min_val],
[max_val, max_val],
alpha=0.1,
label="Plant POR",
)
ax.set_xlim(xlim)
ax.set_ylim(ylim)
ax.set_xlabel("Year")
ax.set_ylabel("Normalized wind speed")
ax.legend(**legend_kwargs)
fig.tight_layout()
plt.show()
if return_fig:
return fig, ax
[docs]
def plot_plant_energy_losses_timeseries(
data: pd.DataFrame,
energy_col: str,
loss_cols: list[str],
energy_label: str,
loss_labels: list[str],
xlim: tuple[datetime.datetime, datetime.datetime] = (None, None),
ylim_energy: tuple[float, float] = (None, None),
ylim_loss: tuple[float, float] = (None, None),
return_fig: bool = False,
figure_kwargs: dict = {},
plot_kwargs: dict = {},
legend_kwargs: dict = {},
):
"""
Plot timeseries of energy, and the loss categories of interest.
Args:
data(:obj:`pandas.DataFrame`): A pandas DataFrame containing energy production and losses.
energy_col(:obj:`str`): The name of the column in :py:attr:`data` containing the energy production.
loss_cols(:obj:`list[str]`): The name(s) of the column(s) in :py:attr:`data` containing the loss data.
energy_label(:obj:`str`): The legend label and y-axis label for the energy plot.
loss_labels(:obj:`list[str]`): The legend labels losses plot.
xlim (:obj:`tuple[datetime.datetime, datetime.datetime]`, optional): A tuple of datetimes
representing the x-axis plotting display limits. Defaults to None.
ylim_energy (:obj:`tuple[float, float]`, optional): A tuple of the y-axis plotting display
limits for the gross energy plot (top figure). Defaults to None.
ylim_loss (:obj:`tuple[float, float]`, optional): A tuple of the y-axis plotting display
limits for the loss plot (bottom figure). Defaults to (None, None).
return_fig (:obj:`bool`, optional): Flag to return the figure and axes objects. Defaults to False.
figure_kwargs (:obj:`dict`, optional): Additional figure instantiation keyword arguments
that are passed to ``plt.figure()``. Defaults to {}.
plot_kwargs (:obj:`dict`, optional): Additional plotting keyword arguments that are passed to
``ax.plot()``. Defaults to {}.
legend_kwargs (:obj:`dict`, optional): Additional legend keyword arguments that are passed to
``ax.legend()``. Defaults to {}.
Returns:
None | tuple[matplotlib.pyplot.Figure, tuple[matplotlib.pyplot.Axes, matplotlib.pyplot.Axes]]:
If :py:attr:`return_fig` is True, then the figure and axes objects are returned for further
tinkering/saving.
"""
figure_kwargs.setdefault("figsize", (12, 9))
figure_kwargs.setdefault("dpi", 200)
fig = plt.figure(**figure_kwargs)
ax1 = fig.add_subplot(211)
ax2 = fig.add_subplot(212, sharex=ax1)
axes = (ax1, ax2)
# Plot the gross energy production
ax1.plot(data[energy_col], ".-", label=energy_label, **plot_kwargs)
ax1.set_xlabel("Year")
ax1.set_ylabel(energy_label)
# Joint availability and curtailment plot
for col, label in zip(loss_cols, loss_labels):
ax2.plot(data[col] * 100, ".-", label=label, **plot_kwargs)
ax2.set_xlabel("Year")
ax2.set_ylabel("Loss (%)")
for ax in axes:
ax.legend(**legend_kwargs)
ax1.set_xlim(xlim)
ax1.set_ylim(ylim_energy)
ax2.set_ylim(ylim_loss)
fig.tight_layout()
plt.show()
if return_fig:
return fig, axes
[docs]
def plot_distributions(
data: pd.DataFrame,
which: list[str],
xlabels: list[str],
xlim: tuple[tuple[float, float], ...] = None,
ylim: tuple[tuple[float, float], ...] = None,
return_fig: bool = False,
figure_kwargs: dict = {},
plot_kwargs: dict = {},
annotate_kwargs: dict = {},
title: str | None = None,
) -> None | tuple[plt.Figure, plt.Axes]:
"""
Plot a distribution of AEP values from the Monte-Carlo OA method
Args:
aep(:obj:`pandas.DataFrame`): The pandas DataFrame of results data.
which:(:obj:`list[str]`): The list of columns in data that should have their distributions plot.
xlabels:(obj:`list[str]`): The list of x-axis labels
xlim(:obj:`tuple[tuple[float, float], ...]`, optional): A tuple of tuples (or None)
corresponding to each of elements of :py:attr:`which` that get passed to ``ax.set_xlim()``.
Defaults to None.
ylim(:obj:`tuple[tuple[float, float], ...]`, optional): A tuple of tuples (or None)
corresponding to each of elements of :py:attr:`which` that get passed to ``ax.set_ylim()``.
Defaults to None.
return_fig (:obj:`bool`, optional): Flag to return the figure and axes objects. Defaults to ``False``.
figure_kwargs (:obj:`dict`, optional): Additional figure instantiation keyword arguments
that are passed to ```plt.figure()```. Defaults to {}.
plot_kwargs (:obj:`dict`, optional): Additional plotting keyword arguments that are passed to
``ax.hist()``. Defaults to {}.
annotate_kwargs (:obj:`dict`, optional): Additional annotation keyword arguments that are
passed to ``ax.annotate()``. Defaults to {}.
title (:str:, optional): Title to place over all subplots.
Returns:
None | tuple[matplotlib.pyplot.Figure, matplotlib.pyplot.Axes]: If :py:attr:`return_fig` is
True, then the figure and axes objects are returned for further tinkering/saving.
"""
if xlim is None:
xlim = tuple([(None, None) for _ in range(len(which))])
if ylim is None:
ylim = tuple([(None, None) for _ in range(len(which))])
if len(which) != len(xlabels) != len(xlim) != len(ylim):
raise ValueError(
"The inputs to `which`, `xlabels`, `xlim`, and `ylim` must be the same length."
)
annotate_kwargs.setdefault("fontsize", 12)
figure_kwargs.setdefault("figsize", (14, 12))
figure_kwargs.setdefault("dpi", 200)
fig = plt.figure(**figure_kwargs)
axes = fig.subplots(2, 2, gridspec_kw=dict(wspace=0.1, hspace=0.2))
for ax, col, label, _xlim, _ylim in zip(axes.flatten(), which, xlabels, xlim, ylim):
vals = data[col].values
u_vals = vals.mean()
ax.hist(vals, 40, density=1, **plot_kwargs)
ax.annotate(
f"Mean = {u_vals:.1f}",
(0.05, 0.9),
xycoords="axes fraction",
**annotate_kwargs,
)
uncertainty = (vals.std() / u_vals) if u_vals != 0.0 else 0.0
ax.annotate(
f"Uncertainty = {uncertainty * 100:.1f}%",
(0.05, 0.85),
xycoords="axes fraction",
**annotate_kwargs,
)
ax.set_xlabel(label)
ax.set_xlim(_xlim)
ax.set_ylim(_ylim)
if title:
plt.suptitle(title)
# Delete the extra axes
if (n_delete := len(axes.flatten()) - len(which)) > 0:
n = len(axes.flatten())
for i in range(1, n_delete + 1):
fig.delaxes(axes.flatten()[n - i])
plt.show()
if return_fig:
return fig, axes
def _generate_swarm_values(y, n_bins=None, width: float = 0.5):
"""Create the x-coordiantes for `y` so that plotting each value in the distribution of
:py:attr:`y` appears like that of a `seaborn.swarmplot` without requiring an additional dependency.
Args:
y (:obj:`pandas.Series`): The values to generate a matching x-value for a non-overlapping
scatter plot of all the points in the distribution.
n_bins (:obj:`int`, optional): The number of bins to use to generate the x-coordinates. If
``None``, then it is ``y.size // 6``. Defaults to None.
width (:obj:`float`, optional): The maximum width of the x data in either
direction. Defaults to 0.5.
Returns:
:obj:`numpy.ndarray` An array of x-coordinates to plot as a scatter against :py:attr:`y`.
"""
if n_bins is None:
n_bins = y.size // 6
# Get the upper bound of each bin
x = np.zeros_like(y)
y_min, y_max = y.min(), y.max()
dy = (y_max - y_min) / n_bins
y_bins = np.linspace(y_min + dy, y_max - dy, n_bins - 1)
# Divide the indices into their appropriate bins
i = np.arange(y.size)
ix_bin_groups = [0] * n_bins
y_bin_groups = [0] * n_bins
n_max = 0
for j, y_bin in enumerate(y_bins):
ix_bin = y <= y_bin
ix_bin_groups[j], y_bin_groups[j] = i[ix_bin], y[ix_bin]
n_max = max(n_max, len(ix_bin_groups[j]))
i, y = i[~ix_bin], y[~ix_bin]
# Fill in the last bin grouping values
ix_bin_groups[-1], y_bin_groups[-1] = i, y
n_max = max(n_max, len(ix_bin_groups[-1]))
# Assign the x indices in alternating fashion for each bin to ensure the x values are roughly symmetric
dx = 1 / (n_max // 2)
for i, vals in zip(ix_bin_groups, y_bin_groups):
if len(i) > 1:
j = len(i) % 2
i = i[np.argsort(vals)]
a = i[j::2]
b = i[j + 1 :: 2]
x[a] = (0.5 + j / 3 + np.arange(len(b))) * dx * width
x[b] = (0.5 + j / 3 + np.arange(len(b))) * -dx * width
return x
[docs]
def plot_boxplot(
x: pd.Series,
y: pd.Series,
xlabel: str,
ylabel: str,
ylim: tuple[float | None, float | None] = (None, None),
with_points: bool = False,
points_label: str | None = None,
return_fig: bool = False,
figure_kwargs: dict = {},
plot_kwargs_box: dict = {},
plot_kwargs_points: dict = {},
legend_kwargs: dict = {},
) -> None | tuple[plt.Figure, plt.Axes]:
"""Plot box plots of AEP results sliced by a specified Monte Carlo parameter
Args:
x(:obj:`pandas.Series`): The data that splits the results in y.
y(:obj:`pandas.Series`): The resulting data to be splity by x.
xlabel(:obj:`str`): The x-axis label.
ylabel(:obj:`str`): The y-axis label.
ylim(:obj:`tuple[float, float]`, optional): A tuple of the y-axis plotting display limits.
Defaults to None.
with_points(:obj:`bool`, optional): Flag to plot the individual points like a seaborn
``swarmplot``. Defaults to False.
points_label(:obj:`bool` | None, optional): Legend label for the points, if plotting.
Defaults to None.
return_fig(:obj:`bool`, optional): Flag to return the figure and axes objects. Defaults to False.
figure_kwargs(:obj:`dict`, optional): Additional figure instantiation keyword arguments
that are passed to `plt.figure()`. Defaults to {}.
plot_kwargs_box(:obj:`dict`, optional): Additional plotting keyword arguments that are passed to
``ax.boxplot()``. Defaults to {}.
plot_kwargs_points(:obj:`dict`, optional): Additional plotting keyword arguments that are passed to
``ax.boxplot()``. Defaults to {}.
legend_kwargs(:obj:`dict`, optional): Additional legend keyword arguments that are passed to
``ax.legend()``. Defaults to {}.
Returns:
None | tuple[matplotlib.pyplot.Figure, matplotlib.pyplot.Axes, dict]: If :py:attr:`return_fig` is
True, then the figure object, axes object, and a dictionary of the boxplot objects are
returned for further tinkering/saving.
"""
df = pd.DataFrame(data={"x": x, "y": y})
figure_kwargs.setdefault("figsize", (8, 6))
fig = plt.figure(**figure_kwargs)
ax = fig.add_subplot(111)
parameters = x.unique()
parameters.sort()
y_groups = [df.loc[df["x"] == el, "y"] for el in parameters]
plot_kwargs_box.setdefault("labels", parameters)
box_data = ax.boxplot(y_groups, **plot_kwargs_box)
if with_points:
widths = plot_kwargs_box.get("widths", np.full(len(y_groups), 0.5))
plot_kwargs_points.setdefault("marker", "o")
plot_kwargs_points.setdefault("facecolor", "none")
plot_kwargs_points.setdefault("edgecolor", "green")
plot_kwargs_points.setdefault("alpha", 0.5)
for x_start, (_y, width) in enumerate(zip(y_groups, widths)):
_x = _generate_swarm_values(_y, width=width * 0.9) + x_start + 1
label = points_label if x_start == width.size - 1 else None
ax.scatter(_x, _y, zorder=0, label=label, **plot_kwargs_points)
handles, labels = [box_data["fliers"][0]], ["Outliers"]
_handles, _labels = ax.get_legend_handles_labels()
handles.extend(_handles)
labels.extend(_labels)
ax.legend(handles, labels, **legend_kwargs)
ax.set_ylabel(ylabel)
ax.set_xlabel(xlabel)
ax.set_ylim(ylim)
fig.tight_layout()
plt.show()
if return_fig:
return fig, ax, box_data
[docs]
def plot_waterfall(
data: list[float] | NDArrayFloat,
index: list[str],
ylabel: str | None = None,
ylim: tuple[float, float] = (None, None),
return_fig: bool = False,
plot_kwargs: dict = {},
figure_kwargs: dict = {},
) -> None | tuple:
"""
Produce a waterfall plot showing the progression from the EYA estimates to the calculated OA
estimates of AEP.
Args:
data(array-like): data to be used to create waterfall.
index(:obj:`list`): List of string values to be used for x-axis labels, which should
have one more value than the number of points in :py:attr:`data` to account for
the calculated OA total.
ylabel(:obj:`str`): The y-axis label. Defaults to None.
ylim(:obj:`tuple[float | None, float | None]`): The y-axis minimum and maximum display
range. Defaults to (None, None).
return_fig(:obj:`bool`, optional): Set to True to return the figure and axes objects,
otherwise set to False. Defaults to False.
figure_kwargs(:obj:`dict`, optional): Additional keyword arguments that should be
passed to ``plt.figure()``. Defaults to {}.
plot_kwargs(:obj:`dict`, optional): Additional keyword arguments that should be
passed to ``ax.plot()``. Defaults to {}.
legend_kwargs(:obj:`dict`, optional): Additional keyword arguments that should be
passed to` `ax.legend()``. Defaults to {}.
Returns:
None | tuple[plt.Figure, plt.Axes]: If :py:attr:`return_fig`, then return the figure
and axes objects in addition to showing the plot.
"""
# Store data and create a bottom series to use for the waterfall
plot_data = pd.DataFrame(data={"amount": data}, index=index[:-1])
bottom = plot_data.amount.cumsum().shift(1).fillna(0)
# Get the net total number for the final element in the waterfall
total = plot_data.sum().amount
final_name = index[-1]
plot_data.loc[final_name] = total
bottom.loc[final_name] = 0
# Set the defaults for plotting, if none were provided
figure_kwargs.setdefault("figsize", (12, 6))
plot_kwargs.setdefault("width", 0.8)
width = plot_kwargs["width"]
# Create the figure and axis
fig = plt.figure(**figure_kwargs)
ax = fig.add_subplot(111)
# Plot the bar chart with vertical waterfall lines
x = np.arange(plot_data.shape[0])
ax.bar(x, plot_data.amount, bottom=bottom, **plot_kwargs)
ax.hlines(
bottom[1:-1].tolist() + [total],
xmin=x[:-1] - width / 2.0,
xmax=x[:-1] + 1 + width / 2.0,
colors="tab:orange",
)
# Add the annotations above/below each bar with a +/- label on difference for each category
offset_pos = plot_data.amount.max() * 0.05
offset_neg = plot_data.amount.max() * 0.09
for i, (y, diff) in enumerate(zip(bottom.values, plot_data.amount.values)):
if i in (0, len(x) - 1):
continue
if np.sign(diff) == 1:
y += diff + offset_pos
else:
y += diff - offset_neg
ax.annotate(f"{diff:+,.1f}", (i, y), ha="center")
# Add the styling and labeling, as specified by the user
ax.set_xticks(x)
ax.set_xticklabels(index)
ax.set_ylim(ylim)
ax.set_ylabel(ylabel)
fig.tight_layout()
plt.show()
if return_fig:
return fig, ax
[docs]
def plot_power_curves(
data: dict[str, pd.DataFrame],
power_col: str,
windspeed_col: str,
flag_col: str = None,
turbines: list[str] | None = None,
flag_labels: tuple[str, str] = ("Flagged Readings", "Power Curve"),
max_cols: int = 3,
xlim: tuple[float, float] = (None, None),
ylim: tuple[float, float] = (None, None),
legend: bool = False,
return_fig: bool = False,
figure_kwargs: dict = {},
legend_kwargs: dict = {},
plot_kwargs: dict = {},
):
"""Plots a series of power curves for a dictionary of turbine data, allowing for an optional
filtering for singling out readings in the figure.
Args:
data(:obj:`dict[str, pd.DataFrame]`): The dictionary of turbine IDs and and SCADA data.
wind_speed_col(:obj:`pandas.Series`): A pandas Series or numpy array of the recorded wind
speeds, in m/s.
power_col(:obj:`pandas.Series` | :obj:`np.ndarray`): A pandas Series or numpy array of the
recorded power, in kW.
flag_col(:obj:`np.ndarray` | :obj:`pd.Series`): A pandas Series or numpy array of booleans for
which points to flag in the windspeed and power data.
turbines(:obj:`list[str]`, optional): The list of turbines to be plot, if not all of the
keys in :py:attr:`data`.
flag_labels (:obj:`tuple[str, str]`, optional): The labels to give to the scatter points,
corresponding to the flagged readings and raw readings, respectively. Defaults to
("Flagged Readings", "Power Curve").
max_cols(:obj:`int`, optional): The maximum number of columns in the plot. Defaults to 3.
xlim(:obj:`tuple[float, float]`, optional): A tuple of the x-axis (min, max) values.
Defaults to (None, None).
ylim(:obj:`tuple[float, float]`, optional): A tuple of the y-axis (min, max) values.
Defaults to (None, None).
legend(:obj:`bool`, optional): Set to True to place a legend in the figure, otherwise set
to False. Defaults to False.
return_fig(:obj:`bool`, optional): Set to True to return the figure and axes objects,
otherwise set to False. Defaults to False.
figure_kwargs(:obj:`dict`, optional): Additional keyword arguments that should be passed to
``plt.figure()``. Defaults to {}.
plot_kwargs(:obj:`dict`, optional): Additional keyword arguments that should be passed
to ``ax.scatter()``. Defaults to {}.
legend_kwargs(:obj:`dict`, optional): Additional keyword arguments that should be passed to
``ax.legend()``. Defaults to {}.
Returns:
None | tuple[plt.Figure, plt.Axes]: Returns the figure and axes objects if
:py:attr:`return_fig` is True.
"""
turbines = list(data.keys()) if turbines is None else turbines
num_cols = len(turbines)
num_rows = int(np.ceil(num_cols / max_cols))
figure_kwargs.setdefault("dpi", 200)
figure_kwargs.setdefault("figsize", (15, num_rows * 5))
fig, axes_list = plt.subplots(num_rows, max_cols, **figure_kwargs)
for i, (t, ax) in enumerate(zip(turbines, axes_list.flatten())):
plot_data = data[t]
label = "Power Curve" if flag_labels is None else flag_labels[1]
ax.scatter(plot_data[windspeed_col], plot_data[power_col], label=label, **plot_kwargs)
if flag_col is not None:
plot_data = plot_data.loc[plot_data[flag_col]]
label = "Flagged Readings" if flag_labels is None else flag_labels[0]
ax.scatter(plot_data[windspeed_col], plot_data[power_col], label=label, **plot_kwargs)
ax.set_title(t)
ax.set_xlim(xlim)
ax.set_ylim(ylim)
ax.yaxis.set_major_formatter(StrMethodFormatter("{x:,.0f}"))
if legend:
ax.legend(**legend_kwargs)
if i % max_cols == 0:
ax.set_ylabel("Power (kW)")
if i in range(max_cols * (num_rows - 1), num_cols):
ax.set_xlabel("Wind Speed (m/s)")
num_axes = axes_list.size
if i < num_axes - 1:
for j in range(i + 1, num_axes):
fig.delaxes(axes_list.flatten()[j])
fig.tight_layout()
plt.show()
if return_fig:
return fig, ax
[docs]
def plot_wake_losses(
bins: NDArrayFloat,
efficiency_data_por: NDArrayFloat,
efficiency_data_lt: NDArrayFloat,
energy_data_por: NDArrayFloat = None,
energy_data_lt: NDArrayFloat = None,
bin_axis_label: str = "wd",
turbine_id: str = None,
xlim: tuple[float, float] = (None, None),
ylim_efficiency: tuple[float, float] = (None, None),
ylim_energy: tuple[float, float] = (None, None),
return_fig: bool = False,
figure_kwargs: dict = None,
plot_kwargs_line: dict = {},
plot_kwargs_fill: dict = {},
legend_kwargs: dict = {},
):
"""Plots wake losses in the form of wind farm efficiency as well as normalized wind plant energy
production for both the period of record and with the long-term correction as a function of either
wind direction or wind speed. If the data arguments contain two dimensions, 95% confidence intervals
will be plotted for each variable.
Args:
bins (:obj:`np.ndarray`): Wind direction or wind speed bin values representing the x-axis in
the plots.
efficiency_data_por (:obj:`np.ndarray`): 1D or 2D array containing wind farm or wind turbine
efficiency for the period of record for each bin in the :py:attr:`bins` argument. If a 2D array is
provided, the second dimension should contain results from different Monte Carlo iterations
and 95% confidence intervals will be plotted.
efficiency_data_lt (:obj:`np.ndarray`): 1D or 2D array containing long-term corrected wind farm
or wind turbine efficiency for each bin in the :py:attr:`bins` argument. If a 2D array is provided,
the second dimension should contain results from different Monte Carlo iterations and 95%
confidence intervals will be plotted.
energy_data_por (:obj:`np.ndarray`, optional): Optional 1D or 2D array containing normalized
energy production for the period of record for each bin in the `bins` argument. If a 2D
array is provided, the second dimension should contain results from different Monte Carlo
iterations and 95% confidence intervals will be plotted. If a value of None is provided,
normalized energy will not be plotted. Defaults to None.
energy_data_lt (:obj:`np.ndarray`, optional): Optional 1D or 2D array containing normalized
long-term corrected energy production for each bin in the :py:attr:`bins` argument. If a 2D array
is provided, the second dimension should contain results from different Monte Carlo
iterations and 95% confidence intervals will be plotted. If a value of None is provided,
normalized energy will not be plotted. Defaults to None.
bin_axis_label (str, optional): The label to use for the bin variable (x) axis. Defaults to None.
turbine_id (str, optional): Name of turbine if data are provided for a single wind turbine.
Used to determine title and plot axis labels. Defaults to None.
xlim (:obj:`tuple[float, float]`, optional): A tuple of floats representing the x-axis
wind direction plotting display limits (degrees). Defaults to (None, None).
ylim_efficiency (:obj:`tuple[float, float]`, optional): A tuple of the y-axis plotting display
limits for the wind farm efficiency plot (top plot). Defaults to (None, None).
ylim_energy (:obj:`tuple[float, float]`, optional): If :py:attr:`energy_data_por` and :py:attr:`energy_data_lt`
arguments are provided, a tuple of the y-axis plotting display limits for the wind farm
energy distribution plot (bottom plot). Defaults to (None, None).
return_fig (:obj:`bool`, optional): Flag to return the figure and axes objects. Defaults to False.
figure_kwargs (:obj:`dict`, optional): Additional figure instantiation keyword arguments
that are passed to `plt.figure()`. Defaults to None.
plot_kwargs_line (:obj:`dict`, optional): Additional plotting keyword arguments that are passed to
`ax.plot()` for plotting lines for the wind farm efficiency and, if :py:attr:`energy_data_por` and
`energy_data_lt` arguments are provided, energy distributions subplots. Defaults to {}.
plot_kwargs_fill (:obj:`dict`, optional): If `UQ` is True, additional plotting keyword arguments
that are passed to `ax.fill_between()` for plotting shading regions for 95% confidence
intervals for the wind farm efficiency and, if :py:attr:`energy_data_por` and `energy_data_lt` arguments
are provided, energy distributions subplots. Defaults to {}.
legend_kwargs (:obj:`dict`, optional): Additional legend keyword arguments that are passed to
`ax.legend()` for the wind farm efficiency and, if :py:attr:`energy_data_por` and `energy_data_lt`
arguments are provided, energy distributions subplots. Defaults to {}.
Returns:
None | tuple[matplotlib.pyplot.Figure, matplotlib.pyplot.Axes] | tuple[matplotlib.pyplot.Figure, tuple [matplotlib.pyplot.Axes, matplotlib.pyplot.Axes]]:
If :py:attr:`return_fig` is True, then the figure and axes object(s), corresponding to the wake
loss plot or, if :py:attr:`energy_data_por` and :py:attr:`energy_data_lt` arguments are provided, wake loss
and normalized energy plots, are returned for further tinkering/saving.
"""
color_codes = ["#4477AA", "#228833"]
plot_kwargs_fill.setdefault("alpha", 0.2)
if xlim == (None, None):
xlim = (bins[0], bins[-1])
if figure_kwargs is None:
figure_kwargs = {}
# determine if confidence intervals should be plotted (i.e., UQ) based on dimension of data
if (efficiency_data_por.ndim == 1) & (efficiency_data_lt.ndim == 1):
UQ = False
elif (efficiency_data_por.ndim == 2) & (efficiency_data_lt.ndim == 2):
UQ = True
else:
raise ValueError(
"The inputs `efficiency_data_por` and `efficiency_data_por` must have the same dimensions."
)
# determine if normalized energy should be plotted
if (energy_data_por is not None) & (energy_data_lt is not None):
if (not UQ) & (energy_data_por.ndim == 1) & (energy_data_lt.ndim == 1):
plot_norm_energy = True
elif UQ & (energy_data_por.ndim == 2) & (energy_data_lt.ndim == 2):
plot_norm_energy = True
else:
raise ValueError(
"The inputs `energy_data_por` and `energy_data_lt` must both have the same dimensions"
"as `efficiency_data_por` and `efficiency_data_lt`."
)
elif (energy_data_por is None) & (energy_data_lt is None):
plot_norm_energy = False
else:
raise TypeError(
"The inputs `energy_data_por` and `energy_data_lt` must either both be provided or both be None."
)
if plot_norm_energy:
figure_kwargs.setdefault("figsize", (9, 9.1))
fig = plt.figure(**figure_kwargs)
ax1 = fig.add_subplot(211)
ax2 = fig.add_subplot(212, sharex=ax1)
axs = (ax1, ax2)
else:
figure_kwargs.setdefault("figsize", (9, 5))
fig = plt.figure(**figure_kwargs)
ax1 = fig.add_subplot(111)
axs = [ax1]
axs[0].plot(xlim, [1, 1], "k", linewidth=1.5)
if UQ:
axs[0].plot(
bins,
np.mean(efficiency_data_por, axis=0),
color=color_codes[0],
label="Period of Record",
**plot_kwargs_line,
)
axs[0].fill_between(
bins,
np.percentile(efficiency_data_por, 2.5, axis=0),
np.percentile(efficiency_data_por, 97.5, axis=0),
color=color_codes[0],
label="_nolegend_",
**plot_kwargs_fill,
)
axs[0].plot(
bins,
np.mean(efficiency_data_lt, axis=0),
color=color_codes[1],
label="Long-Term Corrected",
**plot_kwargs_line,
)
axs[0].fill_between(
bins,
np.percentile(efficiency_data_lt, 2.5, axis=0),
np.percentile(efficiency_data_lt, 97.5, axis=0),
color=color_codes[1],
label="_nolegend_",
**plot_kwargs_fill,
)
if plot_norm_energy:
axs[1].plot(
bins,
np.mean(energy_data_por, axis=0),
color=color_codes[0],
label="Period of Record",
**plot_kwargs_line,
)
axs[1].fill_between(
bins,
np.percentile(energy_data_por, 2.5, axis=0),
np.percentile(energy_data_por, 97.5, axis=0),
color=color_codes[0],
label="_nolegend_",
**plot_kwargs_fill,
)
axs[1].plot(
bins,
np.mean(energy_data_lt, axis=0),
color=color_codes[1],
label="Long-Term Corrected",
**plot_kwargs_line,
)
axs[1].fill_between(
bins,
np.percentile(energy_data_lt, 2.5, axis=0),
np.percentile(energy_data_lt, 97.5, axis=0),
color=color_codes[1],
label="_nolegend_",
**plot_kwargs_fill,
)
else: # without UQ
axs[0].plot(
bins,
efficiency_data_por,
color=color_codes[0],
label="Period of Record",
**plot_kwargs_line,
)
axs[0].plot(
bins,
efficiency_data_lt,
color=color_codes[1],
label="Long-Term Corrected",
**plot_kwargs_line,
)
if plot_norm_energy:
axs[1].plot(
bins,
energy_data_por,
color=color_codes[0],
label="Period of Record",
**plot_kwargs_line,
)
axs[1].plot(
bins,
energy_data_lt,
color=color_codes[1],
label="Long-Term Corrected",
**plot_kwargs_line,
)
axs[0].set_xlim(xlim)
axs[0].set_ylim(ylim_efficiency)
axs[len(axs) - 1].set_xlabel(bin_axis_label)
axs[0].legend(**legend_kwargs)
if turbine_id is not None:
axs[0].set_title(f"Wind Turbine {turbine_id}")
axs[0].set_ylabel("Wind Turbine Efficiency (-)")
else:
axs[0].set_ylabel("Wind Plant Efficiency (-)")
if plot_norm_energy:
axs[1].set_ylim(ylim_energy)
axs[1].legend(**legend_kwargs)
axs[1].set_ylabel("Normalized Wind Plant\nEnergy Production (-)")
plt.tight_layout()
if return_fig:
return fig, axs
else:
plt.tight_layout()
if return_fig:
return fig, ax1
[docs]
def plot_yaw_misalignment(
ws_bins: list[float],
vane_bins: list[float],
power_values_vane_ws: NDArrayFloat,
curve_fit_params_ws: NDArrayFloat,
mean_vane_angle_ws: NDArrayFloat,
yaw_misalignment_ws: NDArrayFloat,
turbine_id: str,
power_performance_label: str = "Normalized Cp (-)",
xlim: tuple[float, float] = (None, None),
ylim: tuple[float, float] = (None, None),
return_fig: bool = False,
figure_kwargs: dict = None,
plot_kwargs_curve: dict = {},
plot_kwargs_line: dict = {},
plot_kwargs_fill: dict = {},
legend_kwargs: dict = {},
):
"""Plots power performance vs. wind vane angle along with the best-fit cosine curve for each
wind speed bin for a single turbine. The mean wind vane angle and the wind vane angle where
power performance is maximized are shown for each wind speed bin. Additionally, the yaw
misalignments for each wind speed bin as well as the mean yaw misalignment avergaged over all
wind speed bins are listed. If UQ is used, 95% confidence intervals will be plotted for the
binned power performance values and listed for the yaw misalignment estiamtes.
Args:
ws_bins (list[float]): Wind speed bin values for which yaw misalignment plots are
produced (m/s).
vane_bins (list[float]): Wind vane angle bin values for which power performance values are
plotted (degrees).
power_values_vane_ws (:obj:`np.ndarray`): 2D or 3D array containing power performance data
for each wind speed bin in the :py:attr:`ws_bins` argument (first dimension if a 2D array) and
each wind vane bin in the `vane_bins` argument (second dimension if a 2D array). If a
3D array is provided, the first dimension should contain results from different Monte
Carlo iterations and 95% confidence intervals will be plotted.
curve_fit_params_ws (:obj:`np.ndarray`): 2D or 3D array containing optimal cosine curve fit
parameters (magnitude, offset (degrees), and cosine exponent) for each wind speed bin in
the `ws_bins` argument (first dimension if a 2D array). If a 3D array is provided, the
first dimension should contain results from different Monte Carlo iterations and 95%
confidence intervals will be plotted. The last dimension contains the optimal curve
fit parameters.
mean_vane_angle_ws (:obj:`np.ndarray`): Array containing mean wind vane angles for each
wind speed bin in the :py:attr:`ws_bins` argument (degrees).
yaw_misalignment_ws (:obj:`np.ndarray`): 1D or 2D array containing yaw misalignment values
for each wind speed bin in the :py:attr:`ws_bins` argument (degrees). If a 2D array is provided,
the first dimension should contain results from different Monte Carlo iterations and
95% confidence intervals will be plotted.
turbine_id (str, optional): Name of turbine for which yaw misalignment data are
provided. Used to determine title and plot axis labels. Defaults to None.
power_performance_label (str, optional): The label to use for the power performance (y) axis.
Defaults to "Normalized Cp (-)".
xlim (:obj:`tuple[float, float]`, optional): A tuple of floats representing the x-axis
wind vane angle plotting display limits (degrees). Defaults to (None, None).
ylim (:obj:`tuple[float, float]`, optional): A tuple of the y-axis plotting display limits
for the power performance vs. wind vane plots. Defaults to (None, None).
return_fig (:obj:`bool`, optional): Flag to return the figure and axes objects. Defaults to False.
figure_kwargs (:obj:`dict`, optional): Additional figure instantiation keyword arguments
that are passed to ``plt.figure()``. Defaults to None.
plot_kwargs_curve (:obj:`dict`, optional): Additional plotting keyword arguments that are passed to
``ax.plot()`` for plotting lines for the power performance vs. wind vane plots. Defaults to {}.
plot_kwargs_line (:obj:`dict`, optional): Additional plotting keyword arguments that are passed to
``ax.plot()`` for plotting vertical lines indicating mean vane angle and vane angle where
power is maximized. Defaults to {}.
plot_kwargs_fill (:obj:`dict`, optional): If :py:attr:`UQ` is True, additional plotting keyword arguments
that are passed to ``ax.fill_between()`` for plotting shading regions for 95% confidence
intervals for power performance vs. wind vane. Defaults to {}.
legend_kwargs (:obj:`dict`, optional): Additional legend keyword arguments that are passed to
``ax.legend()`` for the power performance vs. wind vane plots. Defaults to {}.
Returns:
None | tuple[matplotlib.pyplot.Figure, matplotlib.pyplot.Axes]:
If `return_fig` is True, then the figure and axes object(s) corresponding to the yaw
misalignment plots are returned for further tinkering/saving.
"""
from openoa.analysis.yaw_misalignment import cos_curve
power_color_code = "#4477AA"
mean_vane_color_code = "#000000"
curve_fit_color_code = "#EE6677"
plot_kwargs_fill.setdefault("alpha", 0.2)
if figure_kwargs is None:
figure_kwargs = {}
# determine if confidence intervals should be plotted (i.e., UQ) based on dimension of data
if (
(power_values_vane_ws.ndim == 2)
& (curve_fit_params_ws.ndim == 2)
& (yaw_misalignment_ws.ndim == 1)
):
UQ = False
elif (
(power_values_vane_ws.ndim == 3)
& (curve_fit_params_ws.ndim == 3)
& (yaw_misalignment_ws.ndim == 2)
):
UQ = True
else:
raise ValueError(
"The inputs `power_values_vane_ws`, `curve_fit_params_ws`, and `yaw_misalignment_ws` "
"must have either 2, 2, and 1 dimensions or 3, 3, and 2 dimensions, respectively."
)
# Select figure size based on number of subplots
if len(ws_bins) == 1:
figure_kwargs.setdefault("figsize", (6, 5))
fig, axs = plt.subplots(1, 1, **figure_kwargs)
axs = [[axs]]
N_col = 1
elif len(ws_bins) == 2:
figure_kwargs.setdefault("figsize", (11, 5))
fig, axs = plt.subplots(1, 2, sharex=True, **figure_kwargs)
N_col = 2
elif len(ws_bins) == 3:
figure_kwargs.setdefault("figsize", (16, 5))
fig, axs = plt.subplots(1, 3, sharex=True, **figure_kwargs)
N_col = 3
elif len(ws_bins) == 4:
figure_kwargs.setdefault("figsize", (11, 10))
fig, axs = plt.subplots(2, 2, sharex=True, **figure_kwargs)
N_col = 2
elif len(ws_bins) >= 13:
figure_kwargs.setdefault("figsize", (16, 17))
fig, axs = plt.subplots(
int(np.floor((len(ws_bins) - 1) / 3) + 1), 3, sharex=True, **figure_kwargs
)
N_col = 3
elif len(ws_bins) >= 10:
figure_kwargs.setdefault("figsize", (16, 14))
fig, axs = plt.subplots(4, 3, sharex=True, **figure_kwargs)
N_col = 3
elif len(ws_bins) >= 7:
figure_kwargs.setdefault("figsize", (16, 11))
fig, axs = plt.subplots(3, 3, sharex=True, **figure_kwargs)
N_col = 3
elif len(ws_bins) >= 5:
figure_kwargs.setdefault("figsize", (16, 7.5))
fig, axs = plt.subplots(2, 3, sharex=True, **figure_kwargs)
N_col = 3
for i, ws in enumerate(ws_bins):
ax = axs[int(np.floor(i / N_col))][i % N_col]
if UQ:
norm_factor = curve_fit_params_ws[:, i, 0].mean()
y_min = np.nanmin(np.nanpercentile(power_values_vane_ws[:, i, :], 2.5, 0))
y_max = np.nanmax(np.nanpercentile(power_values_vane_ws[:, i, :], 97.5, 0))
ax.fill_between(
vane_bins,
np.percentile(power_values_vane_ws[:, i, :], 2.5, 0) / norm_factor,
np.percentile(power_values_vane_ws[:, i, :], 97.5, 0) / norm_factor,
color=power_color_code,
label="_nolegend_",
**plot_kwargs_fill,
)
ax.scatter(
vane_bins,
np.mean(power_values_vane_ws[:, i, :], 0) / norm_factor,
color=power_color_code,
)
ax.plot(
vane_bins,
cos_curve(
vane_bins,
curve_fit_params_ws[:, i, 0].mean() / norm_factor,
curve_fit_params_ws[:, i, 1].mean(),
curve_fit_params_ws[:, i, 2].mean(),
),
color=curve_fit_color_code,
label="_nolabel_",
)
ax.plot(
2 * [curve_fit_params_ws[:, i, 1].mean()],
[
0.01 * np.floor(y_min / norm_factor / 0.01),
0.01 * np.ceil(y_max / norm_factor / 0.01),
],
color=curve_fit_color_code,
linestyle="--",
label=rf"Max. Power Vane Angle = {round(curve_fit_params_ws[:,i,1].mean(),1)}$^\circ$", # noqa: W605
)
yaw_mis_mean = np.round(np.mean(yaw_misalignment_ws[:, i]), 1)
yaw_mis_lb = np.round(np.percentile(yaw_misalignment_ws[:, i], 2.5), 1)
yaw_mis_ub = np.round(np.percentile(yaw_misalignment_ws[:, i], 97.5), 1)
ax.set_title(
f"{ws} m/s\nYaw Misalignment = "
rf"{yaw_mis_mean}$^\circ$ [{yaw_mis_lb}$^\circ$, {yaw_mis_ub}$^\circ$]" # noqa: W605
)
else:
norm_factor = curve_fit_params_ws[i, 0]
y_min = np.nanmin(power_values_vane_ws[i, :])
y_max = np.nanmax(power_values_vane_ws[i, :])
if xlim == (None, None):
valid_vane_indices = np.where(~np.isnan(np.nanmean(power_values_vane_ws, 0)))[0]
xlim = (
vane_bins[valid_vane_indices[0]] - 1.0,
vane_bins[valid_vane_indices[-1]] + 1.0,
)
ax.scatter(vane_bins, power_values_vane_ws[i, :] / norm_factor, color=power_color_code)
ax.plot(
vane_bins,
cos_curve(
vane_bins,
curve_fit_params_ws[i, 0] / norm_factor,
curve_fit_params_ws[i, 1],
curve_fit_params_ws[i, 2],
),
color=curve_fit_color_code,
label="_nolabel_",
)
ax.plot(
2 * [curve_fit_params_ws[i, 1]],
[
0.01 * np.floor(y_min / norm_factor / 0.01),
0.01 * np.ceil(y_max / norm_factor / 0.01),
],
color=curve_fit_color_code,
linestyle="--",
label=rf"Max. Power Vane Angle = {round(curve_fit_params_ws[i,1],1)}$^\circ$", # noqa: W605
)
ax.set_title(
f"{ws} m/s\nYaw Misalignment = {np.round(yaw_misalignment_ws[i],1)}$^\\circ$" # noqa: W605
)
ax.plot(
2 * [mean_vane_angle_ws[i]],
[
0.01 * np.floor(y_min / norm_factor / 0.01),
0.01 * np.ceil(y_max / norm_factor / 0.01),
],
color=mean_vane_color_code,
linestyle="--",
label=rf"Mean Vane Angle = {round(mean_vane_angle_ws[i],1)}$^\circ$", # noqa: W605
)
ax.grid("on")
ax.legend(**legend_kwargs)
ax.set_ylabel(power_performance_label)
if ylim != (None, None):
ax.set_ylim(ylim)
else:
ax.set_ylim(
(
0.1 * np.floor(y_min / norm_factor / 0.1),
0.1 * np.ceil(y_max / norm_factor / 0.1),
)
)
# remove unused subplots and add x axis labels
last_row = int(np.floor((len(ws_bins) - 1) / N_col))
if (len(ws_bins) >= 5) & (len(ws_bins) % 3 > 0):
for i in range(len(ws_bins) % 3, 3):
axs[last_row][i].remove()
axs[last_row - 1][i].tick_params(labelbottom=True)
axs[last_row - 1][i].set_xlabel(r"Wind Vane Angle ($^\circ$)") # noqa: W605
for i in range(len(ws_bins) % 3):
axs[last_row][i].set_xlabel(r"Wind Vane Angle ($^\circ$)") # noqa: W605
else:
for i in range(N_col):
axs[last_row][i].set_xlabel(r"Wind Vane Angle ($^\circ$)") # noqa: W605
mean_yaw_mis = np.round(np.mean(yaw_misalignment_ws), 1)
if UQ:
yaw_misalignment_95CI = np.round(
np.percentile(np.mean(yaw_misalignment_ws, 1), [2.5, 97.5]), 1
)
fig.suptitle(
rf"Turbine {turbine_id}, Yaw Misalignment = {mean_yaw_mis}$^\circ$ " # noqa: W605
rf"[{yaw_misalignment_95CI[0]}$^\circ$, {yaw_misalignment_95CI[1]}$^\circ$]" # noqa: W605
)
else:
fig.suptitle(
rf"Turbine {turbine_id}, Mean Yaw Misalignment = {str(mean_yaw_mis)}$^\circ$" # noqa: W605
)
plt.tight_layout()
if xlim == (None, None):
valid_vane_indices = np.where(~np.isnan(np.nanmean(power_values_vane_ws, (0, 1))))[0]
xlim = (vane_bins[valid_vane_indices[0]] - 1.0, vane_bins[valid_vane_indices[-1]] + 1.0)
axs[0][0].set_xlim(xlim)
if return_fig:
return fig, axs