Source code for windkit.wind_farm.loss

# (c) 2026 DTU Wind Energy
"""
Class and associated methods to work with AEP losses.
"""

__all__ = [
    "get_loss_table",
    "validate_loss_table",
    "total_loss",
    "loss_table_summary",
    "total_loss_factor",
]

import numpy as np
import pandas as pd
from pandas.api.types import is_numeric_dtype
from pathlib import Path

MANDATORY_COLUMNS = ["loss_name", "loss_percentage"]
OPTIONAL_COLUMNS = [
    "loss_category",
    "loss_lower_bound",
    "loss_upper_bound",
    "loss_default",
    "description",
]

TABLES_DIR = Path(__file__).parent.parent / "data"


[docs] def get_loss_table(table_name="dtu_default"): """Get a DataFrame from the supported loss tables. The loss table contains information about various types of losses. Mandatory fields of a loss table are: names and values of the losses (as a percentage of total energy [%]). Optional fields are: category, lower and upper bounds, default values, and descriptions. The first rows of the ``dtu_default`` table: ========================== =============== ============= ================ ================ ============ =========== loss_name loss_percentage loss_category loss_lower_bound loss_upper_bound loss_default description ========================== =============== ============= ================ ================ ============ =========== turbine_availability 1.0 Availability 0 5 1.0 ... balance_plant_availability 0.0 Availability 0 5 0.0 ... grid_availability 0.5 Availability 0 5 0.5 ... electrical_operation 0.5 Electrical 0 5 0.5 ... wf_consumption 0.2 Electrical 0 5 0.2 ... ========================== =============== ============= ================ ================ ============ =========== Parameters ---------- table_name : str The name of the table to get. Default is 'dtu_default'. Returns ------- DataFrame The DataFrame containing the loss table. Notes ----- - There is no fixed order of columns in a loss table. Methods work with the columns based on their names. - The names of the columns must follow the syntax shown in the example table above. Examples -------- >>> get_loss_table('dtu_default') """ if table_name == "dtu_default": dtu_table_path = TABLES_DIR / "loss_tables/dtu_loss_table.csv" return pd.read_csv(dtu_table_path) else: raise ValueError( f"Only 'dtu_default' is supported as table_name. Got '{table_name}' instead." )
[docs] def validate_loss_table(loss_table): """Perform several checks to ensure that the loss table DataFrame is valid. Parameters ---------- loss_table : DataFrame The DataFrame containing the losses. Raises ------ ValueError - If mandatory columns are missing, incorrectly defined, or contain missing values. - If the loss_percentage column contains invalid data types. - If loss_percentage values are outside the specified bounds. Notes ----- - The function assumes that the DataFrame columns are named exactly as specified in the mandatory columns list: MANDATORY_COLUMNS = ['loss_name', 'loss_percentage']. - The 'loss_percentage' values must be within the range specified by 'loss_lower_bound' and 'loss_upper_bound' for each row, if these are present. Examples -------- >>> loss_table = get_loss_table('dtu_default') >>> validate_loss_table(loss_table) """ # Check if all mandatory columns are present for col in MANDATORY_COLUMNS: if col not in loss_table.columns: raise ValueError(f"Mandatory column '{col}' is missing from the DataFrame.") # Check if mandatory columns have no missing values and check that loss_percentage values are numbers for col in MANDATORY_COLUMNS: if loss_table[col].isnull().any(): raise ValueError(f"Mandatory column '{col}' contains missing values.") if col == "loss_percentage": if not is_numeric_dtype(loss_table[col]): raise ValueError( f"Mandatory column '{col}' should contain only floats or integers." ) # Check if value is between lower_bound and upper_bound if ( "loss_lower_bound" in loss_table.columns and "loss_upper_bound" in loss_table.columns ): for i in range(len(loss_table)): lower_bound = loss_table["loss_lower_bound"][i] upper_bound = loss_table["loss_upper_bound"][i] loss_percentage = loss_table["loss_percentage"][i] if loss_percentage < lower_bound or loss_percentage > upper_bound: raise ValueError( f"Loss_percentage value {loss_percentage} is outside of bounds ({lower_bound}, {upper_bound}) for row {i}" )
[docs] def total_loss(loss_table): """Calculate the total losses in the DataFrame. This is the plain sum of the entered loss percentages, a common reporting convention. It is not the compounded (effective) total loss, which is ``(1 - total_loss_factor(loss_table)) * 100``. Parameters ---------- loss_table : DataFrame The DataFrame containing the losses. Returns ------- float The sum of the loss percentages. """ return np.sum(loss_table["loss_percentage"])
[docs] def loss_table_summary(loss_table): """Print a summary of the losses in the DataFrame. Parameters ---------- loss_table : DataFrame The DataFrame containing the losses. """ # Group by loss_category and sum the Value column category_totals = loss_table.groupby("loss_category")["loss_percentage"].sum() # Calculate the total losses total_losses = total_loss(loss_table) # Create the summary string summary = "Loss Summary:\n" for category, total in category_totals.items(): summary += f"Total value of losses for {category}: {total} %\n" summary += f"Total value of all losses: {total_losses} %\n" summary += ( f"Compounded total loss: {(1 - total_loss_factor(loss_table)) * 100:.2f} %" ) return print(summary)
[docs] def total_loss_factor(loss_table): r"""Calculate the total loss factor in the DataFrame. The losses are compounded multiplicatively, so the total loss factor is the product of the individual loss factors: :math:`\prod_i (1 - p_i / 100)`, where :math:`p_i` are the loss percentages. Parameters ---------- loss_table : DataFrame The DataFrame containing the losses. Returns ------- float The fraction of energy remaining after applying all losses. """ return float(np.prod(1 - loss_table["loss_percentage"] / 100))