From 934111ba31eccd7c85063dc674fa5d4d27da50c0 Mon Sep 17 00:00:00 2001 From: RHammond2 <13874373+RHammond2@users.noreply.github.com> Date: Tue, 17 Oct 2023 17:33:43 -0700 Subject: [PATCH 1/6] add basic plotting functionality --- wombat/core/simulation_api.py | 4 +- wombat/utilities/__init__.py | 1 + wombat/utilities/plot.py | 302 +++++++++++++++++++++++++++++++ wombat/windfarm/system/system.py | 6 +- wombat/windfarm/windfarm.py | 4 +- 5 files changed, 312 insertions(+), 5 deletions(-) create mode 100644 wombat/utilities/plot.py diff --git a/wombat/core/simulation_api.py b/wombat/core/simulation_api.py index a02f5b12..76216344 100644 --- a/wombat/core/simulation_api.py +++ b/wombat/core/simulation_api.py @@ -305,7 +305,9 @@ def _setup_simulation(self): self.port = Port( self.env, self.windfarm, self.repair_manager, self.config.port ) - self.service_equipment.extend(self.port.service_equipment_manager.items) + self.service_equipment.extend( + self.port.service_equipment_manager.items # type: ignore + ) if self.config.project_capacity * 1000 != round(self.windfarm.capacity, 6): raise ValueError( diff --git a/wombat/utilities/__init__.py b/wombat/utilities/__init__.py index 803740fa..35ce42a6 100644 --- a/wombat/utilities/__init__.py +++ b/wombat/utilities/__init__.py @@ -11,3 +11,4 @@ ) from .logging import setup_logger, format_events_log_message from .utilities import IEC_power_curve, _mean, cache, create_variable_from_string +from .plot import plot_farm, plot_farm_availability, plot_detailed_availability diff --git a/wombat/utilities/plot.py b/wombat/utilities/plot.py new file mode 100644 index 00000000..c5443645 --- /dev/null +++ b/wombat/utilities/plot.py @@ -0,0 +1,302 @@ +"""Provides expoerimental plotting routines to help with simulation diagnostics.""" + +import numpy as np +import pandas as pd +import networkx as nx +import matplotlib as mpl +import matplotlib.pyplot as plt + +from wombat import Simulation +from wombat.windfarm import Windfarm + + +def plot_farm( + windfarm: Windfarm, + figure_kwargs: dict = {}, + draw_kwargs: dict = {}, + return_fig: bool = False, +) -> None | tuple[plt.figure, plt.axes]: + """Plot the graph representation of the windfarm as represented through WOMBAT. + + Args: + figure_kwargs : dict, optional + Customized keyword arguments for matplotlib figure instantiation that + will passed as ``plt.figure(**figure_kwargs). Defaults to {}.`` + draw_kwargs : dict, optional + Customized keyword arguments for ``networkx.draw()`` that can will + passed as ``nx.draw(**figure_kwargs). Defaults to {}.`` + return_fig : bool, optional + Whether or not to return the figure and axes objects for further editing + and/or saving. Defaults to False. + + Returns + ------- + None | tuple[plt.figure, plt.axes]: _description_ + """ + figure_kwargs.setdefault("figsize", (14, 12)) + figure_kwargs.setdefault("dpi", 200) + + fig = plt.figure(**figure_kwargs) + ax = fig.add_subplot(111) + + positions = { + name: np.array([node["longitude"], node["latitude"]]) + for name, node in windfarm.graph.nodes(data=True) + } + + draw_kwargs.setdefault("with_labels", True) + draw_kwargs.setdefault("horizontalalignment", "right") + draw_kwargs.setdefault("verticalalignment", "bottom") + draw_kwargs.setdefault("font_weight", "bold") + draw_kwargs.setdefault("font_size", 10) + draw_kwargs.setdefault("node_color", "#E37225") + nx.draw(windfarm.graph, pos=positions, ax=ax, **draw_kwargs) + + fig.tight_layout() + plt.show() + + if return_fig: + return fig, ax + return None + + +def plot_farm_availability( + sim: Simulation, + individual_turbines: bool = False, + farm_95_CI: bool = False, + figure_kwargs: dict = {}, + plot_kwargs: dict = {}, + legend_kwargs: dict = {}, + tick_fontsize: int = 12, + label_fontsize: int = 16, + return_fig: bool = False, +) -> tuple[plt.Figure | plt.Axes] | None: + """Plots a line chart of the monthly availability at the wind farm level. + + Parameters + ---------- + sim : Simulation + A ``Simulation`` object that has been run. + individual_turbines : bool, optional + Indicates if faint gray lines should be added in the background for the + availability of each turbine, by default False. + farm_95_CI : bool, optional + Indicates if the 95% CI area fill should be added in the background. + figure_kwargs : dict, optional + Custom parameters for ``plt.figure()``, by default ``figsize=(15, 7)`` and + ``dpi=300``. + plot_kwargs : dict, optional + Custom parameters to be passed to ``ax.plot()``, by default a label consisting + of the simulation name and project-level availability. + legend_kwargs : dict, optional + Custom parameters to be passed to ``ax.legend()``, by default ``fontsize=14``. + tick_fontsize : int, optional + The x- and y-axis tick label fontsize, by default 12. + label_fontsize : int, optional + The x- and y-axis label fontsize, by default 16. + return_fig : bool, optional + If ``True``, return the figure and Axes object, otherwise don't, by default + False. + + Returns + ------- + tuple[plt.Figure, plt.Axes] | None + See :py:attr:`return_fig` for details. + _description_ + """ + # Get the availability data + metrics = sim.metrics + availability = metrics.time_based_availability("project", "windfarm").values[0][0] + windfarm_availability = metrics.time_based_availability("month-year", "windfarm") + turbine_availability = metrics.time_based_availability("month-year", "turbine") + + # Set the defaults + figure_kwargs.setdefault("figsize", (15, 7)) + figure_kwargs.setdefault("dpi", 300) + plot_kwargs.setdefault("label", f"{sim.env.simulation_name}: {availability:.2%}") + legend_kwargs.setdefault("fontsize", 14) + + fig = plt.figure(**figure_kwargs) + ax = fig.add_subplot(111) + + x = range(windfarm_availability.shape[0]) + + # Individual turbine availability lines + if individual_turbines: + for turbine in turbine_availability.columns: + ax.plot( + x, + turbine_availability[turbine].values, + color="lightgray", + alpha=0.75, + linewidth=0.25, + ) + + # CI error bars surrounding the lines + if farm_95_CI: + N = turbine_availability.shape[1] + Z90, Z95, Z99 = (1.64, 1.96, 2.57) # noqa: disable=F841 + tm = turbine_availability.values.mean(axis=1) + tsd = turbine_availability.values.std(axis=1) + ci_lo = tm - Z95 * (tsd / np.sqrt(N)) + ci_hi = tm + Z95 * (tsd / np.sqrt(N)) + ax.fill_between( + x, ci_lo, ci_hi, alpha=0.5, label="95% CI for Individual Turbines" + ) + + ax.plot( + x, + windfarm_availability.windfarm.values, + **plot_kwargs, + ) + + years = list(range(sim.env.start_year, sim.env.end_year + 1)) + xticks_major = [x * 12 for x in range(len(years))] + xticks_minor = list(range(0, 12 * len(years), 3)) + xlabels_major = [f"{year:>6}" for year in years] + xlabels_minor = ["", "Apr", "", "Oct"] * len(years) + + ax.set_ylim(0, 1) + ax.set_yticks(np.linspace(0, 1, 11)) + ax.set_yticklabels([f"{y:.0%}" for y in ax.get_yticks()], fontsize=tick_fontsize) + + ax.set_xlim(0, windfarm_availability.shape[0]) + ax.set_xticks(xticks_major) + for t in ax.get_xticklabels(): + t.set_y(-0.05) + ax.set_xticks(xticks_minor, minor=True) + ax.set_xticklabels(xlabels_major, ha="left", fontsize=tick_fontsize) + ax.set_xticklabels(xlabels_minor, minor=True, rotation=90) + + ax.set_xlabel("Simulation Time", fontsize=label_fontsize) + ax.set_ylabel("Monthly Availability", fontsize=label_fontsize) + + ax.legend(**legend_kwargs) + + ax.grid(axis="both", which="major") + ax.grid(alpha=0.5, which="minor") + + fig.tight_layout() + if return_fig: + return fig, ax # type: ignore + return None + + +def plot_detailed_availability( + sim: Simulation, + figure_kwargs: dict = {}, + cbar_label_fontsize: int = 14, + return_fig: bool = False, +): + """Plots an hourly view of the operational levels of the wind farm and individual + turbines as a heatmap. + + Parameters + ---------- + sim : Simulation + A ``Simulation`` object that has been run. + figure_kwargs : dict, optional + Custom settings for ``plt.figure()``, by default ``figsize=(15, 10)`` + and ``dpi=300``. + cbar_label_fontsize : int, optional + The default fontsize used in the color bar legend for the axis label, by default + 14. + return_fig : bool, optional + If ``True``, return the figure and Axes object, otherwise don't, by default + False. + + Returns + ------- + tuple[plt.Figure, plt.Axes] | None + See :py:attr:`return_fig` for details. + """ + # Set the defaults + figure_kwargs.setdefault("figsize", (15, 10)) + figure_kwargs.setdefault("dpi", 300) + + # Get the requisite data + op = sim.metrics.operations + x_ix = [ + c + for c in op.columns + if c not in ("env_datetime", "env_time", "year", "month", "day") + ] + turbines = op[x_ix].values.astype(float).T + env_time = op.env_time.values + env_datetime = op.env_datetime.reset_index(drop=True) + + fig = plt.figure(**figure_kwargs) + ax = fig.add_subplot(111) + + # Create the custom color map + bounds = [0, 0.05, 0.5, 0.75, 0.9, 0.95, 1] + YlGnBu_discrete7 = mpl.colors.ListedColormap( + ["#ffffcc", "#c7e9b4", "#7fcdbb", "#41b6c4", "#1d91c0", "#225ea8", "#0c2c84"] + ) + norm = mpl.colors.BoundaryNorm(bounds, YlGnBu_discrete7.N) + + # Plot the turbine availability + ax.imshow( + turbines, aspect="auto", cmap=YlGnBu_discrete7, norm=norm, interpolation="none" + ) + + # Format the y-axis + ax.set_yticks(np.arange(len(x_ix))) + ax.set_yticklabels(x_ix) + ax.set_ylim(-0.5, len(x_ix) - 0.5) + ax.hlines( + np.arange(len(x_ix)) + 0.5, + env_time.min(), + env_time.max(), + color="white", + linewidth=1, + ) + + # Create the major x-tick data + env_dt_yr = env_datetime.dt.year + ix = ~env_dt_yr.duplicated() + x_major_ticks = env_dt_yr.loc[ix].index[1:] + x_major_ticklabels = env_dt_yr.loc[ix].values[1:] + + # Create the minor x-tick data + env_datetime_df = env_datetime.to_frame() + env_datetime_df["month"] = env_datetime_df.env_datetime.dt.month + env_datetime_df["month_year"] = pd.DatetimeIndex( + env_datetime_df.env_datetime + ).to_period("m") + ix = ~env_datetime_df.month_year.duplicated() & ( + env_datetime_df.month.isin((4, 7, 10)) + ) + x_minor_ticks = env_datetime_df.loc[ix].index.values + x_minor_ticklabels = [ + "" if x == "Jul" else x + for x in env_datetime_df.loc[ix].env_datetime.dt.strftime("%b") + ] + + # Set the x-ticks + ax.set_xticks(x_major_ticks) + ax.set_xticklabels(x_major_ticklabels) + for t in ax.get_xticklabels(): + t.set_y(-0.05) + ax.set_xticks(x_minor_ticks, minor=True) + ax.set_xticklabels(x_minor_ticklabels, minor=True, rotation=90) + + # Create a color bar legend that is propportionally spaced + cbar = ax.figure.colorbar( + mpl.cm.ScalarMappable(cmap=YlGnBu_discrete7, norm=norm), + ax=ax, + ticks=bounds, + spacing="proportional", + format=mpl.ticker.PercentFormatter(xmax=1), + ) + cbar.ax.set_ylabel( + "Availability", rotation=-90, va="bottom", fontsize=cbar_label_fontsize + ) + + ax.grid(axis="x", which="major") + ax.grid(alpha=0.7, axis="x", which="minor") + + fig.tight_layout() + if return_fig: + return fig, ax + return None diff --git a/wombat/windfarm/system/system.py b/wombat/windfarm/system/system.py index e74c9217..feebfab5 100644 --- a/wombat/windfarm/system/system.py +++ b/wombat/windfarm/system/system.py @@ -187,7 +187,8 @@ def operating_level(self) -> float: Operating level of the turbine. """ if self.cable_failure.triggered and self.servicing.triggered: - return reduce(mul, [sub.operating_level for sub in self.subassemblies]) + ol: float = reduce(mul, [sub.operating_level for sub in self.subassemblies]) + return ol return 0.0 @property @@ -201,7 +202,8 @@ def operating_level_wo_servicing(self) -> float: Operating level of the turbine. """ if self.cable_failure.triggered: - return reduce(mul, [sub.operating_level for sub in self.subassemblies]) + ol: float = reduce(mul, [sub.operating_level for sub in self.subassemblies]) + return ol return 0.0 def power(self, windspeed: list[float] | np.ndarray) -> np.ndarray: diff --git a/wombat/windfarm/windfarm.py b/wombat/windfarm/windfarm.py index b1920d8a..f3d12ce0 100644 --- a/wombat/windfarm/windfarm.py +++ b/wombat/windfarm/windfarm.py @@ -104,7 +104,7 @@ def _create_graph_layout(self, windfarm_layout: str) -> None: layout.id == substation, "substation_id" ].values[0] - self.turbine_id = layout.loc[~substation_filter, "id"].values + self.turbine_id: np.ndarray = layout.loc[~substation_filter, "id"].values substations = layout[substation_filter].copy() turbines = layout[~substation_filter].copy() substation_sections = [ @@ -130,7 +130,7 @@ def _create_graph_layout(self, windfarm_layout: str) -> None: cable=row.upstream_cable.values[0], ) - self.graph = windfarm + self.graph: nx.DiGraph = windfarm self.layout_df = layout def _create_turbines_and_substations(self) -> None: From 3e8622d24bda3f0aa338498465b9a4eae70020e2 Mon Sep 17 00:00:00 2001 From: RHammond2 <13874373+RHammond2@users.noreply.github.com> Date: Thu, 19 Oct 2023 10:10:00 -0700 Subject: [PATCH 2/6] allow for time or enery avail and fix memory carryover bug --- wombat/core/simulation_api.py | 27 ++++++---- wombat/utilities/__init__.py | 1 - wombat/utilities/plot.py | 86 ++++++++++++++++++++++++-------- wombat/windfarm/system/system.py | 4 +- 4 files changed, 84 insertions(+), 34 deletions(-) diff --git a/wombat/core/simulation_api.py b/wombat/core/simulation_api.py index 76216344..ae9607f5 100644 --- a/wombat/core/simulation_api.py +++ b/wombat/core/simulation_api.py @@ -292,22 +292,33 @@ def _setup_simulation(self): self.windfarm = Windfarm(self.env, self.config.layout, self.repair_manager) # Create the servicing equipment and set the necessary environment variables - self.service_equipment = [] + self.service_equipment: dict[str, ServiceEquipment] = {} # type: ignore for service_equipment in self.config.service_equipment: equipment = ServiceEquipment( self.env, self.windfarm, self.repair_manager, service_equipment ) equipment.finish_setup_with_environment_variables() - self.service_equipment.append(equipment) + name = equipment.settings.name + if name in self.service_equipment: + raise ValueError( + f"Servicing equipment `{name}` already exists, please use unique" + " names for all servicing equipment." + ) + self.service_equipment[name] = equipment # type: ignore # Create the port and add any tugboats to the available servicing equipment list if self.config.port is not None: self.port = Port( self.env, self.windfarm, self.repair_manager, self.config.port ) - self.service_equipment.extend( - self.port.service_equipment_manager.items # type: ignore - ) + for service_equipment in self.port.service_equipment_manager.items: + name = service_equipment.settings.name # type: ignore + if name in self.service_equipment: + raise ValueError( + f"Servicing equipment `{name}` already exists, please use" + " unique names for all servicing equipment." + ) + self.service_equipment[name] = service_equipment # type: ignore if self.config.project_capacity * 1000 != round(self.windfarm.capacity, 6): raise ValueError( @@ -372,7 +383,7 @@ def initialize_metrics(self) -> None: substation_id=self.windfarm.substation_id.tolist(), turbine_id=self.windfarm.turbine_id.tolist(), substation_turbine_map=substation_turbine_map, - service_equipment_names=[el.settings.name for el in self.service_equipment], + service_equipment_names=[*self.service_equipment], # type: ignore ) def save_metrics_inputs(self) -> None: @@ -398,9 +409,7 @@ def save_metrics_inputs(self) -> None: "substation_id": self.windfarm.substation_id.tolist(), "turbine_id": self.windfarm.turbine_id.tolist(), "substation_turbine_map": substation_turbine_map, - "service_equipment_names": [ - el.settings.name for el in self.service_equipment - ], + "service_equipment_names": [*self.service_equipment], } with open(self.env.metrics_input_fname, "w") as f: diff --git a/wombat/utilities/__init__.py b/wombat/utilities/__init__.py index 35ce42a6..803740fa 100644 --- a/wombat/utilities/__init__.py +++ b/wombat/utilities/__init__.py @@ -11,4 +11,3 @@ ) from .logging import setup_logger, format_events_log_message from .utilities import IEC_power_curve, _mean, cache, create_variable_from_string -from .plot import plot_farm, plot_farm_availability, plot_detailed_availability diff --git a/wombat/utilities/plot.py b/wombat/utilities/plot.py index c5443645..ef62973c 100644 --- a/wombat/utilities/plot.py +++ b/wombat/utilities/plot.py @@ -10,10 +10,10 @@ from wombat.windfarm import Windfarm -def plot_farm( +def plot_farm_layout( windfarm: Windfarm, - figure_kwargs: dict = {}, - draw_kwargs: dict = {}, + figure_kwargs: dict | None = None, + plot_kwargs: dict | None = None, return_fig: bool = False, ) -> None | tuple[plt.figure, plt.axes]: """Plot the graph representation of the windfarm as represented through WOMBAT. @@ -22,9 +22,11 @@ def plot_farm( figure_kwargs : dict, optional Customized keyword arguments for matplotlib figure instantiation that will passed as ``plt.figure(**figure_kwargs). Defaults to {}.`` - draw_kwargs : dict, optional - Customized keyword arguments for ``networkx.draw()`` that can will - passed as ``nx.draw(**figure_kwargs). Defaults to {}.`` + plot_kwargs : dict, optional + Customized parameters for ``networkx.draw()`` that can will passed as + ``nx.draw(**figure_kwargs)``. Defaults to ``with_labels=True``, + ``horizontalalignment=right``, ``verticalalignment=bottom``, + ``font_weight=bold``, ``font_size=10``, and ``node_color=#E37225``. return_fig : bool, optional Whether or not to return the figure and axes objects for further editing and/or saving. Defaults to False. @@ -33,24 +35,31 @@ def plot_farm( ------- None | tuple[plt.figure, plt.axes]: _description_ """ + # Set the defaults for plotting + if figure_kwargs is None: + figure_kwargs = {} + if plot_kwargs is None: + plot_kwargs = {} figure_kwargs.setdefault("figsize", (14, 12)) figure_kwargs.setdefault("dpi", 200) + plot_kwargs.setdefault("with_labels", True) + plot_kwargs.setdefault("horizontalalignment", "right") + plot_kwargs.setdefault("verticalalignment", "bottom") + plot_kwargs.setdefault("font_weight", "bold") + plot_kwargs.setdefault("font_size", 10) + plot_kwargs.setdefault("node_color", "#E37225") fig = plt.figure(**figure_kwargs) ax = fig.add_subplot(111) + # Get the node positions and all related edges, except the self-connected ones positions = { name: np.array([node["longitude"], node["latitude"]]) for name, node in windfarm.graph.nodes(data=True) } + edges = [el for el in windfarm.graph.edges if el[0] != el[1]] - draw_kwargs.setdefault("with_labels", True) - draw_kwargs.setdefault("horizontalalignment", "right") - draw_kwargs.setdefault("verticalalignment", "bottom") - draw_kwargs.setdefault("font_weight", "bold") - draw_kwargs.setdefault("font_size", 10) - draw_kwargs.setdefault("node_color", "#E37225") - nx.draw(windfarm.graph, pos=positions, ax=ax, **draw_kwargs) + nx.draw(windfarm.graph, pos=positions, edgelist=edges, ax=ax, **plot_kwargs) fig.tight_layout() plt.show() @@ -62,11 +71,12 @@ def plot_farm( def plot_farm_availability( sim: Simulation, + which: str = "energy", individual_turbines: bool = False, farm_95_CI: bool = False, - figure_kwargs: dict = {}, - plot_kwargs: dict = {}, - legend_kwargs: dict = {}, + figure_kwargs: dict | None = None, + plot_kwargs: dict | None = None, + legend_kwargs: dict | None = None, tick_fontsize: int = 12, label_fontsize: int = 16, return_fig: bool = False, @@ -77,6 +87,9 @@ def plot_farm_availability( ---------- sim : Simulation A ``Simulation`` object that has been run. + which : str + One of "time" or "energy", to indicate the basis for the availability + calculation, by default "energy". individual_turbines : bool, optional Indicates if faint gray lines should be added in the background for the availability of each turbine, by default False. @@ -106,14 +119,41 @@ def plot_farm_availability( """ # Get the availability data metrics = sim.metrics - availability = metrics.time_based_availability("project", "windfarm").values[0][0] - windfarm_availability = metrics.time_based_availability("month-year", "windfarm") - turbine_availability = metrics.time_based_availability("month-year", "turbine") + if which == "time": + availability = metrics.time_based_availability("project", "windfarm").values[0][ + 0 + ] + windfarm_availability = metrics.time_based_availability( + "month-year", "windfarm" + ) + turbine_availability = metrics.time_based_availability("month-year", "turbine") + label = f"{sim.env.simulation_name} Time-Based Availability: {availability:.2%}" + elif which == "energy": + availability = metrics.production_based_availability( + "project", "windfarm" + ).values[0][0] + windfarm_availability = metrics.production_based_availability( + "month-year", "windfarm" + ) + turbine_availability = metrics.production_based_availability( + "month-year", "turbine" + ) + label = ( + f"{sim.env.simulation_name} Energy-Based Availability: {availability:.2%}" + ) + else: + raise ValueError("`which` must be one of 'energy' or 'time'.") # Set the defaults + if figure_kwargs is None: + figure_kwargs = {} + if plot_kwargs is None: + plot_kwargs = {} + if legend_kwargs is None: + legend_kwargs = {} figure_kwargs.setdefault("figsize", (15, 7)) figure_kwargs.setdefault("dpi", 300) - plot_kwargs.setdefault("label", f"{sim.env.simulation_name}: {availability:.2%}") + plot_kwargs.setdefault("label", label) legend_kwargs.setdefault("fontsize", 14) fig = plt.figure(**figure_kwargs) @@ -182,9 +222,9 @@ def plot_farm_availability( return None -def plot_detailed_availability( +def plot_operational_levels( sim: Simulation, - figure_kwargs: dict = {}, + figure_kwargs: dict | None = None, cbar_label_fontsize: int = 14, return_fig: bool = False, ): @@ -211,6 +251,8 @@ def plot_detailed_availability( See :py:attr:`return_fig` for details. """ # Set the defaults + if figure_kwargs is None: + figure_kwargs = {} figure_kwargs.setdefault("figsize", (15, 10)) figure_kwargs.setdefault("dpi", 300) diff --git a/wombat/windfarm/system/system.py b/wombat/windfarm/system/system.py index feebfab5..90e6abd3 100644 --- a/wombat/windfarm/system/system.py +++ b/wombat/windfarm/system/system.py @@ -188,7 +188,7 @@ def operating_level(self) -> float: """ if self.cable_failure.triggered and self.servicing.triggered: ol: float = reduce(mul, [sub.operating_level for sub in self.subassemblies]) - return ol + return ol # type: ignore return 0.0 @property @@ -203,7 +203,7 @@ def operating_level_wo_servicing(self) -> float: """ if self.cable_failure.triggered: ol: float = reduce(mul, [sub.operating_level for sub in self.subassemblies]) - return ol + return ol # type: ignore return 0.0 def power(self, windspeed: list[float] | np.ndarray) -> np.ndarray: From fdb6c3651445c90e98874e2bd19a7ae96eaa9682 Mon Sep 17 00:00:00 2001 From: RHammond2 <13874373+RHammond2@users.noreply.github.com> Date: Thu, 19 Oct 2023 10:10:55 -0700 Subject: [PATCH 3/6] update changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 373bd14e..15725521 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ - `Metrics.equipment_labor_cost_breakdowns` now has a `by_equipment` boolean flag, so that the labor and equipment costs can be broken down by category and equipment. Additionally, `total_hours` has been added to the results, resulting in fewer computed metrics across the same set of breakdowns. - "request canceled" and "complete" are now updated in the logging to directly state if it's a "repair" or "maintenance" task that was completed or canceled to ensure consistency across the logging messages. As a result, `Metrics.task_completion_rate()` can now correctly differentiate between the completed tasks effectively. +- The use of unique naming for the servicing equipment is now enforced to ensure that there is no overlap and potential confusion in the model. +- New, experimental plotting functionality has been added via `wombat.utilities.plot`. + - `plot_farm_layout` plots the graph layout of the farm. Note that this will not work if realistic lat/lon pairs have not been provided in the layout CSV. + - `plot_farm_availability` plots a line chart of the monthly overall windfarm availability. Additional toggles allow for the plotting of individual turbines in the background and/or the a 95% confidence interval band around the farm's availability + - `plot_detailed_availability` plots a heatmap of the hourly turbine and farm operational levels to help in debugging simulations where availability may be running suspiciously low (i.e., a turbine might have shut down or a cable failure didn't get repaired within a reasonable time frame). +- `Simulation.service_equipment` is now a dictionary to provide clear access to the servicing equipment details. ### Methodology Updates From be94b6d8739eea7c955eb897f00331f5bef6c18f Mon Sep 17 00:00:00 2001 From: RHammond2 <13874373+RHammond2@users.noreply.github.com> Date: Thu, 19 Oct 2023 17:14:52 -0700 Subject: [PATCH 4/6] add windfarm operations calculation at the front end and fix windfarm productino bug --- wombat/core/environment.py | 76 ++++++++++++++++++++++++++++++++++- wombat/core/post_processor.py | 34 +++------------- wombat/windfarm/windfarm.py | 7 ++++ 3 files changed, 86 insertions(+), 31 deletions(-) diff --git a/wombat/core/environment.py b/wombat/core/environment.py index 53915914..b5cc4fd0 100644 --- a/wombat/core/environment.py +++ b/wombat/core/environment.py @@ -751,6 +751,78 @@ def load_events_log_dataframe(self) -> pd.DataFrame: ) return log_df + def _calculate_windfarm_total( + self, op: pd.DataFrame, prod: pd.DataFrame | None = None + ) -> pd.DataFrame: + """Calculates the overall wind farm operational level, accounting for substation + downtime by multiplying the sum of all downstream turbine operational levels by + the substation's operational level. + + Parameters + ---------- + op : pd.DataFrame + The turbine and substation operational level DataFrame. + + Notes + ----- + This is a crude cap on the operations, and so a smarter way of capping + the availability should be added in the future. + + Returns + ------- + pd.DataFrame + The aggregate wind farm operational level. + """ + t_id = self.windfarm.turbine_id + turbines = self.windfarm.turbine_weights[t_id].values * op[t_id] + total = np.sum( + [ + op[[sub]] + * np.array( + [ + [math.fsum(row)] + for _, row in turbines[val["turbines"]].iterrows() + ] + ).reshape(-1, 1) + for sub, val in self.windfarm.substation_turbine_map.items() + ], + axis=0, + ) + return total + + def _calculate_adjusted_production( + self, op: pd.DataFrame, prod: pd.DataFrame + ) -> pd.DataFrame: + """Calculates the overall wind farm power production and adjusts individual + turbine production by accounting for substation downtime. This is done by + multiplying the all downstream turbine operational levels by the substation's + operational level. + + Parameters + ---------- + op : pd.DataFrame + The operational level DataFrame with turbine, substation, and windfarm + columns. + prod : pd.DataFrame + The turbine energy production DataFrame. + + Notes + ----- + This is a crude cap on the operations, and so a smarter way of capping + the availability should be added in the future. + + Returns + ------- + pd.DataFrame + Either the aggregate wind farm operational level or the total wind farm + energy production if the :py:attr:`prod` is provided. + """ + # Adjust individual turbine production for substation downtime + for sub, val in self.windfarm.substation_turbine_map.items(): + prod[val["turbines"]] *= op[[sub]].values + prod.windfarm = prod[self.windfarm.turbine_id].sum(axis=1) + return prod + def load_operations_log_dataframe(self) -> pd.DataFrame: """Imports the logging file created in ``run`` and returns it as a formatted ``pandas.DataFrame``. @@ -769,7 +841,7 @@ def load_operations_log_dataframe(self) -> pd.DataFrame: .set_index("datetime") .sort_values("datetime") ) - + log_df["windfarm"] = self._calculate_windfarm_total(log_df) return log_df def power_production_potential_to_csv( # type: ignore @@ -829,7 +901,7 @@ def power_production_potential_to_csv( # type: ignore # the max of the substation's operating capacity and then summed. production_df = potential_df.copy() production_df[turbines] *= operations[turbines].values - production_df.windfarm = production_df[turbines].sum(axis=1) + production_df = self._calculate_adjusted_production(operations, production_df) pa.csv.write_csv( pa.Table.from_pandas(production_df), self.power_production_fname, diff --git a/wombat/core/post_processor.py b/wombat/core/post_processor.py index 6e120d21..af5d90db 100644 --- a/wombat/core/post_processor.py +++ b/wombat/core/post_processor.py @@ -3,7 +3,6 @@ import warnings from copy import deepcopy -from math import fsum from typing import TYPE_CHECKING from pathlib import Path from itertools import chain, product @@ -196,19 +195,19 @@ def __init__( if isinstance(events, str): events = self._read_data(events) - self.events = self._apply_inflation_rate(self._tidy_data(events, kind="events")) + self.events = self._apply_inflation_rate(self._tidy_data(events)) if isinstance(operations, str): operations = self._read_data(operations) - self.operations = self._tidy_data(operations, kind="operations") + self.operations = self._tidy_data(operations) if isinstance(potential, str): potential = self._read_data(potential) - self.potential = self._tidy_data(potential, kind="potential") + self.potential = self._tidy_data(potential) if isinstance(production, str): production = self._read_data(production) - self.production = self._tidy_data(production, kind="production") + self.production = self._tidy_data(production) @classmethod def from_simulation_outputs(cls, fpath: Path | str, fname: str) -> Metrics: @@ -232,7 +231,7 @@ def from_simulation_outputs(cls, fpath: Path | str, fname: str) -> Metrics: metrics = cls(**data) return metrics - def _tidy_data(self, data: pd.DataFrame, kind: str) -> pd.DataFrame: + def _tidy_data(self, data: pd.DataFrame) -> pd.DataFrame: """Tidies the "raw" csv-converted data to be able to be used among the ``Metrics`` class. @@ -240,12 +239,6 @@ def _tidy_data(self, data: pd.DataFrame, kind: str) -> pd.DataFrame: ---------- data : pd.DataFrame The csv log data. - kind : str - The category of the input provided to ``data``. Should be one of: - - "operations" - - "events" - - "potential" - - "production" Returns ------- @@ -270,23 +263,6 @@ def _tidy_data(self, data: pd.DataFrame, kind: str) -> pd.DataFrame: month=data.env_datetime.dt.month, day=data.env_datetime.dt.day, ) - if kind == "operations": - data[self.turbine_id] = data[self.turbine_id].astype(float) - turbines = ( - self.turbine_weights[self.turbine_id].values * data[self.turbine_id] - ) - windfarm = np.sum( - [ - data[[sub]] - * np.array( - [[fsum(row)] for _, row in turbines[val["turbines"]].iterrows()] - ).reshape(-1, 1) - for sub, val in self.substation_turbine_map.items() - ], - axis=0, - ) - windfarm = pd.DataFrame(windfarm, columns=["windfarm"], index=data.index) - data = pd.concat([data, windfarm], axis=1) return data def _read_data(self, fname: str) -> pd.DataFrame: diff --git a/wombat/windfarm/windfarm.py b/wombat/windfarm/windfarm.py index f3d12ce0..bf14cdcd 100644 --- a/wombat/windfarm/windfarm.py +++ b/wombat/windfarm/windfarm.py @@ -286,6 +286,13 @@ def _create_substation_turbine_map(self) -> None: self.substation_turbine_map: dict[str, dict[str, np.ndarray]] = s_t_map + # Calculate the turbine weights + self.turbine_weights: pd.DataFrame = ( + pd.concat([pd.DataFrame(val) for val in s_t_map.values()]) + .set_index("turbines") + .T + ) + def _create_wind_farm_map(self) -> None: """Creates a secondary graph object strictly for traversing the windfarm to turn on/off the appropriate turbines, substations, and cables more easily. From f2e23bbf902b9313da5148e67739ad00a7d72497 Mon Sep 17 00:00:00 2001 From: RHammond2 <13874373+RHammond2@users.noreply.github.com> Date: Thu, 19 Oct 2023 17:52:14 -0700 Subject: [PATCH 5/6] update changelog --- wombat/core/environment.py | 7 +++-- wombat/core/post_processor.py | 53 ++++++++++++++++++++++------------- 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/wombat/core/environment.py b/wombat/core/environment.py index b5cc4fd0..c8e745cd 100644 --- a/wombat/core/environment.py +++ b/wombat/core/environment.py @@ -818,10 +818,11 @@ def _calculate_adjusted_production( energy production if the :py:attr:`prod` is provided. """ # Adjust individual turbine production for substation downtime + prod = prod.copy() for sub, val in self.windfarm.substation_turbine_map.items(): prod[val["turbines"]] *= op[[sub]].values prod.windfarm = prod[self.windfarm.turbine_id].sum(axis=1) - return prod + return prod[["windfarm"]] def load_operations_log_dataframe(self) -> pd.DataFrame: """Imports the logging file created in ``run`` and returns it as a formatted @@ -901,7 +902,9 @@ def power_production_potential_to_csv( # type: ignore # the max of the substation's operating capacity and then summed. production_df = potential_df.copy() production_df[turbines] *= operations[turbines].values - production_df = self._calculate_adjusted_production(operations, production_df) + production_df.windfarm = self._calculate_adjusted_production( + operations, production_df + ) pa.csv.write_csv( pa.Table.from_pandas(production_df), self.power_production_fname, diff --git a/wombat/core/post_processor.py b/wombat/core/post_processor.py index af5d90db..4e040a36 100644 --- a/wombat/core/post_processor.py +++ b/wombat/core/post_processor.py @@ -51,18 +51,23 @@ def _check_frequency(frequency: str, which: str = "all") -> str: def _calculate_time_availability( - availability: np.ndarray, by_turbine: bool = False + availability: pd.DataFrame, + by_turbine: bool = False, + turbine_id: list[str] | None = None, ) -> float | np.ndarray: """Calculates the availability ratio of the whole timeseries or the whole timeseries, by turbine. Parameters ---------- - availability : np.ndarray + availability : pd.DataFrame Timeseries array of operating ratios. by_turbine : bool, optional If True, calculates the availability rate of each column, otherwise across the whole array, by default False. + turbine_id : list[str], optional + A list of turbine IDs that is required if :py:attr:`by_turbine` is ``True``, by + default None. Returns ------- @@ -72,8 +77,8 @@ def _calculate_time_availability( """ availability = availability > 0 if by_turbine: - return availability.sum(axis=0) / availability.shape[0] - return availability.sum() / availability.size + return availability[turbine_id].values.sum(axis=0) / availability.shape[0] + return availability.windfarm.values.sum() / availability.windfarm.size class Metrics: @@ -370,13 +375,15 @@ def time_based_availability(self, frequency: str, by: str) -> pd.DataFrame: for sub, val in self.substation_turbine_map.items(): turbine_operations[val["turbines"]] *= self.operations[[sub]].values - hourly = turbine_operations.loc[:, self.turbine_id].values + hourly = turbine_operations.loc[:, ["windfarm"] + self.turbine_id] # TODO: The below should be better summarized as: # (availability > 0).groupby().sum() / groupby().count() if frequency == "project": - availability = _calculate_time_availability(hourly, by_turbine=by_turbine) + availability = _calculate_time_availability( + hourly, by_turbine=by_turbine, turbine_id=self.turbine_id + ) if not by_turbine: return pd.DataFrame([availability], columns=["windfarm"]) @@ -392,7 +399,9 @@ def time_based_availability(self, frequency: str, by: str) -> pd.DataFrame: counts = counts[self.turbine_id] if by_turbine else counts[["windfarm"]] annual = [ _calculate_time_availability( - hourly[date_time.year == year], by_turbine=by_turbine + hourly[date_time.year == year], + by_turbine=by_turbine, + turbine_id=self.turbine_id, ) for year in counts.index ] @@ -403,7 +412,9 @@ def time_based_availability(self, frequency: str, by: str) -> pd.DataFrame: counts = counts[self.turbine_id] if by_turbine else counts[["windfarm"]] monthly = [ _calculate_time_availability( - hourly[date_time.month == month], by_turbine=by_turbine + hourly[date_time.month == month], + by_turbine=by_turbine, + turbine_id=self.turbine_id, ) for month in counts.index ] @@ -416,6 +427,7 @@ def time_based_availability(self, frequency: str, by: str) -> pd.DataFrame: _calculate_time_availability( hourly[(date_time.year == year) & (date_time.month == month)], by_turbine=by_turbine, + turbine_id=self.turbine_id, ) for year, month in counts.index ] @@ -448,23 +460,24 @@ def production_based_availability(self, frequency: str, by: str) -> pd.DataFrame raise ValueError('``by`` must be one of "windfarm" or "turbine".') by_turbine = by == "turbine" - production = self.production.loc[:, self.turbine_id] - potential = self.potential.loc[:, self.turbine_id] + if by_turbine: + production = self.production.loc[:, self.turbine_id] + potential = self.potential.loc[:, self.turbine_id] + else: + production = self.production[["windfarm"]].copy() + potential = self.potential[["windfarm"]].copy() if frequency == "project": production = production.values potential = potential.values if (potential == 0).sum() > 0: potential[potential == 0] = 1 - if not by_turbine: - return pd.DataFrame( - [production.sum() / potential.sum()], columns=["windfarm"] - ) - availability = pd.DataFrame( - (production.sum(axis=0) / potential.sum(axis=0)).reshape(1, -1), - columns=self.turbine_id, - ) - return availability + + availability = production.sum(axis=0) / potential.sum(axis=0) + if by_turbine: + return pd.DataFrame([availability], columns=self.turbine_id) + else: + return pd.DataFrame([availability], columns=["windfarm"]) production["year"] = production.index.year.values production["month"] = production.index.month.values @@ -472,7 +485,7 @@ def production_based_availability(self, frequency: str, by: str) -> pd.DataFrame potential["year"] = potential.index.year.values potential["month"] = potential.index.month.values - group_cols = deepcopy(self.turbine_id) + group_cols = deepcopy(self.turbine_id) if by_turbine else ["windfarm"] if frequency == "annual": group_cols.insert(0, "year") production = production[group_cols].groupby("year").sum() From 4c0c2dc262a58924131c9c1259b4d6b763ffff05 Mon Sep 17 00:00:00 2001 From: RHammond2 <13874373+RHammond2@users.noreply.github.com> Date: Thu, 19 Oct 2023 17:52:27 -0700 Subject: [PATCH 6/6] update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15725521..0baca9b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - Traveling to a system for a repair where the timing extends beyond the end of the shift, but into the next shift, is now registered as a shift delay just like travel weather delays that extend beyond the end of the current shift but before the start of the next shift. This has a small positive impact on availability because a turbine or cable may not start being repaired until weather is more consistently clear, rather than starting it and extending it for many shifts. - `Windfarm.cable()` now correctly identifies 2 and 3 length cable naming conventions to differentiate which version of the cable id is being retrieved. - An edge case where events occurred just after the end of a simulation has been resolved by checking the datetime stamp of that event and not adding any action log to the simulation that is after the `WombatEnvironment.end_datetime`. +- A bug in how the total wind farm operating level was calculated is updated to account for substation downtime, rather than using a sum of all turbine values. +- `Metrics.time_based_availability` and `Metrics.production_based_availability` have been updated to use to take advantage of the above fix. Similarly, the time-based availability skews higher now, as is expected when taking into account all availability greater than 0, and the energy-based availability drops moderately as a result of accounting for the substation downtime. ### General Updates