Source code for wombat.core.data_classes

"""Turbine and turbine component shared utilities."""


from __future__ import annotations

import datetime
from math import fsum
from typing import TYPE_CHECKING, Any, Callable
from pathlib import Path
from functools import partial, update_wrapper
from collections.abc import Sequence

import attr
import attrs
import numpy as np
import pandas as pd
from attrs import Factory, Attribute, field, define

from wombat.utilities.time import HOURS_IN_DAY, HOURS_IN_YEAR, parse_date


if TYPE_CHECKING:
    from wombat.core import ServiceEquipment


# Define the valid servicing equipment types
VALID_EQUIPMENT = (
    "CTV",  # Crew tranfer vessel or onsite vehicle
    "SCN",  # Small crane
    "MCN",  # Medium crane
    "LCN",  # Large crane
    "CAB",  # Cabling equipment
    "RMT",  # Remote reset or anything performed remotely
    "DRN",  # Drone
    "DSV",  # Diving support vessel
    "TOW",  # Tugboat or support vessel for moving a turbine to a repair facility
    "AHV",  # Anchor handling vessel, typically a tugboat, w/o trigger tow-to-port
    "VSG",  # Vessel support group, any group of vessels required for a single operation
)

# Define the valid unscheduled and valid strategies
UNSCHEDULED_STRATEGIES = ("requests", "downtime")
VALID_STRATEGIES = tuple(["scheduled"] + list(UNSCHEDULED_STRATEGIES))


def convert_to_list(
    value: Sequence | str | int | float,
    manipulation: Callable | None = None,
) -> list:
    """Convert an unknown element that could be a list or single, non-sequence element
    to a list of elements.

    Parameters
    ----------
    value : Sequence | str | int | float
        The unknown element to be converted to a list of element(s).
    manipulation: Callable | None
        A function to be performed upon the individual elements, by default None.

    Returns
    -------
    list
        The new list of elements.
    """
    if isinstance(value, (str, int, float)):
        value = [value]
    if manipulation is not None:
        return [manipulation(el) for el in value]
    return list(value)


convert_to_list_upper = partial(convert_to_list, manipulation=str.upper)
update_wrapper(convert_to_list_upper, convert_to_list)

convert_to_list_lower = partial(convert_to_list, manipulation=str.lower)
update_wrapper(convert_to_list_lower, convert_to_list)


def clean_string_input(value: str | None) -> str | None:
    """Convert a string to lower case and and removes leading and trailing white spaces.

    Parameters
    ----------
    value: str
        The user input string.

    Returns
    -------
    str
        value.lower().strip()
    """
    if value is None:
        return value
    return value.lower().strip()


def annual_date_range(
    start_day: int,
    end_day: int,
    start_month: int,
    end_month: int,
    start_year: int,
    end_year: int,
) -> np.ndarray:
    """Create a series of date ranges across multiple years between the starting date
    and ending date for each year from starting year to ending year. Will only work if
    the end year is the same as the starting year or later, and the ending month, day
    combinatoion is after the starting month, day combination.

    Parameters
    ----------
    start_day : int
        Starting day of the month.
    end_day : int
        Ending day of the month.
    start_month : int
        Starting month.
    end_month : int
        Ending month.
    start_year : int
        First year of the date range.
    end_year : int
        Last year of the date range.

    Raises
    ------
    ValueError: Raised if the ending month, day combination occur after the starting
        month, day combination.
    ValueError: Raised if the ending year is prior to the starting year.

    Yields
    ------
    np.ndarray
        A ``numpy.ndarray`` of ``datetime.date`` objects.
    """
    # Check the year bounds
    if end_year < start_year:
        raise ValueError(
            f"The end_year ({start_year}) is later than the start_year ({end_year})."
        )

    # Check the month, date combination bounds
    start = datetime.datetime(2022, start_month, start_day)
    end = datetime.datetime(2022, end_month, end_day)
    if end < start:
        raise ValueError(
            f"The starting month/day combination: {start}, is after the ending: {end}."
        )

    # Create a list of arrays of date ranges for each year
    start = datetime.datetime(1, start_month, start_day)
    end = datetime.datetime(1, end_month, end_day)
    date_ranges = [
        pd.date_range(start.replace(year=year), end.replace(year=year)).date
        for year in range(start_year, end_year + 1)
    ]

    # Return a 1-D array
    return np.hstack(date_ranges)


def annualized_date_range(
    start_date: datetime.datetime,
    start_year: int,
    end_date: datetime.datetime,
    end_year: int,
) -> np.ndarray:
    """Create an annualized list of dates based on the simulation years.

    Parameters
    ----------
    start_date : str
        The string start month and day in MM/DD or MM-DD format.
    start_year : int
        The starting year in YYYY format.
    end_date : str
        The string end month and day in MM/DD or MM-DD format.
    end_year : int
        The ending year in YYYY format.

    Returns
    -------
    set[datetime.datetime]
        The set of dates the servicing equipment is available for operations.
    """
    additional = 1 if end_date < start_date else 0
    date_list = [
        pd.date_range(
            start_date.replace(year=year),
            end_date.replace(year=year + additional),
            freq="D",
        ).date
        for year in range(start_year - additional, end_year + 1)
    ]
    dates = np.hstack(date_list)
    start = datetime.date(start_year, 1, 1)
    end = datetime.date(end_year, 12, 31)
    dates = dates[(dates >= start) & (dates <= end)]
    return dates


def convert_ratio_to_absolute(ratio: int | float, total: int | float) -> int | float:
    """Convert a proportional cost to an absolute cost.

    Parameters
    ----------
    ratio : int | float
        The proportional materials cost for a ``Maintenance`` or ``Failure``.
    total: int | float
        The turbine's replacement cost.

    Returns
    -------
    int | float
        The absolute cost of the materials.
    """
    if ratio <= 1:
        return ratio * total
    return ratio


def check_start_stop_dates(
    instance: Any, attribute: attr.Attribute, value: datetime.datetime | None
) -> None:
    """Ensure the date range start and stop points are not the same."""
    if attribute.name == "non_operational_end":
        start_date = instance.non_operational_start
        start_name = "non_operational_start"
    else:
        start_date = instance.reduced_speed_start
        start_name = "reduced_speed_start"

    if value is None:
        if start_date is None:
            return
        raise ValueError(
            "A starting date was provided, but no ending date was provided"
            f" for `{attribute.name}`."
        )

    if start_date is None:
        raise ValueError(
            "An ending date was provided, but no starting date was provided"
            f" for `{start_name}`."
        )

    if start_date == value:
        raise ValueError(
            f"Starting date (`{start_name}`={start_date} and ending date"
            f" (`{attribute.name}`={value}) cannot be the same date."
        )


def valid_hour(
    instance: Any,
    attribute: Attribute,
    value: int,  # pylint: disable=W0613
) -> None:
    """Validate that the input is a valid time or null value (-1).

    Parameters
    ----------
    instance : Any
        A class object.
    attribute : Attribute
        The attribute being validated.
    value : int
        A whole number, hour of the day or -1 (null), where 24 indicates end of the day.

    Raises
    ------
    ValueError
        Raised if ``value`` is not between -1 and 24, inclusive.
    """
    if value == -1:
        pass
    elif value < 0 or value > 24:
        raise ValueError(f"Input {attribute.name} must be between 0 and 24, inclusive.")


def validate_0_1_inclusive(
    instance,
    attribute: Attribute,
    value: int | float,  # pylint: disable=W0613
) -> None:
    """Check to see if the reduction factor is between 0 and 1, inclusive.

    Parameters
    ----------
    value : int | float
        The input value for `speed_reduction_factor`.
    """
    if value < 0 or value > 1:
        raise ValueError(
            f"Input for {attribute.name} must be between 0 and 1, inclusive, not:"
            f" {value=}."
        )


[docs] @define class FromDictMixin: """A Mixin class to allow for kwargs overloading when a data class doesn't have a specific parameter definied. This allows passing of larger dictionaries to a data class without throwing an error. Raises ------ AttributeError Raised if the required class inputs are not provided. """
[docs] @classmethod def from_dict(cls, data: dict): """Map a data dictionary to an `attrs`-defined class. TODO: Add an error to ensure that either none or all the parameters are passed Parameters ---------- data : dict The data dictionary to be mapped. Returns ------- cls : Any The `attrs`-defined class. """ if TYPE_CHECKING: assert hasattr(cls, "__attrs_attrs__") # Get all parameters from the input dictionary that map to the class init kwargs = { a.name: data[a.name] for a in cls.__attrs_attrs__ if a.name in data and a.init } # Map the inputs that must be provided: # 1) must be initialized # 2) no default value defined required_inputs = [ a.name for a in cls.__attrs_attrs__ if a.init and isinstance(a.default, attr._make._Nothing) # type: ignore ] undefined = sorted(set(required_inputs) - set(kwargs)) if undefined: raise AttributeError( f"The class defintion for {cls.__name__} is missing the following" f" inputs: {undefined}" ) return cls(**kwargs)
[docs] @define(frozen=True, auto_attribs=True) class Maintenance(FromDictMixin): """Data class to store maintenance data used in subassembly and cable modeling. Parameters ---------- time : float Amount of time required to perform maintenance, in hours. materials : float Cost of materials required to perform maintenance, in USD. frequency : float Optimal number of days between performing maintenance, in days. service_equipment: list[str] | str Any combination of th following ``Equipment.capability`` options. - RMT: remote (no actual equipment BUT no special implementation) - DRN: drone - CTV: crew transfer vessel/vehicle - SCN: small crane (i.e., cherry picker) - MCN: medium crane (i.e., field support vessel) - LCN: large crane (i.e., heavy lift vessel) - CAB: cabling vessel/vehicle - DSV: diving support vessel - TOW: tugboat or towing equipment - AHV: anchor handling vessel (tugboat that doesn't trigger tow-to-port) - VSG: vessel support group (group of vessels required for single operation) system_value : Union[int, float] Turbine replacement value. Used if the materials cost is a proportional cost. description : str A short text description to be used for logging. operation_reduction : float Performance reduction caused by the failure, between (0, 1]. Defaults to 0. .. warning:: As of v0.7, availability is very sensitive to the usage of this parameter, and so it should be used carefully. level : int, optional Severity level of the maintenance. Defaults to 0. """ time: float = field(converter=float) materials: float = field(converter=float) frequency: float = field(converter=float) service_equipment: list[str] = field( converter=convert_to_list_upper, validator=attrs.validators.deep_iterable( member_validator=attrs.validators.in_(VALID_EQUIPMENT), iterable_validator=attrs.validators.instance_of(list), ), ) system_value: int | float = field(converter=float) description: str = field(default="routine maintenance", converter=str) operation_reduction: float = field(default=0.0, converter=float) level: int = field(default=0, converter=int) request_id: str = field(init=False) replacement: bool = field(default=False, init=False) def __attrs_post_init__(self): """Convert frequency to hours (simulation time scale) and the equipment requirement to a list. """ object.__setattr__(self, "frequency", self.frequency * HOURS_IN_DAY) object.__setattr__( self, "materials", convert_ratio_to_absolute(self.materials, self.system_value), )
[docs] def assign_id(self, request_id: str) -> None: """Assign a unique identifier to the request. Parameters ---------- request_id : str The ``wombat.core.RepairManager`` generated identifier. """ object.__setattr__(self, "request_id", request_id)
[docs] @define(frozen=True, auto_attribs=True) class Failure(FromDictMixin): """Data class to store failure data used in subassembly and cable modeling. Parameters ---------- scale : float Weibull scale parameter for a failure classification. shape : float Weibull shape parameter for a failure classification. time : float Amount of time required to complete the repair, in hours. materials : float Cost of the materials required to complete the repair, in $USD. operation_reduction : float Performance reduction caused by the failure, between (0, 1]. .. warning:: As of v0.7, availability is very sensitive to the usage of this parameter, and so it should be used carefully. level : int, optional Level of severity, will be generated in the ``ComponentData.create_severities`` method. service_equipment: list[str] | str Any combination of the following ``Equipment.capability`` options: - RMT: remote (no actual equipment BUT no special implementation) - DRN: drone - CTV: crew transfer vessel/vehicle - SCN: small crane (i.e., cherry picker) - MCN: medium crane (i.e., field support vessel) - LCN: large crane (i.e., heavy lift vessel) - CAB: cabling vessel/vehicle - DSV: diving support vessel - TOW: tugboat or towing equipment - AHV: anchor handling vessel (tugboat that doesn't trigger tow-to-port) - VSG: vessel support group (group of vessels required for single operation) system_value : Union[int, float] Turbine replacement value. Used if the materials cost is a proportional cost. replacement : bool True if triggering the failure requires a subassembly replacement, False, if only a repair is necessary. Defaults to False description : str A short text description to be used for logging. rng : np.random._generator.Generator .. note:: Do not provide this, it comes from :py:class:`wombat.core.environment.WombatEnvironment` The shared random generator used for the Weibull sampling. This is fed through the simulation environment to ensure consistent seeding between simulations. """ scale: float = field(converter=float) shape: float = field(converter=float) time: float = field(converter=float) materials: float = field(converter=float) operation_reduction: float = field(converter=float) level: int = field(converter=int) service_equipment: list[str] | str = field( converter=convert_to_list_upper, validator=attrs.validators.deep_iterable( member_validator=attrs.validators.in_(VALID_EQUIPMENT), iterable_validator=attrs.validators.instance_of(list), ), ) system_value: int | float = field(converter=float) rng: np.random._generator.Generator = field( eq=False, validator=attrs.validators.instance_of(np.random._generator.Generator), ) replacement: bool = field(default=False, converter=bool) description: str = field(default="failure", converter=str) request_id: str = field(init=False) def __attrs_post_init__(self): """Create the actual Weibull distribution and converts equipment requirements to a list. """ object.__setattr__( self, "service_equipment", convert_to_list(self.service_equipment, str.upper), ) object.__setattr__( self, "materials", convert_ratio_to_absolute(self.materials, self.system_value), )
[docs] def hours_to_next_failure(self) -> float | None: """Sample the next time to failure in a Weibull distribution. If the ``scale`` and ``shape`` parameters are set to 0, then the model will return ``None`` to cause the subassembly to timeout to the end of the simulation. Returns ------- float | None Returns ``None`` for a non-modelled failure, or the time until the next simulated failure. """ if self.scale == self.shape == 0: return None return self.rng.weibull(self.shape, size=1)[0] * self.scale * HOURS_IN_YEAR
[docs] def assign_id(self, request_id: str) -> None: """Assign a unique identifier to the request. Parameters ---------- request_id : str The ``wombat.core.RepairManager`` generated identifier. """ object.__setattr__(self, "request_id", request_id)
[docs] @define(frozen=True, auto_attribs=True) class SubassemblyData(FromDictMixin): """Data storage and validation class for the subassemblies. Parameters ---------- name : str Name of the component/subassembly. maintenance : list[dict[str, float | str]] List of the maintenance classification dictionaries. This will be converted to a list of ``Maintenance`` objects in the post initialization hook. failures : dict[int, dict[str, float | str]] Dictionary of failure classifications in a numerical (ordinal) categorization order. This will be converted to a dictionary of ``Failure`` objects in the post initialization hook. system_value : int | float Turbine's cost of replacement. Used in case percentages of turbine cost are used in place of an absolute cost. """ name: str = field(converter=str) maintenance: list[Maintenance | dict[str, float | str]] failures: list[Failure | dict[str, float | str]] | dict[ int, Failure | dict[str, float | str] ] system_value: int | float = field(converter=float) rng: np.random._generator.Generator = field( validator=attrs.validators.instance_of(np.random._generator.Generator) ) def __attrs_post_init__(self): """Convert the maintenance and failure data to ``Maintenance`` and ``Failure`` objects, respectively. """ for kwargs in self.maintenance: if TYPE_CHECKING: assert isinstance(kwargs, dict) kwargs.update({"system_value": self.system_value}) object.__setattr__( self, "maintenance", [ Maintenance.from_dict(kw) if isinstance(kw, dict) else kw for kw in self.maintenance ], ) for kwargs in self.failures.values(): # type: ignore if TYPE_CHECKING: assert isinstance(kwargs, dict) kwargs.update({"system_value": self.system_value}) failures_list = [] rng_dict = {"rng": self.rng} if TYPE_CHECKING: assert isinstance(self.failures, dict) for config in self.failures.values(): if TYPE_CHECKING: assert isinstance(config, dict) config.update(rng_dict) failures_list.append(Failure.from_dict(config)) object.__setattr__(self, "failures", failures_list)
[docs] @define(frozen=True, auto_attribs=True) class RepairRequest(FromDictMixin): """Repair/Maintenance request data class. Parameters ---------- system_id : str ``System.id``. system_name : str ``System.name``. subassembly_id : str ``Subassembly.id``. subassembly_name : str ``Subassembly.name``. severity_level : int ``Maintenance.level`` or ``Failure.level``. details : Failure | Maintenance The actual data class. cable : bool Indicator that the request is for a cable, by default False. upstream_turbines : list[str] The cable's upstream turbines, by default []. No need to use this if ``cable`` == False. upstream_cables : list[str] The cable's upstream cables, by default []. No need to use this if ``cable`` == False. """ system_id: str = field(converter=str) system_name: str = field(converter=str) subassembly_id: str = field(converter=str) subassembly_name: str = field(converter=str) severity_level: int = field(converter=int) details: Failure | Maintenance = field( validator=attrs.validators.instance_of((Failure, Maintenance)) ) cable: bool = field(default=False, converter=bool, kw_only=True) upstream_turbines: list[str] = field(default=Factory(list), kw_only=True) upstream_cables: list[str] = field(default=Factory(list), kw_only=True) prior_operating_level: float = field( default=1, kw_only=True, validator=validate_0_1_inclusive ) request_id: str = field(init=False)
[docs] def assign_id(self, request_id: str) -> None: """Assign a unique identifier to the request. Parameters ---------- request_id : str The ``wombat.core.RepairManager`` generated identifier. """ object.__setattr__(self, "request_id", request_id) self.details.assign_id(request_id)
def __eq__(self, other) -> bool: """Redefines the equality method to only check for the ``request_id``.""" return self.request_id == other.request_id
[docs] @define(frozen=True, auto_attribs=True) class ServiceCrew(FromDictMixin): """An internal data class for the indivdual crew units that are on the servicing equipment. Parameters ---------- n_day_rate: int Number of salaried workers. day_rate: float Day rate for salaried workers, in USD. n_hourly_rate: int Number of hourly/subcontractor workers. hourly_rate: float Hourly labor rate for subcontractors, in USD. """ n_day_rate: int = field(converter=int) day_rate: float = field(converter=float) n_hourly_rate: int = field(converter=int) hourly_rate: float = field(converter=float)
class DateLimitsMixin: """Base servicing equpment dataclass. Only meant to reduce repeated code across the ``ScheduledServiceEquipmentData`` and ``UnscheduledServiceEquipmentData`` classes. """ # MyPy type hints port_distance: float = field(converter=float) non_operational_start: datetime.datetime = field(converter=parse_date) non_operational_end: datetime.datetime = field(converter=parse_date) reduced_speed_start: datetime.datetime = field(converter=parse_date) reduced_speed_end: datetime.datetime = field(converter=parse_date) def _set_environment_shift(self, start: int, end: int) -> None: """Set the ``workday_start`` and ``workday_end`` to the environment's values. Parameters ---------- start : int Starting hour of a workshift. end : int Ending hour of a workshift. """ object.__setattr__(self, "workday_start", start) object.__setattr__(self, "workday_end", end) if self.workday_start == 0 and self.workday_end == 24: # type: ignore object.__setattr__(self, "non_stop_shift", True) def _set_port_distance(self, distance: int | float | None) -> None: """Set ``port_distance`` from the environment's or port's variables. Parameters ---------- distance : int | float The distance to port that must be traveled for servicing equipment. """ if distance is None: return if distance <= 0: return if self.port_distance <= 0: object.__setattr__(self, "port_distance", float(distance)) def _compare_dates( self, new_start: datetime.datetime | None, new_end: datetime.datetime | None, which: str, ) -> tuple[datetime.datetime | None, datetime.datetime | None]: """Compare the orignal and newly input starting ane ending date for either the non-operational date range or the reduced speed date range. Parameters ---------- new_start : datetime.datetime | None The overriding start date if it is an earlier date than the original. new_end : datetime.datetime | None The overriding end date if it is a later date than the original. which : str One of "reduced_speed" or "non_operational" Returns ------- tuple[datetime.datetime | None, datetime.datetime | None] The more conservative date pair between the new values and original values. Raises ------ ValueError Raised if an invalid value to ``which`` is provided. """ if which in ("reduced_speed", "non_operational"): original_start = getattr(self, f"{which}_start") original_end = getattr(self, f"{which}_end") else: raise ValueError( "`which` must be one of 'reduced_speed' or 'non_operational'." ) if original_start is not None: if new_start is not None: new_start = min(new_start, original_start) else: new_start = original_start else: object.__setattr__(self, f"{which}_start", new_start) if original_end is not None: if new_end is not None: new_end = max(new_end, original_end) else: new_end = original_end else: object.__setattr__(self, f"{which}_end", new_end) return new_start, new_end def set_non_operational_dates( self, start_date: datetime.datetime | None = None, start_year: int | None = None, end_date: datetime.datetime | None = None, end_year: int | None = None, ) -> None: """Create an annualized list of dates where servicing equipment should be unavailable. Takes a a ``start_year`` and ``end_year`` to determine how many years should be covered. Parameters ---------- start_date : datetime.datetime | None The starting date for the annual date range of non-operational dates, by default None. start_year : int | None The first year that has a non-operational date range, by default None. end_date : datetime.datetime | None The ending date for the annual range of non-operational dates, by default None. end_year : int | None The last year that should have a non-operational date range, by default None. Raises ------ ValueError Raised if the starting and ending dates are the same date. """ start_date, end_date = self._compare_dates( start_date, end_date, "non_operational" ) object.__setattr__(self, "non_operational_start", start_date) object.__setattr__(self, "non_operational_end", end_date) # Check that the base dates are valid if self.non_operational_start is None or self.non_operational_end is None: object.__setattr__( self, "non_operational_dates", np.empty(0, dtype="object") ) object.__setattr__(self, "non_operational_dates_set", set()) return # Check that the input year range is valid if not isinstance(start_year, int): raise ValueError( f"Input to `start_year`: {start_year}, must be an integer." ) if not isinstance(end_year, int): raise ValueError(f"Input to `end_year`: {end_year}, must be an integer.") if end_year < start_year: raise ValueError( "`start_year`: {start_year}, must less than or equal to the" f" `end_year`: {end_year}" ) # Create the date range dates = annualized_date_range( self.non_operational_start, start_year, self.non_operational_end, end_year ) object.__setattr__(self, "non_operational_dates", dates) object.__setattr__(self, "non_operational_dates_set", set(dates)) # Update the operating dates field to ensure there is no overlap if hasattr(self, "operating_dates") and hasattr(self, "non_operational_dates"): operating = np.setdiff1d(self.operating_dates, self.non_operational_dates) object.__setattr__(self, "operating_dates", operating) object.__setattr__(self, "operating_dates_set", set(operating)) def set_reduced_speed_parameters( self, start_date: datetime.datetime | None = None, start_year: int | None = None, end_date: datetime.datetime | None = None, end_year: int | None = None, speed: float = 0.0, ) -> None: """Create an annualized list of dates where servicing equipment should be operating with reduced speeds. The passed ``start_date``, ``end_date``, and ``speed`` will override provided values if they are more conservative than the current settings, or a value for ``speed`` will only override the existing value if the passed value is slower. Takes a ``start_year`` and ``end_year`` to determine how many years should be covered by this setting. Parameters ---------- start_date : datetime.datetime | None The starting date for the annual date range of reduced speeds, by default None. start_year : int | None The first year that has a reduced speed date range, by default None. end_date : datetime.datetime | None The ending date for the annual range of reduced speeds, by default None. end_year : int | None The last year that should have a reduced speed date range, by default None. speed : float The maximum operating speed under a speed-restricted scenario. Raises ------ ValueError Raised if the starting and ending dates are the same date. """ # Check that the base dates are valid and override if no values were provided # or if the new value is more conservative than the originally provided value start_date, end_date = self._compare_dates( start_date, end_date, "reduced_speed" ) object.__setattr__(self, "reduced_speed_start", start_date) object.__setattr__(self, "reduced_speed_end", end_date) if start_date is None or end_date is None: object.__setattr__(self, "reduced_speed_dates", np.empty(0, dtype="object")) object.__setattr__(self, "reduced_speed_dates_set", set()) return # Check that the input year range is valid if not isinstance(start_year, int): raise ValueError( f"Input to `start_year`: {start_year}, must be an integer." ) if not isinstance(end_year, int): raise ValueError(f"Input to `end_year`: {end_year}, must be an integer.") if end_year < start_year: raise ValueError( "`start_year`: {start_year}, must less than or equal to the" f" `end_year`: {end_year}" ) # Create the date range dates = annualized_date_range( self.reduced_speed_start, start_year, self.reduced_speed_end, end_year ) object.__setattr__(self, "reduced_speed_dates", dates) object.__setattr__(self, "reduced_speed_dates_set", set(dates)) # Update the reduced speed if none was originally provided if TYPE_CHECKING: assert hasattr(self, "reduced_speed") # mypy helper if speed != 0 and (self.reduced_speed == 0 or speed < self.reduced_speed): object.__setattr__(self, "reduced_speed", speed)
[docs] @define(frozen=True, auto_attribs=True) class ScheduledServiceEquipmentData(FromDictMixin, DateLimitsMixin): """The data class specification for servicing equipment that will use a pre-scheduled basis for returning to site. Parameters ---------- name: str Name of the piece of servicing equipment. equipment_rate: float Day rate for the equipment/vessel, in USD. n_crews : int Number of crew units for the equipment. .. note:: The input to this does not matter yet, as multi-crew functionality is not yet implemented. crew : ServiceCrew The crew details, see :py:class:`ServiceCrew` for more information. Dictionary of labor costs with the following: ``n_day_rate``, ``day_rate``, ``n_hourly_rate``, and ``hourly_rate``. start_month : int The day to start operations for the rig and crew. start_day : int The month to start operations for the rig and crew. start_year : int The year to start operations for the rig and crew. end_month : int The month to end operations for the rig and crew. end_day : int The day to end operations for the rig and crew. end_year : int The year to end operations for the rig and crew. .. note:: if the rig comes annually, then the enter the year for the last year that the rig and crew will be available. capability : str The type of capabilities the equipment contains. Must be one of: - RMT: remote (no actual equipment BUT no special implementation) - DRN: drone - CTV: crew transfer vessel/vehicle - SCN: small crane (i.e., cherry picker) - MCN: medium crane (i.e., field support vessel) - LCN: large crane (i.e., heavy lift vessel) - CAB: cabling vessel/vehicle - DSV: diving support vessel - AHV: anchor handling vessel (tugboat that doesn't trigger tow-to-port) - VSG: vessel support group (group of vessels required for single operation) Please note that "TOW" is unavailable for scheduled servicing equipment mobilization_cost : float Cost to mobilize the rig and crew. mobilization_days : int Number of days it takes to mobilize the equipment. speed : float Maximum transit speed, km/hr. speed_reduction_factor : flaot Reduction factor for traveling in inclement weather, default 0. When 0, travel is stopped when either `max_windspeed_transport` or `max_waveheight_transport` is reached, and when 1, `speed` is used. max_windspeed_transport : float Maximum windspeed for safe transport, m/s. max_windspeed_repair : float Maximum windspeed for safe operations, m/s. max_waveheight_transport : float Maximum waveheight for safe transport, m, default 1000 (land-based). max_waveheight_repair : float Maximum waveheight for safe operations, m, default 1000 (land-based). workday_start : int The starting hour of a workshift, in 24 hour time. workday_end : int The ending hour of a workshift, in 24 hour time. crew_transfer_time : float The number of hours it takes to transfer the crew from the equipment to the system, e.g. how long does it take to transfer the crew from the CTV to the turbine, default 0. onsite : bool Indicator for if the servicing equipment and crew are based onsite. .. note:: If based onsite, be sure that the start and end dates represent the first and last day/month of the year, respectively, and the start and end years represent the fist and last year in the weather file. method : str Determines if the equipment will do all maximum severity repairs first or do all the repairs at one turbine before going to the next, by default severity. Must be one of "severity" or "turbine". port_distance : int | float The distance, in km, the equipment must travel to go between port and site, by default 0. non_operational_start : str | datetime.datetime | None The starting month and day, e.g., MM/DD, M/D, MM-DD, etc. for an annualized period of prohibited operations. When defined at the environment level, an undefined or later starting date will be overridden, by default None. non_operational_end : str | datetime.datetime | None The ending month and day, e.g., MM/DD, M/D, MM-DD, etc. for an annualized period of prohibited operations. When defined at the environment level, an undefined or earlier ending date will be overridden, by default None. reduced_speed_start : str | datetime.datetime | None The starting month and day, e.g., MM/DD, M/D, MM-DD, etc. for an annualized period of reduced speed operations. When defined at the environment level, an undefined or later starting date will be overridden, by default None. reduced_speed_end : str | datetime.datetime | None The ending month and day, e.g., MM/DD, M/D, MM-DD, etc. for an annualized period of reduced speed operations. When defined at the environment level, an undefined or earlier ending date will be overridden, by default None. reduced_speed : float The maximum operating speed during the annualized reduced speed operations. When defined at the environment level, an undefined or faster value will be overridden, by default 0.0. """ name: str = field(converter=str) equipment_rate: float = field(converter=float) n_crews: int = field(converter=int) crew: ServiceCrew = field(converter=ServiceCrew.from_dict) capability: list[str] = field( converter=convert_to_list_upper, validator=attrs.validators.deep_iterable( member_validator=attrs.validators.in_(VALID_EQUIPMENT), iterable_validator=attrs.validators.instance_of(list), ), ) speed: float = field(converter=float, validator=attrs.validators.gt(0)) max_windspeed_transport: float = field(converter=float) max_windspeed_repair: float = field(converter=float) mobilization_cost: float = field(default=0, converter=float) mobilization_days: int = field(default=0, converter=int) max_waveheight_transport: float = field(default=1000.0, converter=float) max_waveheight_repair: float = field(default=1000.0, converter=float) workday_start: int = field(default=-1, converter=int, validator=valid_hour) workday_end: int = field(default=-1, converter=int, validator=valid_hour) crew_transfer_time: float = field(converter=float, default=0.0) speed_reduction_factor: float = field( default=0.0, converter=float, validator=validate_0_1_inclusive ) port_distance: float = field(default=0.0, converter=float) onsite: bool = field(default=False, converter=bool) method: str = field( default="severity", converter=[str, str.lower], validator=attrs.validators.in_(["turbine", "severity"]), ) start_month: int = field( default=-1, converter=int, validator=attrs.validators.ge(0) ) start_day: int = field(default=-1, converter=int, validator=attrs.validators.ge(0)) start_year: int = field(default=-1, converter=int, validator=attrs.validators.ge(0)) end_month: int = field(default=-1, converter=int, validator=attrs.validators.ge(0)) end_day: int = field(default=-1, converter=int, validator=attrs.validators.ge(0)) end_year: int = field(default=-1, converter=int, validator=attrs.validators.ge(0)) strategy: str = field( default="scheduled", validator=attrs.validators.in_(["scheduled"]) ) operating_dates: np.ndarray = field(factory=list, init=False) operating_dates_set: set = field(factory=set, init=False) non_operational_start: datetime.datetime = field(default=None, converter=parse_date) non_operational_end: datetime.datetime = field( default=None, converter=parse_date, validator=check_start_stop_dates ) reduced_speed_start: datetime.datetime = field(default=None, converter=parse_date) reduced_speed_end: datetime.datetime = field( default=None, converter=parse_date, validator=check_start_stop_dates ) reduced_speed: float = field(default=0, converter=float) non_operational_dates: pd.DatetimeIndex = field(factory=list, init=False) non_operational_dates_set: pd.DatetimeIndex = field(factory=set, init=False) reduced_speed_dates: pd.DatetimeIndex = field(factory=set, init=False) non_stop_shift: bool = field(default=False, init=False) def create_date_range(self) -> np.ndarray: """Create an ``np.ndarray`` of valid operational dates.""" start_date = datetime.datetime( self.start_year, self.start_month, self.start_day ) # If `onsite` is True, then create the range in one go because there should be # no discrepancies in the range. # Othewise, create a specified date range between two dates in the year range. if self.onsite: end_date = datetime.datetime(self.end_year, self.end_month, self.end_day) date_range = pd.date_range(start_date, end_date).date else: date_range = annual_date_range( self.start_day, self.end_day, self.start_month, self.end_month, self.start_year, self.end_year, ) return date_range def __attrs_post_init__(self) -> None: """Post-initialization.""" object.__setattr__(self, "operating_dates", self.create_date_range()) object.__setattr__(self, "operating_dates_set", set(self.operating_dates)) if self.workday_start == 0 and self.workday_end == 24: object.__setattr__(self, "non_stop_shift", True)
[docs] @define(frozen=True, auto_attribs=True) class UnscheduledServiceEquipmentData(FromDictMixin, DateLimitsMixin): """The data class specification for servicing equipment that will use either a basis of windfarm downtime or total number of requests serviceable by the equipment. Parameters ---------- name: str Name of the piece of servicing equipment. equipment_rate: float Day rate for the equipment/vessel, in USD. n_crews : int Number of crew units for the equipment. .. note: the input to this does not matter yet, as multi-crew functionality is not yet implemented. crew : ServiceCrew The crew details, see :py:class:`ServiceCrew` for more information. Dictionary of labor costs with the following: ``n_day_rate``, ``day_rate``, ``n_hourly_rate``, and ``hourly_rate``. charter_days : int The number of days the servicing equipment can be chartered for. capability : str The type of capabilities the equipment contains. Must be one of: - RMT: remote (no actual equipment BUT no special implementation) - DRN: drone - CTV: crew transfer vessel/vehicle - SCN: small crane (i.e., cherry picker) - MCN: medium crane (i.e., field support vessel) - LCN: large crane (i.e., heavy lift vessel) - CAB: cabling vessel/vehicle - DSV: diving support vessel - TOW: tugboat or towing equipment - AHV: anchor handling vessel (tugboat that doesn't trigger tow-to-port) - VSG: vessel support group (group of vessels required for single operation) speed : float Maximum transit speed, km/hr. tow_speed : float The maximum transit speed when towing, km/hr. .. note:: This is only required for when the servicing equipment is tugboat enabled for a tow-to-port scenario (capability = "TOW") speed_reduction_factor : flaot Reduction factor for traveling in inclement weather, default 0. When 0, travel is stopped when either `max_windspeed_transport` or `max_waveheight_transport` is reached, and when 1, `speed` is used. max_windspeed_transport : float Maximum windspeed for safe transport, m/s. max_windspeed_repair : float Maximum windspeed for safe operations, m/s. max_waveheight_transport : float Maximum waveheight for safe transport, m, default 1000 (land-based). max_waveheight_repair : float Maximum waveheight for safe operations, m, default 1000 (land-based). mobilization_cost : float Cost to mobilize the rig and crew, default 0. mobilization_days : int Number of days it takes to mobilize the equipment, default 0. strategy : str For any unscheduled maintenance servicing equipment, this determines the strategy for dispatching. Should be on of "downtime" or "requests". strategy_threshold : str For downtime-based scenarios, this is based on the operating level, and should be in the range (0, 1). For reqest-based scenarios, this is the maximum number of requests that are allowed to build up for any given type of unscheduled servicing equipment, should be an integer >= 1. workday_start : int The starting hour of a workshift, in 24 hour time. workday_end : int The ending hour of a workshift, in 24 hour time. crew_transfer_time : float The number of hours it takes to transfer the crew from the equipment to the system, e.g. how long does it take to transfer the crew from the CTV to the turbine, default 0. onsite : bool Indicator for if the rig and crew are based onsite. .. note:: if the rig and crew are onsite be sure that the start and end dates represent the first and last day/month of the year, respectively, and the start and end years represent the fist and last year in the weather file. method : str Determines if the ship will do all maximum severity repairs first or do all the repairs at one turbine before going to the next, by default severity. Should by one of "severity" or "turbine". unmoor_hours : int | float The number of hours required to unmoor a floating offshore wind turbine in order to tow it to port, by default 0. .. note:: Required for the tugboat/towing capability, otherwise unused. reconnection_hours : int | float The number of hours required to reconnect a floating offshore wind turbine after being towed back to site, by default 0. .. note:: Required for the tugboat/towing capability, otherwise unused. port_distance : int | float The distance, in km, the equipment must travel to go between port and site, by default 0. non_operational_start : str | datetime.datetime | None The starting month and day, e.g., MM/DD, M/D, MM-DD, etc. for an annualized period of prohibited operations. When defined at the environment level or the port level, if a tugboat, an undefined or later starting date will be overridden, by default None. non_operational_end : str | datetime.datetime | None The ending month and day, e.g., MM/DD, M/D, MM-DD, etc. for an annualized period of prohibited operations. When defined at the environment level or the port level, if a tugboat, an undefined or earlier ending date will be overridden, by default None. reduced_speed_start : str | datetime.datetime | None The starting month and day, e.g., MM/DD, M/D, MM-DD, etc. for an annualized period of reduced speed operations. When defined at the environment level or the port level, if a tugboat, an undefined or later starting date will be overridden, by default None. reduced_speed_end : str | datetime.datetime | None The ending month and day, e.g., MM/DD, M/D, MM-DD, etc. for an annualized period of reduced speed operations. When defined at the environment level or the port level, if a tugboat, an undefined or earlier ending date will be overridden, by default None. reduced_speed : float The maximum operating speed during the annualized reduced speed operations. When defined at the environment level, an undefined or faster value will be overridden, by default 0.0. """ name: str = field(converter=str) equipment_rate: float = field(converter=float) n_crews: int = field(converter=int) crew: ServiceCrew = field(converter=ServiceCrew.from_dict) capability: list[str] = field( converter=convert_to_list_upper, validator=attrs.validators.deep_iterable( member_validator=attrs.validators.in_(VALID_EQUIPMENT), iterable_validator=attrs.validators.instance_of(list), ), ) speed: float = field(converter=float, validator=attrs.validators.gt(0)) max_windspeed_transport: float = field(converter=float) max_windspeed_repair: float = field(converter=float) mobilization_cost: float = field(default=0, converter=float) mobilization_days: int = field(default=0, converter=int) max_waveheight_transport: float = field(default=1000.0, converter=float) max_waveheight_repair: float = field(default=1000.0, converter=float) workday_start: int = field(default=-1, converter=int, validator=valid_hour) workday_end: int = field(default=-1, converter=int, validator=valid_hour) crew_transfer_time: float = field(converter=float, default=0.0) speed_reduction_factor: float = field( default=0.0, converter=float, validator=validate_0_1_inclusive ) port_distance: float = field(default=0.0, converter=float) onsite: bool = field(default=False, converter=bool) method: str = field( # type: ignore default="severity", converter=[str, str.lower], validator=attrs.validators.in_(["turbine", "severity"]), ) strategy: str | None = field( default="unscheduled", converter=clean_string_input, validator=attrs.validators.in_(UNSCHEDULED_STRATEGIES), ) strategy_threshold: int | float = field(default=-1, converter=float) charter_days: int = field( default=-1, converter=int, validator=attrs.validators.gt(0) ) tow_speed: float = field( default=1, converter=float, validator=attrs.validators.gt(0) ) unmoor_hours: int | float = field(default=0, converter=float) reconnection_hours: int | float = field(default=0, converter=float) non_operational_start: datetime.datetime = field(default=None, converter=parse_date) non_operational_end: datetime.datetime = field(default=None, converter=parse_date) reduced_speed_start: datetime.datetime = field(default=None, converter=parse_date) reduced_speed_end: datetime.datetime = field(default=None, converter=parse_date) reduced_speed: float = field(default=0, converter=float) non_operational_dates: pd.DatetimeIndex = field(factory=list, init=False) non_operational_dates_set: pd.DatetimeIndex = field(factory=set, init=False) reduced_speed_dates: pd.DatetimeIndex = field(factory=set, init=False) non_stop_shift: bool = field(default=False, init=False)
[docs] @strategy_threshold.validator # type: ignore def _validate_threshold( self, attribute: Attribute, # pylint: disable=W0613 value: int | float, ) -> None: """Ensure a valid threshold is provided for a given ``strategy``.""" if self.strategy == "downtime": if value <= 0 or value >= 1: raise ValueError( "Downtime-based strategies must have a ``strategy_threshold``", "between 0 and 1, non-inclusive!", ) if self.strategy == "requests": if value <= 0: raise ValueError( "Requests-based strategies must have a ``strategy_threshold``", "greater than 0!", )
def __attrs_post_init__(self) -> None: """Post-initialization hook.""" if self.workday_start == 0 and self.workday_end == 24: object.__setattr__(self, "non_stop_shift", True)
[docs] @define(frozen=True, auto_attribs=True) class ServiceEquipmentData(FromDictMixin): """Helps to determine the type ServiceEquipment that should be used, based on the repair strategy for its operation. See :py:class:`~data_classes.ScheduledServiceEquipmentData` or :py:class:`~data_classes.UnscheduledServiceEquipmentData` for more details on each classifcation. Parameters ---------- data_dict : dict The dictionary that will be used to create the appropriate ServiceEquipmentData. This should contain a field called 'strategy' with either "scheduled" or "unscheduled" as a value if strategy is not provided as a keyword argument. strategy : str, optional Should be one of "scheduled", "requests", "downtime". If nothing is provided, the equipment configuration will be checked. Raises ------ ValueError Raised if ``strategy`` is not one of "scheduled" or "unscheduled". Examples -------- The below workflow is how a new data :py:class:`~data_classes.ScheduledServiceEquipmentData` object could be created via a generic/routinized creation method, and is how the :py:class:`~service_equipment.ServiceEquipment`'s ``__init__`` method creates the settings data. >>> from wombat.core.data_classes import ServiceEquipmentData >>> >>> data_dict = { >>> "name": "Crew Transfer Vessel 1", >>> "equipment_rate": 1750, >>> "start_month": 1, >>> "start_day": 1, >>> "end_month": 12, >>> "end_day": 31, >>> "start_year": 2002, >>> "end_year": 2014, >>> "onsite": True, >>> "capability": "CTV", >>> "max_severity": 10, >>> "mobilization_cost": 0, >>> "mobilization_days": 0, >>> "speed": 37.04, >>> "max_windspeed_transport": 99, >>> "max_windspeed_repair": 99, >>> "max_waveheight_transport": 1.5, >>> "max_waveheight_repair": 1.5, >>> "strategy": scheduled, >>> "crew_transfer_time": 0.25, >>> "n_crews": 1, >>> "crew": { >>> "day_rate": 0, >>> "n_day_rate": 0, >>> "hourly_rate": 0, >>> "n_hourly_rate": 0, >>> }, >>> } >>> equipment = ServiceEquipmentData(data_dict).determine_type() >>> type(equipment) """ data_dict: dict strategy: str | None = field( kw_only=True, default=None, converter=clean_string_input ) def __attrs_post_init__(self): """Post-initialization.""" if self.strategy is None: object.__setattr__( self, "strategy", clean_string_input(self.data_dict["strategy"]) ) if self.strategy not in VALID_STRATEGIES: raise ValueError( f"ServiceEquipment strategy should be one of {VALID_STRATEGIES};" f" input: {self.strategy}." )
[docs] def determine_type( self, ) -> ScheduledServiceEquipmentData | UnscheduledServiceEquipmentData: """Generate the appropriate ServiceEquipmentData variation. Returns ------- Union[ScheduledServiceEquipmentData, UnscheduledServiceEquipmentData] The appropriate ``xxServiceEquipmentData`` schema depending on the strategy the ``ServiceEquipment`` will use. """ if self.strategy == "scheduled": return ScheduledServiceEquipmentData.from_dict(self.data_dict) elif self.strategy in UNSCHEDULED_STRATEGIES: return UnscheduledServiceEquipmentData.from_dict(self.data_dict) else: # This should not be able to be reached raise ValueError("Invalid strategy provided!")
@define(auto_attribs=True) class EquipmentMap: """Internal mapping for servicing equipment strategy information.""" strategy_threshold: int | float equipment: ServiceEquipment # noqa: F821 @define(auto_attribs=True) class StrategyMap: """Internal mapping for equipment capabilities and their data.""" CTV: list[EquipmentMap] = field(factory=list) SCN: list[EquipmentMap] = field(factory=list) MCN: list[EquipmentMap] = field(factory=list) LCN: list[EquipmentMap] = field(factory=list) CAB: list[EquipmentMap] = field(factory=list) RMT: list[EquipmentMap] = field(factory=list) DRN: list[EquipmentMap] = field(factory=list) DSV: list[EquipmentMap] = field(factory=list) TOW: list[EquipmentMap] = field(factory=list) AHV: list[EquipmentMap] = field(factory=list) VSG: list[EquipmentMap] = field(factory=list) is_running: bool = field(default=False, init=False) def update( self, capability: str, threshold: int | float, equipment: ServiceEquipment, # noqa: F821 ) -> None: """Update the strategy mapping between capability types and the available ``ServiceEquipment`` objects. Parameters ---------- capability : str The ``equipment``'s capability. threshold : int | float The threshold for ``equipment``'s strategy. equipment : ServiceEquipment The actual ``ServiceEquipment`` object to be logged. Raises ------ ValueError Raised if there is an invalid capability, though this shouldn't be able to be reached. """ # Using a mypy ignore because of an unpatched bug using data classes if capability == "CTV": self.CTV.append(EquipmentMap(threshold, equipment)) # type: ignore elif capability == "SCN": self.SCN.append(EquipmentMap(threshold, equipment)) # type: ignore elif capability == "MCN": self.MCN.append(EquipmentMap(threshold, equipment)) # type: ignore elif capability == "LCN": self.LCN.append(EquipmentMap(threshold, equipment)) # type: ignore elif capability == "CAB": self.CAB.append(EquipmentMap(threshold, equipment)) # type: ignore elif capability == "RMT": self.RMT.append(EquipmentMap(threshold, equipment)) # type: ignore elif capability == "DRN": self.DRN.append(EquipmentMap(threshold, equipment)) # type: ignore elif capability == "DSV": self.DSV.append(EquipmentMap(threshold, equipment)) # type: ignore elif capability == "TOW": self.TOW.append(EquipmentMap(threshold, equipment)) # type: ignore elif capability == "AHV": self.AHV.append(EquipmentMap(threshold, equipment)) # type: ignore elif capability == "VSG": self.VSG.append(EquipmentMap(threshold, equipment)) # type: ignore else: # This should not even be able to be reached raise ValueError( f"Invalid servicing equipment '{capability}' has been provided!" ) self.is_running = True def get_mapping(self, capability) -> list[EquipmentMap]: """Gets the attribute matching the desired :py:attr:`capability`. Parameters ---------- capability : str A string matching one of the ``UnscheduledServiceEquipmentData.capability`` (or scheduled) options. Returns ------- list[EquipmentMap] Returns the matching mapping of available servicing equipment. """ if capability == "CTV": return self.CTV if capability == "SCN": return self.SCN if capability == "MCN": return self.MCN if capability == "LCN": return self.LCN if capability == "CAB": return self.CAB if capability == "RMT": return self.RMT if capability == "DRN": return self.DRN if capability == "DSV": return self.DSV if capability == "TOW": return self.TOW if capability == "AHV": return self.AHV if capability == "VSG": return self.VSG # This should not even be able to be reached raise ValueError( f"Invalid servicing equipmen capability '{capability}' has been provided!" ) def move_equipment_to_end(self, capability: str, ix: int) -> None: """Moves a used equipment to the end of the mapping list to ensure a broader variety of servicing equipment are used throughout a simulation. Parameters ---------- capability : str A string matching one of ``capability`` options for servicing equipment dataclasses. ix : int The index of the used servicing equipent. """ if capability == "CTV": self.CTV.append(self.CTV.pop(ix)) elif capability == "SCN": self.SCN.append(self.SCN.pop(ix)) elif capability == "MCN": self.LCN.append(self.MCN.pop(ix)) elif capability == "LCN": self.LCN.append(self.LCN.pop(ix)) elif capability == "CAB": self.CAB.append(self.CAB.pop(ix)) elif capability == "RMT": self.RMT.append(self.RMT.pop(ix)) elif capability == "DRN": self.DRN.append(self.DRN.pop(ix)) elif capability == "DSV": self.DSV.append(self.DSV.pop(ix)) elif capability == "TOW": self.TOW.append(self.TOW.pop(ix)) elif capability == "AHV": self.AHV.append(self.AHV.pop(ix)) elif capability == "VSG": self.VSG.append(self.VSG.pop(ix)) else: # This should not even be able to be reached raise ValueError( f"Invalid servicing equipmen capability {capability} has been provided!" )
[docs] @define(frozen=True, auto_attribs=True) class PortConfig(FromDictMixin, DateLimitsMixin): """Port configurations for offshore wind power plant scenarios. Parameters ---------- name : str The name of the port, if multiple are used, then be sure this is unique. tugboats : list[str] file, or list of files to create the port's tugboats. .. note:: Each tugboat is considered to be a tugboat + supporting vessels as the primary purpose to tow turbines between a repair port and site. n_crews : int The number of service crews available to be working on repairs simultaneously; each crew is able to service exactly one repair. crew : ServiceCrew The crew details, see :py:class:`ServiceCrew` for more information. Dictionary of labor costs with the following: ``n_day_rate``, ``day_rate``, ``n_hourly_rate``, and ``hourly_rate``. max_operations : int Total number of turbines the port can handle simultaneously. workday_start : int The starting hour of a workshift, in 24 hour time. workday_end : int The ending hour of a workshift, in 24 hour time. site_distance : int | float Distance, in km, a tugboat has to travel to get between site and port. annual_fee : int | float The annualized fee for access to the repair port that will be distributed monthly in the simulation and accounted for on the first of the month from the start of the simulation to the end of the simulation. .. note:: Don't include this cost in both this category and either the ``FixedCosts.operations_management_administration`` bucket or ``FixedCosts.marine_management`` category. non_operational_start : str | datetime.datetime | None The starting month and day, e.g., MM/DD, M/D, MM-DD, etc. for an annualized period of prohibited operations. When defined at the port level, an undefined or later starting date will be overridden by the environment, and any associated tubboats will have this value overridden using the same logic, by default None. non_operational_end : str | datetime.datetime | None The ending month and day, e.g., MM/DD, M/D, MM-DD, etc. for an annualized period of prohibited operations. When defined at the port level, an undefined or earlier ending date will be overridden by the environment, and any associated tubboats will have this value overridden using the same logic, by default None. reduced_speed_start : str | datetime.datetime | None The starting month and day, e.g., MM/DD, M/D, MM-DD, etc. for an annualized period of reduced speed operations. When defined at the port level, an undefined or later starting date will be overridden by the environment, and any associated tubboats will have this value overridden using the same logic, by default None. reduced_speed_end : str | datetime.datetime | None The ending month and day, e.g., MM/DD, M/D, MM-DD, etc. for an annualized period of reduced speed operations. When defined at the port level, an undefined or earlier ending date will be overridden by the environment, and any associated tubboats will have this value overridden using the same logic, by default None. reduced_speed : float The maximum operating speed during the annualized reduced speed operations. When defined at the port level, an undefined or faster value will be overridden by the environment, and any associated tubboats will have this value overridden using the same logic, by default 0.0. """ name: str = field(converter=str) tugboats: list[str | Path] = field(converter=convert_to_list) crew: ServiceCrew = field(converter=ServiceCrew.from_dict) n_crews: int = field(default=1, converter=int) max_operations: int = field(default=1, converter=int) workday_start: int = field(default=-1, converter=int, validator=valid_hour) workday_end: int = field(default=-1, converter=int, validator=valid_hour) site_distance: float = field(default=0.0, converter=float) annual_fee: float = field( default=0, converter=float, validator=attrs.validators.gt(0) ) non_operational_start: datetime.datetime = field(default=None, converter=parse_date) non_operational_end: datetime.datetime = field(default=None, converter=parse_date) reduced_speed_start: datetime.datetime = field(default=None, converter=parse_date) reduced_speed_end: datetime.datetime = field(default=None, converter=parse_date) reduced_speed: float = field(default=0, converter=float) non_operational_dates: pd.DatetimeIndex = field(factory=set, init=False) reduced_speed_dates: pd.DatetimeIndex = field(factory=set, init=False) non_operational_dates_set: pd.DatetimeIndex = field(factory=set, init=False) reduced_speed_dates_set: pd.DatetimeIndex = field(factory=set, init=False) non_stop_shift: bool = field(default=False, init=False) def __attrs_post_init__(self) -> None: """Post-initialization hook.""" if self.workday_start == 0 and self.workday_end == 24: object.__setattr__(self, "non_stop_shift", True)
[docs] @define(frozen=True, auto_attribs=True) class FixedCosts(FromDictMixin): """Fixed costs for operating a windfarm. All values are assumed to be in $/kW/yr. Parameters ---------- operations : float Non-maintenance costs of operating the project. If a value is provided for this attribute, then it will zero out all other values, otherwise it will be set to the sum of the remaining values. operations_management_administration: float Activities necessary to forecast, dispatch, sell, and manage the production of power from the plant. Includes both the onsite and offsite personnel, software, and equipment to coordinate high voltage equipment, switching, port activities, and marine activities. .. note:: This should only be used when not breaking down the cost into the following categories: ``project_management_administration``, ``operation_management_administration``, ``marine_management``, and/or ``weather_forecasting`` project_management_administration : float Financial reporting, public relations, procurement, parts and stock management, H&SE management, training, subcontracts, and general administration. marine_management : float Coordination of port equipment, vessels, and personnel to carry out inspections and maintenance of generation and transmission equipment. weather_forecasting : float Daily forecast of metocean conditions used to plan maintenance visits and estimate project power production. condition_monitoring : float Monitoring of SCADA data from wind turbine components to optimize performance and identify component faults. operating_facilities : float Co-located offices, parts store, quayside facilities, helipad, refueling facilities, hanger (if necesssary), etc. environmental_health_safety_monitoring : float Coordination and monitoring to ensure compliance with HSE requirements during operations. insurance : float Insurance policies during operational period including All Risk Property, Buisness Interuption, Third Party Liability, and Brokers Fee, and Storm Coverage. .. note:: This should only be used when not breaking down the cost into the following categories: ``brokers_fee``, ``operations_all_risk``, ``business_interruption``, ``third_party_liability``, and/or ``storm_coverage`` brokers_fee : float Fees for arranging the insurance package. operations_all_risk : float All Risk Property (physical damage). Sudden and unforseen physical loss or physical damage to teh plant/assets during the operational phase of a project. business_interruption : float Sudden and unforseen loss or physical damage to the plant/assets during the operational phase of a project causing an interruption. third_party_liability : float Liability imposed by law, and/or Express Contractual Liability, for bodily injury or property damage. storm_coverage : float Coverage from huricane and tropical storm events (tyipcally for Atlantic Coast projects). annual_leases_fees : float Ongoing payments, including but not limited to: payments to regulatory body for permission to operate at project site (terms defined within lease); payments to Transmission Systems Operators or Transmission Asseet Owners for rights to transport generated power. .. note:: This should only be used when not breaking down the cost into the following categories: ``submerge_land_lease_costs`` and/or ``transmission_charges_rights`` submerge_land_lease_costs : float Payments to submerged land owners for rights to build project during operations. transmission_charges_rights : float Any payments to Transmissions Systems Operators or Transmission Asset Owners for rights to transport generated power. onshore_electrical_maintenance : float Inspections of cables, transformer, switch gears, power compensation equipment, etc. and infrequent repairs .. warning:: This should only be used if not modeling these as processes within the model. Currently, onshore modeling is not included. labor : float The costs associated with labor, if not being modeled through the simulated processes. """ # Total Cost operations: float = field(default=0) # Operations, Management, and General Administration operations_management_administration: float = field(default=0) project_management_administration: float = field(default=0) marine_management: float = field(default=0) weather_forecasting: float = field(default=0) condition_monitoring: float = field(default=0) operating_facilities: float = field(default=0) environmental_health_safety_monitoring: float = field(default=0) # Insurance insurance: float = field(default=0) brokers_fee: float = field(default=0) operations_all_risk: float = field(default=0) business_interruption: float = field(default=0) third_party_liability: float = field(default=0) storm_coverage: float = field(default=0) # Annual Leases and Fees annual_leases_fees: float = field(default=0) submerge_land_lease_costs: float = field(default=0) transmission_charges_rights: float = field(default=0) onshore_electrical_maintenance: float = field(default=0) labor: float = field(default=0) resolution: dict = field(init=False) hierarchy: dict = field(init=False) def cost_category_validator(self, name: str, sub_names: list[str]): """Either updates the higher-level cost to be the sum of the category's lower-level costs or uses a supplied higher-level cost and zeros out the lower-level costs. Parameters ---------- name : str The higher-level cost category's name. sub_names : List[str] The lower-level cost names associated with the higher-level category. """ if getattr(self, name) <= 0: object.__setattr__(self, name, fsum(getattr(self, el) for el in sub_names)) else: for cost in sub_names: object.__setattr__(self, cost, 0) def __attrs_post_init__(self): """Post-initialization.""" grouped_names = { "operations_management_administration": [ "project_management_administration", "marine_management", "weather_forecasting", "condition_monitoring", ], "insurance": [ "brokers_fee", "operations_all_risk", "business_interruption", "third_party_liability", "storm_coverage", ], "annual_leases_fees": [ "submerge_land_lease_costs", "transmission_charges_rights", ], } individual_names = [ "operating_facilities", "environmental_health_safety_monitoring", "onshore_electrical_maintenance", "labor", ] # Check each category for aggregation for _name, _sub_names in grouped_names.items(): self.cost_category_validator(_name, _sub_names) # Check for single value aggregation self.cost_category_validator("operations", [*grouped_names] + individual_names) # Create the value resolution mapping object.__setattr__( self, "resolution", { "low": ["operations"], "medium": [ "operations_management_administration", "insurance", "annual_leases_fees", "operating_facilities", "environmental_health_safety_monitoring", "onshore_electrical_maintenance", "labor", ], "high": [ "project_management_administration", "marine_management", "weather_forecasting", "condition_monitoring", "brokers_fee", "operations_all_risk", "business_interruption", "third_party_liability", "storm_coverage", "submerge_land_lease_costs", "transmission_charges_rights", "operating_facilities", "environmental_health_safety_monitoring", "onshore_electrical_maintenance", "labor", ], }, ) object.__setattr__( self, "hierarchy", { "operations": { "operations_management_administration": [ "project_management_administration", "marine_management", "weather_forecasting", "condition_monitoring", ], "insurance": [ "brokers_fee", "operations_all_risk", "business_interruption", "third_party_liability", "storm_coverage", ], "annual_leases_fees": [ "submerge_land_lease_costs", "transmission_charges_rights", ], "operating_facilities": [], "environmental_health_safety_monitoring": [], "onshore_electrical_maintenance": [], "labor": [], } }, )
[docs] @define(auto_attribs=True) class SubString: """A list of the upstream connections for a turbine and its downstream connector. Parameters ---------- downstream : str The downstream turbine/substation connection id. upstream : list[str] A list of the upstream turbine connections. """ downstream: str upstream: list[str]
[docs] @define(auto_attribs=True) class String: """All of the connection information for a complete string in a wind farm. Parameters ---------- start : str The substation's ID (``System.id``) upstream_map : dict[str, SubString] The dictionary of each turbine ID in the string and it's upstream ``SubString``. """ start: str upstream_map: dict[str, SubString]
[docs] @define(auto_attribs=True) class SubstationMap: """A mapping of every ``String`` connected to a substation, excluding export connections to other substations. Parameters ---------- string_starts : list[str] A list of every first turbine's ``System.id`` in a string connected to the substation. string_map : dict[str, String] A dictionary mapping each string starting turbine to its ``String`` data. downstream : str The ``System.id`` of where the export cable leads. This should be the same ``System.id`` as the substation for an interconnection point, or another connecting substation. """ string_starts: list[str] string_map: dict[str, String] downstream: str
[docs] @define(auto_attribs=True) class WindFarmMap: """A list of the upstream connections for a turbine and its downstream connector. Parameters ---------- substation_map : list[str] A dictionary mapping of each substation and its ``SubstationMap``. export_cables : list[tuple[str, str]] A list of the export cable connections. """ substation_map: dict[str, SubstationMap] export_cables: list[tuple[str, str]]
[docs] def get_upstream_connections( self, substation: str, string_start: str, node: str, return_cables: bool = True ) -> list[str] | tuple[list[str], list[str]]: """Retrieve the upstream turbines (and optionally cables) within the wind farm graph. Parameters ---------- substation : str The substation's ``System.id``. string_start : str The ``System.id`` of the first turbine in the string. node : str The ``System.id`` of the ending node for a cable connection. return_cables : bool Indicates if the ``Cable.id`` should be generated for each of the turbines, by default True. Returns ------- list[str] | tuple[list[str], list[str]] A list of ``System.id`` for all of the upstream turbines of ``node`` if ``cables=False``, otherwise the upstream turbine and the ``Cable.id`` lists are returned. """ strings = self.substation_map[substation].string_map upstream = strings[string_start].upstream_map turbines = upstream[node].upstream if return_cables: cables = [ f"cable::{node if i == 0 else turbines[i - 1]}::{t}" for i, t in enumerate(turbines) ] return turbines, cables return turbines
[docs] def get_upstream_connections_from_substation( self, substation: str, return_cables: bool = True, by_string: bool = True ) -> ( list[str] | tuple[list[str], list[str]] | list[list[str]] | tuple[list[list[str]], list[list[str]]] ): """Retrieve the upstream turbines (and optionally, cables) connected to a py:attr:`substation` in the wind farm graph. Parameters ---------- substation : str The py:attr:`System.id` for the substation. return_cables : bool, optional Indicates if the ``Cable.id`` should be generated for each of the turbines, by default True by_string : bool, optional Indicates if the list of turbines (and cables) should be a nested list for each string (py:obj:`True`), or as 1-D list (py:obj:`False`), by default True. Returns ------- list[str] | tuple[list[str], list[str]] A list of ``System.id`` for all of the upstream turbines of ``node`` if ``return_cables=False``, otherwise the upstream turbine and the ``Cable.id`` lists are returned. These are bifurcated in lists of lists for each string if ``by_string=True`` """ turbines = [] cables = [] substation_map = self.substation_map[substation] start_nodes = substation_map.string_starts for start_node in start_nodes: # Add the starting node of the string and substation-turbine array cable _turbines = [start_node] _cables = [f"cable::{substation}::{start_node}"] # Add the main components of the string _t, _c = self.get_upstream_connections(substation, start_node, start_node) _turbines.extend(_t) _cables.extend(_c) turbines.append(_turbines) cables.append(_cables) if not by_string: turbines = [el for string in turbines for el in string] # type: ignore cables = [el for string in cables for el in string] # type: ignore if return_cables: return turbines, cables return turbines