From 57a2b154fbcec9ebd3b4662ca6fa8341646f94ff Mon Sep 17 00:00:00 2001 From: RHammond2 <13874373+RHammond2@users.noreply.github.com> Date: Mon, 3 Mar 2025 10:18:17 -0800 Subject: [PATCH 01/16] adopt hours_to_next_event for maintenance and failures for consistency --- tests/test_data_classes.py | 4 ++-- wombat/core/data_classes.py | 19 +++++++++++++++++-- wombat/windfarm/system/cable.py | 6 +++--- wombat/windfarm/system/subassembly.py | 6 +++--- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/tests/test_data_classes.py b/tests/test_data_classes.py index a9c20cd7..844aa983 100644 --- a/tests/test_data_classes.py +++ b/tests/test_data_classes.py @@ -308,7 +308,7 @@ def test_Failure(): assert cls.system_value == 100000 assert cls.description == "test" # TODO: UPDATE THIS BEFORE PR - # assert cls.hours_to_next_failure() == 1394.372138301769 + # assert cls.hours_to_next_event() == 1394.372138301769 # Test that the default values work inputs_all = { @@ -390,7 +390,7 @@ def test_Failure(): "rng": RNG, } cls = Failure.from_dict(inputs_all) - assert cls.hours_to_next_failure() is None + assert cls.hours_to_next_event() is None # TODO: Write a test for the weibull function diff --git a/wombat/core/data_classes.py b/wombat/core/data_classes.py index 29d34b6f..d1a22729 100644 --- a/wombat/core/data_classes.py +++ b/wombat/core/data_classes.py @@ -411,7 +411,6 @@ 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", @@ -428,6 +427,22 @@ def assign_id(self, request_id: str) -> None: """ object.__setattr__(self, "request_id", request_id) + def hours_to_next_event(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.frequency is None: + return None + + return self.frequency * HOURS_IN_DAY + @define(frozen=True, auto_attribs=True) class Failure(FromDictMixin): @@ -519,7 +534,7 @@ def __attrs_post_init__(self): convert_ratio_to_absolute(self.materials, self.system_value), ) - def hours_to_next_failure(self) -> float | None: + def hours_to_next_event(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. diff --git a/wombat/windfarm/system/cable.py b/wombat/windfarm/system/cable.py index 8a1d8d47..d5c20845 100644 --- a/wombat/windfarm/system/cable.py +++ b/wombat/windfarm/system/cable.py @@ -342,8 +342,8 @@ def run_single_maintenance(self, maintenance: Maintenance) -> Generator: Time between maintenance requests. """ while True: - hours_to_next = maintenance.frequency - if hours_to_next == 0: + hours_to_next = maintenance.hours_to_next_event() + if hours_to_next is None: remainder = self.env.max_run_time - self.env.now try: yield self.env.timeout(remainder) @@ -380,7 +380,7 @@ def run_single_failure(self, failure: Failure) -> Generator: Time between failure events that need to request a repair. """ while True: - hours_to_next = failure.hours_to_next_failure() + hours_to_next = failure.hours_to_next_event() if hours_to_next is None: remainder = self.env.max_run_time - self.env.now try: diff --git a/wombat/windfarm/system/subassembly.py b/wombat/windfarm/system/subassembly.py index b0cb58c9..1a09f0f6 100644 --- a/wombat/windfarm/system/subassembly.py +++ b/wombat/windfarm/system/subassembly.py @@ -182,8 +182,8 @@ def run_single_maintenance(self, maintenance: Maintenance) -> Generator: Time between maintenance requests. """ while True: - hours_to_next = maintenance.frequency - if hours_to_next == 0: + hours_to_next = maintenance.hours_to_next_event() + if hours_to_next is None: remainder = self.env.max_run_time - self.env.now try: yield self.env.timeout(remainder) @@ -225,7 +225,7 @@ def run_single_failure(self, failure: Failure) -> Generator: Time between failure events that need to request a repair. """ while True: - hours_to_next = failure.hours_to_next_failure() + hours_to_next = failure.hours_to_next_event() if hours_to_next is None: remainder = self.env.max_run_time - self.env.now try: From 9f7acc3ff19fb1c4cb79732013f7d4e4602aa9c5 Mon Sep 17 00:00:00 2001 From: RHammond2 <13874373+RHammond2@users.noreply.github.com> Date: Mon, 3 Mar 2025 10:37:46 -0800 Subject: [PATCH 02/16] fix erroneous change and update maintenance tests for changes --- tests/test_data_classes.py | 9 ++++++--- wombat/core/data_classes.py | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/test_data_classes.py b/tests/test_data_classes.py index 844aa983..6da93ffb 100644 --- a/tests/test_data_classes.py +++ b/tests/test_data_classes.py @@ -229,7 +229,8 @@ def test_Maintenance(): assert cls.description == "test" assert cls.time == 14.0 assert cls.materials == 100.0 - assert cls.frequency == 200.0 * 24 + assert cls.frequency == 200.0 + assert cls.hours_to_next_event() == 200.0 * 24 assert cls.service_equipment == ["CTV"] assert cls.operation_reduction == 0.5 assert cls.system_value == 100000.0 @@ -247,7 +248,8 @@ def test_Maintenance(): assert cls.description == class_data["description"].default assert cls.time == 14.0 assert cls.materials == 100.0 - assert cls.frequency == 200.0 * 24 + assert cls.frequency == 200.0 + assert cls.hours_to_next_event() == 200.0 * 24 assert cls.service_equipment == ["CTV"] assert cls.operation_reduction == class_data["operation_reduction"].default assert cls.system_value == 100000.0 @@ -266,7 +268,8 @@ def test_Maintenance(): assert cls.description == "test" assert cls.time == 14.0 assert cls.materials == 25000.0 - assert cls.frequency == 200.0 * 24 + assert cls.frequency == 200.0 + assert cls.hours_to_next_event() == 200.0 * 24 assert cls.service_equipment == ["CTV", "DSV"] assert cls.operation_reduction == 0.5 assert cls.system_value == 100000.0 diff --git a/wombat/core/data_classes.py b/wombat/core/data_classes.py index d1a22729..82bc2beb 100644 --- a/wombat/core/data_classes.py +++ b/wombat/core/data_classes.py @@ -438,7 +438,7 @@ def hours_to_next_event(self) -> float | None: Returns ``None`` for a non-modelled failure, or the time until the next simulated failure. """ - if self.frequency is None: + if self.frequency == 0: return None return self.frequency * HOURS_IN_DAY From 10fb110be7094d6b3e85be5931d7fa0b9e39bb8e Mon Sep 17 00:00:00 2001 From: RHammond2 <13874373+RHammond2@users.noreply.github.com> Date: Mon, 3 Mar 2025 14:09:46 -0800 Subject: [PATCH 03/16] add placeholder support for date-basis and ensure tests still pass --- tests/test_data_classes.py | 8 +- wombat/core/data_classes.py | 129 ++++++++++++++++++++++++-- wombat/windfarm/system/cable.py | 38 +++++++- wombat/windfarm/system/subassembly.py | 18 ++-- 4 files changed, 168 insertions(+), 25 deletions(-) diff --git a/tests/test_data_classes.py b/tests/test_data_classes.py index 6da93ffb..6e94f108 100644 --- a/tests/test_data_classes.py +++ b/tests/test_data_classes.py @@ -230,7 +230,7 @@ def test_Maintenance(): assert cls.time == 14.0 assert cls.materials == 100.0 assert cls.frequency == 200.0 - assert cls.hours_to_next_event() == 200.0 * 24 + assert cls.hours_to_next_event(0, 0) == (200.0 * 24, False) assert cls.service_equipment == ["CTV"] assert cls.operation_reduction == 0.5 assert cls.system_value == 100000.0 @@ -249,7 +249,7 @@ def test_Maintenance(): assert cls.time == 14.0 assert cls.materials == 100.0 assert cls.frequency == 200.0 - assert cls.hours_to_next_event() == 200.0 * 24 + assert cls.hours_to_next_event(0, 0) == (200.0 * 24, False) assert cls.service_equipment == ["CTV"] assert cls.operation_reduction == class_data["operation_reduction"].default assert cls.system_value == 100000.0 @@ -269,7 +269,7 @@ def test_Maintenance(): assert cls.time == 14.0 assert cls.materials == 25000.0 assert cls.frequency == 200.0 - assert cls.hours_to_next_event() == 200.0 * 24 + assert cls.hours_to_next_event(0, 0) == (200.0 * 24, False) assert cls.service_equipment == ["CTV", "DSV"] assert cls.operation_reduction == 0.5 assert cls.system_value == 100000.0 @@ -311,7 +311,7 @@ def test_Failure(): assert cls.system_value == 100000 assert cls.description == "test" # TODO: UPDATE THIS BEFORE PR - # assert cls.hours_to_next_event() == 1394.372138301769 + # assert cls.hours_to_next_event(0, 0) == 1394.372138301769, False # Test that the default values work inputs_all = { diff --git a/wombat/core/data_classes.py b/wombat/core/data_classes.py index 82bc2beb..d9d09350 100644 --- a/wombat/core/data_classes.py +++ b/wombat/core/data_classes.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re import datetime from math import fsum from typing import TYPE_CHECKING, Any, Callable @@ -296,6 +297,59 @@ def validate_0_1_inclusive( ) +def one_of(items: Sequence) -> Callable: + """Validates that the user input is one of :py:attr:`items`. + + Parameters + ---------- + items : Sequence + A list-like of valid values. + """ + + def validator(cls, attribute: attrs.Attribute, value: Any) -> None: + """Validator method that is returned to attrs.""" + if value not in items: + raise ValueError(f"`{attribute.name}` must be one of: {','.join(items)}") + + return validator + + +def convert_frequency(value: int | float | str) -> float | str: + """Converts frequency number to a float or date string to a standard format "MM/DD". + + Parameters + ---------- + value : int | float | str + The number of days or years or date (month and day) of the first occurrence. + + Returns + ------- + float | str + The number of days or years, or date of the first occurence in MM/DD format. + + Raises + ------ + ValueError + Raised if the string isn't composed of a month and day separated by either a "-" + or "/". + ValueError + Raised if :py:attr:`value` is not a number, or a "MM/DD" "MM-DD" formatted date. + """ + if isinstance(value, (int, float)): + return float(value) + + if isinstance(value, str): + split_value = re.split(r"-|/", value) + if len(split_value) != 2: + raise ValueError( + "Date-based values must adhere to either 'MM-DD' or 'MM/DD' format." + ) + return f"{split_value[0]:0>2}/{split_value[1]:0>2}" + raise ValueError( + "Input must be a number days or years, or of 'MM/DD' date formatting." + ) + + @define class FromDictMixin: """A Mixin class to allow for kwargs overloading when a data class doesn't @@ -392,7 +446,7 @@ class Maintenance(FromDictMixin): time: float = field(converter=float) materials: float = field(converter=float) - frequency: float = field(converter=float) + frequency: int | float | str = field(converter=float) service_equipment: list[str] = field( converter=convert_to_list_upper, validator=attrs.validators.deep_iterable( @@ -402,6 +456,20 @@ class Maintenance(FromDictMixin): ) system_value: int | float = field(converter=float) description: str = field(default="routine maintenance", converter=str) + frequency_basis: str = field( + default="days", + converter=(str.strip, str.lower), + validator=one_of( + ( + "days", + "years", + "date-annual", + "date-semiannual", + "date-quarterly", + "date-biannual", + ) + ), + ) operation_reduction: float = field(default=0.0, converter=float) level: int = field(default=0, converter=int) request_id: str = field(init=False) @@ -427,21 +495,64 @@ def assign_id(self, request_id: str) -> None: """ object.__setattr__(self, "request_id", request_id) - def hours_to_next_event(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. + def _hours_to_next_date( + self, now_hours: float, now_date: datetime.datetime + ) -> float: + """Determines the number of hours until the next date in the date-based + frequency sequence. + + Parameters + ---------- + now_hours : float + Corresponds to ``WombatEnvironment.now``. + now_date : float + Corresponds to ``WombatEnvironment.simulation_time``. + + Returns + ------- + float + The number of hours until the next maintenance event. + """ + return 1.0 + + def hours_to_next_event( + self, now_hours: float, now_date: datetime.datetime + ) -> tuple[float | None, float]: + """Calculate the next time the maintenance event should occur, and if downtime + should be discounted. Returns ------- float | None - Returns ``None`` for a non-modelled failure, or the time until the next - simulated failure. + Returns ``None`` for a non-modelled maintenance task, and the number of + hours until the next event otherwise. + bool + False indicates that accrued downtime should not be counted towards the + failure, and True indicates that it should count towards the timing. """ if self.frequency == 0: - return None + return None, False + + if self.frequency_basis == "days": + if TYPE_CHECKING: + assert isinstance(self.frequency, float) + return self.frequency * HOURS_IN_DAY, False + + if self.frequency_basis == "years": + if TYPE_CHECKING: + assert isinstance(self.frequency, float) + return self.frequency * HOURS_IN_YEAR, False - return self.frequency * HOURS_IN_DAY + if TYPE_CHECKING: + assert isinstance(self.frequency, str) + if self.frequency.startswith("date"): + return self._hours_to_next_date(now_hours, now_date), True + + msg = ( + f"Incorrect and uncaught `frequency_basis` ({self.frequency_basis}) or" + f" `frequency` ({self.frequency}) input." + ) + raise ValueError(msg) @define(frozen=True, auto_attribs=True) diff --git a/wombat/windfarm/system/cable.py b/wombat/windfarm/system/cable.py index d5c20845..57aee354 100644 --- a/wombat/windfarm/system/cable.py +++ b/wombat/windfarm/system/cable.py @@ -341,8 +341,33 @@ def run_single_maintenance(self, maintenance: Maintenance) -> Generator: simpy.events.Timeout Time between maintenance requests. """ + # while True: + # hours_to_next = maintenance.hours_to_next_event() + # if hours_to_next is None: + # remainder = self.env.max_run_time - self.env.now + # try: + # yield self.env.timeout(remainder) + # except simpy.Interrupt: + # remainder -= self.env.now + # else: + # while hours_to_next > 0: + # start = -1 # Ensure an interruption before processing is caught + # try: + # # If the replacement has not been completed, then wait + # yield self.servicing & self.downstream_failure & self.broken + + # start = self.env.now + # yield self.env.timeout(hours_to_next) + # hours_to_next = 0 + # self.trigger_request(maintenance) + # except simpy.Interrupt as i: + # if i.cause == "replacement": + # return + # hours_to_next -= 0 if start == -1 else self.env.now - start while True: - hours_to_next = maintenance.hours_to_next_event() + hours_to_next, accrue_downtime = maintenance.hours_to_next_event( + self.env.now, self.env.simulation_time + ) if hours_to_next is None: remainder = self.env.max_run_time - self.env.now try: @@ -353,13 +378,18 @@ def run_single_maintenance(self, maintenance: Maintenance) -> Generator: while hours_to_next > 0: start = -1 # Ensure an interruption before processing is caught try: - # If the replacement has not been completed, then wait - yield self.servicing & self.downstream_failure & self.broken - + # Downtime doesn't accrue for date-based maintenance + if not accrue_downtime: + yield ( + self.system.servicing + & self.downstream_failure + & self.broken + ) start = self.env.now yield self.env.timeout(hours_to_next) hours_to_next = 0 self.trigger_request(maintenance) + except simpy.Interrupt as i: if i.cause == "replacement": return diff --git a/wombat/windfarm/system/subassembly.py b/wombat/windfarm/system/subassembly.py index 1a09f0f6..4fec348f 100644 --- a/wombat/windfarm/system/subassembly.py +++ b/wombat/windfarm/system/subassembly.py @@ -182,7 +182,9 @@ def run_single_maintenance(self, maintenance: Maintenance) -> Generator: Time between maintenance requests. """ while True: - hours_to_next = maintenance.hours_to_next_event() + hours_to_next, accrue_downtime = maintenance.hours_to_next_event( + self.env.now, self.env.simulation_time + ) if hours_to_next is None: remainder = self.env.max_run_time - self.env.now try: @@ -193,13 +195,13 @@ def run_single_maintenance(self, maintenance: Maintenance) -> Generator: while hours_to_next > 0: start = -1 # Ensure an interruption before processing is caught try: - # Wait until these events are triggered and back to operational - yield ( - self.system.servicing - & self.system.cable_failure - & self.broken - ) - + # Downtime doesn't accrue for date-based maintenance + if not accrue_downtime: + yield ( + self.system.servicing + & self.system.cable_failure + & self.broken + ) start = self.env.now yield self.env.timeout(hours_to_next) hours_to_next = 0 From bba2c6ba1f58efc8bc9661f850c17f31022f35b7 Mon Sep 17 00:00:00 2001 From: RHammond2 <13874373+RHammond2@users.noreply.github.com> Date: Mon, 3 Mar 2025 16:59:30 -0800 Subject: [PATCH 04/16] build out the basics for the event date timing functionality --- pyproject.toml | 1 + wombat/core/data_classes.py | 61 ++++++++++++++++++++++++++- wombat/windfarm/system/subassembly.py | 5 +++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3b54c8bb..02d43f60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "types-typed-ast>=1.5", "types-PyYAML>=6", "types-python-dateutil>=2.8", + "python-dateutil", ] keywords = [ "python3", diff --git a/wombat/core/data_classes.py b/wombat/core/data_classes.py index d9d09350..ff4de610 100644 --- a/wombat/core/data_classes.py +++ b/wombat/core/data_classes.py @@ -15,6 +15,7 @@ import numpy as np import pandas as pd from attrs import Factory, Attribute, field, define +from dateutil.relativedelta import relativedelta from wombat.utilities.time import HOURS_IN_DAY, HOURS_IN_YEAR, parse_date @@ -446,7 +447,7 @@ class Maintenance(FromDictMixin): time: float = field(converter=float) materials: float = field(converter=float) - frequency: int | float | str = field(converter=float) + frequency: int | float | str = field(converter=convert_frequency) service_equipment: list[str] = field( converter=convert_to_list_upper, validator=attrs.validators.deep_iterable( @@ -474,6 +475,7 @@ class Maintenance(FromDictMixin): level: int = field(default=0, converter=int) request_id: str = field(init=False) replacement: bool = field(default=False, init=False) + event_dates: list[str] = field(default=Factory(list), init=False) def __attrs_post_init__(self): """Convert frequency to hours (simulation time scale) and the equipment @@ -495,6 +497,63 @@ def assign_id(self, request_id: str) -> None: """ object.__setattr__(self, "request_id", request_id) + def _update_date_based_timing( + self, start: datetime.datetime, end: datetime.datetime + ) -> None: + """Creates the list of dates where a maintenance request should occur. + + Parameters + ---------- + start : datetime.datetime + The starting date and time of the simulation + (``WombatEnvironment.start_datetime``). + end : datetime.datetime + The ending date and time of the simulation + (``WombatEnvironment.start_datetime``). + + Raises + ------ + ValueError + Raised if an invalid ``Maintenance.frequency_basis`` is used. + """ + if self.frequency_basis in ("days", "years"): + return None + + if TYPE_CHECKING: + assert isinstance(self.frequency, str) + basis = self.frequency_basis.replace("date-", "") + start_month, start_day = self.frequency.split("/") + start_dt = datetime.datetime( + year=start.year, month=int(start_month), day=int(start_day) + ) + years = end.year - start.year + 1 + + match basis: + case "biannual": + diff = relativedelta(years=2) + periods = years // 2 + case "annual": + diff = relativedelta(years=1) + periods = years + case "semiannual": + diff = relativedelta(months=6) + periods = (years * 12) // 6 + case "quarterly": + diff = relativedelta(months=3) + periods = (years * 12) // 3 + case _: + raise ValueError(f"Invalid `frequency_basis` for {self.description}.") + print(diff, periods) + + event_dates = [start_dt + diff * i for i in range(periods + 1)] + event_dates = [ + date + for date in event_dates + if start < date <= end and (date - start) > (start + diff - start) + ] + + object.__setattr__(self, "event_dates", event_dates) + def _hours_to_next_date( self, now_hours: float, now_date: datetime.datetime ) -> float: diff --git a/wombat/windfarm/system/subassembly.py b/wombat/windfarm/system/subassembly.py index 4fec348f..9dcdef41 100644 --- a/wombat/windfarm/system/subassembly.py +++ b/wombat/windfarm/system/subassembly.py @@ -67,6 +67,11 @@ def _create_processes(self): Creates a dictionary to keep track of the running processes within the subassembly. """ + for maintenance in self.data.maintenance: + maintenance._update_date_based_timing( + self.env.start_datetime, self.env.end_datetime + ) + for failure in self.data.failures: level = failure.level desc = failure.description From e5fbe4dd09b1eed73ea3172b770c15a7a24cd290 Mon Sep 17 00:00:00 2001 From: RHammond2 <13874373+RHammond2@users.noreply.github.com> Date: Tue, 4 Mar 2025 15:30:03 -0800 Subject: [PATCH 05/16] update frequency to be a relativedelta and unify hours to next --- wombat/core/data_classes.py | 81 +++++++++++++-------------- wombat/windfarm/system/cable.py | 5 +- wombat/windfarm/system/subassembly.py | 2 +- 3 files changed, 43 insertions(+), 45 deletions(-) diff --git a/wombat/core/data_classes.py b/wombat/core/data_classes.py index ff4de610..0372f567 100644 --- a/wombat/core/data_classes.py +++ b/wombat/core/data_classes.py @@ -17,7 +17,7 @@ from attrs import Factory, Attribute, field, define from dateutil.relativedelta import relativedelta -from wombat.utilities.time import HOURS_IN_DAY, HOURS_IN_YEAR, parse_date +from wombat.utilities.time import HOURS_IN_YEAR, parse_date, convert_dt_to_hours if TYPE_CHECKING: @@ -415,7 +415,7 @@ class Maintenance(FromDictMixin): Amount of time required to perform maintenance, in hours. materials : float Cost of materials required to perform maintenance, in USD. - frequency : float + frequency : int | str Optimal number of days between performing maintenance, in days. service_equipment: list[str] | str Any combination of th following ``Equipment.capability`` options. @@ -447,7 +447,7 @@ class Maintenance(FromDictMixin): time: float = field(converter=float) materials: float = field(converter=float) - frequency: int | float | str = field(converter=convert_frequency) + frequency: relativedelta | int | str = field(converter=convert_frequency) service_equipment: list[str] = field( converter=convert_to_list_upper, validator=attrs.validators.deep_iterable( @@ -475,7 +475,7 @@ class Maintenance(FromDictMixin): level: int = field(default=0, converter=int) request_id: str = field(init=False) replacement: bool = field(default=False, init=False) - event_dates: list[str] = field(default=Factory(list), init=False) + event_dates: list[datetime.datetime] = field(default=Factory(list), init=False) def __attrs_post_init__(self): """Convert frequency to hours (simulation time scale) and the equipment @@ -500,7 +500,11 @@ def assign_id(self, request_id: str) -> None: def _update_date_based_timing( self, start: datetime.datetime, end: datetime.datetime ) -> None: - """Creates the list of dates where a maintenance request should occur. + """Creates the list of dates where a maintenance request should occur with a + buffer to ensure events can't occur within the first 60% of the frequency limit, + and so that the first event after the end timing (or at the exact end) is + included to ensure there is no special handling required for calculating the + final timeout. Parameters ---------- @@ -516,7 +520,12 @@ def _update_date_based_timing( ValueError Raised if an invalid ``Maintenance.frequency_basis`` is used. """ - if self.frequency_basis in ("days", "years"): + if self.frequency_basis in ("days", "years", "months"): + if self.frequency == 0: + diff = relativedelta(dt1=end, dt2=start) + relativedelta(years=1) + else: + diff = relativedelta(**{self.frequency_basis: self.frequency}) # type: ignore + object.__setattr__(self, "frequency", diff) return None if TYPE_CHECKING: @@ -543,39 +552,42 @@ def _update_date_based_timing( periods = (years * 12) // 3 case _: raise ValueError(f"Invalid `frequency_basis` for {self.description}.") - print(diff, periods) - event_dates = [start_dt + diff * i for i in range(periods + 1)] - event_dates = [ - date - for date in event_dates - if start < date <= end and (date - start) > (start + diff - start) - ] + _event_dates = [start_dt + diff * i for i in range(periods + 2)] + event_dates = [] + for date in _event_dates: + if (date > start) and date - start > (start + diff - start) * 0.6: + if date >= end: + event_dates.append(date) + break + event_dates.append(date) + object.__setattr__(self, "frequency", diff) object.__setattr__(self, "event_dates", event_dates) - def _hours_to_next_date( - self, now_hours: float, now_date: datetime.datetime - ) -> float: + def _hours_to_next_date(self, now_date: datetime.datetime) -> float | None: """Determines the number of hours until the next date in the date-based frequency sequence. Parameters ---------- - now_hours : float - Corresponds to ``WombatEnvironment.now``. now_date : float Corresponds to ``WombatEnvironment.simulation_time``. Returns ------- - float - The number of hours until the next maintenance event. + float | None + The number of hours until the next maintenance event, or None to ensure + no events are set to occur until the after end of the simulation. """ - return 1.0 + for date in self.event_dates: + if date > now_date: + return convert_dt_to_hours(date - now_date) + + return None def hours_to_next_event( - self, now_hours: float, now_date: datetime.datetime + self, now_date: datetime.datetime ) -> tuple[float | None, float]: """Calculate the next time the maintenance event should occur, and if downtime should be discounted. @@ -589,29 +601,12 @@ def hours_to_next_event( False indicates that accrued downtime should not be counted towards the failure, and True indicates that it should count towards the timing. """ - if self.frequency == 0: - return None, False - - if self.frequency_basis == "days": - if TYPE_CHECKING: - assert isinstance(self.frequency, float) - return self.frequency * HOURS_IN_DAY, False - - if self.frequency_basis == "years": - if TYPE_CHECKING: - assert isinstance(self.frequency, float) - return self.frequency * HOURS_IN_YEAR, False + if self.frequency_basis.startswith("date"): + return self._hours_to_next_date(now_date), True if TYPE_CHECKING: - assert isinstance(self.frequency, str) - if self.frequency.startswith("date"): - return self._hours_to_next_date(now_hours, now_date), True - - msg = ( - f"Incorrect and uncaught `frequency_basis` ({self.frequency_basis}) or" - f" `frequency` ({self.frequency}) input." - ) - raise ValueError(msg) + assert isinstance(self.frequency, relativedelta) + return convert_dt_to_hours(now_date + self.frequency - now_date), False @define(frozen=True, auto_attribs=True) diff --git a/wombat/windfarm/system/cable.py b/wombat/windfarm/system/cable.py index 57aee354..080452d3 100644 --- a/wombat/windfarm/system/cable.py +++ b/wombat/windfarm/system/cable.py @@ -161,6 +161,9 @@ def _create_processes(self): yield desc, self.env.process(self.run_single_failure(failure)) for i, maintenance in enumerate(self.data.maintenance): + maintenance._update_date_based_timing( + self.env.start_datetime, self.env.end_datetime + ) desc = maintenance.description yield desc, self.env.process(self.run_single_maintenance(maintenance)) @@ -366,7 +369,7 @@ def run_single_maintenance(self, maintenance: Maintenance) -> Generator: # hours_to_next -= 0 if start == -1 else self.env.now - start while True: hours_to_next, accrue_downtime = maintenance.hours_to_next_event( - self.env.now, self.env.simulation_time + self.env.simulation_time ) if hours_to_next is None: remainder = self.env.max_run_time - self.env.now diff --git a/wombat/windfarm/system/subassembly.py b/wombat/windfarm/system/subassembly.py index 9dcdef41..bdfe1ebd 100644 --- a/wombat/windfarm/system/subassembly.py +++ b/wombat/windfarm/system/subassembly.py @@ -188,7 +188,7 @@ def run_single_maintenance(self, maintenance: Maintenance) -> Generator: """ while True: hours_to_next, accrue_downtime = maintenance.hours_to_next_event( - self.env.now, self.env.simulation_time + self.env.simulation_time ) if hours_to_next is None: remainder = self.env.max_run_time - self.env.now From 2ef18542b8fa89c8c2a52a1ccc1e2c05f0c58ad0 Mon Sep 17 00:00:00 2001 From: RHammond2 <13874373+RHammond2@users.noreply.github.com> Date: Tue, 4 Mar 2025 16:18:23 -0800 Subject: [PATCH 06/16] fix innerworkings to ensure tests pass --- tests/test_cable.py | 4 +- tests/test_data_classes.py | 19 +++++--- wombat/core/data_classes.py | 28 +++++------ wombat/windfarm/system/cable.py | 68 ++++++++------------------- wombat/windfarm/system/subassembly.py | 2 +- 5 files changed, 48 insertions(+), 73 deletions(-) diff --git a/tests/test_cable.py b/tests/test_cable.py index 549a3c00..3f47d598 100644 --- a/tests/test_cable.py +++ b/tests/test_cable.py @@ -86,9 +86,7 @@ def test_cable_failures(env_setup): # for p in cable.processes.values(): # pprint(p._target.__dict__) assert getattr(list(cable.processes.values())[0]._target, "_delay", None) is None - assert getattr(list(cable.processes.values())[1]._target, "_delay", None) == ( - 1416 - catastrophic_timeout - ) + assert getattr(list(cable.processes.values())[1]._target, "_delay", None) is None # Check the failure was submitted and no other items exist for this cable assert sum(req.system_id == "cable::OSS1::S00T1" for req in manager.items) == 1 diff --git a/tests/test_data_classes.py b/tests/test_data_classes.py index 6e94f108..dff69bf0 100644 --- a/tests/test_data_classes.py +++ b/tests/test_data_classes.py @@ -9,6 +9,7 @@ import numpy as np import pytest import numpy.testing as npt +from dateutil.relativedelta import relativedelta from wombat.core.library import load_yaml from wombat.core.data_classes import ( @@ -215,6 +216,9 @@ class DictClass(FromDictMixin): def test_Maintenance(): """Tests the `Maintenance` class.""" + start = datetime.datetime(2000, 1, 1) + end = datetime.datetime(2001, 1, 1) + max_run_time = (end - start).days * 24 # Test for all inputs being provided and converted inputs_all = { "description": "test", @@ -226,11 +230,12 @@ def test_Maintenance(): "system_value": 100000, } cls = Maintenance.from_dict(inputs_all) + cls._update_date_based_timing(start, end, max_run_time) assert cls.description == "test" assert cls.time == 14.0 assert cls.materials == 100.0 - assert cls.frequency == 200.0 - assert cls.hours_to_next_event(0, 0) == (200.0 * 24, False) + assert cls.frequency == relativedelta(days=200) + assert cls.hours_to_next_event(start) == (200.0 * 24, False) assert cls.service_equipment == ["CTV"] assert cls.operation_reduction == 0.5 assert cls.system_value == 100000.0 @@ -244,12 +249,13 @@ def test_Maintenance(): "system_value": 100000, } cls = Maintenance.from_dict(inputs_defaults) + cls._update_date_based_timing(start, end, max_run_time) class_data = attr.fields_dict(Maintenance) assert cls.description == class_data["description"].default assert cls.time == 14.0 assert cls.materials == 100.0 - assert cls.frequency == 200.0 - assert cls.hours_to_next_event(0, 0) == (200.0 * 24, False) + assert cls.frequency == relativedelta(days=200) + assert cls.hours_to_next_event(start) == (200.0 * 24, False) assert cls.service_equipment == ["CTV"] assert cls.operation_reduction == class_data["operation_reduction"].default assert cls.system_value == 100000.0 @@ -265,11 +271,12 @@ def test_Maintenance(): "system_value": 100000, } cls = Maintenance.from_dict(inputs_system_value) + cls._update_date_based_timing(start, end, max_run_time) assert cls.description == "test" assert cls.time == 14.0 assert cls.materials == 25000.0 - assert cls.frequency == 200.0 - assert cls.hours_to_next_event(0, 0) == (200.0 * 24, False) + assert cls.frequency == relativedelta(days=200) + assert cls.hours_to_next_event(start) == (200.0 * 24, False) assert cls.service_equipment == ["CTV", "DSV"] assert cls.operation_reduction == 0.5 assert cls.system_value == 100000.0 diff --git a/wombat/core/data_classes.py b/wombat/core/data_classes.py index 0372f567..858e2442 100644 --- a/wombat/core/data_classes.py +++ b/wombat/core/data_classes.py @@ -498,7 +498,7 @@ def assign_id(self, request_id: str) -> None: object.__setattr__(self, "request_id", request_id) def _update_date_based_timing( - self, start: datetime.datetime, end: datetime.datetime + self, start: datetime.datetime, end: datetime.datetime, max_run_time: int ) -> None: """Creates the list of dates where a maintenance request should occur with a buffer to ensure events can't occur within the first 60% of the frequency limit, @@ -513,7 +513,7 @@ def _update_date_based_timing( (``WombatEnvironment.start_datetime``). end : datetime.datetime The ending date and time of the simulation - (``WombatEnvironment.start_datetime``). + (``WombatEnvironment.end_datetime``). Raises ------ @@ -522,7 +522,7 @@ def _update_date_based_timing( """ if self.frequency_basis in ("days", "years", "months"): if self.frequency == 0: - diff = relativedelta(dt1=end, dt2=start) + relativedelta(years=1) + diff = relativedelta(hours=max_run_time) else: diff = relativedelta(**{self.frequency_basis: self.frequency}) # type: ignore object.__setattr__(self, "frequency", diff) @@ -565,7 +565,7 @@ def _update_date_based_timing( object.__setattr__(self, "frequency", diff) object.__setattr__(self, "event_dates", event_dates) - def _hours_to_next_date(self, now_date: datetime.datetime) -> float | None: + def _hours_to_next_date(self, now_date: datetime.datetime) -> float: """Determines the number of hours until the next date in the date-based frequency sequence. @@ -576,27 +576,27 @@ def _hours_to_next_date(self, now_date: datetime.datetime) -> float | None: Returns ------- - float | None - The number of hours until the next maintenance event, or None to ensure - no events are set to occur until the after end of the simulation. + float + The number of hours until the next maintenance event. + + Raises + ------ + ValueError """ for date in self.event_dates: if date > now_date: return convert_dt_to_hours(date - now_date) - return None + raise RuntimeError("Setup did not produce an extra event for safety.") - def hours_to_next_event( - self, now_date: datetime.datetime - ) -> tuple[float | None, float]: + def hours_to_next_event(self, now_date: datetime.datetime) -> tuple[float, bool]: """Calculate the next time the maintenance event should occur, and if downtime should be discounted. Returns ------- - float | None - Returns ``None`` for a non-modelled maintenance task, and the number of - hours until the next event otherwise. + float + Returns the number of hours until the next event. bool False indicates that accrued downtime should not be counted towards the failure, and True indicates that it should count towards the timing. diff --git a/wombat/windfarm/system/cable.py b/wombat/windfarm/system/cable.py index 080452d3..e3e1ec64 100644 --- a/wombat/windfarm/system/cable.py +++ b/wombat/windfarm/system/cable.py @@ -162,7 +162,7 @@ def _create_processes(self): for i, maintenance in enumerate(self.data.maintenance): maintenance._update_date_based_timing( - self.env.start_datetime, self.env.end_datetime + self.env.start_datetime, self.env.end_datetime, self.env.max_run_time ) desc = maintenance.description yield desc, self.env.process(self.run_single_maintenance(maintenance)) @@ -344,59 +344,29 @@ def run_single_maintenance(self, maintenance: Maintenance) -> Generator: simpy.events.Timeout Time between maintenance requests. """ - # while True: - # hours_to_next = maintenance.hours_to_next_event() - # if hours_to_next is None: - # remainder = self.env.max_run_time - self.env.now - # try: - # yield self.env.timeout(remainder) - # except simpy.Interrupt: - # remainder -= self.env.now - # else: - # while hours_to_next > 0: - # start = -1 # Ensure an interruption before processing is caught - # try: - # # If the replacement has not been completed, then wait - # yield self.servicing & self.downstream_failure & self.broken - - # start = self.env.now - # yield self.env.timeout(hours_to_next) - # hours_to_next = 0 - # self.trigger_request(maintenance) - # except simpy.Interrupt as i: - # if i.cause == "replacement": - # return - # hours_to_next -= 0 if start == -1 else self.env.now - start while True: hours_to_next, accrue_downtime = maintenance.hours_to_next_event( self.env.simulation_time ) - if hours_to_next is None: - remainder = self.env.max_run_time - self.env.now + while hours_to_next > 0: + start = -1 # Ensure an interruption before processing is caught try: - yield self.env.timeout(remainder) - except simpy.Interrupt: - remainder -= self.env.now - else: - while hours_to_next > 0: - start = -1 # Ensure an interruption before processing is caught - try: - # Downtime doesn't accrue for date-based maintenance - if not accrue_downtime: - yield ( - self.system.servicing - & self.downstream_failure - & self.broken - ) - start = self.env.now - yield self.env.timeout(hours_to_next) - hours_to_next = 0 - self.trigger_request(maintenance) - - except simpy.Interrupt as i: - if i.cause == "replacement": - return - hours_to_next -= 0 if start == -1 else self.env.now - start + # Downtime doesn't accrue for date-based maintenance + if not accrue_downtime: + yield ( + self.system.servicing + & self.downstream_failure + & self.broken + ) + start = self.env.now + yield self.env.timeout(hours_to_next) + hours_to_next = 0 + self.trigger_request(maintenance) + + except simpy.Interrupt as i: + if i.cause == "replacement": + return + hours_to_next -= 0 if start == -1 else self.env.now - start def run_single_failure(self, failure: Failure) -> Generator: """Runs a process to trigger one type of failure repair request throughout the diff --git a/wombat/windfarm/system/subassembly.py b/wombat/windfarm/system/subassembly.py index bdfe1ebd..1a64a9a1 100644 --- a/wombat/windfarm/system/subassembly.py +++ b/wombat/windfarm/system/subassembly.py @@ -69,7 +69,7 @@ def _create_processes(self): """ for maintenance in self.data.maintenance: maintenance._update_date_based_timing( - self.env.start_datetime, self.env.end_datetime + self.env.start_datetime, self.env.end_datetime, self.env.max_run_time ) for failure in self.data.failures: From c18dc37a80194b61aeee5a1dff0ed1e43b2dabc3 Mon Sep 17 00:00:00 2001 From: RHammond2 <13874373+RHammond2@users.noreply.github.com> Date: Tue, 4 Mar 2025 16:19:34 -0800 Subject: [PATCH 07/16] remove extra handling in subassembly maintenance --- wombat/windfarm/system/subassembly.py | 43 +++++++++++---------------- 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/wombat/windfarm/system/subassembly.py b/wombat/windfarm/system/subassembly.py index 1a64a9a1..6a87f8d9 100644 --- a/wombat/windfarm/system/subassembly.py +++ b/wombat/windfarm/system/subassembly.py @@ -190,32 +190,25 @@ def run_single_maintenance(self, maintenance: Maintenance) -> Generator: hours_to_next, accrue_downtime = maintenance.hours_to_next_event( self.env.simulation_time ) - if hours_to_next is None: - remainder = self.env.max_run_time - self.env.now + while hours_to_next > 0: + start = -1 # Ensure an interruption before processing is caught try: - yield self.env.timeout(remainder) - except simpy.Interrupt: - remainder -= self.env.now - else: - while hours_to_next > 0: - start = -1 # Ensure an interruption before processing is caught - try: - # Downtime doesn't accrue for date-based maintenance - if not accrue_downtime: - yield ( - self.system.servicing - & self.system.cable_failure - & self.broken - ) - start = self.env.now - yield self.env.timeout(hours_to_next) - hours_to_next = 0 - self.trigger_request(maintenance) - - except simpy.Interrupt as i: - if i.cause == "replacement": - return - hours_to_next -= 0 if start == -1 else self.env.now - start + # Downtime doesn't accrue for date-based maintenance + if not accrue_downtime: + yield ( + self.system.servicing + & self.system.cable_failure + & self.broken + ) + start = self.env.now + yield self.env.timeout(hours_to_next) + hours_to_next = 0 + self.trigger_request(maintenance) + + except simpy.Interrupt as i: + if i.cause == "replacement": + return + hours_to_next -= 0 if start == -1 else self.env.now - start def run_single_failure(self, failure: Failure) -> Generator: """Runs a process to trigger one type of failure repair request throughout the From c40070b07b38dbab5564c087317b08607f410a6e Mon Sep 17 00:00:00 2001 From: RHammond2 <13874373+RHammond2@users.noreply.github.com> Date: Tue, 4 Mar 2025 16:26:24 -0800 Subject: [PATCH 08/16] add support for months input --- wombat/core/data_classes.py | 1 + 1 file changed, 1 insertion(+) diff --git a/wombat/core/data_classes.py b/wombat/core/data_classes.py index 858e2442..260bb9db 100644 --- a/wombat/core/data_classes.py +++ b/wombat/core/data_classes.py @@ -464,6 +464,7 @@ class Maintenance(FromDictMixin): ( "days", "years", + "months", "date-annual", "date-semiannual", "date-quarterly", From 6bc6535386f8e0d54c396021608404a05f2c5d49 Mon Sep 17 00:00:00 2001 From: RHammond2 <13874373+RHammond2@users.noreply.github.com> Date: Wed, 5 Mar 2025 11:19:18 -0800 Subject: [PATCH 09/16] separate maintenance tests and increase robustness of frequency --- tests/test_data_classes.py | 94 ++++++++++++++++++++++++++++++++--- wombat/core/data_classes.py | 99 ++++++++++++++++--------------------- 2 files changed, 130 insertions(+), 63 deletions(-) diff --git a/tests/test_data_classes.py b/tests/test_data_classes.py index dff69bf0..102d5300 100644 --- a/tests/test_data_classes.py +++ b/tests/test_data_classes.py @@ -214,12 +214,11 @@ class DictClass(FromDictMixin): DictClass.from_dict({}) -def test_Maintenance(): - """Tests the `Maintenance` class.""" +def test_Maintenance_conversion(): + """Tests the `Maintenance` class for all inputs being provided and converted.""" start = datetime.datetime(2000, 1, 1) end = datetime.datetime(2001, 1, 1) max_run_time = (end - start).days * 24 - # Test for all inputs being provided and converted inputs_all = { "description": "test", "time": 14, @@ -235,12 +234,71 @@ def test_Maintenance(): assert cls.time == 14.0 assert cls.materials == 100.0 assert cls.frequency == relativedelta(days=200) + assert cls.frequency_basis == "days" assert cls.hours_to_next_event(start) == (200.0 * 24, False) assert cls.service_equipment == ["CTV"] assert cls.operation_reduction == 0.5 assert cls.system_value == 100000.0 - # Test for default values being populated + +def test_Maintenance_frequency(): + """Tests the `Maintenance` class ``frequency`` and ``frequency_basis`` handling.""" + start = datetime.datetime(2000, 1, 1) + end = datetime.datetime(2001, 1, 1) + max_run_time = (end - start).days * 24 + + # Test maximum time limit replaces a 0-valued input + inputs_all = { + "description": "test", + "time": 14, + "materials": 100, + "frequency": 0, + "frequency_basis": "days", + "service_equipment": "ctv", + "operation_reduction": 0.5, + "system_value": 100000, + } + cls = Maintenance.from_dict(inputs_all) + cls._update_date_based_timing(start, end, max_run_time) + assert cls.frequency == relativedelta(days=(end - start).days) + assert cls.frequency_basis == "days" + + # Test the conversion for standard, non-date inputs + start = datetime.datetime(2000, 2, 1) + end = datetime.datetime(2001, 1, 1) + max_run_time = (end - start).days * 24 + + inputs_all["frequency"] = 3 / 1 + inputs_all["frequency_basis"] = "date-monthly" + cls = Maintenance.from_dict(inputs_all) + cls._update_date_based_timing(start, end, max_run_time) + assert cls.frequency == relativedelta(months=3) + + inputs_all["frequency"] = 4 + inputs_all["frequency_basis"] = "years" + cls = Maintenance.from_dict(inputs_all) + cls._update_date_based_timing(start, end, max_run_time) + assert cls.frequency == relativedelta(years=4) + + # Test the conversion for date-based inputs + inputs_all["frequency"] = 3 + inputs_all["frequency_basis"] = "months" + cls = Maintenance.from_dict(inputs_all) + cls._update_date_based_timing(start, end, max_run_time) + assert cls.frequency == relativedelta(months=3) + + inputs_all["frequency"] = 4 + inputs_all["frequency_basis"] = "years" + cls = Maintenance.from_dict(inputs_all) + cls._update_date_based_timing(start, end, max_run_time) + assert cls.frequency == relativedelta(years=4) + + +def test_Maintenance_defaults(): + """Tests the `Maintenance` class for default values being populated.""" + start = datetime.datetime(2000, 1, 1) + end = datetime.datetime(2001, 1, 1) + max_run_time = (end - start).days * 24 inputs_defaults = { "time": 14, "materials": 100, @@ -255,12 +313,20 @@ def test_Maintenance(): assert cls.time == 14.0 assert cls.materials == 100.0 assert cls.frequency == relativedelta(days=200) + assert cls.frequency_basis == "days" assert cls.hours_to_next_event(start) == (200.0 * 24, False) assert cls.service_equipment == ["CTV"] assert cls.operation_reduction == class_data["operation_reduction"].default assert cls.system_value == 100000.0 - # Test for proportional materials cost, relative to system value + +def test_Maintenance_proportional_materials(): + """Tests the `Maintenance` classfor proportional materials cost, relative to system + value. + """ + start = datetime.datetime(2000, 1, 1) + end = datetime.datetime(2001, 1, 1) + max_run_time = (end - start).days * 24 inputs_system_value = { "description": "test", "time": 14, @@ -281,7 +347,23 @@ def test_Maintenance(): assert cls.operation_reduction == 0.5 assert cls.system_value == 100000.0 - # Test for assign_id + +def test_Maintenance_assign_id(): + """Tests the `Maintenance` class.""" + start = datetime.datetime(2000, 1, 1) + end = datetime.datetime(2001, 1, 1) + max_run_time = (end - start).days * 24 + inputs_system_value = { + "description": "test", + "time": 14, + "materials": 0.25, + "frequency": 200, + "service_equipment": ["ctv", "dsv"], + "operation_reduction": 0.5, + "system_value": 100000, + } + cls = Maintenance.from_dict(inputs_system_value) + cls._update_date_based_timing(start, end, max_run_time) correct_id = "M00001" cls.assign_id(request_id=correct_id) assert cls.request_id == correct_id diff --git a/wombat/core/data_classes.py b/wombat/core/data_classes.py index 260bb9db..c69dd2e8 100644 --- a/wombat/core/data_classes.py +++ b/wombat/core/data_classes.py @@ -2,8 +2,8 @@ from __future__ import annotations -import re import datetime +from copy import deepcopy from math import fsum from typing import TYPE_CHECKING, Any, Callable from pathlib import Path @@ -14,7 +14,7 @@ import attrs import numpy as np import pandas as pd -from attrs import Factory, Attribute, field, define +from attrs import Factory, Attribute, field, define, validators from dateutil.relativedelta import relativedelta from wombat.utilities.time import HOURS_IN_YEAR, parse_date, convert_dt_to_hours @@ -315,40 +315,31 @@ def validator(cls, attribute: attrs.Attribute, value: Any) -> None: return validator -def convert_frequency(value: int | float | str) -> float | str: - """Converts frequency number to a float or date string to a standard format "MM/DD". +def to_datetime(value: str | datetime.datetime) -> datetime.datetime: + """Converts a date string to a Python ``datetime`` object. Parameters ---------- - value : int | float | str + value : str | datetime.datetime The number of days or years or date (month and day) of the first occurrence. Returns ------- - float | str - The number of days or years, or date of the first occurence in MM/DD format. + datetime.datetime + The datetime object corresponding to the input string. Raises ------ ValueError - Raised if the string isn't composed of a month and day separated by either a "-" - or "/". - ValueError - Raised if :py:attr:`value` is not a number, or a "MM/DD" "MM-DD" formatted date. + Raised if a string or datetime object is not passed. """ - if isinstance(value, (int, float)): - return float(value) + if isinstance(value, datetime.datetime): + return value if isinstance(value, str): - split_value = re.split(r"-|/", value) - if len(split_value) != 2: - raise ValueError( - "Date-based values must adhere to either 'MM-DD' or 'MM/DD' format." - ) - return f"{split_value[0]:0>2}/{split_value[1]:0>2}" - raise ValueError( - "Input must be a number days or years, or of 'MM/DD' date formatting." - ) + return pd.to_datetime(value).to_pydatetime() + + raise ValueError("Input must be a datetime or string.") @define @@ -447,7 +438,7 @@ class Maintenance(FromDictMixin): time: float = field(converter=float) materials: float = field(converter=float) - frequency: relativedelta | int | str = field(converter=convert_frequency) + frequency: relativedelta | int = field(converter=int) service_equipment: list[str] = field( converter=convert_to_list_upper, validator=attrs.validators.deep_iterable( @@ -463,15 +454,19 @@ class Maintenance(FromDictMixin): validator=one_of( ( "days", - "years", "months", - "date-annual", - "date-semiannual", - "date-quarterly", - "date-biannual", + "years", + "date-days", + "date-months", + "date-years", ) ), ) + start_date: datetime.datetime = field( + default="01/01/1990", + converter=to_datetime, + validator=validators.instance_of(datetime.datetime), + ) operation_reduction: float = field(default=0.0, converter=float) level: int = field(default=0, converter=int) request_id: str = field(init=False) @@ -498,7 +493,7 @@ def assign_id(self, request_id: str) -> None: """ object.__setattr__(self, "request_id", request_id) - def _update_date_based_timing( + def _update_event_timing( self, start: datetime.datetime, end: datetime.datetime, max_run_time: int ) -> None: """Creates the list of dates where a maintenance request should occur with a @@ -515,49 +510,39 @@ def _update_date_based_timing( end : datetime.datetime The ending date and time of the simulation (``WombatEnvironment.end_datetime``). + max_run_time : datetime.datetime + The maximum run time of the simulation, in hours + (``WombatEnvironment.max_run_time``). Raises ------ ValueError Raised if an invalid ``Maintenance.frequency_basis`` is used. """ - if self.frequency_basis in ("days", "years", "months"): - if self.frequency == 0: - diff = relativedelta(hours=max_run_time) - else: - diff = relativedelta(**{self.frequency_basis: self.frequency}) # type: ignore - object.__setattr__(self, "frequency", diff) - return None + n_frequency = deepcopy(self.frequency) + if self.frequency == 0: + diff = relativedelta(hours=max_run_time) - if TYPE_CHECKING: - assert isinstance(self.frequency, str) basis = self.frequency_basis.replace("date-", "") - start_month, start_day = self.frequency.split("/") - start_dt = datetime.datetime( - year=start.year, month=int(start_month), day=int(start_day) - ) - years = end.year - start.year + 1 + diff = relativedelta(**{basis: self.frequency}) # type: ignore + years = end.year - min(self.start_date.year, start.year) + 1 + if TYPE_CHECKING: + assert isinstance(n_frequency, int) match basis: - case "biannual": - diff = relativedelta(years=2) - periods = years // 2 - case "annual": - diff = relativedelta(years=1) - periods = years - case "semiannual": - diff = relativedelta(months=6) - periods = (years * 12) // 6 - case "quarterly": - diff = relativedelta(months=3) - periods = (years * 12) // 3 + case "days": + periods = (years * 365) // n_frequency + case "months": + periods = (years * 12) // n_frequency + case "years": + periods = years // n_frequency case _: raise ValueError(f"Invalid `frequency_basis` for {self.description}.") - _event_dates = [start_dt + diff * i for i in range(periods + 2)] + _event_dates = [self.start_date + diff * i for i in range(periods + 2)] event_dates = [] for date in _event_dates: - if (date > start) and date - start > (start + diff - start) * 0.6: + if date > start: if date >= end: event_dates.append(date) break From 1b6fe12c44f4aebfc4b5f612f1fe4d72fd876c18 Mon Sep 17 00:00:00 2001 From: RHammond2 <13874373+RHammond2@users.noreply.github.com> Date: Wed, 5 Mar 2025 13:14:08 -0800 Subject: [PATCH 10/16] add support for starting date and fix tests --- tests/test_cable.py | 5 ++++- tests/test_data_classes.py | 28 +++++++++++++-------------- wombat/core/data_classes.py | 11 +++++++++-- wombat/windfarm/system/cable.py | 2 +- wombat/windfarm/system/subassembly.py | 2 +- 5 files changed, 29 insertions(+), 19 deletions(-) diff --git a/tests/test_cable.py b/tests/test_cable.py index 3f47d598..f803a00c 100644 --- a/tests/test_cable.py +++ b/tests/test_cable.py @@ -86,7 +86,10 @@ def test_cable_failures(env_setup): # for p in cable.processes.values(): # pprint(p._target.__dict__) assert getattr(list(cable.processes.values())[0]._target, "_delay", None) is None - assert getattr(list(cable.processes.values())[1]._target, "_delay", None) is None + assert ( + getattr(list(cable.processes.values())[1]._target, "_delay", None) + == env.max_run_time - catastrophic_timeout + ) # Check the failure was submitted and no other items exist for this cable assert sum(req.system_id == "cable::OSS1::S00T1" for req in manager.items) == 1 diff --git a/tests/test_data_classes.py b/tests/test_data_classes.py index 102d5300..b8b6f4c4 100644 --- a/tests/test_data_classes.py +++ b/tests/test_data_classes.py @@ -229,7 +229,7 @@ def test_Maintenance_conversion(): "system_value": 100000, } cls = Maintenance.from_dict(inputs_all) - cls._update_date_based_timing(start, end, max_run_time) + cls._update_event_timing(start, end, max_run_time) assert cls.description == "test" assert cls.time == 14.0 assert cls.materials == 100.0 @@ -259,38 +259,38 @@ def test_Maintenance_frequency(): "system_value": 100000, } cls = Maintenance.from_dict(inputs_all) - cls._update_date_based_timing(start, end, max_run_time) + cls._update_event_timing(start, end, max_run_time) assert cls.frequency == relativedelta(days=(end - start).days) - assert cls.frequency_basis == "days" + assert cls.frequency_basis == "date-hours" # Test the conversion for standard, non-date inputs start = datetime.datetime(2000, 2, 1) end = datetime.datetime(2001, 1, 1) max_run_time = (end - start).days * 24 - inputs_all["frequency"] = 3 / 1 - inputs_all["frequency_basis"] = "date-monthly" + inputs_all["frequency"] = 3 + inputs_all["frequency_basis"] = "months" cls = Maintenance.from_dict(inputs_all) - cls._update_date_based_timing(start, end, max_run_time) + cls._update_event_timing(start, end, max_run_time) assert cls.frequency == relativedelta(months=3) inputs_all["frequency"] = 4 inputs_all["frequency_basis"] = "years" cls = Maintenance.from_dict(inputs_all) - cls._update_date_based_timing(start, end, max_run_time) + cls._update_event_timing(start, end, max_run_time) assert cls.frequency == relativedelta(years=4) # Test the conversion for date-based inputs inputs_all["frequency"] = 3 - inputs_all["frequency_basis"] = "months" + inputs_all["frequency_basis"] = "date-months" cls = Maintenance.from_dict(inputs_all) - cls._update_date_based_timing(start, end, max_run_time) + cls._update_event_timing(start, end, max_run_time) assert cls.frequency == relativedelta(months=3) inputs_all["frequency"] = 4 - inputs_all["frequency_basis"] = "years" + inputs_all["frequency_basis"] = "date-years" cls = Maintenance.from_dict(inputs_all) - cls._update_date_based_timing(start, end, max_run_time) + cls._update_event_timing(start, end, max_run_time) assert cls.frequency == relativedelta(years=4) @@ -307,7 +307,7 @@ def test_Maintenance_defaults(): "system_value": 100000, } cls = Maintenance.from_dict(inputs_defaults) - cls._update_date_based_timing(start, end, max_run_time) + cls._update_event_timing(start, end, max_run_time) class_data = attr.fields_dict(Maintenance) assert cls.description == class_data["description"].default assert cls.time == 14.0 @@ -337,7 +337,7 @@ def test_Maintenance_proportional_materials(): "system_value": 100000, } cls = Maintenance.from_dict(inputs_system_value) - cls._update_date_based_timing(start, end, max_run_time) + cls._update_event_timing(start, end, max_run_time) assert cls.description == "test" assert cls.time == 14.0 assert cls.materials == 25000.0 @@ -363,7 +363,7 @@ def test_Maintenance_assign_id(): "system_value": 100000, } cls = Maintenance.from_dict(inputs_system_value) - cls._update_date_based_timing(start, end, max_run_time) + cls._update_event_timing(start, end, max_run_time) correct_id = "M00001" cls.assign_id(request_id=correct_id) assert cls.request_id == correct_id diff --git a/wombat/core/data_classes.py b/wombat/core/data_classes.py index c69dd2e8..1f2e36b0 100644 --- a/wombat/core/data_classes.py +++ b/wombat/core/data_classes.py @@ -519,12 +519,19 @@ def _update_event_timing( ValueError Raised if an invalid ``Maintenance.frequency_basis`` is used. """ + # For events that should not occur, overwrite the user-passed timing settings + # to ensure there is a single event scheduled for just after the simulation end n_frequency = deepcopy(self.frequency) if self.frequency == 0: + basis = "days" + n_frequency = 1 diff = relativedelta(hours=max_run_time) + object.__setattr__(self, "frequency_basis", "date-hours") + object.__setattr__(self, "start_date", start) + else: + basis = self.frequency_basis.replace("date-", "") + diff = relativedelta(**{basis: self.frequency}) # type: ignore - basis = self.frequency_basis.replace("date-", "") - diff = relativedelta(**{basis: self.frequency}) # type: ignore years = end.year - min(self.start_date.year, start.year) + 1 if TYPE_CHECKING: diff --git a/wombat/windfarm/system/cable.py b/wombat/windfarm/system/cable.py index e3e1ec64..7dfcc989 100644 --- a/wombat/windfarm/system/cable.py +++ b/wombat/windfarm/system/cable.py @@ -161,7 +161,7 @@ def _create_processes(self): yield desc, self.env.process(self.run_single_failure(failure)) for i, maintenance in enumerate(self.data.maintenance): - maintenance._update_date_based_timing( + maintenance._update_event_timing( self.env.start_datetime, self.env.end_datetime, self.env.max_run_time ) desc = maintenance.description diff --git a/wombat/windfarm/system/subassembly.py b/wombat/windfarm/system/subassembly.py index 6a87f8d9..1b002a86 100644 --- a/wombat/windfarm/system/subassembly.py +++ b/wombat/windfarm/system/subassembly.py @@ -68,7 +68,7 @@ def _create_processes(self): subassembly. """ for maintenance in self.data.maintenance: - maintenance._update_date_based_timing( + maintenance._update_event_timing( self.env.start_datetime, self.env.end_datetime, self.env.max_run_time ) From c2245b0a2904a41768c83151513e8d4afe142fc5 Mon Sep 17 00:00:00 2001 From: RHammond2 <13874373+RHammond2@users.noreply.github.com> Date: Wed, 5 Mar 2025 15:04:18 -0800 Subject: [PATCH 11/16] fix control flow issues for no inputs and increase test robustness --- tests/test_data_classes.py | 89 +++++++++++++++++++++++++++++++++++++ wombat/core/data_classes.py | 37 ++++++++------- 2 files changed, 111 insertions(+), 15 deletions(-) diff --git a/tests/test_data_classes.py b/tests/test_data_classes.py index b8b6f4c4..0c0ab990 100644 --- a/tests/test_data_classes.py +++ b/tests/test_data_classes.py @@ -12,6 +12,7 @@ from dateutil.relativedelta import relativedelta from wombat.core.library import load_yaml +from wombat.utilities.time import convert_dt_to_hours from wombat.core.data_classes import ( Failure, FixedCosts, @@ -224,6 +225,8 @@ def test_Maintenance_conversion(): "time": 14, "materials": 100, "frequency": 200, + "frequency_basis": "days", + "start_date": "02-01-2000", "service_equipment": "ctv", "operation_reduction": 0.5, "system_value": 100000, @@ -235,6 +238,7 @@ def test_Maintenance_conversion(): assert cls.materials == 100.0 assert cls.frequency == relativedelta(days=200) assert cls.frequency_basis == "days" + assert cls.start_date == datetime.datetime(2000, 2, 1) assert cls.hours_to_next_event(start) == (200.0 * 24, False) assert cls.service_equipment == ["CTV"] assert cls.operation_reduction == 0.5 @@ -270,28 +274,83 @@ def test_Maintenance_frequency(): inputs_all["frequency"] = 3 inputs_all["frequency_basis"] = "months" + inputs_all["start_date"] = "01/01/2000" cls = Maintenance.from_dict(inputs_all) cls._update_event_timing(start, end, max_run_time) assert cls.frequency == relativedelta(months=3) + assert cls.frequency_basis == "months" + assert cls.start_date == datetime.datetime(2000, 1, 1) + assert cls.event_dates == [] inputs_all["frequency"] = 4 inputs_all["frequency_basis"] = "years" + inputs_all["start_date"] = None cls = Maintenance.from_dict(inputs_all) cls._update_event_timing(start, end, max_run_time) assert cls.frequency == relativedelta(years=4) + current_dt = datetime.datetime(2002, 2, 1) + expected_dt = datetime.datetime(2006, 2, 1) + expected_hours = convert_dt_to_hours(expected_dt - current_dt) + assert (expected_hours, False) == cls.hours_to_next_event(current_dt) # Test the conversion for date-based inputs inputs_all["frequency"] = 3 inputs_all["frequency_basis"] = "date-months" + inputs_all["start_date"] = "01/01/2000" cls = Maintenance.from_dict(inputs_all) cls._update_event_timing(start, end, max_run_time) assert cls.frequency == relativedelta(months=3) + assert cls.event_dates == [ + datetime.datetime(2000, 4, 1), + datetime.datetime(2000, 7, 1), + datetime.datetime(2000, 10, 1), + datetime.datetime(2001, 1, 1), + ] + current_dt = datetime.datetime(2000, 12, 1) + expected_dt = datetime.datetime(2001, 1, 1) + expected_hours = convert_dt_to_hours(expected_dt - current_dt) + assert (expected_hours, True) == cls.hours_to_next_event(current_dt) + start = datetime.datetime(2000, 2, 1) + end = datetime.datetime(2006, 1, 1) + max_run_time = (end - start).days * 24 inputs_all["frequency"] = 4 inputs_all["frequency_basis"] = "date-years" + inputs_all["start_date"] = None + cls = Maintenance.from_dict(inputs_all) + cls._update_event_timing(start, end, max_run_time) + assert cls.frequency == relativedelta(years=4) + assert cls.event_dates == [ + datetime.datetime(2004, 2, 1), + datetime.datetime(2008, 2, 1), + ] + current_dt = start + expected_dt = datetime.datetime(2004, 2, 1) + expected_hours = convert_dt_to_hours(expected_dt - current_dt) + assert (expected_hours, True) == cls.hours_to_next_event(current_dt) + + inputs_all["start_date"] = "06-30-2002" cls = Maintenance.from_dict(inputs_all) cls._update_event_timing(start, end, max_run_time) assert cls.frequency == relativedelta(years=4) + assert cls.event_dates == [ + datetime.datetime(2002, 6, 30), + datetime.datetime(2006, 6, 30), + ] + + start = datetime.datetime(2000, 1, 1) + end = datetime.datetime(2001, 1, 1) + max_run_time = (end - start).days * 24 + inputs_all["frequency"] = 180 + inputs_all["frequency_basis"] = "date-days" + inputs_all["start_date"] = "01/01/2000" + cls = Maintenance.from_dict(inputs_all) + cls._update_event_timing(start, end, max_run_time) + assert cls.event_dates == [ + datetime.datetime(2000, 6, 29), + datetime.datetime(2000, 12, 26), + datetime.datetime(2001, 6, 24), + ] def test_Maintenance_defaults(): @@ -314,12 +373,42 @@ def test_Maintenance_defaults(): assert cls.materials == 100.0 assert cls.frequency == relativedelta(days=200) assert cls.frequency_basis == "days" + assert cls.start_date == start + assert cls.event_dates == [] assert cls.hours_to_next_event(start) == (200.0 * 24, False) assert cls.service_equipment == ["CTV"] assert cls.operation_reduction == class_data["operation_reduction"].default assert cls.system_value == 100000.0 +def test_Maintenance_no_events(): + """Tests the `Maintenance` class for when there is no modeled maintenance.""" + start = datetime.datetime(2000, 1, 1) + end = datetime.datetime(2001, 1, 1) + max_run_time = (end - start).days * 24 + inputs = { + "time": 0, + "materials": 0, + "frequency": 0, + "service_equipment": "ctv", + "system_value": 0, + } + cls = Maintenance.from_dict(inputs) + cls._update_event_timing(start, end, max_run_time) + class_data = attr.fields_dict(Maintenance) + assert cls.description == class_data["description"].default + assert cls.time == 0 + assert cls.materials == 0 + assert cls.frequency == relativedelta(hours=max_run_time) + assert cls.frequency_basis == "date-hours" + assert cls.start_date == start + assert cls.event_dates == [end + relativedelta(hours=1)] + assert cls.hours_to_next_event(start) == (max_run_time + 1, True) + assert cls.service_equipment == ["CTV"] + assert cls.operation_reduction == class_data["operation_reduction"].default + assert cls.system_value == 0 + + def test_Maintenance_proportional_materials(): """Tests the `Maintenance` classfor proportional materials cost, relative to system value. diff --git a/wombat/core/data_classes.py b/wombat/core/data_classes.py index 1f2e36b0..2b78d3e7 100644 --- a/wombat/core/data_classes.py +++ b/wombat/core/data_classes.py @@ -14,7 +14,7 @@ import attrs import numpy as np import pandas as pd -from attrs import Factory, Attribute, field, define, validators +from attrs import Factory, Attribute, field, define, converters, validators from dateutil.relativedelta import relativedelta from wombat.utilities.time import HOURS_IN_YEAR, parse_date, convert_dt_to_hours @@ -462,10 +462,10 @@ class Maintenance(FromDictMixin): ) ), ) - start_date: datetime.datetime = field( - default="01/01/1990", - converter=to_datetime, - validator=validators.instance_of(datetime.datetime), + start_date: datetime.datetime | None = field( + default=None, + converter=converters.optional(to_datetime), + validator=validators.optional(validators.instance_of(datetime.datetime)), ) operation_reduction: float = field(default=0.0, converter=float) level: int = field(default=0, converter=int) @@ -519,21 +519,29 @@ def _update_event_timing( ValueError Raised if an invalid ``Maintenance.frequency_basis`` is used. """ + if self.start_date is None: + object.__setattr__(self, "start_date", start) + if TYPE_CHECKING: + assert isinstance(self.start_date, datetime.datetime) + # For events that should not occur, overwrite the user-passed timing settings # to ensure there is a single event scheduled for just after the simulation end n_frequency = deepcopy(self.frequency) if self.frequency == 0: - basis = "days" - n_frequency = 1 diff = relativedelta(hours=max_run_time) + object.__setattr__(self, "frequency", diff) object.__setattr__(self, "frequency_basis", "date-hours") - object.__setattr__(self, "start_date", start) - else: - basis = self.frequency_basis.replace("date-", "") - diff = relativedelta(**{basis: self.frequency}) # type: ignore + object.__setattr__(self, "event_dates", [end + relativedelta(hours=1)]) + return - years = end.year - min(self.start_date.year, start.year) + 1 + if not self.frequency_basis.startswith("date"): + diff = relativedelta(**{self.frequency_basis: self.frequency}) # type: ignore + object.__setattr__(self, "frequency", diff) + return + basis = self.frequency_basis.replace("date-", "") + diff = relativedelta(**{basis: self.frequency}) # type: ignore + years = end.year - min(self.start_date.year, start.year) + 1 if TYPE_CHECKING: assert isinstance(n_frequency, int) match basis: @@ -550,10 +558,9 @@ def _update_event_timing( event_dates = [] for date in _event_dates: if date > start: + event_dates.append(date) if date >= end: - event_dates.append(date) break - event_dates.append(date) object.__setattr__(self, "frequency", diff) object.__setattr__(self, "event_dates", event_dates) @@ -598,7 +605,7 @@ def hours_to_next_event(self, now_date: datetime.datetime) -> tuple[float, bool] return self._hours_to_next_date(now_date), True if TYPE_CHECKING: - assert isinstance(self.frequency, relativedelta) + assert isinstance(self.frequency, datetime.datetime) return convert_dt_to_hours(now_date + self.frequency - now_date), False From 890001123dfabb5b937fece023820ab9d6a84aaa Mon Sep 17 00:00:00 2001 From: RHammond2 <13874373+RHammond2@users.noreply.github.com> Date: Wed, 5 Mar 2025 16:03:06 -0800 Subject: [PATCH 12/16] add prior start date staggering support for timing based --- tests/test_data_classes.py | 43 +++++++++++++++++++++++++++++++------ wombat/core/data_classes.py | 9 +++++++- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/tests/test_data_classes.py b/tests/test_data_classes.py index 0c0ab990..6dd3602a 100644 --- a/tests/test_data_classes.py +++ b/tests/test_data_classes.py @@ -239,7 +239,7 @@ def test_Maintenance_conversion(): assert cls.frequency == relativedelta(days=200) assert cls.frequency_basis == "days" assert cls.start_date == datetime.datetime(2000, 2, 1) - assert cls.hours_to_next_event(start) == (200.0 * 24, False) + assert cls.hours_to_next_event(start) == (31 * 24, False) assert cls.service_equipment == ["CTV"] assert cls.operation_reduction == 0.5 assert cls.system_value == 100000.0 @@ -272,15 +272,20 @@ def test_Maintenance_frequency(): end = datetime.datetime(2001, 1, 1) max_run_time = (end - start).days * 24 + # Prior starting date gets adjusted to first post-simulation start inputs_all["frequency"] = 3 inputs_all["frequency_basis"] = "months" inputs_all["start_date"] = "01/01/2000" + expected_first_dt = datetime.datetime(2000, 4, 1) cls = Maintenance.from_dict(inputs_all) cls._update_event_timing(start, end, max_run_time) assert cls.frequency == relativedelta(months=3) assert cls.frequency_basis == "months" - assert cls.start_date == datetime.datetime(2000, 1, 1) + assert cls.start_date == expected_first_dt assert cls.event_dates == [] + current_dt = start + expected_hours = convert_dt_to_hours(expected_first_dt - current_dt) + assert (expected_hours, False) == cls.hours_to_next_event(current_dt) inputs_all["frequency"] = 4 inputs_all["frequency_basis"] = "years" @@ -288,12 +293,35 @@ def test_Maintenance_frequency(): cls = Maintenance.from_dict(inputs_all) cls._update_event_timing(start, end, max_run_time) assert cls.frequency == relativedelta(years=4) + current_dt = datetime.datetime(2000, 2, 1) + expected_dt = datetime.datetime(2004, 2, 1) + expected_hours = convert_dt_to_hours(expected_dt - current_dt) + assert (expected_hours, False) == cls.hours_to_next_event(current_dt) current_dt = datetime.datetime(2002, 2, 1) expected_dt = datetime.datetime(2006, 2, 1) expected_hours = convert_dt_to_hours(expected_dt - current_dt) assert (expected_hours, False) == cls.hours_to_next_event(current_dt) + # Staggered start date for timing based + start = datetime.datetime(2000, 1, 1) + end = datetime.datetime(2010, 12, 31) + max_run_time = (end - start).days * 24 + inputs_all["frequency"] = 1 + inputs_all["frequency_basis"] = "years" + inputs_all["start_date"] = "01/01/2008" + cls = Maintenance.from_dict(inputs_all) + cls._update_event_timing(start, end, max_run_time) + expected_dt = datetime.datetime(2008, 1, 1) + assert cls.start_date == expected_dt + current_dt = start + expected_hours = convert_dt_to_hours(expected_dt - current_dt) + assert (expected_hours, False) == cls.hours_to_next_event(current_dt) + # Test the conversion for date-based inputs + # Months and basic start date + start = datetime.datetime(2000, 2, 1) + end = datetime.datetime(2001, 1, 1) + max_run_time = (end - start).days * 24 inputs_all["frequency"] = 3 inputs_all["frequency_basis"] = "date-months" inputs_all["start_date"] = "01/01/2000" @@ -311,6 +339,7 @@ def test_Maintenance_frequency(): expected_hours = convert_dt_to_hours(expected_dt - current_dt) assert (expected_hours, True) == cls.hours_to_next_event(current_dt) + # Years and default start date start = datetime.datetime(2000, 2, 1) end = datetime.datetime(2006, 1, 1) max_run_time = (end - start).days * 24 @@ -329,6 +358,7 @@ def test_Maintenance_frequency(): expected_hours = convert_dt_to_hours(expected_dt - current_dt) assert (expected_hours, True) == cls.hours_to_next_event(current_dt) + # Years and staggered start date inputs_all["start_date"] = "06-30-2002" cls = Maintenance.from_dict(inputs_all) cls._update_event_timing(start, end, max_run_time) @@ -338,18 +368,19 @@ def test_Maintenance_frequency(): datetime.datetime(2006, 6, 30), ] + # Days and prior start date start = datetime.datetime(2000, 1, 1) end = datetime.datetime(2001, 1, 1) max_run_time = (end - start).days * 24 inputs_all["frequency"] = 180 inputs_all["frequency_basis"] = "date-days" - inputs_all["start_date"] = "01/01/2000" + inputs_all["start_date"] = "12/31/1999" cls = Maintenance.from_dict(inputs_all) cls._update_event_timing(start, end, max_run_time) assert cls.event_dates == [ - datetime.datetime(2000, 6, 29), - datetime.datetime(2000, 12, 26), - datetime.datetime(2001, 6, 24), + datetime.datetime(2000, 6, 28), + datetime.datetime(2000, 12, 25), + datetime.datetime(2001, 6, 23), ] diff --git a/wombat/core/data_classes.py b/wombat/core/data_classes.py index 2b78d3e7..b6863235 100644 --- a/wombat/core/data_classes.py +++ b/wombat/core/data_classes.py @@ -519,8 +519,10 @@ def _update_event_timing( ValueError Raised if an invalid ``Maintenance.frequency_basis`` is used. """ + date_based = self.frequency_basis.startswith("date-") if self.start_date is None: object.__setattr__(self, "start_date", start) + if TYPE_CHECKING: assert isinstance(self.start_date, datetime.datetime) @@ -534,9 +536,11 @@ def _update_event_timing( object.__setattr__(self, "event_dates", [end + relativedelta(hours=1)]) return - if not self.frequency_basis.startswith("date"): + if not date_based: diff = relativedelta(**{self.frequency_basis: self.frequency}) # type: ignore object.__setattr__(self, "frequency", diff) + if self.start_date < start and not date_based: + object.__setattr__(self, "start_date", self.start_date + diff) return basis = self.frequency_basis.replace("date-", "") @@ -606,6 +610,9 @@ def hours_to_next_event(self, now_date: datetime.datetime) -> tuple[float, bool] if TYPE_CHECKING: assert isinstance(self.frequency, datetime.datetime) + + if now_date < self.start_date: + return convert_dt_to_hours(self.start_date - now_date), False return convert_dt_to_hours(now_date + self.frequency - now_date), False From 2d00ffac9a7dc3c666494ec8b9965fe8d95d9b6d Mon Sep 17 00:00:00 2001 From: RHammond2 <13874373+RHammond2@users.noreply.github.com> Date: Wed, 5 Mar 2025 16:03:14 -0800 Subject: [PATCH 13/16] update changelog --- CHANGELOG.md | 111 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 72 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f128fb13..2d6db387 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,45 +4,78 @@ ### Features -- Multiple instances of a servicing equipment can be created with a list of the name - and number of them in the following forms. This creates copies where the equipment's - name is suffixed with a 1-indexed indication of which number it is, such as - "Crew Transfer Vessel -1" through "Crew Transfer Vessel - 4" for the below example. - - ```yaml - ... - servicing_equipment: - - - ctv.yaml - - 4 - - [hlv.yaml, 2] - - dsv.yaml - ... - ``` - -- WOMBAT configurations now allow for the embedding of servicing equipment, turbine, - substation, cable, and port data within the main configuration. Now, the only files - required outside the primary configuration YAML are the weather profile and layout. - To utilize this update, data can be included in the following form: - - ```yaml - servicing_equipment: - - [7, ctv] - ... # Other configuration details - vessels: - ctv: - ... # Contents of library/corewind/vessels/ctv.yaml - turbines: - corewind_15MW: - ... # Contents of library/corewind/turbines/corewind_15MW.yaml - substations: - corewind_substation: - ... # Contents of library/corewind/substations/corewind_substation.yaml - cables: - corewind_array: - ... # Contents of library/corewind/cables/corewind_array.yaml - corewind_export: - ... # Contents of library/corewind/cables/corewind_export.yaml - ``` +#### Date-based maintenance and improved timing + +The frequency of maintenance events is now significantly more customizable by enabling +custom starting dates and more resolved frequency inputs. There is no change required +for existing configurations as the default value will `frequency` number of days until +the next event. + +- `frequency` is an integer input (0 for unmodeled) +- `frequency_basis` indicates the time basis for `frequency`, which is modeled in two + forms: + - timing based: amount of operational time that should pass before an event occurs, + which does not count downtime (repairs, upstream failure, or system offline) in the + time until the next event. + - "days": number of days between events (default) + - "months": number of months between events + - "years": number of years between events + - date based: uses a set schedule for when events should occur, regardless of downtime + and will use `start_date` as the first occurrence of the event. + - "date-days": number of days between events + - "date-months": number of months between events + - "date-years": number of years between events +- `start_date`: The first occurrence of the maintenance event, which defaults to the + simulation start date + the expected interval. If the input is prior to the simulation + start date, then it is treated as a staggered timing, so, for instance, a biannual + event starting the year prior to the simulation ends up being staggered with the first + occurrence in the first year of the simulation, not the second. Similarly, this + allows for maintenance activities to start well into the simulation period allowing + for costs on OEM-warrantied maintenance activities to be unmodeled. + +#### Repeat vessel configuration simplification + +Multiple instances of a servicing equipment can be created with a list of the name +and number of them in the following forms. This creates copies where the equipment's +name is suffixed with a 1-indexed indication of which number it is, such as +"Crew Transfer Vessel -1" through "Crew Transfer Vessel - 4" for the below example. + +```yaml +... +servicing_equipment: + - - ctv.yaml + - 4 + - [hlv.yaml, 2] + - dsv.yaml +... +``` + +#### Single(ish) file configuration + +WOMBAT configurations now allow for the embedding of servicing equipment, turbine, +substation, cable, and port data within the main configuration. Now, the only files +required outside the primary configuration YAML are the weather profile and layout. +To utilize this update, data can be included in the following form: + +```yaml +servicing_equipment: + - [7, ctv] +... # Other configuration details +vessels: + ctv: + ... # Contents of library/corewind/vessels/ctv.yaml +turbines: + corewind_15MW: + ... # Contents of library/corewind/turbines/corewind_15MW.yaml +substations: + corewind_substation: + ... # Contents of library/corewind/substations/corewind_substation.yaml +cables: + corewind_array: + ... # Contents of library/corewind/cables/corewind_array.yaml + corewind_export: + ... # Contents of library/corewind/cables/corewind_export.yaml +``` ### Updates From f489d5b7d173f59ff73114cfcf1eadef7919564b Mon Sep 17 00:00:00 2001 From: RHammond2 <13874373+RHammond2@users.noreply.github.com> Date: Wed, 5 Mar 2025 16:12:46 -0800 Subject: [PATCH 14/16] add examples to changelog --- CHANGELOG.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d6db387..d03471d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,41 @@ the next event. allows for maintenance activities to start well into the simulation period allowing for costs on OEM-warrantied maintenance activities to be unmodeled. +A few examples of more complex scenarios assuming a 1/1/2000 simulation starting date: + +- Semiannual event to occur starting in the 3rd month of the simulation: + + ```yaml + frequency: 6 + frequency_basis: months + start_date: "9/1/1999" + ``` + +- Summer-based annual event with the first occurrence in the 3rd year of the simulation: + + ```yaml + frequency: 1 + frequency_basis: years + start_date: "6/1/2003" + ``` + +- Annual, June maintenance activity: + + ```yaml + frequency: 1 + frequency_basis: "date-years" + start_date: "6/1/2000" + ``` + +- Biannual, June maintenance activity that should start in the first year, and every + other year after that: + + ```yaml + frequency: 1 + frequency_basis: "date-years" + start_date: "6/1/2000" + ``` + #### Repeat vessel configuration simplification Multiple instances of a servicing equipment can be created with a list of the name From e4205422e190c09cb7622a12868af00a017d7db3 Mon Sep 17 00:00:00 2001 From: RHammond2 <13874373+RHammond2@users.noreply.github.com> Date: Tue, 11 Mar 2025 15:38:45 -0700 Subject: [PATCH 15/16] update frequency documentation --- docs/API/types.md | 7 +++--- docs/examples/how_to.md | 47 ++++++++++++++++++++++++++++++++++++- docs/examples/index.md | 2 +- wombat/core/data_classes.py | 33 +++++++++++++++++++++++++- 4 files changed, 83 insertions(+), 6 deletions(-) diff --git a/docs/API/types.md b/docs/API/types.md index d8c2340f..70009c4a 100644 --- a/docs/API/types.md +++ b/docs/API/types.md @@ -1,5 +1,5 @@ (types)= -# Data Classes +# Conifgurations (Data Classes) The WOMBAT architecture relies heavily on a base set of data classes to process most of the model's inputs. This enables a rigid, yet robust data model to properly define a @@ -36,7 +36,8 @@ hoping for the best. :members: :undoc-members: :exclude-members: time, materials, frequency, equipment, system_value, description, - level, operation_reduction, rng, service_equipment, replacement + level, operation_reduction, rng, service_equipment, replacement, frequency_basis, + start_date, request_id, event_dates ``` (types:maintenance:unscheduled)= @@ -48,7 +49,7 @@ hoping for the best. :undoc-members: :exclude-members: scale, shape, time, materials, operation_reduction, weibull, name, maintenance, failures, level, equipment, system_value, description, rng, - service_equipment, replacement + service_equipment, replacement, request_id ``` (types:maintenance:requests)= diff --git a/docs/examples/how_to.md b/docs/examples/how_to.md index 57204d3d..7ccaa02c 100644 --- a/docs/examples/how_to.md +++ b/docs/examples/how_to.md @@ -543,6 +543,52 @@ project_capacity: 240 # pointer is provided here and located in: dinwoodie / project / port ``` +```{note} +As of v0.10, multiple vessels can be modeled with a single configuration, and vessel, +turbine, cable, and substation configurations can be included directly in the contents +of the primary configuration. +``` + +Alternatively, the configuration can be simplified to be of the following form to utilize +repeated vessel configurations and the single file configuration. The new keys for where +to embed the data directly correspond to the folder names where the files originally +resided. For instance, below embedding `/vessels/ctv.yaml` into the +main configuration means that we need a new key called `vessels`, with a subkey +underneath called `ctv`. + +```{code-block} yaml +name: dinwoodie_base +weather: alpha_ventus_weather_2002_2014.csv # located in: dinwoodie / weather +service_equipment: +# YAML-encoded list, but could also be created in standard Python list notation with +# square brackets: [ctv1.yaml, ctv2.yaml, ..., hlv_requests.yaml] +# All below equipment configurations are located in: dinwoodie / vessels + - [ctv, 3] + - fsv_requests.yaml + - hlv_requests.yaml +layout: layout.csv # located in: dinwoodie / windfarm +inflation_rate: 0 +fixed_costs: fixed_costs.yaml # located in: dinwoodie / project / config +workday_start: 7 +workday_end: 19 +start_year: 2003 +end_year: 2012 +project_capacity: 240 +vessels: + ctv: + ... # contents of ctv1.yaml without the numbered naming which is created automatically +turbines: + ... +cables: + ... +substations: + ... +``` + +For a complete example of this, please see the +`library/corewind/project/config/morro_bay_in_situ_consolidated.yaml` configuration +file. + ## Create a simulation There are two ways that this could be done, the first is to use the classmethod @@ -627,7 +673,6 @@ timing = end - start print(f"Run time: {timing / 60:,.2f} minutes") ``` - ## Metric computation For a more complete view of what metrics can be compiled, please see the [metrics notebook](metrics_demonstration.md), though for the sake of demonstration a few methods will diff --git a/docs/examples/index.md b/docs/examples/index.md index fa0d0c77..1ab80fae 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -55,7 +55,7 @@ events. The following will break down the differences between each of these syst modeling assumptions as well as the basic operations of the maintenance and failure models. - Maintenance - - `frequency`: based on a fixed number of days between an event + - `frequency`: based on an amount of time between events and a given starting date. - `operation_reduction`: the percentage of degradation the triggering of this event cause - Failure diff --git a/wombat/core/data_classes.py b/wombat/core/data_classes.py index b6863235..b363efcd 100644 --- a/wombat/core/data_classes.py +++ b/wombat/core/data_classes.py @@ -407,7 +407,8 @@ class Maintenance(FromDictMixin): materials : float Cost of materials required to perform maintenance, in USD. frequency : int | str - Optimal number of days between performing maintenance, in days. + Number of days, months, or years between events, see :py:attr:`frequency_basis` + for further configuration. Defaults to days as the basis. service_equipment: list[str] | str Any combination of th following ``Equipment.capability`` options. @@ -434,6 +435,36 @@ class Maintenance(FromDictMixin): level : int, optional Severity level of the maintenance. Defaults to 0. + frequency_basis : str, optional + The basis of the frequency input. Defaults to "days". Must be one of the + following inputs: + + - days: :py:attr:`frequency` corresponds to the number of days between events. + - months: :py:attr:`frequency` corresponds to the number of months between + events. + + - years: :py:attr:`frequency` corresponds to the number of years between events. + - date-days: :py:attr:`frequency` corresponds to the exact number of days + between events, starting with :py:attr:`start_date` (i.e. downtime does not + impact timing). + + - date-months: :py:attr:`frequency` corresponds to the number of months between + events, starting with :py:attr:`start_date` (i.e. downtime does not impact + timing). + + - date-years: :py:attr:`frequency` corresponds to the number of years between + events, starting with :py:attr:`start_date` (i.e. downtime does not impact + timing). + + start_date : str, optional + The date in "MM/DD/YYYY" or similarly legible format + (``pandas.to_datetime(start_date)`` used to convert internally) of the first + instance of the maintenance event. For instance, this allows for annual summer + maintenance activities for reduced downtime of fleet-wide preventative + maintenance, or for allowing a delayed start to certain activities (nothing + in the first five years, then run on a regular schedule). Defaults to the + frequency + simulation start date and is used for any + :py:attr:`frequency_basis`. """ time: float = field(converter=float) From bf66a46070a99760fa2c3874501f43d9e44ef6e1 Mon Sep 17 00:00:00 2001 From: RHammond2 <13874373+RHammond2@users.noreply.github.com> Date: Thu, 13 Mar 2025 10:53:42 -0700 Subject: [PATCH 16/16] remove custom and redundany in validator --- wombat/core/data_classes.py | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/wombat/core/data_classes.py b/wombat/core/data_classes.py index b363efcd..bc935f56 100644 --- a/wombat/core/data_classes.py +++ b/wombat/core/data_classes.py @@ -298,23 +298,6 @@ def validate_0_1_inclusive( ) -def one_of(items: Sequence) -> Callable: - """Validates that the user input is one of :py:attr:`items`. - - Parameters - ---------- - items : Sequence - A list-like of valid values. - """ - - def validator(cls, attribute: attrs.Attribute, value: Any) -> None: - """Validator method that is returned to attrs.""" - if value not in items: - raise ValueError(f"`{attribute.name}` must be one of: {','.join(items)}") - - return validator - - def to_datetime(value: str | datetime.datetime) -> datetime.datetime: """Converts a date string to a Python ``datetime`` object. @@ -482,7 +465,7 @@ class Maintenance(FromDictMixin): frequency_basis: str = field( default="days", converter=(str.strip, str.lower), - validator=one_of( + validator=attrs.validators.in_( ( "days", "months",