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 json
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 wombat.core import Simulation
from floris.tools import FlorisInterface
from floris.tools.wind_rose import WindRose
from wombat.core.data_classes import FromDictMixin

from waves.utilities import (
    load_yaml,
    resolve_path,
    check_monthly_wind_rose,
    create_monthly_wind_rose,
    run_parallel_time_series_floris,
    calculate_monthly_wind_rose_results,
)


[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. 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_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. 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. 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. loss_ratio : float, optional Additional non-wake losses to deduct from the total energy production. Should be represented as a decimal in the range of [0, 1]. Defaults to None. 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) 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))) ) 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_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))) ) 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))) ) loss_ratio: 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) 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) floris: FlorisInterface = 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], FlorisInterface] = field(init=False, factory=dict) floris_results_type: str = field(init=False) 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_wombat() self.setup_floris() if self.connect_floris_to_layout: self.connect_floris_to_turbines() if self.connect_orbit_array_design: 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] @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.")
# ********************************************************************************************** # 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, "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 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_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 self.floris = FlorisInterface(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.reinitialize(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 preprocess_monthly_floris( self, reinitialize_kwargs: dict | None = None, run_kwargs: dict | None = None, cut_in_wind_speed: float | None = None, cut_out_wind_speed: float | None = None, ) -> tuple[ list[tuple[FlorisInterface, pd.DataFrame, tuple[int, int], dict, dict]], np.ndarray, ]: """Creates the monthly chunked inputs to run a parallelized FLORIS time series analysis. Parameters ---------- reinitialize_kwargs : dict | None, optional Any keyword arguments to be assed to ``FlorisInterface.reinitialize()``. Defaults to None. run_kwargs : dict | None, optional Any keyword arguments to be assed to ``FlorisInterface.calculate_wake()``. Defaults to None. cut_in_wind_speed : float, optional The wind speed, in m/s, at which a turbine will start producing power. cut_out_wind_speed : float, optional The wind speed, in m/s, at which a turbine will stop producing power. Returns ------- tuple[list[tuple[FlorisInterface, pd.DataFrame, tuple[int, int], dict, dict]], np.ndarray] A list of tuples of: - a copy of the ``FlorisInterface`` object - tuple of year and month - a copy of ``reinitialize_kwargs`` - c copy of ``run_kwargs`` """ if reinitialize_kwargs is None: reinitialize_kwargs = {} if run_kwargs is None: run_kwargs = {} month_list = range(1, 13) year_list = range(self.operations_start.year, self.operations_end.year + 1) if TYPE_CHECKING: assert isinstance(self.weather, pd.DataFrame) # mypy helper weather = self.weather.loc[ self.operations_start : self.operations_end, [self.floris_windspeed, self.floris_wind_direction], ].rename( columns={ self.floris_windspeed: "windspeed", self.floris_wind_direction: "wind_direction", } ) zero_power_filter = np.full((weather.shape[0]), True) if cut_out_wind_speed is not None: zero_power_filter = weather.windspeed < cut_out_wind_speed if cut_in_wind_speed is not None: zero_power_filter &= weather.windspeed >= cut_in_wind_speed args = [ ( deepcopy(self.floris), weather.loc[f"{month}/{year}"], (year, month), reinitialize_kwargs, run_kwargs, ) for month, year in product(month_list, year_list) ] return args, zero_power_filter
[docs] def run_wind_rose_aep( self, full_wind_rose: bool = False, run_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. run_kwargs : dict | None, optional Arguments that are provided to ``FlorisInterface.get_farm_AEP_wind_rose_class()``. Defaults to None. From FLORIS: - cut_in_wind_speed (float, optional): Wind speed in m/s below which any calculations are ignored and the wind farm is known to produce 0.0 W of power. Note that to prevent problems with the wake models at negative / zero wind speeds, this variable must always have a positive value. Defaults to 0.001 [m/s]. - cut_out_wind_speed (float, optional): Wind speed above which the wind farm is known to produce 0.0 W of power. If None is specified, will assume that the wind farm does not cut out at high wind speeds. Defaults to None. - 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. - no_wake: (bool, optional): When *True* updates the turbine quantities without calculating the wake or adding the wake to the flow field. This can be useful when quantifying the loss in AEP due to wakes. Defaults to *False*. """ if run_kwargs is None: run_kwargs = {} if full_wind_rose: if TYPE_CHECKING: assert isinstance(self.weather, pd.DataFrame) # mypy helper weather = self.weather.loc[:, [self.floris_wind_direction, self.floris_windspeed]] else: if TYPE_CHECKING: assert isinstance(self.weather, pd.DataFrame) # mypy helper weather = self.weather.loc[ self.operations_start : self.operations_end, [self.floris_wind_direction, self.floris_windspeed], ] # recreate the FlorisInterface object for the wind rose settings wd, ws = weather.values.T self.project_wind_rose = WindRose() project_wind_rose_df = self.project_wind_rose.make_wind_rose_from_user_data( wd, ws ) # noqa: F841 pylint: disable=W0612 self.monthly_wind_rose = create_monthly_wind_rose( weather.rename(columns={self.floris_wind_direction: "wd", self.floris_windspeed: "ws"}) ) self.monthly_wind_rose = check_monthly_wind_rose( self.project_wind_rose, self.monthly_wind_rose ) self.project_wind_rose.df.set_index(["wd", "ws"]).unstack().values freq_monthly = { k: wr.df.set_index(["wd", "ws"]).unstack().values for k, wr in self.monthly_wind_rose.items() } # Recreating FlorisInterface.get_farm_AEP() w/o some of the quality checks # because the parameters are coming directly from other FLORIS objects, and # not user inputs wd = project_wind_rose_df.wd.unique() ws = project_wind_rose_df.ws.unique() n_wd = wd.size n_ws = ws.size ix_evaluate = ws >= run_kwargs["cut_in_wind_speed"] if run_kwargs["cut_out_wind_speed"] is not None: ix_evaluate &= ws < run_kwargs["cut_out_wind_speed"] farm_potential_power = np.zeros((n_wd, n_ws)) farm_production_power = np.zeros((n_wd, n_ws)) turbine_potential_power = np.zeros((n_wd, n_ws, self.floris.floris.farm.n_turbines)) turbine_production_power = np.zeros((n_wd, n_ws, self.floris.floris.farm.n_turbines)) if np.any(ix_evaluate): ws_subset = ws[ix_evaluate] yaw_angles = run_kwargs.get("yaw_angles", None) if yaw_angles is not None: yaw_angles = yaw_angles[:, ix_evaluate] self.floris.reinitialize(wind_speeds=ws_subset, wind_directions=wd) # Calculate the potential energy self.floris.calculate_no_wake(yaw_angles=yaw_angles) farm_potential_power[:, ix_evaluate] = self.floris.get_farm_power( turbine_weights=run_kwargs["turbine_weights"] ) turbine_potential_power[:, ix_evaluate, :] = self.floris.get_turbine_powers() if (weights := run_kwargs["turbine_weights"]) is not None: turbine_potential_power *= weights # Calculate the produced power self.floris.calculate_wake(yaw_angles=yaw_angles) farm_production_power[:, ix_evaluate] = self.floris.get_farm_power( turbine_weights=run_kwargs["turbine_weights"] ) turbine_production_power[:, ix_evaluate, :] = self.floris.get_turbine_powers() if (weights := run_kwargs["turbine_weights"]) is not None: turbine_production_power *= weights else: self.floris.reinitialize(wind_speeds=ws, wind_directions=wd) # 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() # 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()
[docs] def run_floris( self, which: str, reinitialize_kwargs: dict | None = None, run_kwargs: dict | None = None, full_wind_rose: bool = False, cut_in_wind_speed: float = 0.001, cut_out_wind_speed: float | None = None, nodes: int = -1, ) -> 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 ---------- which : str One of "wind_rose" or "time_series" to run either a simulation-level wind rose analysis or hourly time-series analysis for the base AEP model. reinitialize_kwargs : dict | None, optional Any keyword arguments to be assed to ``FlorisInterface.reinitialize()``. Defaults to None. run_kwargs : dict | None, optional Any keyword arguments to be assed to ``FlorisInterface.calculate_wake()``. 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. cut_in_wind_speed : float, optional The wind speed, in m/s, at which a turbine will start producing power. Should only be a value if running a time series analysis. Defaults to 0.001. cut_out_wind_speed : float, optional The wind speed, in m/s, at which a turbine will stop producing power. Should only be a value if running a time series analysis. Defaults to None. nodes : int, optional The number of nodes to parallelize over. If -1, then it will use the floor of 80% of the available CPUs on the computer. Defaults to -1. Raises ------ ValueError: Raised if :py:attr:`which` is not one of "wind_rose" or "time_series". """ if reinitialize_kwargs is None: reinitialize_kwargs = {} if run_kwargs is None: run_kwargs = {} if which == "wind_rose": # TODO: Change this to be modify the standard behavior, and get the turbine # powers to properly account for availability later # Set the FLORIS defaults run_kwargs.setdefault("cut_in_wind_speed", cut_in_wind_speed) run_kwargs.setdefault("cut_out_wind_speed", cut_out_wind_speed) run_kwargs.setdefault("turbine_weights", None) run_kwargs.setdefault("yaw_angles", None) run_kwargs.setdefault("no_wake", False) self.run_wind_rose_aep(full_wind_rose=full_wind_rose, run_kwargs=run_kwargs) self.floris_results_type = "wind_rose" elif which == "time_series": parallel_args, zero_power_filter = self.preprocess_monthly_floris( reinitialize_kwargs, run_kwargs, cut_in_wind_speed, cut_out_wind_speed ) fi_dict, turbine_powers = run_parallel_time_series_floris(parallel_args, nodes) self._fi_dict = fi_dict self.turbine_aep_mwh = turbine_powers self.connect_floris_to_turbines(x_col=self.floris_x_col, y_col=self.floris_y_col) self.turbine_potential_energy.columns = self.floris_turbine_order self.turbine_potential_energy = ( self.turbine_potential_energy.where( np.repeat( zero_power_filter.reshape(-1, 1), self.turbine_aep_mwh.shape[1], axis=1, ), 0.0, ) / 1e6 ) n_years = self.turbine_potential_energy.index.year.unique().size self.project_potential_energy = self.turbine_potential_energy.values.sum() / n_years self.floris_results_type = "time_series" else: raise ValueError(f"`which` must be one of: 'wind_rose' or 'time_series', not: {which}")
[docs] def run( self, which_floris: str | None = None, floris_reinitialize_kwargs: dict | None = None, floris_run_kwargs: dict | None = None, full_wind_rose: bool = False, skip: list[str] | None = None, cut_in_wind_speed: float = 0.001, cut_out_wind_speed: float | None = None, nodes: int = -1, ) -> None: """Run all three models in serial, or a subset if ``skip`` is used. Parameters ---------- which_floris : str | None, optional One of "wind_rose" or "time_series" if computing the farm's AEP based on a wind rose, or based on time series corresponding to the WOMBAT simulation period, respectively. Defaults to None. floris_reinitialize_kwargs : dict | None Any additional ``FlorisInterface.reinitialize`` keyword arguments. Defaults to None. floris_run_kwargs : dict | None Any additional ``FlorisInterface.get_farm_AEP`` or ``FlorisInterface.calculate_wake()`` keyword arguments, depending on ``which_floris`` is used. 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. cut_in_wind_speed : float, optional The wind speed, in m/s, at which a turbine will start producing power. Can also be provided in ``floris_reinitialize_kwargs`` for a wind rose analysis, but must be provided here for a time series analysis. Defaults to 0.001. cut_out_wind_speed : float, optional The wind speed, in m/s, at which a turbine will stop producing power. Can also be provided in ``floris_reinitialize_kwargs`` for a wind rose analysis, but must be provided here for a time series analysis. Defaults to None. nodes : int, optional The number of nodes to parallelize over. If -1, then it will use the floor of 80% of the available CPUs on the computer. Defaults to -1. Raises ------ ValueError Raised if ``which_floris`` is not one of "wind_rose" or "time_series". """ if floris_reinitialize_kwargs is None: floris_reinitialize_kwargs = {} if floris_run_kwargs is None: floris_run_kwargs = {} if skip is None: skip = [] if "floris" not in skip: if which_floris not in ("wind_rose", "time_series"): raise ValueError( "`which_floris` must be one of: 'wind_rose' or 'time_series' when running" f" FLORIS, not: {which_floris}" ) if which_floris == "wind_rose": floris_reinitialize_kwargs.update( {"cut_in_wind_speed": cut_in_wind_speed, "cut_out_wind_speed": cut_out_wind_speed} ) if "orbit" not in skip: self.orbit.run() if "wombat" not in skip: self.wombat.run() if "floris" not in skip: if TYPE_CHECKING: assert isinstance(which_floris, str) self.run_floris( which=which_floris, reinitialize_kwargs=floris_reinitialize_kwargs, run_kwargs=floris_run_kwargs, full_wind_rose=full_wind_rose, cut_in_wind_speed=cut_in_wind_speed, cut_out_wind_speed=cut_out_wind_speed, nodes=nodes, )
[docs] def reinitialize( self, orbit_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. 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. """ if orbit_config is not None: self.orbit_config = orbit_config self.setup_orbit() 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() 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 if self.wombat_config is not None: return len(self.wombat.windfarm.turbine_id) if self.floris_config is not None: return self.floris.farm.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 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 subsations in the project. Returns ------- int The number of substations in the project. """ if self.orbit_config is not None or "OffshoreSubstationDesign" not in self.orbit._phases: return self.orbit_config_dict["oss_design"]["num_substations"] if self.wombat_config is not None: return len(self.wombat.windfarm.substation_id) raise RuntimeError("No models wer provided, cannot calculate value.")
[docs] 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. ValueError Raised if an invalid units input was provided. """ if self.orbit_config is not None: capacity = self.orbit.capacity elif self.wombat_config is not None: capacity = self.wombat.windfarm.capacity / 1000 else: raise RuntimeError("No models wer provided, cannot calculate value.") units = units.lower() if units == "kw": return capacity * 1000 if units == "mw": return capacity if units == "gw": return capacity / 1000 raise ValueError("`units` must be one of: 'kw', 'mw', or 'gw'.")
[docs] def capex( self, breakdown: bool = False, per_capacity: str | None = None ) -> pd.DataFrame | float: """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. 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. 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 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"]) ) if per_capacity is None: if breakdown: return capex return capex.values[0, 0] per_capacity = per_capacity.lower() 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 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. """ 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)
[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. """ 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." )
# Operational metrics
[docs] 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 (wtihout 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. 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``. """ availability = self.wombat.metrics.production_based_availability( frequency="month-year", by="turbine" ).loc[:, self.floris_turbine_order] if self.floris_results_type == "wind_rose": power = pd.DataFrame(0.0, dtype=float, index=availability.index, columns=["drop"]) power = power.merge( self.turbine_potential_energy, how="left", left_on="month", right_index=True, ).drop(labels=["drop"], axis=1) energy_gwh = power / 1000 if self.floris_results_type == "time_series": energy_gwh = self.turbine_aep_mwh / 1000 energy_gwh *= ( self.turbine_potential_energy.assign( year=energy_gwh.index.year, month=energy_gwh.index.month ) .groupby(["year", "month"]) .sum() .loc[energy_gwh.index] ) unit_map = {"kw": "kWh", "mw": "MWh", "gw": "GWh"} if by == "windfarm": energy_gwh = energy_gwh.sum(axis=1).to_frame(f"Energy Losses ({unit_map[units]})") # 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 Losses ({unit_map[units]})").T ) else: energy_gwh = energy_gwh.values.sum() if aep: if frequency != "project": raise ValueError("`aep` can only be set to True, if `frequency`='project'.") energy_gwh /= self.operations_years if units == "kw": energy = energy_gwh * 1e6 if units == "mw": energy = energy_gwh * 1e3 if units == "gw": energy = energy_gwh if per_capacity is None: return energy return energy / self.capacity(per_capacity)
[docs] def energy_production( self, frequency: str = "project", by: str = "windfarm", units: str = "gw", per_capacity: str | None = None, with_losses: bool = False, loss_ratio: float | None = None, aep: bool = False, ) -> pd.DataFrame | float: """Computes the energy production, or annual energy production, in GWh, for the simulation by extrapolating the monthly contributions to AEP if FLORIS (with wakes) results were computed by a wind rose, or using the time series results, and multiplying it by the WOMBAT monthly availability (``Metrics.production_based_availability``). 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. 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. with_losses : bool, optional Use the :py:attr:`loss_ratio` or :py:attr:`Project.loss_ratio` to post-hoc consider non-wake and non-availability losses in the energy production aggregation. Defaults to False. loss_ratio : float, optional The decimal non-wake and non-availability losses ratio to apply to the energy production. If None, then it will attempt to use the :py:attr:`loss_ratio` provided in the Project configuration. 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``. """ # 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 # For the wind rose outputs, only consider project-level availability because # wind rose AEP is a long-term estimation of energy production availability = self.wombat.metrics.production_based_availability( frequency="month-year", by="turbine" ).loc[:, self.floris_turbine_order] if self.floris_results_type == "wind_rose": power = pd.DataFrame(0.0, dtype=float, index=availability.index, columns=["drop"]) power = power.merge( self.turbine_production_energy, how="left", left_on="month", right_index=True, ).drop(labels=["drop"], axis=1) energy_gwh = availability * power / 1000 if self.floris_results_type == "time_series": energy_gwh = self.turbine_aep_mwh / 1000 energy_gwh *= ( self.turbine_potential_energy.assign( year=energy_gwh.index.year, month=energy_gwh.index.month ) .groupby(["year", "month"]) .sum() .loc[energy_gwh.index] ) if by == "windfarm": energy_gwh = energy_gwh.sum(axis=1).to_frame("Energy Production (GWh)") # 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="Energy Production (GWh)").T else: energy_gwh = energy_gwh.values.sum() if with_losses: # Check that a loss_ratio exists if loss_ratio is None: if (loss_ratio := self.loss_ratio) is None: raise ValueError( "`loss_ratio` wasn't defined in the Project settings or in the method" " keyword arguments." ) # Get the base production numbers from WOMBAT base_production = self.energy_potential(frequency=frequency, by=by, units="gw") energy_gwh -= base_production * loss_ratio if aep: if frequency != "project": raise ValueError("`aep` can only be set to True, if `frequency`='project'.") energy_gwh /= self.operations_years if units == "kw": energy = energy_gwh * 1e6 if units == "mw": energy = energy_gwh * 1e3 if units == "gw": energy = energy_gwh if per_capacity is None: return energy return energy / self.capacity(per_capacity)
[docs] def energy_losses( self, frequency: str = "project", by: str = "windfarm", units: str = "gw", per_capacity: str | None = None, with_losses: bool = False, 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. 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. 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. with_losses : bool, optional Use the :py:attr:`loss_ratio` or :py:attr:`Project.loss_ratio` to post-hoc consider non-wake and non-availability losses in the energy production aggregation. Defaults to False. loss_ratio : float, optional The decimal non-wake and non-availability losses ratio to apply to the energy production. If None, then it will attempt to use the :py:attr:`loss_ratio` provided in the Project configuration. Defaults to None. 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``. """ potential = self.energy_potential( "month-year", by="turbine", units="kw", ) production = self.energy_production( # type: ignore "month-year", by="turbine", units="kw", with_losses=with_losses, loss_ratio=loss_ratio, )[self.wombat.metrics.turbine_id] losses = potential - production unit_map = {"kw": "kWh", "mw": "MWh", "gw": "GWh"} if by == "windfarm": losses = losses.sum(axis=1).to_frame(f"Energy Losses ({unit_map[units]})") if frequency == "project": losses = losses.sum(axis=0) if by == "windfarm": losses = losses.values[0] if aep: losses /= self.operations_years elif frequency == "annual": losses = losses.groupby("year").sum() if units == "mw": losses /= 1e3 elif units == "gw": losses /= 1e6 if per_capacity is None: return losses return losses / self.capacity(per_capacity)
[docs] 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] def capacity_factor( self, which: str, frequency: str = "project", by: str = "windfarm", with_losses: bool = False, 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". with_losses : bool, optional Use the :py:attr:`loss_ratio` or :py:attr:`Project.loss_ratio` to post-hoc consider non-wake and non-availability losses in the energy production aggregation. Defaults to False. .. note:: This will only be checked for :py:attr:`which` = "net". loss_ratio : float, optional The decimal non-wake and non-availability losses ratio to apply to the energy production. If None, then it will attempt to use the :py:attr:`loss_ratio` provided in the Project configuration. Defaults to None. .. note:: This will only be used when for :py:attr:`which` = "net". 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".') opts = ("project", "annual", "month-year") if frequency not in opts: raise ValueError(f"`frequency` must be one of {opts}.") # type: ignore by = by.lower().strip() if by not in ("windfarm", "turbine"): raise ValueError('``by`` must be one of "windfarm" or "turbine".') by_turbine = by == "turbine" if which == "net": numerator = self.energy_production( frequency="month-year", by="turbine", units="kw", with_losses=with_losses, loss_ratio=loss_ratio, ) 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] 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] 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 the frequency input opts = ("project", "annual", "month-year") if frequency not in opts: raise ValueError(f"`frequency` must be one of {opts}.") # type: ignore # 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." ) if self.floris_results_type == "wind_rose": revenue = self.energy_production(frequency=frequency) * 1000 * offtake_price # MWh else: 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] 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. """ # 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"], ) 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] 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] 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 the frequency input opts = ("project", "annual", "month-year") if frequency not in opts: raise ValueError(f"`frequency` must be one of {opts}.") # type: ignore # 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, with_losses=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