Source code for waves.project

"""Provides the ``Project`` class that ties to together ORBIT (CapEx), WOMBAT (OpEx), and
FLORIS (AEP) simulation libraries for a simplified modeling workflow.
"""

from __future__ import annotations

import re
import json
import math
from copy import deepcopy
from typing import TYPE_CHECKING
from pathlib import Path
from functools import reduce, partial
from itertools import product

import yaml
import attrs
import numpy as np
import pandas as pd
import pyarrow as pa
import networkx as nx
import pyarrow.csv  # pylint: disable=W0611
import numpy_financial as npf
import matplotlib.pyplot as plt
from attrs import field, define
from ORBIT import ProjectManager, load_config
from floris import WindRose, TimeSeries, FlorisModel
from landbosse import landbosse_runner  # noqa: E402
from wombat.core import Simulation
from wombat.core.data_classes import FromDictMixin

from waves.utilities import (
    load_yaml,
    resolve_path,
    check_monthly_wind_rose,
    create_monthly_wind_rose,
    calculate_monthly_wind_rose_results,
)
from waves.utilities.met_data import compute_shear, extrapolate_windspeed, fit_weibull_distribution
from waves.utilities.validators import check_dict_consistency, validate_common_inputs


[docs] def convert_to_multi_index( date_tuples: tuple[int, int] | list[tuple[int, int]] | pd.MultiIndex | None, name: str ) -> pd.MultiIndex: """Converts year and month tuple(s) into a pandas MultiIndex. Parameters ---------- date_tuples : tuple[int, int] | list[tuple[int, int]] | pd.MultiIndex | None A single (``tuple``), or many combinations (``list`` of ``tuple``s) of ``int`` year and ``int`` month. If a ``MultiIndex`` or ``None`` is passed, it will be returned as-is. name : str The name of the variable to ensure that a helpful error is raised in case of invalid inputs. Returns ------- pd.MultiIndex A pandas MultIndex with index columns: "year" and "month", or None, if None is passed. Raises ------ ValueError Raised if the year, month combinations are not length 2 and are not tuples """ if date_tuples is None or isinstance(date_tuples, pd.MultiIndex): return date_tuples if isinstance(date_tuples, tuple): date_tuples = [date_tuples] for date_tuple in date_tuples: if not (isinstance(date_tuple, tuple) and len(date_tuple) == 2): raise ValueError( f"The input to `{name}` must contain tuple(s) of length 2 for" " (year, month) combination(s)." ) return pd.MultiIndex.from_tuples(date_tuples, names=["year", "month"])
[docs] def load_weather(value: str | Path | pd.DataFrame) -> pd.DataFrame: """Loads in the weather file using PyArrow, but returing a ``pandas.DataFrame`` object. Must have the column "datetime", which can be converted to a ``pandas.DatetimeIndex``. Args: value : str | Path | pd.DataFrame The input file name and path, or a ``pandas.DataFrame`` (gets passed back without modification). Returns ------- pd.DataFrame The full weather profile with the column "datetime" as a ``pandas.DatetimeIndex``. """ if isinstance(value, pd.DataFrame): return value value = resolve_path(value) convert_options = pa.csv.ConvertOptions( timestamp_parsers=[ "%m/%d/%y %H:%M", "%m/%d/%y %I:%M", "%m/%d/%Y %H:%M", "%m/%d/%Y %I:%M", "%m-%d-%y %H:%M", "%m-%d-%y %I:%M", "%m-%d-%Y %H:%M", "%m-%d-%Y %I:%M", ] ) weather = ( pa.csv.read_csv(value, convert_options=convert_options) .to_pandas() .set_index("datetime") .fillna(0.0) .resample("h") .interpolate(limit_direction="both", limit=5) ) return weather
[docs] @define(auto_attribs=True) class Project(FromDictMixin): """The unified interface for creating, running, and assessing analyses that combine ORBIT, WOMBAT, and FLORIS. Parameters ---------- library_path : str | pathlib.Path The file path where the configuration data for ORBIT, WOMBAT, and FLORIS can be found. turbine_type : str The type of wind turbine used. Must be one of "land", "fixed", or "floating". weather_profile : str | pathlib.Path The file path where the weather profile data is located, with the following column requirements: - datetime: The timestamp column - orbit_weather_cols: see ``orbit_weather_cols`` - floris_windspeed: see ``floris_windspeed`` - floris_wind_direction: see ``floris_wind_direction`` orbit_weather_cols : list[str] The windspeed and wave height column names in ``weather`` to use for running ORBIT. Defaults to ``["windspeed", "wave_height"]``. floris_windspeed : str The windspeed column in ``weather`` that will be used for the FLORIS wake analysis. Defaults to "windspeed_100m". floris_wind_direction : str The wind direction column in ``weather`` that will be used for the FLORIS wake analysis. Defaults to "wind_direction_100m". floris_turbulence_intensity : str | None The turbulence intensity column in ``weather`` that will be used for the FLORIS wake analysis. If None, then the input from ``floris_config`` will be used. Defaults to "None". floris_x_col : str The column of x-coordinates in the WOMBAT layout file that corresponds to the ``Floris.farm.layout_x`` Defaults to "floris_x". floris_y_col : str The column of x-coordinates in the WOMBAT layout file that corresponds to the ``Floris.farm.layout_y`` Defaults to "floris_y". orbit_config : str | pathlib.Path | dict | None The ORBIT configuration file name or dictionary. If None, will not set up the ORBIT simulation component. landbosse_config : str | pathlib.Path | dict | None The LandBOSSE configuration file name or dictionary. If None, will not set up the LandBOSSE simulation component. wombat_config : str | pathlib.Path | dict | None The WOMBAT configuration file name or dictionary. If None, will not set up the WOMBAT simulation component. floris_config : str | pathlib.Path | dict | None The FLORIS configuration file name or dictionary. If None, will not set up the FLORIS simulation component. .. note:: The farm:trubine_library_path is automatically set to use the :py:attr:`library_path` / turbines folder to maintain consistency. connect_floris_to_layout : bool, optional If True, automatically connect the FLORIS and WOMBAT layout files, so that the simulation results can be linked. If False, don't connec the two models. Defaults to True. .. note:: This should only be set to False if the FLORIS and WOMBAT layouts need to be connected in an additional step connect_orbit_array_design : bool, optional If True, the ORBIT array cable lengths will be calculated on initialization and added into the primary layout file. offtake_price : float, optional The price paid per MWh of energy produced. Defaults to None. fixed_charge_rate : float, optional Revenue per amount of investment required to cover the investment cost, with the default provided through the NREL 2021 Cost of Energy report [1]_. Defaults to 0.0582. discount_rate : float, optional The minimum acceptable rate of return, or the assumed return on an alternative investment of comparable risk. Defaults to None. finance_rate : float, optional Interest rate paid on the cash flows. Defaults to None. reinvestment_rate : float, optional Interest rate paid on the cash flows upon reinvestment. Defaults to None. environmental_loss_ratio : float, optional Percentage of environmental losses to deduct from the total energy production. Should be represented as a decimal in the range of [0, 1]. Defaults to 0.0159. orbit_start_date : str | None The date to use for installation phase start timings that are set to "0" in the ``install_phases`` configuration. If None the raw configuration data will be used. Defaults to None. soft_capex_date : tuple[int, int] | list[tuple[int, int]] | None, optional The date(s) where the ORBIT soft CapEx costs should be applied as a tuple of year and month, for instance: ``(2020, 1)`` for January 2020. Alternatively multiple dates can be set, which evenly divides the cost over all the dates, by providing a list of year and month combinations, for instance, a semi-annual 2 year cost starting in 2020 would look like: ``[(2020, 1), (2020, 7), (2021, 1), (2021, 7)]``. If None is provided, then the CapEx date will be the same as the start of the installation. Defaults to None project_capex_date : tuple[int, int] | list[tuple[int, int]] | None, optional The date(s) where the ORBIT project CapEx costs should be applied as a tuple of year and month, for instance: ``(2020, 1)`` for January 2020. Alternatively multiple dates can be set, which evenly divides the cost over all the dates, by providing a list of year and month combinations, for instance, a semi-annual 2 year cost starting in 2020 would look like: ``[(2020, 1), (2020, 7), (2021, 1), (2021, 7)]``. If None is provided, then the CapEx date will be the same as the start of the installation. Defaults to None system_capex_date : tuple[int, int] | list[tuple[int, int]] | None, optional The date(s) where the ORBIT system CapEx costs should be applied as a tuple of year and month, for instance: ``(2020, 1)`` for January 2020. Alternatively multiple dates can be set, which evenly divides the cost over all the dates, by providing a list of year and month combinations, for instance, a semi-annual 2 year cost starting in 2020 would look like: ``[(2020, 1), (2020, 7), (2021, 1), (2021, 7)]``. If None is provided, then the CapEx date will be the same as the start of the installation. Defaults to None. turbine_capex_date : tuple[int, int] | list[tuple[int, int]] | None, optional The date(s) where the ORBIT turbine CapEx costs should be applied as a tuple of year and month, for instance: ``(2020, 1)`` for January 2020. Alternatively multiple dates can be set, which evenly divides the cost over all the dates, by providing a list of year and month combinations, for instance, a semi-annual 2 year cost starting in 2020 would look like: ``[(2020, 1), (2020, 7), (2021, 1), (2021, 7)]``. If None is provided, then the CapEx date will be the same as the start of the installation. Defaults to None. report_config : dict[str, dict], optional A dictionary that can be passed to :py:meth:`generate_report`, and be used as the ``metrics_configuration`` dictionary. An additional field of ``name`` is required as input, which will be passed to ``simulation_name``. Defaults to None. References ---------- .. [1] Stehly, Tyler, and Duffy, Patrick. 2021 Cost of Wind Energy Review. United States: N. p., 2022. Web. doi:10.2172/1907623. """ library_path: Path = field(converter=resolve_path) turbine_type: str = field(converter=str.lower, validator=attrs.validators.instance_of(str)) weather_profile: str = field(converter=str) orbit_config: str | Path | dict | None = field( default=None, validator=attrs.validators.instance_of((str, Path, dict, type(None))) ) wombat_config: str | Path | dict | None = field( default=None, validator=attrs.validators.instance_of((str, Path, dict, type(None))) ) floris_config: str | Path | dict | None = field( default=None, validator=attrs.validators.instance_of((str, Path, dict, type(None))) ) landbosse_config: str | Path | dict | None = field( default=None, validator=attrs.validators.instance_of((str, Path, dict, type(None))) ) orbit_start_date: str | None = field(default=None) orbit_weather_cols: list[str] = field( default=["windspeed", "wave_height"], validator=attrs.validators.deep_iterable( member_validator=attrs.validators.instance_of(str), iterable_validator=attrs.validators.instance_of(list), ), ) floris_windspeed: str = field(default="windspeed", converter=str) floris_wind_direction: str = field(default="wind_direction", converter=str) floris_turbulence_intensity: str | None = field(default=None) floris_x_col: str = field(default="floris_x", converter=str) floris_y_col: str = field(default="floris_y", converter=str) connect_floris_to_layout: bool = field( default=True, validator=attrs.validators.instance_of(bool) ) connect_orbit_array_design: bool = field( default=True, validator=attrs.validators.instance_of(bool) ) offtake_price: float | int = field( default=None, validator=attrs.validators.instance_of((float, int, type(None))) ) fixed_charge_rate: float = field( default=0.0582, validator=attrs.validators.instance_of((float, type(None))) ) environmental_loss_ratio: float = field( default=0.0159, validator=attrs.validators.instance_of((float, type(None))) ) discount_rate: float = field( default=None, validator=attrs.validators.instance_of((float, type(None))) ) finance_rate: float = field( default=None, validator=attrs.validators.instance_of((float, type(None))) ) reinvestment_rate: float = field( default=None, validator=attrs.validators.instance_of((float, type(None))) ) soft_capex_date: tuple[int, int] | list[tuple[int, int]] | None = field( default=None, converter=partial(convert_to_multi_index, name="soft_capex_date") ) project_capex_date: tuple[int, int] | list[tuple[int, int]] | None = field( default=None, converter=partial(convert_to_multi_index, name="project_capex_date") ) system_capex_date: tuple[int, int] | list[tuple[int, int]] | None = field( default=None, converter=partial(convert_to_multi_index, name="system_capex_date") ) turbine_capex_date: tuple[int, int] | list[tuple[int, int]] | None = field( default=None, converter=partial(convert_to_multi_index, name="turbine_capex_date") ) report_config: dict[str, dict] | None = field( default=None, validator=attrs.validators.instance_of((dict, type(None))) ) # Internally created attributes, aka, no user inputs to these weather: pd.DataFrame = field(init=False) orbit_config_dict: dict = field(factory=dict, init=False) landbosse_config_dict: dict = field(factory=dict, init=False) wombat_config_dict: dict = field(factory=dict, init=False) floris_config_dict: dict = field(factory=dict, init=False) wombat: Simulation = field(init=False) orbit: ProjectManager = field(init=False) landbosse: landbosse_runner.LandBOSSERunner = field(init=False) floris: FlorisModel = field(init=False) project_wind_rose: WindRose = field(init=False) monthly_wind_rose: WindRose = field(init=False) floris_turbine_order: list[str] = field(init=False, factory=list) turbine_potential_energy: pd.DataFrame = field(init=False) turbine_production_energy: pd.DataFrame = field(init=False) project_potential_energy: pd.DataFrame = field(init=False) project_production_energy: pd.DataFrame = field(init=False) _fi_dict: dict[tuple[int, int], FlorisModel] = field(init=False, factory=dict) operations_start: pd.Timestamp = field(init=False) operations_end: pd.Timestamp = field(init=False) operations_years: int = field(init=False) def __attrs_post_init__(self) -> None: """Post-initialization hook to complete the setup.""" if isinstance(self.weather_profile, str | Path): weather_path = self.library_path / "weather" / self.weather_profile self.weather = load_weather(weather_path) self.setup_orbit() self.setup_landbosse() self.setup_wombat() self.setup_floris() self.check_consistent_config() if self.connect_floris_to_layout: self.connect_floris_to_turbines() if self.connect_orbit_array_design and self.orbit_config is not None: self.connect_orbit_cable_lengths() # ********************************************************************************************** # Input validation methods # **********************************************************************************************
[docs] @library_path.validator # type: ignore def library_exists(self, attribute: attrs.Attribute, value: Path) -> None: """Validates that the user input to :py:attr:`library_path` is a valid directory. Parameters ---------- attribute : attrs.Attribute The attrs Attribute information/metadata/configuration. value : Path The user input. Raises ------ FileNotFoundError Raised if :py:attr:`value` does not exist. ValueError Raised if the :py:attr:`value` exists, but is not a directory. """ if not value.exists(): raise FileNotFoundError(f"The input path to {attribute.name} cannot be found: {value}") if not value.is_dir(): raise ValueError(f"The input path to {attribute.name}: {value} is not a directory.")
[docs] @turbine_type.validator # type: ignore def validate_turbine_type(self, attribute: attrs.Attribute, value: str) -> None: """Validates that :py:attr`turbine_type` is one of "land", "fixed", or "floating". Parameters ---------- attribute : attrs.Attribute The attrs Attribute information/metadata/configuration. value : str The user input. Raises ------ ValueError Raised if not one of "land", "fixed", or "floating". """ if value not in ("land", "fixed", "floating"): raise ValueError(f"{attribute.name} must be one 'land', 'fixed', or floating'.")
[docs] @report_config.validator # type: ignore def validate_report_config(self, attribute: attrs.Attribute, value: dict | None) -> None: """Validates the user input for :py:attr:`report_config`. Parameters ---------- attribute : attrs.Attribute The attrs Attribute information/metadata/configuration. value : dict | None _description_ Raises ------ ValueError Raised if :py:attr:`report_config` is not a dictionary. KeyError Raised if :py:attr:`report_config` does not contain a key, value pair for "name". """ if value is None: return if not isinstance(value, dict): raise ValueError("`report_config` must be a dictionary, if provided") if "name" not in value: raise KeyError("A key, value pair for `name` must be provided for the simulation name.")
# ********************************************************************************************** # Configuration methods # **********************************************************************************************
[docs] @classmethod def from_file(cls, library_path: str | Path, config_file: str | Path) -> Project: """Creates a ``Project`` object from either a JSON or YAML file. See :py:class:`Project` for configuration requirements. Parameters ---------- library_path : str | Path The library path to be used in the simulation. config_file : str | Path The configuration file to create a :py:class:`Project` object from, which should be located at: ``library_path`` / project / config / ``config_file``. Raises ------ FileExistsError Raised if :py:attr:`library_path` is not a valid directory. ValueError Raised if :py:attr:`config_file` is not a JSON or YAML file. Returns ------- Project An initialized Project object. """ library_path = Path(library_path).resolve() if not library_path.is_dir(): raise FileExistsError(f"{library_path} cannot be found.") config_file = Path(config_file) if config_file.suffix == ".json": with open(library_path / "project/config" / config_file) as f: config_dict = dict(json.load(f)) if config_file.suffix in (".yml", ".yaml"): config_dict = load_yaml(library_path / "project/config", config_file) else: raise ValueError( "The configuration file must be a JSON (.json) or YAML (.yaml or .yml) file." ) config_dict["library_path"] = library_path return Project.from_dict(config_dict)
@property def config_dict(self) -> dict: """Generates a configuration dictionary that can be saved to a new file for later re/use. Returns ------- dict YAML-safe dictionary of a Project-loadable configuration. """ wombat_config_dict = deepcopy(self.wombat_config_dict) config_dict = { "library_path": str(self.library_path), "orbit_config": self.orbit_config_dict, "landbosse_config": self.landbosse_config_dict, "wombat_config": wombat_config_dict, "floris_config": self.floris_config_dict, "weather_profile": self.weather_profile, "orbit_weather_cols": self.orbit_weather_cols, "floris_windspeed": self.floris_windspeed, "floris_wind_direction": self.floris_wind_direction, "floris_x_col": self.floris_x_col, "floris_y_col": self.floris_y_col, } return config_dict
[docs] def check_consistent_config(self) -> None: """Check the configurations of the models after they have been set up but before running the model to ensure the basicinputs to each model are consistent. """ num_turbines = {} project_capacity = {} num_substations = {} if bool(self.orbit_config_dict): num_turbines["orbit"] = self.orbit_config_dict["plant"]["num_turbines"] orbit_num_substation = self.orbit_config_dict.get("oss_design", {}).get( "num_substations" ) if orbit_num_substation is not None: num_substations["orbit"] = orbit_num_substation if bool(self.landbosse_config_dict): num_turbines["landbosse"] = self.landbosse_config_dict["num_turbines"] project_capacity["landbosse"] = ( self.landbosse_config_dict["turbine_rating_MW"] * num_turbines["landbosse"] ) num_substations["landbosse"] = 1 if bool(self.wombat_config_dict): num_turbines["wombat"] = len(self.wombat.windfarm.turbine_id) project_capacity["wombat"] = self.wombat.windfarm.capacity / 1000.0 num_substations["wombat"] = len(self.wombat.windfarm.substation_id) if bool(self.floris_config_dict): num_turbines["floris"] = self.floris.n_turbines check_dict_consistency(num_turbines, "num_turbines") check_dict_consistency(project_capacity, "project_capacity") check_dict_consistency(num_substations, "num_substations")
[docs] def save_config(self, config_file: str | Path) -> None: """Saves a copy of the Project configuration settings to recreate the results of the current settings. Parameters ---------- config_file : str | Path The name to use for saving to a YAML configuration file. """ config_dict = self.config_dict with open(self.library_path / "project/config" / config_file, "w") as f: yaml.dump(config_dict, f, default_flow_style=False)
# ********************************************************************************************** # Setup and setup assisting methods # **********************************************************************************************
[docs] def setup_orbit(self) -> None: """Creates the ORBIT Project Manager object and readies it for running an analysis.""" if self.orbit_config is None: print("No ORBIT configuration provided, skipping model setup.") return if isinstance(self.orbit_config, str | Path): orbit_config = self.library_path / "project/config" / self.orbit_config self.orbit_config_dict = load_config(orbit_config) else: self.orbit_config_dict = self.orbit_config if self.orbit_start_date is not None: for phase, start in self.orbit_config_dict["install_phases"].items(): if start == 0: self.orbit_config_dict["install_phases"][phase] = self.orbit_start_date if TYPE_CHECKING: assert isinstance(self.weather, pd.DataFrame) # mypy helper self.orbit = ProjectManager( self.orbit_config_dict, library_path=str(self.library_path), weather=self.weather.loc[:, self.orbit_weather_cols], )
[docs] def setup_landbosse(self) -> None: """Creates the LandBOSSE runner object and readies it for running an analysis.""" if self.landbosse_config is None: print("No LandBOSSE configuration provided, skipping model setup.") return if isinstance(self.landbosse_config, str | Path): landbosse_config = self.library_path / "project/config" / self.landbosse_config self.landbosse_config_dict = load_config(landbosse_config) else: self.landbosse_config_dict = self.landbosse_config if self.report_config is not None: self.landbosse_config_dict["id"] = self.report_config["name"] else: self.landbosse_config_dict["id"] = "" landbosse_table_path = ( self.library_path / "project/config" / self.landbosse_config_dict["data_tables"] ) landbosse_table_dict = pd.read_excel(landbosse_table_path, sheet_name=None) self.landbosse_config_dict["data_table"] = landbosse_table_dict if TYPE_CHECKING: assert isinstance(self.weather, pd.DataFrame) # mypy helper self.landbosse = landbosse_runner.LandBOSSERunner( input_config=self.landbosse_config_dict, weather=self.weather, )
[docs] def setup_wombat(self) -> None: """Creates the WOMBAT Simulation object and readies it for running an analysis.""" if self.wombat_config is None: print("No WOMBAT configuration provided, skipping model setup.") return if isinstance(self.wombat_config, str | Path): wombat_config = ( self.library_path / "project/config" / self.wombat_config # type: ignore ) else: wombat_config = self.wombat_config # type: ignore self.wombat = Simulation.from_config(self.library_path, wombat_config) self.wombat_config_dict = attrs.asdict(self.wombat.config) self.operations_start = self.wombat.env.start_datetime self.operations_end = self.wombat.env.end_datetime start = self.wombat.env.start_datetime end = self.wombat.env.end_datetime diff = end - start self.operations_years = round((diff.days + (diff.seconds / 60 / 60) / 24) / 365.25, 1)
[docs] def setup_floris(self) -> None: """Creates the FLORIS FlorisInterface object and readies it for running an analysis. """ if self.floris_config is None: print("No FLORIS configuration provided, skipping model setup.") return if isinstance(self.floris_config, str | Path): self.floris_config_dict = load_yaml( self.library_path / "project/config", self.floris_config ) else: self.floris_config_dict = self.floris_config # Ensure the project turbine library is used for the floris turbine library turbine_library = self.library_path / "turbines" self.floris_config_dict["farm"]["turbine_library_path"] = turbine_library # Check that an array of turbulence intensities is provided in the weather profile to ensure # the wind rose and time series methods don't break during runtime. if self.floris_turbulence_intensity is None: ti = self.floris_config_dict["flow_field"]["turbulence_intensities"] if len(ti) > 1: msg = ( "Include floris:flow_field:turbulence_intensities in the weather profile for" " more than a single value." ) raise ValueError(msg) self.floris = FlorisModel(configuration=self.floris_config_dict)
[docs] def connect_floris_to_turbines(self, x_col: str = "floris_x", y_col: str = "floris_y"): """Generates ``floris_turbine_order`` from the WOMBAT ``Windfarm.layout_df``. Parameters ---------- x_col : str, optional The column name in the layout corresponding to the FLORIS x poszitions, by default "floris_x". y_col : str, optional The column name in the layout corresponding to the FLORIS y positions, by default "floris_y". """ layout = self.wombat.windfarm.layout_df self.floris_turbine_order = [ layout.loc[(layout[x_col] == x) & (layout[y_col] == y), "id"].values[0] for x, y in zip(self.floris.layout_x, self.floris.layout_y) ]
[docs] def connect_orbit_cable_lengths(self, save_results: bool = True) -> None: """Runs the ORBIT design phases, so that the array system has computed the necessary cable length and distance measures, then attaches the cable length calculations back to the layout file, saves the results to the layout files, and reloads both ORBIT and WOMBAT with this data. Parameters ---------- save_results : bool, optional Save the resulting, updated layout table to both ``library_path``/project/plant/``wombat_config_dict["layout"]`` and ``library_path``/cables/``wombat_config_dict["layout"]`` for WOMBAT and ORBIT compatibility, respectively. """ # Get the correct design phase design_phases = self.orbit_config_dict["design_phases"] if "ArraySystemDesign" in design_phases: name = "ArraySystemDesign" elif "CustomArraySystemDesign" in design_phases: name = "CustomArraySystemDesign" else: raise RuntimeError( "None of `ArraySystemDesign` or `CustomArraySystemDesign` were included in the" "ORBIT configuration" ) # Run the design phases if not already if name not in self.orbit._phases: self.orbit.run_design_phase(name) array = self.orbit._phases[name] locations = array.location_data.copy() cable_lengths = array.sections_cable_lengths.copy() # Loop through the substations, then strings to combine the calculated cable lengths with # the appropriate turbines, according to the turbine order on each string i = 0 for oss in locations.substation_id.unique(): oss_ix = locations.substation_id == oss oss_layout = locations.loc[oss_ix] string_id = np.sort(oss_layout.string.unique()) for string in string_id: string_ix = oss_ix & (locations.string == string) cable_order = locations.loc[string_ix, "order"].values locations.loc[string_ix, "cable_length"] = cable_lengths[string + i, cable_order] i = string + 1 # Add the cable length values to the layout file id_ix = locations.id.values self.wombat.windfarm.layout_df.loc[ self.wombat.windfarm.layout_df.id.isin(id_ix), "cable_length" ] = locations.cable_length # Save the updated data to the original layout locations if save_results: layout_file_name = self.wombat_config_dict["layout"] self.wombat.windfarm.layout_df.to_csv( self.library_path / "project/plant" / layout_file_name, index=False, ) # Unset the ORBIT settings to ensure the design result isn't double counted self.setup_orbit()
[docs] def generate_floris_positions_from_layout( self, x_col: str = "easting", y_col: str = "northing", update_config: bool = True, config_fname: str | None = None, ) -> None: """Updates the FLORIS layout_x and layout_y based on the relative coordinates from the WOMBAT layout file. Parameters ---------- x_col : str, optional The relative, distance-based x-coordinate column name. Defaults to "easting". y_col : str, optional The relative, distance-based y-coordinate column name. Defaults to "northing". update_config : bool, optional Run ``FlorisInterface.reinitialize`` with the updated ``layout_x`` and ``layout_y`` values. Defaults to True. config_fname : str | None, optional Provide a file name if ``update_config`` and this new configuration should be saved. Defaults to None. """ layout = self.wombat.windfarm.layout_df x_min = layout[x_col].min() y_min = layout[y_col].min() layout.assign(floris_x=layout[x_col] - x_min, floris_y=layout[y_col] - y_min) layout = layout.loc[ layout.id.isin(self.wombat.windfarm.turbine_id), ["floris_x", "floris_y"] ] x, y = layout.values.T self.floris.set(layout_x=x, layout_y=y) if update_config: if TYPE_CHECKING: assert isinstance(self.floris_config_dict, dict) # mypy helper self.floris_config_dict["farm"]["layout_x"] = x.tolist() self.floris_config_dict["farm"]["layout_y"] = y.tolist() if config_fname is not None: full_path = self.library_path / "project/config" / config_fname with open(full_path, "w") as f: yaml.dump(self.floris_config_dict, f, default_flow_style=False) print(f"Updated FLORIS configuration saved to: {full_path}.")
# ********************************************************************************************** # Run methods # **********************************************************************************************
[docs] def run_wind_rose_aep( self, full_wind_rose: bool = False, set_kwargs: dict | None = None, ): """Runs the custom FLORIS WindRose AEP methodology that allows for gathering of intermediary results. Parameters ---------- full_wind_rose : bool, optional If True, the full wind profile will be used, otherwise, if False, the wind profile will be limited to just the simulation period. Defaults to False. set_kwargs : dict | None, optional Arguments that are provided to ``FlorisInterface.get_farm_AEP_wind_rose_class()``. Defaults to None. From FLORIS: - yaw_angles (NDArrayFloat | list[float] | None, optional): The relative turbine yaw angles in degrees. If None is specified, will assume that the turbine yaw angles are all zero degrees for all conditions. Defaults to None. - turbine_weights (NDArrayFloat | list[float] | None, optional): weighing terms that allow the user to emphasize power at particular turbines and/or completely ignore the power from other turbines. This is useful when, for example, you are modeling multiple wind farms in a single floris object. If you only want to calculate the power production for one of those farms and include the wake effects of the neighboring farms, you can set the turbine_weights for the neighboring farms' turbines to 0.0. The array of turbine powers from floris is multiplied with this array in the calculation of the objective function. If None, this is an array with all values 1.0 and with shape equal to (n_wind_directions, n_wind_speeds, n_turbines). Defaults to None. """ if set_kwargs is None: set_kwargs = {} if TYPE_CHECKING: assert isinstance(self.weather, pd.DataFrame) # mypy helper weather = self.weather.copy() if not full_wind_rose: weather = weather.loc[self.operations_start : self.operations_end] if self.floris_turbulence_intensity is None: ti, *_ = self.floris_config_dict["flow_field"]["turbulence_intensities"] ti_col = "floris_turbulence_intensities" weather.loc[:, ti_col] = ti else: ti_col = self.floris_turbulence_intensity weather = weather.rename( columns={self.floris_wind_direction: "wd", self.floris_windspeed: "ws", ti_col: "ti"} )[["wd", "ws", "ti"]] # recreate the FlorisModel object for the wind rose settings wd, ws, ti = weather.values.T project_time_series = TimeSeries( wind_speeds=ws, wind_directions=wd, turbulence_intensities=ti, ) self.project_wind_rose = project_time_series.to_WindRose() self.monthly_wind_rose = create_monthly_wind_rose(weather) self.monthly_wind_rose = check_monthly_wind_rose( self.project_wind_rose, self.monthly_wind_rose ) self.floris.set(wind_data=self.project_wind_rose) # Table of frequencies (wind directions x wind speeds) freq_monthly = {k: wr.freq_table for k, wr in self.monthly_wind_rose.items()} yaw_angles = set_kwargs.get("yaw_angles", None) self.floris.set(yaw_angles=yaw_angles) # Calculate the potential energy self.floris.run_no_wake() turbine_potential_power = self.floris.get_turbine_powers() if (weights := set_kwargs["turbine_weights"]) is not None: turbine_potential_power *= weights # Calculate the monthly potential contribution to AEP from the wind rose self.turbine_potential_energy = calculate_monthly_wind_rose_results( turbine_potential_power, freq_monthly ) self.turbine_potential_energy.columns = self.floris_turbine_order # Mwh self.project_potential_energy = self.turbine_potential_energy.values.sum() # Calculate the produced power self.floris.run() turbine_production_power = self.floris.get_turbine_powers() if (weights := set_kwargs["turbine_weights"]) is not None: turbine_production_power *= weights # Calculate the monthly contribution to AEP from the wind rose self.turbine_production_energy = calculate_monthly_wind_rose_results( turbine_production_power, freq_monthly ) self.turbine_production_energy.columns = self.floris_turbine_order # Mwh self.project_production_energy = self.turbine_production_energy.values.sum()
[docs] def run_floris(self, set_kwargs: dict | None = None, full_wind_rose: bool = False) -> None: """Runs either a FLORIS wind rose analysis for a simulation-level AEP value (``which="wind_rose"``) or a turbine-level time series for the WOMBAT simulation period (``which="time_series"``). Parameters ---------- set_kwargs : dict | None, optional Any keyword arguments to be assed to ``FlorisInterface.reinitialize()``. Defaults to None. full_wind_rose : bool, optional Indicates, for "wind_rose" analyses ONLY, if the full weather profile from ``weather`` (True) or the limited, WOMBAT simulation period (False) should be used for analyis. Defaults to False. Raises ------ ValueError: Raised if :py:attr:`which` is not one of "wind_rose" or "time_series". """ if set_kwargs is None: set_kwargs = {} # Set the FLORIS defaults set_kwargs.setdefault("turbine_weights", None) set_kwargs.setdefault("yaw_angles", None) set_kwargs.setdefault("no_wake", False) self.run_wind_rose_aep(full_wind_rose=full_wind_rose, set_kwargs=set_kwargs)
[docs] def run( self, floris_kwargs: dict | None = None, full_wind_rose: bool = False, skip: list[str] | None = None, ) -> None: """Run all three models in serial, or a subset if ``skip`` is used. Parameters ---------- floris_kwargs : dict | None Any additional ``FlorisModel.set`` keyword arguments. Defaults to None. full_wind_rose : bool, optional Indicates, for "wind_rose" analyses ONLY, if the full weather profile from ``weather`` (True) or the limited, WOMBAT simulation period (False) should be used for analyis. Defaults to False. skip : list[str] | None, optional A list of models to be skipped. This is intended to be used after a model is reinitialized with a new or modified configuration. Defaults to None. """ if floris_kwargs is None: floris_kwargs = {} if skip is None: skip = [] if self.orbit_config is not None and "orbit" not in skip: self.orbit.run() elif self.landbosse_config is not None and "landbosse" not in skip: self.landbosse.run() if "wombat" not in skip: self.wombat.run() if "floris" not in skip: self.run_floris(set_kwargs=floris_kwargs, full_wind_rose=full_wind_rose)
[docs] def reinitialize( self, orbit_config: str | Path | dict | None = None, landbosse_config: str | Path | dict | None = None, wombat_config: str | Path | dict | None = None, floris_config: str | Path | dict | None = None, ) -> None: """Enables a user to reinitialize one or multiple of the CapEx, OpEx, and AEP models. Parameters ---------- orbit_config : str | Path | dict | None, optional ORBIT configuration file or dictionary. Defaults to None. landbosse_config : (str | Path | dict | None, optional LandBOSSE configuration file or dictionary. Defaults to None. wombat_config : str | Path | dict | None, optional WOMBAT configuation file or dictionary. Defaults to None. floris_config : (str | Path | dict | None, optional FLORIS configuration file or dictionary. Defaults to None. Raises ------ RuntimeError Raised if neither ORBIT nor LandBOSSE are configured to run. """ if orbit_config is not None: self.orbit_config = orbit_config self.setup_orbit() elif landbosse_config is not None: self.landbosse_config = landbosse_config self.setup_landbosse() if wombat_config is not None: self.wombat_config = wombat_config self.setup_wombat() if floris_config is not None: self.floris_config = floris_config self.setup_floris() self.check_consistent_config() return
# ********************************************************************************************** # Results methods # ********************************************************************************************** # TODO: Figure out the actual workflows requried to have more complete/easier reporting
[docs] def plot_farm( self, figure_kwargs: dict | None = None, draw_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. Parameters ---------- figure_kwargs : dict | None, optional Customized keyword arguments for matplotlib figure instantiation that will passed as ``plt.figure(**figure_kwargs)``. Defaults to None. draw_kwargs : dict | None, optional Customized keyword arguments for ``networkx.draw()`` that can will passed as ``nx.draw(**figure_kwargs)``. Defaults to None. 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] If :py:attr:`return_fig` is False, then None is returned, otherwise (True) the ``Figure`` and ``Axes`` objects are returned. """ if figure_kwargs is None: figure_kwargs = {} if draw_kwargs is None: draw_kwargs = {} figure_kwargs.setdefault("figsize", (14, 12)) figure_kwargs.setdefault("dpi", 200) fig = plt.figure(**figure_kwargs) ax = fig.add_subplot(111) windfarm = self.wombat.windfarm 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("font_weight", "bold") 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
# Design and installation related metrics
[docs] def n_turbines(self) -> int: """Returns the number of turbines from either ORBIT, WOMBAT, or FLORIS depending on which model is available internally. Returns ------- int The number of turbines in the project. Raises ------ RuntimeError Raised if no model configurations were provided on initialization. """ if self.orbit_config is not None: return self.orbit.num_turbines elif self.landbosse_config is not None: return self.landbosse.result.project_parameters["Number of turbines"] if self.wombat_config is not None: return len(self.wombat.windfarm.turbine_id) if self.floris_config is not None: return self.floris.n_turbines raise RuntimeError("No models were provided, cannot calculate value.")
[docs] def turbine_rating(self) -> float: """Calculates the average turbine rating, in MW, of all the turbines in the project. Returns ------- float The average rating of the turbines, in MW. Raises ------ RuntimeError Raised if no model configurations were provided on initialization. """ if self.orbit_config is not None: return self.orbit.turbine_rating elif self.landbosse_config is not None: return self.landbosse.result.project_parameters["Turbine rating MW"] if self.wombat_config is not None: return self.wombat.windfarm.capacity / 1000 / self.n_turbines() raise RuntimeError("No models were provided, cannot calculate value.")
[docs] def n_substations(self) -> int: """Calculates the number of substations in the project. Returns ------- int The number of substations in the project. """ if self.orbit_config is not None and "OffshoreSubstationDesign" not in self.orbit._phases: return self.orbit_config_dict["oss_design"]["num_substations"] elif self.landbosse_config is not None: return 1 if self.wombat_config is not None: return len(self.wombat.windfarm.substation_id) raise RuntimeError("No models were provided, cannot calculate value.")
[docs] @validate_common_inputs(which=["units"]) def capacity(self, units: str = "mw") -> float: """Calculates the project's capacity in the desired units of kW, MW, or GW. Parameters ---------- units : str, optional One of "kw", "mw", or "gw". Defaults to "mw". Returns ------- float The project capacity, returned in the desired units Raises ------ RuntimeError Raised if no model configurations were provided on initialization. """ if self.orbit_config is not None: capacity = self.orbit.capacity elif self.landbosse_config is not None: capacity = ( self.landbosse.result.project_parameters["Turbine rating MW"] * self.landbosse.result.project_parameters["Number of turbines"] ) elif self.wombat_config is not None: capacity = self.wombat.windfarm.capacity / 1000 else: raise RuntimeError("No models were provided, cannot calculate value.") units = units.lower() if units == "kw": return capacity * 1000 if units == "gw": return capacity / 1000 return capacity
[docs] @validate_common_inputs(which=["per_capacity"]) def capex( self, breakdown: bool = False, per_capacity: str | None = None ) -> pd.DataFrame | float: """Calculate capital expenditure (CapEx) using ORBIT or LandBOSSE outputs. Parameters ---------- breakdown : bool, optional Provide a detailed view of the CapEx breakdown, and a total, which is the sum of the BOS, turbine, project, and soft CapEx categories. Defaults to False. per_capacity : str, optional Provide the CapEx normalized by the project's capacity, in the desired units. If None, then the unnormalized CapEx is returned, otherwise it must be one of "kw", "mw", or "gw". Defaults to None. Raises ------ RuntimeError Raised if neither ORBIT nor LandBOSSE have been run. Returns ------- pd.DataFrame | float Project CapEx, normalized by :py:attr:`per_capacity`, if using, as either a pandas DataFrame if :py:attr:`breakdown` is True, otherwise, a float total. """ if self.orbit_config is not None: capex = self.capex_orbit(breakdown=breakdown) elif self.landbosse_config is not None: capex = self.capex_landbosse(breakdown=breakdown) else: raise RuntimeError("Must run ORBIT or LandBOSSE to calculate capex.") if per_capacity is None: if breakdown: return capex return capex.values[0, 0] capacity = self.capacity(per_capacity) unit_map = {"kw": "kW", "mw": "MW", "gw": "GW"} capex[f"CapEx per {unit_map[per_capacity]}"] = capex / capacity if breakdown: return capex return capex.values[0, 1]
[docs] def capex_orbit(self, *, breakdown: bool = False) -> pd.DataFrame: """Provides a thin wrapper to ORBIT's ``ProjectManager`` CapEx calculations that can provide a breakdown of total or normalize it by the project's capacity, in MW. Parameters ---------- breakdown : bool, optional Provide a detailed view of the CapEx breakdown, and a total, which is the sum of the BOS, turbine, project, and soft CapEx categories. Defaults to False. Returns ------- pd.DataFrame Project CapEx, normalized by :py:attr:`per_capacity`, if using, as either a pandas DataFrame if :py:attr:`breakdown` is True, otherwise, a float total. """ if breakdown: capex = pd.DataFrame.from_dict( self.orbit.capex_breakdown, orient="index", columns=["CapEx"] ) capex.loc["Total"] = self.orbit.total_capex else: capex = pd.DataFrame( [self.orbit.total_capex], columns=["CapEx"], index=pd.Index(["Total"]) ) return capex
[docs] def capex_landbosse(self, *, breakdown: bool = False) -> pd.DataFrame | float: """Calculates project CapEx from LandBOSSE result, in MW. Parameters ---------- breakdown : bool, optional Provide a detailed view of the CapEx breakdown, and a total, which is the sum of the BOS, turbine, project, and soft CapEx categories. Defaults to False. Returns ------- pd.DataFrame | float Project CapEx, normalized by :py:attr:`per_capacity`, if using, as either a pandas DataFrame if :py:attr:`breakdown` is True, otherwise, a float total. """ capex_breakdown = ( self.landbosse.result.operation_cost.groupby("operation_id")["cost_per_project"] .sum() .rename("CapEx") ) capex_total = capex_breakdown.sum() if breakdown: capex = capex_breakdown capex.loc["Total"] = capex_total capex = capex.to_frame() else: capex = pd.DataFrame(data=[capex_total], columns=["CapEx"], index=pd.Index(["Total"])) return capex
[docs] def array_system_total_cable_length(self): """Calculates the total length of the cables in the array system, in km. Returns ------- float Total length, in km, of the array system cables. Raises ------ ValueError Raised if neither ``ArraySystemDesign`` nor ``CustomArraySystem`` design were created in ORBIT. RuntimeError Raised if neither ORBIT nor LandBOSSE have been run. """ if self.orbit_config is not None: if "ArraySystemDesign" in self.orbit._phases: array = self.orbit._phases["ArraySystemDesign"] elif "CustomArraySystemDesign" in self.orbit._phases: array = self.orbit._phases["CustomArraySystemDesign"] else: raise ValueError("No array system design was included in the ORBIT configuration.") # TODO: Fix ORBIT bug for nansum return np.nansum(array.sections_cable_lengths) elif self.landbosse_config is not None: landbosse_vars = self.landbosse.result.model_variables return landbosse_vars.loc[ landbosse_vars["variable_df_key_col_name"] == "Total cable length", "value", ].squeeze() else: raise RuntimeError("Must use either ORBIT or LandBOSSE to estimate array cable length")
[docs] def export_system_total_cable_length(self): """Calculates the total length of the cables in the export system, in km. Returns ------- float Total length, in km, of the export system cables. Raises ------ ValueError Raised if ``ExportSystemDesign`` was not created in ORBIT. RuntimeError Raised if neither ORBIT nor LandBOSSE have been run. """ if self.orbit_config is not None: try: return self.orbit._phases["ExportSystemDesign"].total_length except KeyError: return self.orbit._phases["ElectricalDesign"].total_length except KeyError: raise ValueError( "Neither an `ElectricalDesign` nor an `ExportSystemDesign` phase were defined" " to be able to calculate this metric." ) elif self.landbosse_config is not None: landbosse_vars = self.landbosse.result.model_variables return landbosse_vars.loc[ landbosse_vars["variable_df_key_col_name"] == "Cable Length to Substation (km)", "value", ].squeeze() else: raise RuntimeError("Must use either ORBIT or LandBOSSE to estimate export cable length")
# Operational metrics
[docs] @validate_common_inputs(which=["frequency", "by", "units"]) def _get_floris_energy( self, which: str, frequency: str = "project", by: str = "windfarm", units: str = "gw" ) -> pd.DataFrame | float: unit_map = {"kw": "kWh", "mw": "MWh", "gw": "GWh"} # Check the floris basis if which not in ("potential", "production"): raise ValueError("`which` should be one of 'potential' or 'production'") if which == "potential": energy = self.turbine_potential_energy else: energy = self.turbine_production_energy if by == "windfarm": energy = energy.sum(axis=1).to_frame(f"Energy {which.title()} ({unit_map[units]})") # Use WOMBAT availability for the base index base_ix = self.wombat.metrics.production_based_availability( frequency="month-year", by="turbine" ).index energy_gwh = pd.DataFrame(0.0, dtype=float, index=base_ix, columns=["drop"]) energy_gwh = energy_gwh.merge( energy, how="left", left_on="month", right_index=True, ).drop(labels=["drop"], axis=1) energy_gwh /= 1000 # Aggregate to the desired frequency level (nothing required for month-year) if frequency == "annual": energy_gwh = ( energy_gwh.reset_index(drop=False).groupby("year").sum().drop(columns=["month"]) ) elif frequency == "project": if by == "turbine": energy_gwh = ( energy_gwh.sum(axis=0) .to_frame(name=f"Energy {which.title()} ({unit_map[units]})") .T ) else: energy_gwh = energy_gwh.values.sum() # Convert the units if units == "kw": return energy_gwh * 1e6 if units == "mw": return energy_gwh * 1e3 return energy_gwh
[docs] @validate_common_inputs(which=["frequency", "by", "units", "per_capacity"]) def energy_potential( self, frequency: str = "project", by: str = "windfarm", units: str = "gw", per_capacity: str | None = None, aep: bool = False, ) -> pd.DataFrame | float: """Computes the potential energy production, or annual potential energy production, in GWh, for the simulation by extrapolating the monthly contributions to AEP if FLORIS (without wakes) results were computed by a wind rose, or using the time series results. Parameters ---------- frequency : str, optional One of "project" (project total), "annual" (annual total), or "month-year" (monthly totals for each year). by : str, optional One of "windfarm" (project level) or "turbine" (turbine level) to indicate what level to calculate the energy production. units : str, optional One of "gw", "mw", or "kw" to determine the units for energy production. per_capacity : str, optional Provide the energy production normalized by the project's capacity, in the desired units. If None, then the unnormalized energy production is returned, otherwise it must be one of "kw", "mw", or "gw". Defaults to None. aep : bool, optional Flag to return the energy production normalized by the number of years the plan is in operation. Note that :py:attr:`frequency` must be "project" for this to be computed. Raises ------ ValueError Raised if ``frequency`` is not one of: "project", "annual", "month-year". Returns ------- pd.DataFrame | float The wind farm-level energy prodcution, in GWh, for the desired ``frequency``. """ if aep and frequency != "project": raise ValueError("`aep` can only be set to True, if `frequency`='project'.") energy = self._get_floris_energy( which="potential", frequency=frequency, by=by, units=units, ) if aep: energy /= self.operations_years if per_capacity is None: return energy return energy / self.capacity(per_capacity)
[docs] @validate_common_inputs(which=["frequency", "by", "units", "per_capacity"]) def energy_production( self, frequency: str = "project", by: str = "windfarm", units: str = "gw", per_capacity: str | None = None, environmental_loss_ratio: float | None = None, aep: bool = False, ) -> pd.DataFrame | float: """Computes the energy production, or annual energy production. To compute the losses, the total loss ratio from :py:method:`loss_ratio` is applied to each time step (e.g., month for month-year) rather than using each time step's losses. This lower resolution apporach to turbine and time step energy stems from the nature of the loss method itself, which is intended for farm-level results. Parameters ---------- frequency : str, optional One of "project" (project total), "annual" (annual total), or "month-year" (monthly totals for each year). by : str, optional One of "windfarm" (project level) or "turbine" (turbine level) to indicate what level to calculate the energy production. units : str, optional One of "gw", "mw", or "kw" to determine the units for energy production. per_capacity : str, optional Provide the energy production normalized by the project's capacity, in the desired units. If None, then the unnormalized energy production is returned, otherwise it must be one of "kw", "mw", or "gw". Defaults to None. environmental_loss_ratio : float, optional The decimal environmental loss ratio to apply to the energy production. If None, then it will attempt to use the :py:attr:`environmental_loss_ratio` provided in the Project configuration. Defaults to 0.0159. aep : bool, optional Flag to return the energy production normalized by the number of years the plan is in operation. Note that :py:attr:`frequency` must be "project" for this to be computed. Raises ------ ValueError Raised if ``frequency`` is not one of: "project", "annual", "month-year". Returns ------- pd.DataFrame | float The wind farm-level energy prodcution, in GWh, for the desired ``frequency``. """ if aep and frequency != "project": raise ValueError("`aep` can only be set to True, if `frequency`='project'.") energy = self.energy_potential( frequency=frequency, by=by, units=units, ) if TYPE_CHECKING: assert isinstance(energy, pd.DataFrame) # Convert naming from energy_potential() if by == "windfarm": if frequency != "project": energy.columns = energy.columns.str.replace("Potential", "Production") if frequency == "project": if by == "turbine": energy.index = energy.index.str.replace("Potential", "Production") energy *= 1 - self.loss_ratio(environmental_loss_ratio) if aep: energy /= self.operations_years if per_capacity is None: return energy return energy / self.capacity(per_capacity)
[docs] @validate_common_inputs(which=["frequency", "by", "units", "per_capacity"]) def energy_losses( self, frequency: str = "project", by: str = "windfarm", units: str = "gw", per_capacity: str | None = None, environmental_loss_ratio: float | None = None, aep: bool = False, ) -> pd.DataFrame: """Computes the energy losses for the simulation by subtracting the energy production from the potential energy production. To compute the losses, the total loss ratio from :py:method:`loss_ratio` is applied to each time step (e.g., month for month-year) rather than using each time step's losses. This lower resolution apporach to turbine and time step energy stems from the nature of the loss method itself, which is intended for farm-level results. Parameters ---------- frequency : str, optional One of "project" (project total), "annual" (annual total), or "month-year" (monthly totals for each year). by : str, optional One of "windfarm" (project level) or "turbine" (turbine level) to indicate what level to calculate the energy production. units : str, optional One of "gw", "mw", or "kw" to determine the units for energy production. per_capacity : str, optional Provide the energy production normalized by the project's capacity, in the desired units. If None, then the unnormalized energy production is returned, otherwise it must be one of "kw", "mw", or "gw". Defaults to None. environmental_loss_ratio : float, optional The decimal environmental loss ratio to apply to the energy production. If None, then it will attempt to use the :py:attr:`environmental_loss_ratio` provided in the Project configuration. Defaults to 0.0159. aep : bool, optional AEP for the annualized losses. Only used for :py:attr:`frequency` = "project". Raises ------ ValueError Raised if ``frequency`` is not one of: "project", "annual", "month-year". Returns ------- pd.DataFrame | float The wind farm-level energy prodcution, in GWh, for the desired ``frequency``. """ if aep and frequency != "project": raise ValueError("When `aep=True`, `frequency` must be 'project'.") potential = self.energy_potential( frequency=frequency, by=by, units=units, ) # Compute total loss ratio total_loss_ratio = self.loss_ratio(environmental_loss_ratio=environmental_loss_ratio) losses = potential * total_loss_ratio if TYPE_CHECKING: assert isinstance(losses, pd.DataFrame) # Convert naming from energy_potential() if by == "windfarm": if frequency != "project": losses.columns = losses.columns.str.replace("Potential", "Losses") if frequency == "project": if by == "turbine": losses.index = losses.index.str.replace("Potential", "Losses") if aep: losses /= self.operations_years if per_capacity is None: return losses return losses / self.capacity(per_capacity)
[docs] @validate_common_inputs(which=["frequency", "by", "units", "per_capacity"]) def wake_losses( self, frequency: str = "project", by: str = "windfarm", units: str = "kw", per_capacity: str | None = None, aep: bool = False, ratio: bool = False, ) -> pd.DataFrame | float: """Computes the wake losses, in GWh, for the simulation by extrapolating the monthly wake loss contributions to AEP if FLORIS (with wakes) results were computed by a wind rose, or using the time series results. Parameters ---------- frequency : str, optional One of "project" (project total), "annual" (annual total), or "month-year" (monthly totals for each year). by : str, optional One of "windfarm" (project level) or "turbine" (turbine level) to indicate what level to calculate the wake losses. per_capacity : str, optional Provide the wake losses normalized by the project's capacity, in the desired units. If None, then the unnormalized energy production is returned, otherwise it must be one of "kw", "mw", or "gw". Defaults to None. aep : bool, optional Flag to return the wake losses normalized by the number of years the plan is in operation. Note that :py:attr:`frequency` must be "project" for this to be computed. ratio : bool, optional Flag to return the wake loss ratio or the ratio of wake losses to the potential energy generation. Raises ------ ValueError Raised if ``frequency`` is not one of: "project", "annual", "month-year". Returns ------- pd.DataFrame | float The wind farm-level losses, in GWh, for the desired ``frequency``. """ unit_map = {"kw": "kWh", "mw": "MWh", "gw": "GWh"} # Use WOMBAT availability for the base index potential_energy = self._get_floris_energy( which="potential", frequency=frequency, by=by, units=units, ) waked_energy = self._get_floris_energy( which="production", frequency=frequency, by=by, units=units, ) if TYPE_CHECKING: assert isinstance(potential_energy, pd.DataFrame) assert isinstance(waked_energy, pd.DataFrame) is_float = by == "windfarm" and frequency == "project" if is_float: losses = potential_energy - waked_energy else: losses = potential_energy - waked_energy.values losses.columns = losses.columns.str.replace("Potential", "Losses") try: losses.index = losses.index.str.replace("Potential", "Losses") except AttributeError: pass # no need for multi index compatibility unit_map = {"kw": "kWh", "mw": "MWh", "gw": "GWh"} name = f"Energy Losses ({unit_map[units]})" if ratio: if is_float: return losses / potential_energy losses /= potential_energy.values losses.columns = losses.columns.str.replace(name, "Loss Ratio") try: losses.index = losses.index.str.replace(name, "Loss Ratio") except AttributeError: pass # no need for multi index compatibility if aep: if frequency != "project": raise ValueError("`aep` can only be set to True, if `frequency`='project'.") losses /= self.operations_years if per_capacity is None: return losses return losses / self.capacity(per_capacity)
[docs] def technical_loss_ratio(self) -> float: """Calculate technical losses based on the project type. This method is adopted from ORCA where a 1% hysterisis loss is applied for fixed-bottom turbines. For floating turbines, this is 1% hysterisis, 0.1% for onboard equipment, and 0.1% for rotor misalignment (0.01197901 total). Returns ------- float The technical loss ratio. """ if self.turbine_type == "floating": return 1 - (1 - 0.01) * (1 - 0.001) * (1 - 0.001) elif self.turbine_type == "fixed": return 0.01 else: return 0.0
[docs] def electrical_loss_ratio(self) -> float: """Calculate electrical losses based on ORBIT parameters. Returns ------- float The electrical loss ratio. """ if self.orbit_config is not None: depth = self.orbit_config_dict["site"]["depth"] distance_to_landfall = self.orbit_config_dict["site"]["distance_to_landfall"] # ORCA formula electrical_loss_ratio = ( 2.20224112 + 0.000604121 * depth + 0.0407303367321603 * distance_to_landfall + -0.0003712532582 * distance_to_landfall**2 + 0.0000016525338 * distance_to_landfall**3 + -0.000000003547544 * distance_to_landfall**4 + 0.0000000000029271 * distance_to_landfall**5 ) / 100 else: # https://www.nrel.gov/docs/fy22osti/78715.pdf Fig30 electrical_loss_ratio = 0.028 return electrical_loss_ratio
[docs] def loss_ratio( self, environmental_loss_ratio: float | None = None, breakdown: bool = False, ) -> pd.DataFrame | float: """Calculate total losses based on environmental, availability, wake, technical, and electrical losses. .. note:: This method treats different types of losses as efficiencies and is applied as in Equation 1 from Beiter et al. 2020 (https://www.nrel.gov/docs/fy21osti/77384.pdf). Parameters ---------- environmental_loss_ratio : float, optional The decimal environmental loss ratio to apply to the energy production. If None, then it will attempt to use the :py:attr:`environmental_loss_ratio` provided in the Project configuration. Defaults to 0.0159. breakdown : bool, optional Flag to return the losses breakdown including environmental, availability, wake, technical, electrical losses, and total losses. Returns ------- float | pd.DataFrame The total loss ratio, or the DataFrame of all loss ratios. """ # Check that the environmental loos ratio exists if environmental_loss_ratio is None: if (environmental_loss_ratio := self.environmental_loss_ratio) is None: raise ValueError( "`environmental_loss_ratio` was neither defined in the Project settings nor" " provided in the keyword arguments." ) availability = 1 - self.availability(which="energy", frequency="project", by="windfarm") wake_loss_ratio = self.wake_losses(ratio=True) technical_loss_ratio = self.technical_loss_ratio() electrical_loss_ratio = self.electrical_loss_ratio() total_loss_ratio = 1 - ( (1 - environmental_loss_ratio) * (1 - (availability)) * (1 - wake_loss_ratio) * (1 - technical_loss_ratio) * (1 - electrical_loss_ratio) ) if breakdown: loss_types = [ "Environmental Losses", "Availability Losses", "Wake Losses", "Technical Losses", "Electrical Losses", "Total Losses", ] loss_breakdown = pd.DataFrame( [ 100 * environmental_loss_ratio, 100 * availability, 100 * wake_loss_ratio, 100 * technical_loss_ratio, 100 * electrical_loss_ratio, 100 * total_loss_ratio, ], index=loss_types, columns=["Loss Ratio (%)"], ) return loss_breakdown return total_loss_ratio
[docs] @validate_common_inputs(which=["frequency", "by"]) def availability( self, which: str, frequency: str = "project", by: str = "windfarm" ) -> pd.DataFrame | float: """Calculates the availability based on either a time or energy basis. This is a thin wrapper around `self.wombat.metrics.time_based_availability()` or `self.wombat.metrics.production_based_availability()`. Parameters ---------- which : str One of "energy" or "time" to indicate which basis to use for the availability calculation. For "energy", this indicates the operating capacity of the project, and for "time", this is the ratio of any level operational to all time. frequency : str, optional One of "project" (project total), "annual" (annual total), or "month-year" (monthly totals for each year). by : str, optional One of "windfarm" (project level) or "turbine" (turbine level) to indicate what level to calculate the availability. Returns ------- pd.DataFrame | float The appropriate availability metric, as a DataFrame unless it's calculated at the project/windfarm level. Raises ------ ValueError Raised if :py:attr:`which` is not one of "energy" or "time". """ which = which.lower() if which == "energy": availability = self.wombat.metrics.production_based_availability( frequency=frequency, by=by ) elif which == "time": availability = self.wombat.metrics.time_based_availability(frequency=frequency, by=by) else: raise ValueError("`which` must be one of 'energy' or 'time'.") if frequency == "project" and by == "windfarm": return availability.values[0, 0] return availability
[docs] @validate_common_inputs(which=["frequency", "by"]) def capacity_factor( self, which: str, frequency: str = "project", by: str = "windfarm", environmental_loss_ratio: float | None = None, ) -> pd.DataFrame | float: """Calculates the capacity factor over a project's lifetime as a single value, annual average, or monthly average for the whole windfarm or by turbine. Parameters ---------- which : str One of "net" (realized energy / capacity) or "gross" (potential energy production / capacity). frequency : str One of "project", "annual", "monthly", or "month-year". Defaults to "project". by : str One of "windfarm" or "turbine". Defaults to "windfarm". .. note:: This will only be checked for :py:attr:`which` = "net". environmental_loss_ratio : float, optional The decimal environmental loss ratio to apply to the energy production. If None, then it will attempt to use the :py:attr:`environmental_loss_ratio` provided in the Project configuration. Defaults to 0.0159. Returns ------- pd.DataFrame | float The capacity factor at the desired aggregation level. """ which = which.lower().strip() if which not in ("net", "gross"): raise ValueError('``which`` must be one of "net" or "gross".') by_turbine = by == "turbine" numerator: pd.DataFrame if which == "net": numerator = ( 1 - self.loss_ratio(environmental_loss_ratio=environmental_loss_ratio) ) * self.energy_potential( frequency="month-year", by="turbine", units="kw", ) else: numerator = self.energy_potential( frequency="month-year", by="turbine", units="kw", ) _potential = self.wombat.metrics.potential.loc[ :, ["year", "month"] + self.wombat.metrics.turbine_id ] _capacity = np.ones((_potential.shape[0], self.n_turbines())) * np.array( self.wombat.metrics.turbine_capacities ) capacity = ( pd.DataFrame( np.hstack( ( _potential.year.values.reshape(-1, 1), _potential.month.values.reshape(-1, 1), _capacity, ) ), columns=_potential.columns, ) .groupby(["year", "month"]) .sum() ) if TYPE_CHECKING: assert isinstance(numerator, pd.DataFrame) if frequency == "project": if not by_turbine: return numerator.values.sum() / capacity.values.sum() return ( (numerator.sum(axis=0) / capacity.sum(axis=0)) .to_frame(f"{which.title()} Capacity Factor") .T ) if frequency == "annual": group_cols = ["year"] elif frequency == "monthly": group_cols = ["month"] elif frequency == "month-year": group_cols = ["year", "month"] capacity = ( capacity.reset_index(drop=False)[group_cols + self.wombat.metrics.turbine_id] .groupby(group_cols) .sum() ) numerator = ( numerator.reset_index(drop=False)[group_cols + self.wombat.metrics.turbine_id] .groupby(group_cols) .sum() ) if not by_turbine: numerator = numerator.sum(axis=1).to_frame(name=f"{which.title()} Capacity Factor") capacity = capacity.sum(axis=1).to_frame(name=f"{which.title()} Capacity Factor") return numerator / capacity
[docs] @validate_common_inputs(which=["frequency", "per_capacity"]) def opex( self, frequency: str = "project", per_capacity: str | None = None ) -> pd.DataFrame | float: """Calculates the operational expenditures of the project. Parameters ---------- frequency (str, optional): One of "project", "annual", "monthly", "month-year". Defaults to "project". per_capacity : str, optional Provide the OpEx normalized by the project's capacity, in the desired units. If None, then the unnormalized OpEx is returned, otherwise it must be one of "kw", "mw", or "gw". Defaults to None. Returns ------- pd.DataFrame | float The resulting OpEx DataFrame at the desired frequency, if more granular than the project frequency, otherwise a float. This will be normalized by the capacity, if :py:attr:`per_capacity` is not None. """ opex = self.wombat.metrics.opex(frequency=frequency) if frequency == "project": opex = opex.values[0, 0] if per_capacity is None: return opex per_capacity = per_capacity.lower() return opex / self.capacity(per_capacity)
[docs] @validate_common_inputs(which=["frequency"]) def revenue( self, frequency: str = "project", offtake_price: float | None = None, per_capacity: str | None = None, ) -> pd.DataFrame | float: """Calculates the revenue stream using the WOMBAT availabibility, FLORIS energy production, and WAVES energy pricing. Parameters ---------- frequency : str, optional One of "project", "annual", "monthly", or "month-year". Defaults to "project". offtake_price : float, optional Price paid per MWh of energy produced. Defaults to None. per_capacity : str, optional Provide the revenue normalized by the project's capacity, in the desired units. If None, then the unnormalized revenue is returned, otherwise it must be one of "kw", "mw", or "gw". Defaults to None. Returns ------- pd.DataFrame | float The revenue stream of the wind farm at the provided frequency. """ # Check that an offtake_price exists if offtake_price is None: if (offtake_price := self.offtake_price) is None: raise ValueError( "`offtake_price` wasn't defined in the Project settings or in the method" " keyword arguments." ) revenue = self.energy_production(frequency=frequency) * 1000 * offtake_price # MWh if frequency != "project": if TYPE_CHECKING: assert isinstance(revenue, pd.DataFrame) revenue.columns = ["Revenue"] if per_capacity is None: return revenue per_capacity = per_capacity.lower() return revenue / self.capacity(per_capacity)
[docs] @validate_common_inputs(which=["frequency"]) def capex_breakdown( self, frequency: str = "month-year", installation_start_date: str | None = None, soft_capex_date: tuple[int, int] | list[tuple[int, int]] | None = None, project_capex_date: tuple[int, int] | list[tuple[int, int]] | None = None, system_capex_date: tuple[int, int] | list[tuple[int, int]] | None = None, turbine_capex_date: tuple[int, int] | list[tuple[int, int]] | None = None, breakdown: bool = False, ) -> pd.DataFrame: """Calculates the monthly CapEx breakdwon into a DataFrame, that is returned at the desired frequency, allowing for custom starting dates for the varying CapEx costs. Parameters ---------- frequency : str, optional The desired frequency of the outputs, where "month-year" is the monthly total over the course of a project's life. Must be one of: "project", "annual", "month-year". Defaults to "month-year" installation_start_date : str | None, optional If not provided in the ``Project`` configuration as ``orbit_start_date``, an installation starting date that is parseable from a string by Pandas may be provided here. Defaults to None soft_capex_date : tuple[int, int] | list[tuple[int, int]] | None, optional The date(s) where the ORBIT soft CapEx costs should be applied as a tuple of year and month, for instance: ``(2020, 1)`` for January 2020. Alternatively multiple dates can be set, which evenly divides the cost over all the dates, by providing a list of year and month combinations, for instance, a semi-annual 2 year cost starting in 2020 would look like: ``[(2020, 1), (2020, 7), (2021, 1), (2021, 7)]``. If None is provided, then the CapEx date will be the same as the start of the installation. Defaults to None project_capex_date : tuple[int, int] | list[tuple[int, int]] | None, optional The date(s) where the ORBIT project CapEx costs should be applied as a tuple of year and month, for instance: ``(2020, 1)`` for January 2020. Alternatively multiple dates can be set, which evenly divides the cost over all the dates, by providing a list of year and month combinations, for instance, a semi-annual 2 year cost starting in 2020 would look like: ``[(2020, 1), (2020, 7), (2021, 1), (2021, 7)]``. If None is provided, then the CapEx date will be the same as the start of the installation. Defaults to None system_capex_date : tuple[int, int] | list[tuple[int, int]] | None, optional The date(s) where the ORBIT system CapEx costs should be applied as a tuple of year and month, for instance: ``(2020, 1)`` for January 2020. Alternatively multiple dates can be set, which evenly divides the cost over all the dates, by providing a list of year and month combinations, for instance, a semi-annual 2 year cost starting in 2020 would look like: ``[(2020, 1), (2020, 7), (2021, 1), (2021, 7)]``. If None is provided, then the CapEx date will be the same as the start of the installation. Defaults to None. turbine_capex_date : tuple[int, int] | list[tuple[int, int]] | None, optional The date(s) where the ORBIT turbine CapEx costs should be applied as a tuple of year and month, for instance: ``(2020, 1)`` for January 2020. Alternatively multiple dates can be set, which evenly divides the cost over all the dates, by providing a list of year and month combinations, for instance, a semi-annual 2 year cost starting in 2020 would look like: ``[(2020, 1), (2020, 7), (2021, 1), (2021, 7)]``. If None is provided, then the CapEx date will be the same as the start of the installation. Defaults to None. offtake_price : int | float | None, optional The price paid for the energy produced, by default None. breakdown : bool, optional If True, all the CapEx categories will be provided as a column, in addition to the OpEx and Revenue columns, and the total cost in "cash_flow", othwerwise, only the "cash_flow" column will be provided. Defaults to False. Returns ------- pd.DataFrame Returns the pandas DataFrame of the cashflow with a fixed monthly or annual interval, or a project total for the desired categories. Raises ------ ValueError Raised if ``frequency`` is not one of: "project", "annual", "month-year". TypeError Raised if a valid starting date can't be found for the installation. """ # Find a valid starting date for the installation processes if (start_date := installation_start_date) is None: if (start_date := self.orbit_start_date) is None: start_date = min(el for el in self.orbit_config_dict["install_phases"].values()) try: start_date = pd.to_datetime(start_date) except pd.errors.ParserError: raise TypeError( "Please provide a valid `instatllation_start_date` if no configuration-based" " starting dates were provided." ) # Create the cost dataframes that have a MultiIndex with "year" and "month" columns # Get the installation costs and add in each installation phase's port costs capex_installation = pd.DataFrame(self.orbit.logs) starts = capex_installation.loc[ capex_installation.message == "SIMULATION START", ["phase", "time"] ] phase_df_list = [] for name, time in starts.values: phase = self.orbit._phases[name] total_days = phase.total_phase_time / 24 n_days, remainder = divmod(total_days, 1) day_cost = phase.port_costs / total_days phase_daily_port = np.repeat(day_cost, n_days).tolist() + [day_cost * remainder] timing = (np.arange(n_days + 1) * 24 + time).tolist() phase_df = pd.DataFrame(zip(timing, phase_daily_port), columns=["time", "cost"]) phase_df["phase"] = name phase_df_list.append(phase_df) capex_installation = pd.concat([capex_installation, *phase_df_list], axis=0).sort_values( "time" ) # Put the installation CapEx in a format that matches the OpEx output capex_installation["datetime"] = start_date + pd.to_timedelta( capex_installation.time, unit="hours" ) capex_installation = ( capex_installation.assign( year=capex_installation.datetime.dt.year, month=capex_installation.datetime.dt.month, )[["year", "month", "cost", "phase"]] .groupby(["year", "month", "phase"]) .sum() .unstack(level=2, fill_value=0) ) capex_installation.columns = [f"CapEx_{c}" for c in capex_installation.columns.droplevel(0)] capex_installation.columns.name = None # type: ignore # Check the date values, ensuring a the installation start is the default if none provided default_start = [capex_installation.index[0]] if soft_capex_date is None: if (soft_capex_date := self.soft_capex_date) is None: soft_capex_date = default_start if project_capex_date is None: if (project_capex_date := self.project_capex_date) is None: project_capex_date = default_start if system_capex_date is None: if (system_capex_date := self.system_capex_date) is None: system_capex_date = default_start if turbine_capex_date is None: if (turbine_capex_date := self.turbine_capex_date) is None: turbine_capex_date = default_start # Convert the dates to a pandas MultiIndex to be compatible with concatenating later soft_capex_date_ix = convert_to_multi_index(soft_capex_date, "soft_capex_date") project_capex_date_ix = convert_to_multi_index(project_capex_date, "project_capex_date") system_capex_date_ix = convert_to_multi_index(system_capex_date, "system_capex_date") turbine_capex_date_ix = convert_to_multi_index(turbine_capex_date, "turbine_capex_date") # Create the remaining CapEx dataframes in the OpEx format capex_soft = pd.DataFrame( self.orbit.soft_capex / len(soft_capex_date_ix), index=soft_capex_date_ix, columns=["CapEx_Soft"], ) capex_project = pd.DataFrame( self.orbit.project_capex / len(project_capex_date_ix), index=project_capex_date_ix, columns=["CapEx_Project"], ) capex_turbine = pd.DataFrame( self.orbit.turbine_capex / len(turbine_capex_date_ix), index=turbine_capex_date_ix, columns=["CapEx_Turbine"], ) if self.orbit.system_costs == {}: capex_system = pd.DataFrame([0], columns=["Installation"]) else: capex_system = pd.DataFrame.from_dict(self.orbit.system_costs, orient="index").T capex_system = capex_system.loc[ capex_system.index.repeat(len(system_capex_date_ix)) ].set_index(system_capex_date_ix) / len(system_capex_date_ix) capex_system.columns = [ f"CapEx_{col.replace('Installation', 'System')}" for col in capex_system ] # Combine the CapEx categories and sum for a total CapEx capex_df = reduce( lambda x, y: x.join(y, how="outer"), [ capex_soft, capex_project, capex_turbine, capex_system, capex_installation, ], ).fillna(0) # Fill in the missing time periods to ensure a fixed-interval cash flow years = capex_df.index.get_level_values("year") years = list(range(min(years), max(years))) missing_ix = set(product(years, range(1, 13))).difference(capex_df.index.values) if missing_ix: capex_df = pd.concat( [ capex_df, pd.DataFrame( 0, index=convert_to_multi_index(list(missing_ix), "missing"), columns=capex_df.columns, ), ] ).sort_index() if frequency == "annual": capex_df = ( capex_df.reset_index(drop=False).groupby("year").sum().drop(labels="month", axis=1) ) elif frequency == "project": capex_df = capex_df.sum(axis=0).to_frame(name="Cash Flow").T capex_df["CapEx"] = capex_df.sum(axis=1).sort_index() if breakdown: return capex_df return capex_df.CapEx.to_frame()
[docs] @validate_common_inputs(which=["frequency"]) def cash_flow( self, frequency: str = "month-year", installation_start_date: str | None = None, soft_capex_date: tuple[int, int] | list[tuple[int, int]] | None = None, project_capex_date: tuple[int, int] | list[tuple[int, int]] | None = None, system_capex_date: tuple[int, int] | list[tuple[int, int]] | None = None, turbine_capex_date: tuple[int, int] | list[tuple[int, int]] | None = None, offtake_price: int | float | None = None, breakdown: bool = False, ) -> pd.DataFrame: """Calculates the monthly cashflows into a DataFrame, that is returned at the desired frequency, and with or without a high level breakdown, allowing for custom starting dates for the varying CapEx costs. Parameters ---------- frequency : str, optional The desired frequency of the outputs, where "month-year" is the monthly total over the course of a project's life. Must be one of: "project", "annual", "month-year". Defaults to "month-year" installation_start_date : str | None, optional If not provided in the ``Project`` configuration as ``orbit_start_date``, an installation starting date that is parseable from a string by Pandas may be provided here. Defaults to None soft_capex_date : tuple[int, int] | list[tuple[int, int]] | None, optional The date(s) where the ORBIT soft CapEx costs should be applied as a tuple of year and month, for instance: ``(2020, 1)`` for January 2020. Alternatively multiple dates can be set, which evenly divides the cost over all the dates, by providing a list of year and month combinations, for instance, a semi-annual 2 year cost starting in 2020 would look like: ``[(2020, 1), (2020, 7), (2021, 1), (2021, 7)]``. If None is provided, then the CapEx date will be the same as the start of the installation. Defaults to None project_capex_date : tuple[int, int] | list[tuple[int, int]] | None, optional The date(s) where the ORBIT project CapEx costs should be applied as a tuple of year and month, for instance: ``(2020, 1)`` for January 2020. Alternatively multiple dates can be set, which evenly divides the cost over all the dates, by providing a list of year and month combinations, for instance, a semi-annual 2 year cost starting in 2020 would look like: ``[(2020, 1), (2020, 7), (2021, 1), (2021, 7)]``. If None is provided, then the CapEx date will be the same as the start of the installation. Defaults to None system_capex_date : tuple[int, int] | list[tuple[int, int]] | None, optional The date(s) where the ORBIT system CapEx costs should be applied as a tuple of year and month, for instance: ``(2020, 1)`` for January 2020. Alternatively multiple dates can be set, which evenly divides the cost over all the dates, by providing a list of year and month combinations, for instance, a semi-annual 2 year cost starting in 2020 would look like: ``[(2020, 1), (2020, 7), (2021, 1), (2021, 7)]``. If None is provided, then the CapEx date will be the same as the start of the installation. Defaults to None. turbine_capex_date : tuple[int, int] | list[tuple[int, int]] | None, optional The date(s) where the ORBIT turbine CapEx costs should be applied as a tuple of year and month, for instance: ``(2020, 1)`` for January 2020. Alternatively multiple dates can be set, which evenly divides the cost over all the dates, by providing a list of year and month combinations, for instance, a semi-annual 2 year cost starting in 2020 would look like: ``[(2020, 1), (2020, 7), (2021, 1), (2021, 7)]``. If None is provided, then the CapEx date will be the same as the start of the installation. Defaults to None. offtake_price : int | float | None, optional The price paid for the energy produced, by default None. breakdown : bool, optional If True, all the CapEx categories will be provided as a column, in addition to the OpEx and Revenue columns, and the total cost in "cash_flow", othwerwise, only the "cash_flow" column will be provided. Defaults to False. Returns ------- pd.DataFrame Returns the pandas DataFrame of the cashflow with a fixed monthly or annual interval, or a project total for the desired categories. Raises ------ ValueError Raised if ``frequency`` is not one of: "project", "annual", "month-year". TypeError Raised if a valid starting date can't be found for the installation. """ # Check the frequency input opts = ("project", "annual", "month-year") if frequency not in opts: raise ValueError(f"`frequency` must be one of {opts}.") # type: ignore # Find a valid starting date for the installation processes if (start_date := installation_start_date) is None: if (start_date := self.orbit_start_date) is None: start_date = min(el for el in self.orbit_config_dict["install_phases"].values()) try: start_date = pd.to_datetime(start_date) except pd.errors.ParserError: raise TypeError( "Please provide a valid `instatllation_start_date` if no configuration-based" " starting dates were provided." ) # Create the cost dataframes that have a MultiIndex with "year" and "month" columns # Get the installation costs and add in each installation phase's port costs capex_installation = pd.DataFrame(self.orbit.logs) starts = capex_installation.loc[ capex_installation.message == "SIMULATION START", ["phase", "time"] ] phase_df_list = [] for name, time in starts.values: phase = self.orbit._phases[name] total_days = phase.total_phase_time / 24 n_days, remainder = divmod(total_days, 1) day_cost = phase.port_costs / total_days phase_daily_port = np.repeat(day_cost, n_days).tolist() + [day_cost * remainder] timing = (np.arange(n_days + 1) * 24 + time).tolist() phase_df = pd.DataFrame(zip(timing, phase_daily_port), columns=["time", "cost"]) phase_df["phase"] = name phase_df_list.append(phase_df) capex_installation = pd.concat([capex_installation, *phase_df_list], axis=0).sort_values( "time" ) # Put the installation CapEx in a format that matches the OpEx output capex_installation["datetime"] = start_date + pd.to_timedelta( capex_installation.time, unit="hours" ) capex_installation = ( capex_installation.assign( year=capex_installation.datetime.dt.year, month=capex_installation.datetime.dt.month, )[["year", "month", "cost", "phase"]] .groupby(["year", "month", "phase"]) .sum() .unstack(level=2, fill_value=0) ) capex_installation.columns = [f"CapEx_{c}" for c in capex_installation.columns.droplevel(0)] capex_installation.columns.name = None # type: ignore # Check the date values, ensuring a the installation start is the default if none provided default_start = [capex_installation.index[0]] if soft_capex_date is None: if (soft_capex_date := self.soft_capex_date) is None: soft_capex_date = default_start if project_capex_date is None: if (project_capex_date := self.project_capex_date) is None: project_capex_date = default_start if system_capex_date is None: if (system_capex_date := self.system_capex_date) is None: system_capex_date = default_start if turbine_capex_date is None: if (turbine_capex_date := self.turbine_capex_date) is None: turbine_capex_date = default_start # Convert the dates to a pandas MultiIndex to be compatible with concatenating later soft_capex_date_ix = convert_to_multi_index(soft_capex_date, "soft_capex_date") project_capex_date_ix = convert_to_multi_index(project_capex_date, "project_capex_date") system_capex_date_ix = convert_to_multi_index(system_capex_date, "system_capex_date") turbine_capex_date_ix = convert_to_multi_index(turbine_capex_date, "turbine_capex_date") # Create the remaining CapEx dataframes in the OpEx format capex_soft = pd.DataFrame( self.orbit.soft_capex / len(soft_capex_date_ix), index=soft_capex_date_ix, columns=["CapEx_Soft"], ) capex_project = pd.DataFrame( self.orbit.project_capex / len(project_capex_date_ix), index=project_capex_date_ix, columns=["CapEx_Project"], ) capex_turbine = pd.DataFrame( self.orbit.turbine_capex / len(turbine_capex_date_ix), index=turbine_capex_date_ix, columns=["CapEx_Turbine"], ) capex_system = pd.DataFrame.from_dict(self.orbit.system_costs, orient="index").T capex_system = capex_system.loc[ capex_system.index.repeat(len(system_capex_date_ix)) ].set_index(system_capex_date_ix) / len(system_capex_date_ix) capex_system.columns = [ f"CapEx_{col.replace('Installation', 'System')}" for col in capex_system ] # Combine the CapEx, Opex, and Revenue times and ensure their signs are correct cost_df = reduce( lambda x, y: x.join(y, how="outer"), [ capex_soft, capex_project, capex_turbine, capex_system, capex_installation, self.opex(frequency="month-year"), ], ).fillna(0) cost_df *= -1.0 cost_df = cost_df.join( self.revenue(frequency="month-year", offtake_price=offtake_price) ).fillna(0) # Fill in the missing time periods to ensure a fixed-interval cash flow years = cost_df.index.get_level_values("year") years = list(range(min(years), max(years))) missing_ix = set(product(years, range(1, 13))).difference(cost_df.index.values) if missing_ix: cost_df = pd.concat( [ cost_df, pd.DataFrame( 0, index=convert_to_multi_index(list(missing_ix), "missing"), columns=cost_df.columns, ), ] ).sort_index() if frequency == "annual": cost_df = ( cost_df.reset_index(drop=False).groupby("year").sum().drop(labels="month", axis=1) ) elif frequency == "project": cost_df = cost_df.sum(axis=0).to_frame(name="Cash Flow").T cost_df["cash_flow"] = cost_df.sum(axis=1).sort_index() if breakdown: return cost_df return cost_df.cash_flow.to_frame()
[docs] @validate_common_inputs(which=["frequency"]) def npv( self, frequency: str = "project", discount_rate: float | None = None, offtake_price: float | None = None, cash_flow: pd.DataFrame | None = None, **kwargs: dict, ) -> pd.DataFrame: """Calculates the net present value of the windfarm at a project, annual, or monthly resolution given a base discount rate and offtake price. .. note:: NPV is implemented via https://numpy.org/numpy-financial/latest/npv.html#numpy_financial.npv. Parameters ---------- frequency : str One of "project", "annual", "monthly", or "month-year". discount_rate : float, optional The rate of return that could be earned on alternative investments. Defaults to None. offtake_price : float, optional Price of energy, per MWh. Defaults to None. cash_flow : pd.DataFrame, optional A modified cash flow DataFrame for custom workflows. Must have the "cash_flow" column with consistent time steps (monthly, annually, etc.). Defaults to None. kwargs : dict, optional See :py:meth:`cash_flow` for details on starting date options. Returns ------- pd.DataFrame The project net prsent value at the desired time resolution. """ # Check that the discout rate exists if discount_rate is None: if (discount_rate := self.discount_rate) is None: raise ValueError( "`discount_rate` wasn't defined in the Project settings or in the method" " keyword arguments." ) # Check that the offtake price exists if offtake_price is None: if (offtake_price := self.offtake_price) is None: raise ValueError( "`offtake_price` wasn't defined in the Project settings or in the method" " keyword arguments." ) if cash_flow is None: cash_flow = self.cash_flow( installation_start_date=kwargs.get("installation_start_date", None), # type: ignore project_capex_date=kwargs.get("project_capex_date", None), # type: ignore soft_capex_date=kwargs.get("soft_capex_date", None), # type: ignore system_capex_date=kwargs.get("system_capex_date", None), # type: ignore turbine_capex_date=kwargs.get("turbine_capex_date", None), # type: ignore offtake_price=offtake_price, ) return npf.npv(discount_rate, cash_flow.cash_flow.values)
[docs] def irr( self, offtake_price: float | None = None, finance_rate: float | None = None, reinvestment_rate: float | None = None, cash_flow: pd.DataFrame | None = None, **kwargs, ) -> float: """Calculates the Internal Rate of Return using the ORBIT CapEx as the initial investment in conjunction with the WAVES monthly cash flows. .. note:: This method allows for the caluclation of the modified internal rate of return through https://numpy.org/numpy-financial/latest/mirr.html#numpy_financial.mirr if both the :py:attr:`finance_rate` and the :py:attr:`reinvestment_rate` are provided. Parameters ---------- offtake_price : float, optional Price of energy, per MWh. Defaults to None. finance_rate : float, optional Interest rate paid on the cash flows. Only used if :py:attr:`reinvestment_rate` is also provided. Defaults to None. reinvestment_rate : float, optional Interest rate received on the cash flows upon reinvestment. Only used if :py:attr:`finance_rate` is also provided. cash_flow : pd.DataFrame, optional A modified cash flow DataFrame for custom workflows. Must have the "cash_flow" column with consistent time steps (monthly, annually, etc.). Defaults to None. kwargs : dict, optional See :py:meth:`cash_flow` for details on starting date options. Returns ------- float The IRR. """ # Check that the offtake price exists if offtake_price is None: if (offtake_price := self.offtake_price) is None: raise ValueError( "`offtake_price` wasn't defined in the Project settings or in the method" " keyword arguments." ) # Check to see if the Modified IRR should be used if finance_rate is None: finance_rate = self.finance_rate if reinvestment_rate is None: reinvestment_rate = self.reinvestment_rate if cash_flow is None: cash_flow = self.cash_flow( installation_start_date=kwargs.get("installation_start_date", None), project_capex_date=kwargs.get("project_capex_date", None), soft_capex_date=kwargs.get("soft_capex_date", None), system_capex_date=kwargs.get("system_capex_date", None), turbine_capex_date=kwargs.get("turbine_capex_date", None), offtake_price=offtake_price, ) if finance_rate is None or reinvestment_rate is None: return npf.irr(cash_flow.cash_flow.values) return npf.mirr(cash_flow.cash_flow.values, finance_rate, reinvestment_rate)
[docs] def lcoe( self, fixed_charge_rate: float | None = None, capex: float | None = None, opex: float | None = None, aep: float | None = None, ) -> float: """Calculates the levelized cost of energy (LCOE) as the following: LCOE = (CapEx * FCR + OpEx) / AEP, in $/MWh. Parameters ---------- fixed_charge_rate : float, optional Revenue per amount of investment required to cover the investment cost. Required if no value was provided in the ``Project`` configuration. Defaults to None capex : float, optional Custom CapEx value, in $/kW. Defaults to None. opex : float, optional Custom OpEx value, in $/kW/year. Defaults to None. aep : float, optional Custom AEP value, in MWh/MW/year. Defaults to None. Returns ------- float The levelized cost of energy. Raises ------ ValueError Raised if the input to :py:attr:`units` is not one of "kw", "mw", or "gw". """ # Check that the offtake price exists if fixed_charge_rate is None: if (fixed_charge_rate := self.fixed_charge_rate) is None: raise ValueError( "`fixed_charge_rate` wasn't defined in the Project settings or in the method" " keyword arguments." ) # Check for custom inputs, otherwise compute the necessary metrics # CapEx: $/kW; OpEx: $/kW; AEP: MWh/MW -> LCOE: ($/kW/yr + $/kW/yr) / (MWh/MW): $/MW capex = self.capex(per_capacity="kw") if capex is None else capex opex = self.opex(per_capacity="kw") if opex is None else opex if aep is None: aep = self.energy_production(units="mw", per_capacity="mw", aep=True) if TYPE_CHECKING: assert isinstance(capex, float) and isinstance(opex, float) and isinstance(aep, float) return (capex * self.fixed_charge_rate + opex / self.operations_years) / (aep / 1000)
[docs] def generate_report( self, metrics_configuration: dict[str, dict] | None = None, simulation_name: str | None = None, ) -> pd.DataFrame: """Generates a single row dataframe of all the desired resulting metrics from the project. .. note:: This assumes all results will be a single number, and not a Pandas ``DataFrame`` Parameters ---------- metrics_dict : dict[str, dict], optional The dictionary of dictionaries containing the following key, value pair pattern:: { "Descriptive Name (units)": { "metric": "metric_method_name", "kwargs": {"kwarg1": "kwarg_value_1"} # Exclude if not needed } } For metrics that have no keyword arguments, or where the default parameter values are desired, either an empty dictionary or no dictionary input for "kwargs" is allowed. If no input is provided, then :py:attr:`report_config` will be used to populate simulation_name : str The name that should be given to the resulting index. Returns ------- pd.DataFrame A pandas.DataFrame containing all of the provided outputs defined in :py:attr:`metrics_dict`. Raises ------ ValueError Raised if any of the keys of :py:attr:`metrics_dict` aren't implemented methods. """ if metrics_configuration is None: if self.report_config is None: raise ValueError( "Either a `report_config` must be provided to the class, or" " `metrics_configuration` as a method argument." ) metrics_configuration = {k: v for k, v in self.report_config.items() if k != "name"} if simulation_name is None: if TYPE_CHECKING: assert isinstance(self.report_config, dict) if "name" not in self.report_config: raise ValueError( "Either a `name` key, value pair must be provided in" " `report_config`, or `name` as a method argument." ) simulation_name = self.report_config["name"] # type: ignore invalid_metrics = [ el["metric"] for el in metrics_configuration.values() if not hasattr(self, el["metric"]) ] if invalid_metrics: names = "', '".join(invalid_metrics) raise ValueError(f"None of the following are valid metrics: '{names}'.") results = { name: getattr(self, val["metric"])(**val.get("kwargs", {})) for name, val in metrics_configuration.items() } results_df = pd.DataFrame.from_dict(results, orient="index").T results_df.index = pd.Index([simulation_name]) results_df.index.name = "Project" return results_df
[docs] def generate_report_lcoe_breakdown(self) -> pd.DataFrame: """Generates a dataframe containing the detailed breakdown of LCOE (Levelized Cost of Energy) metrics for the project, which is used to produce LCOE waterfall charts and CapEx donut charts in the Cost of Wind Energy Review. The breakdown includes the contributions of each CapEx and OpEx component (from ORBIT and WOMBAT) to the LCOE in $/MWh. This function calculates the LCOE by considering both CapEx (from ORBIT) and OpEx (from WOMBAT),and incorporates the fixed charge rate (FCR) and net annual energy production (net AEP) into the computation for each component. Returns ------- pd.DataFrame A DataFrame containing the detailed LCOE breakdown with the following columns: - "Component": The name of the project component (e.g., "Turbine", "Balance of System CapEx", "OpEx"). - "Category": The category of the component (e.g., "Turbine", "Balance of System CapEx", "Financial CapEx", "OpEx"). - "Value ($/kW)": The value of the component in $/kW. - "Fixed charge rate (FCR) (real)": The real fixed charge rate (FCR) applied to the component. - "Value ($/kW-yr)": The value of the component in $/kW-yr, after applying the FCR. - "Net AEP (MWh/kW/yr)": The net annual energy production (AEP) in MWh/kW/yr. - "Value ($/MWh)": The value of the component in $/MWh, calculated by dividing the $/kW-yr value by the net AEP. Notes ----- - CapEx components are categorized into "Turbine", "Balance of System CapEx", and "Financial CapEx". - OpEx components are derived from WOMBAT's OpEx metrics, categorized as "OpEx". - The LCOE is calculated by considering both CapEx and OpEx components, and adjusting for net AEP and FCR. - Rows with a value of 0 in the "Value ($/MWh)" column are removed to avoid clutter in annual reporting charts. """ # Static values fcr = self.fixed_charge_rate net_aep = self.energy_production(units="mw", per_capacity="kw", aep=True) # Handle CapEx outputs from ORBIT try: capex_data = self.orbit.capex_detailed_soft_capex_breakdown_per_kw except AttributeError: capex_data = self.orbit.capex_breakdown_per_kw turbine_components = ("Turbine", "Nacelle", "Blades", "Tower", "RNA") financial_components = ( "Construction", "Decommissioning", "Financing", "Contingency", "Soft", ) columns = [ "Component", "Category", "Value ($/kW)", "Fixed charge rate (FCR) (real)", "Value ($/kW-yr)", "Net AEP (MWh/kW/yr)", "Value ($/MWh)", ] df = pd.DataFrame.from_dict(capex_data, orient="index", columns=["Value ($/kW)"]) df["Category"] = "BOS" df.loc[df.index.isin(turbine_components), "Category"] = "Turbine" df.loc[df.index.isin(financial_components), "Category"] = "Financial CapEx" df.Category = df.Category.str.replace("BOS", "Balance of System CapEx") df["Fixed charge rate (FCR) (real)"] = fcr df["Value ($/kW-yr)"] = df["Value ($/kW)"] * fcr df["Value ($/MWh)"] = df["Value ($/kW-yr)"] / net_aep df["Net AEP (MWh/kW/yr)"] = net_aep df = df.reset_index(drop=False) df = df.rename(columns={"index": "Component"}) # Handle OpEx outputs from WOMBAT opex = ( self.wombat.metrics.opex(frequency="annual", by_category=True) .mean(axis=0) .to_frame("Value ($/kW-yr)") .join( self.wombat.metrics.opex(frequency="annual", by_category=True) .sum(axis=0) .to_frame("Value ($/kW)") ) .drop("OpEx") ) opex /= self.capacity("kw") opex.index = opex.index.str.replace("_", " ").str.title() opex.index.name = "Component" opex["Category"] = "OpEx" opex["Fixed charge rate (FCR) (real)"] = fcr opex["Net AEP (MWh/kW/yr)"] = net_aep opex["Value ($/MWh)"] = opex["Value ($/kW-yr)"] / net_aep opex = opex.reset_index(drop=False)[columns] # Concatenate CapEx and OpEx rows df = pd.concat((df, opex)).reset_index(drop=True).reset_index(names=["Original Order"]) # Define the desired order of categories for sorting order_of_categories = ["Turbine", "Balance of System CapEx", "Financial CapEx", "OpEx"] # Sort the dataframe based on the custom category order df["Category"] = pd.Categorical( df["Category"], categories=order_of_categories, ordered=True ) df = ( df.sort_values(by=["Category", "Original Order"]) .drop(columns=["Original Order"]) .reset_index(drop=True) ) # Remove rows where 'Value ($/MWh)' is zero to avoid 0 values on annual reporting charts df = df[df["Value ($/MWh)"] != 0] # Re-order the columns so that it is more intuitive for the analyst df = df[ [ "Component", "Category", "Value ($/kW)", "Fixed charge rate (FCR) (real)", "Value ($/kW-yr)", "Net AEP (MWh/kW/yr)", "Value ($/MWh)", ] ] # Reset index and return the dataframe df = df.reset_index(drop=True) return df
[docs] def generate_report_project_details(self) -> pd.DataFrame: """Generates a DataFrame containing detailed project information, following the format from the table at slide 64 in the Cost of Wind Energy Review: 2024 Edition (https://www.nrel.gov/docs/fy25osti/91775.pdf). This function collects various project parameters such as turbine specifications, wind speed data,energy capture, and efficiency metrics, and formats them into a comprehensive report. Returns ------- pd.DataFrame A DataFrame containing the project details, where each row corresponds to a specific assumption and its associated unit and value. The columns include: - "Assumption": Describes the specific project assumption (e.g., "Wind plant capacity","Turbine rating"). - "Units": The unit of measurement for each assumption (e.g., "MW", "Number", "m/s"). - "Value": The actual value for each assumption, computed based on the project's configurations and available data. """ # Define the project details template weather = self.weather.loc[self.operations_start : self.operations_end] project_details = { "Assumption": [ "Wind plant capacity", "Number of turbines", "Turbine rating", "Rotor diameter", "Hub height", "Specific power", "Water depth", "Substructure type", "Distance to port", "Distance to landfall", "Cut-in wind speed", "Cut-out wind speed", "Average annual wind speed at 50 m", "Average annual wind speed at hub height", "Shear exponent", "Weibull k", "Total system losses", "Availability", "Gross energy capture", "Net energy capture", "Gross capacity factor", "Net capacity factor", ], "Units": [ "MW", "Number", "MW", "m", "m", "W/m2", "m", "-", "km", "km", "m/s", "m/s", "m/s", "m/s", "-", "-", "%", "%", "MWh/MW/year", "MWh/MW/year", "%", "%", ], "Value": [ self.capacity("mw"), self.orbit.num_turbines, self.orbit.turbine_rating, self.orbit.config["turbine"]["rotor_diameter"], self.orbit.config["turbine"]["hub_height"], self.orbit.config["turbine"]["turbine_rating"] * 1000000 / ( math.pi * (self.orbit.config["turbine"]["rotor_diameter"] / 2) ** 2 ), # Specific power self.orbit.config["site"]["depth"], self.determine_substructure_type(), self.orbit.config["site"]["distance"], # Distance to port self.orbit.config["site"]["distance_to_landfall"], self.cut_in_windspeed(), self.cut_out_windspeed(), self.average_wind_speed(50), self.average_wind_speed(self.orbit.config["turbine"]["hub_height"]), np.mean( compute_shear( weather, self.identify_windspeed_columns_and_heights(weather), False, ) ), self.compute_weibull(self.orbit.config["turbine"]["hub_height"]), 100 * self.loss_ratio(), 100 * self.availability(which="energy", frequency="project", by="windfarm"), self.energy_potential(units="mw", per_capacity="mw", aep=True), self.energy_production(units="mw", per_capacity="mw", aep=True), 100 * self.capacity_factor(which="gross", frequency="project", by="windfarm"), 100 * self.capacity_factor(which="net", frequency="project", by="windfarm"), ], } # Create a DataFrame from the template df = pd.DataFrame(project_details) return df
[docs] def determine_substructure_type(self): """Determine the substructure type based on the ORBIT configuration file. This function scans the "design_phases" section of the ORBIT configuration file to identify the substructure type used in the project. The substructure types considered are "Monopile", "SemiSubmersible", "Jacket", and "Spar". The function returns the substructure type as a string if found in the design phases, or "Unknown" if no substructure type is identified. Returns ------- str The substructure type as one of the following: "Monopile", "SemiSubmersible", "Jacket", "Spar", or "Unknown" if no match is found. Notes ----- - The search is case-insensitive and looks for the substructure types as substrings within the design phase names. - If no substructure type is found after checking all phases, the function will return "Unknown". """ if self.turbine_type == "land": raise NotImplementedError("Land-based analyses are not yet supported.") project_data = self.orbit.config["design_phases"] # Iterate over the design_phases to check for the substructure type if "MonopileDesign" in project_data: return "Monopile" if "SemiSubmersibleDesign" in project_data: return "Semi-Submersible" if "JacketDesign" in project_data: return "Jacket" if "SparDesign" in project_data: return "Spar" return None
[docs] def cut_in_windspeed(self): """Determine the cut-in wind speed for the turbine based on the power-thrust table. This function extracts the power and wind speed data from the turbine definitions in the FLORIS model and identifies the cut-in wind speed. The cut-in wind speed is the wind speed at which the turbine begins to produce power after zero power is achieved. The function returns the cut-in wind speed or `None` if no valid value can be determined. Returns ------- float | None The wind speed (in m/s) at which the turbine starts producing power, or `None` if no valid cut-in wind speed can be found. Notes ----- - The cut-in wind speed is identified as the wind speed immediately following the last wind speed with zero power that is lower than the wind speed at which the turbine produces maximum power. - If no valid zero power wind speeds are found below the maximum power wind speed, `None` is returned. """ turbine_data = self.floris.core.farm.turbine_definitions[0]["power_thrust_table"] # Extract power and wind_speed lists from the floris turbine dictionary power = turbine_data["power"] wind_speed = np.array(turbine_data["wind_speed"]) # Find the index of the maximum power max_power_index = np.argmax(power) # Identify the wind speed at the max power max_power_wind_speed = wind_speed[max_power_index] # Identify the wind speeds with 0 power zero_power_indices = [i for i, p in enumerate(power) if p == 0] # Find the first wind speed before the start of the power curve - latest zero before max ws latest_zero_power_ix = min( set(zero_power_indices).intersection(np.where(wind_speed < max_power_wind_speed)[0]) ) if latest_zero_power_ix == set(): return None return wind_speed[latest_zero_power_ix + 1]
[docs] def cut_out_windspeed(self): """Determine the cut-out wind speed for the turbine based on the power-thrust table. This function extracts the power and wind speed data from the turbine definitions in the FLORIS model and identifies the cut-out wind speed. The cut-out wind speed is the wind speed at which the turbine stops producing power, which occurs when the power drops to zero. The function returns the cut-out wind speed or `None` if no valid value can be determined. Returns ------- float | None The wind speed (in m/s) at which the turbine stops producing power, or `None` if no valid cut-out wind speed can be found. Notes ----- - The cut-out wind speed is identified as the wind speed immediately preceding the first wind speed where the turbine generates zero power and the wind speed is greater than the wind speed at which maximum power is produced. - If no valid zero power wind speeds are found above the maximum power wind speed, `None` is returned. """ turbine_data = self.floris.core.farm.turbine_definitions[0]["power_thrust_table"] # Extract power and wind_speed lists from the floris turbine dictionary power = turbine_data["power"] wind_speed = np.array(turbine_data["wind_speed"]) # Find the index of the maximum power max_power_index = np.argmax(power) # Identify the wind speed at the max power max_power_wind_speed = wind_speed[max_power_index] # Identify the wind speeds with 0 power zero_power_indices = [i for i, p in enumerate(power) if p == 0] # Find the first wind speed past the end of the power curve - earliest zero after max ws earliest_zero_power_ix = min( set(zero_power_indices).intersection(np.where(wind_speed > max_power_wind_speed)[0]) ) if earliest_zero_power_ix == set(): return None return wind_speed[earliest_zero_power_ix - 1]
[docs] def calculate_wind_speed(self, height: int | float) -> pd.Series: """Calculates a new array, series, or value of wind speed at a given height. Parameters ---------- height : int | float The new height to calculate the wind speed. Returns ------- pd.Series The wind speed data at :py:arg:`height`. """ weather = self.weather.loc[self.operations_start : self.operations_end] ws_heights = self.identify_windspeed_columns_and_heights(weather) shear = compute_shear(weather, ws_heights) h1, *_ = ws_heights shear = compute_shear(weather, ws_heights) ws = extrapolate_windspeed(weather[h1], ws_heights[h1], height, shear) return ws
[docs] def average_wind_speed(self, height: int | float): """Calculates the average wind speed at a specified height. This method computes the mean wind speed from the weather data at the given height. If the wind speed data for the specified height is not found, and there are not at least two columns of wind speed data, an error is raised. Parameters ---------- height : int The height (in meters) at which the average wind speed is to be calculated. The method expects the column in the weather data to be named in the format 'windspeed_{height}m'. Returns ------- float | str If the wind speed data for the specified height exists, the method returns the mean wind speed as a float (in meters per second). If the data is not found, it returns a string indicating that the wind speed data is missing. Raises ------ KeyError If the column corresponding to the specified height is not present in the weather data. """ column_name = f"windspeed_{height}m" weather = self.weather.loc[self.operations_start : self.operations_end] ws_heights = self.identify_windspeed_columns_and_heights(weather) # Check if the column exists or if there is enough data to compute it if height_exists := column_name in weather.columns: return weather[column_name].mean() if (not height_exists) and len(ws_heights) < 2: msg = ( f"Wind speed at {height} m not provided at {str(self.library_path)}/weather/" f"{str(self.weather_profile)}, nor are there at least two heights to extrapolate" f"the wind speed.\nPlease, add a column to the weather .csv file with the name" f"'windspeed_{height}m', and the respective wind speed data" ) raise KeyError(msg) return self.calculate_wind_speed(height).mean()
[docs] def identify_windspeed_columns_and_heights(self, df): """Identifies columns containing wind speed measurements and their respective sensor heights. Scans the DataFrame to find columns that match the pattern "windspeed_{number}m", where `{number}`corresponds to the sensor height in meters. The function returns a dictionary with the first two matches, where the keys are the column names and the values are the heights in meters. Parameters ---------- df : pandas.DataFrame A DataFrame containing wind speed columns. The columns should follow the naming convention"windspeed_{number}m", where `{number}` represents the sensor height in meters. Returns ------- dict[str, int] A dictionary containing the first two columns that match the "windspeed_{number}m" pattern. The keys are the column names (e.g., "windspeed_10m"), and the values are the corresponding sensor heights (e.g., 10). """ windspeed_columns = {} # Regex pattern to match "windspeed_{number}m" where the name ends with 'm' pattern = r"windspeed_(\d+)m$" for col in df.columns: match = re.match(pattern, col) if match: number = match.group(1) windspeed_columns[col] = int(number) return dict(list(windspeed_columns.items())[:2])
[docs] def compute_weibull(self, height, random_seed=1): """Fits a Weibull distribution to wind speed data at a specified height. This function fits a Weibull distribution to the wind speed data at the specified height from the weather data. It assumes that wind speeds are non-negative, so it fixes the location parameter of the Weibull distribution to 0. If the required column for the given height is not present in the weather data, an error message is printed and the function returns a string indicating the missing data. Parameters ---------- height : int The height (in meters) at which the wind speed data is collected. The function looks for a column named "windspeed_{height}m" in the weather data. random_seed : int, optional A random seed for reproducibility. Defaults to 1. This seed is used to initialize the random number generator for the Weibull fitting procedure. Returns ------- float | str If the wind speed data is available for the specified height, the function returns the shape parameter of the fitted Weibull distribution. If the necessary column is missing from the weather data, it returns a string indicating the error (e.g., "wind speed at {height}m not provided"). """ # Set random seed for reproducibility np.random.seed(random_seed) column_name = "windspeed_" + str(height) + "m" weather = self.weather.loc[self.operations_start : self.operations_end] ws_heights = self.identify_windspeed_columns_and_heights(weather) # Check if the column exists or if there is enough data to compute it if height_exists := column_name in weather.columns: wind_speed_data = weather[column_name] return fit_weibull_distribution(wind_speed_data, random_seed) if (not height_exists) and len(ws_heights) < 2: msg = ( f"Wind speed at {height} m not provided at {str(self.library_path)}/weather/" f"{str(self.weather_profile)}, nor are there at least two heights to extrapolate" f"the wind speed.\nPlease, add a column to the weather .csv file with the name" f"'windspeed_{height}m', and the respective wind speed data" ) raise KeyError(msg) # Extract windspeed data wind_speed_data = self.calculate_wind_speed(height) return fit_weibull_distribution(wind_speed_data, random_seed)