Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bug Fix: Operating Reductions Inconsistently Rest #143

Merged
merged 7 commits into from
Apr 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
- Updates Polars API usage to account for a series of deprecation and future warnings.
- Changes the metrics demonstration to use the COREWIND Morro Bay in situ example, and
adds the availability plotting to the demonstration example.
- `RepairRequest.prior_operating_level` has been added to allow 100% reduction factor failures to correctly and consistently restore the operating level of a subassembly following a repair.
- Replaces the `valid_reduction` attrs validator with `validate_0_1_inclusive` to reuse the logic in multiple places without duplicating checking methods.
- Adds a `replacement` flag for interruption methods, so that a failure or replacement comment can be added as a cause for `simpy.process.interrupt`. This update allows the failure and maintenance processes to check if an interruption should cause the process to exit completely. Additionally, the forced exit ensures that processes can't persist after a replacement event when a process is recreated, which was happening in isolated cases.
- Fixes a bug in `RepairManager.purge_subassemble_requests()` where the pending tows are cleared regardless of whether or not the focal subassembly is the cause of the tow, leading to a simulation failure.

## v0.9.3 (15 February 2024)

Expand Down
5 changes: 1 addition & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@
requires = ["setuptools", "setuptools-scm"]
build-backend = "setuptools.build_meta"

[metadata]
version = "attr: wombat.__version__"

[project]
name = "wombat"
dynamic = ["version"]
Expand Down Expand Up @@ -184,7 +181,7 @@ fix = true
# for rules included and matching to prefix.
# TODO: "FBT", "B", "PIE, "T20", "SIM", "PTH", "PD", "I", "PL"
ignore-init-module-imports = true
select = ["F", "E", "W", "C4", "D", "UP", "NPY201"]
select = ["F", "E", "W", "C4", "D", "UP"]

# D205: not using summary lines and descriptions, just descriptions
# D401: don't believe enough in imperative mode to make all the changes currently
Expand Down
12 changes: 7 additions & 5 deletions tests/test_data_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@
UnscheduledServiceEquipmentData,
valid_hour,
convert_to_list,
valid_reduction,
annual_date_range,
clean_string_input,
convert_to_list_lower,
validate_0_1_inclusive,
convert_ratio_to_absolute,
)

Expand Down Expand Up @@ -151,14 +151,16 @@ class HourClass:
assert hour.hour == 24


def test_valid_reduction():
"""Tests the ``valid_reduction`` validator."""
def test_validate_0_1_inclusive():
"""Tests the ``validate_0_1_inclusive`` validator."""

@attr.s(auto_attribs=True)
class ReductionClass:
"""Dummy class for testing ``valid_reduction``."""
"""Dummy class for testing ``validate_0_1_inclusive``."""

speed_reduction: int = attr.ib(converter=float, validator=valid_reduction)
speed_reduction: int = attr.ib(
converter=float, validator=validate_0_1_inclusive
)

# Test the fringes
with pytest.raises(ValueError):
Expand Down
13 changes: 8 additions & 5 deletions wombat/core/data_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ def valid_hour(
raise ValueError(f"Input {attribute.name} must be between 0 and 24, inclusive.")


def valid_reduction(
def validate_0_1_inclusive(
instance,
attribute: Attribute,
value: int | float, # pylint: disable=W0613
Expand All @@ -292,8 +292,8 @@ def valid_reduction(
"""
if value < 0 or value > 1:
raise ValueError(
f"Input for {attribute.name}'s `speed_reduction_factor` must be between"
" 0 and 1, inclusive."
f"Input for {attribute.name} must be between 0 and 1, inclusive, not:"
f" {value=}."
)


Expand Down Expand Up @@ -650,6 +650,9 @@ class RepairRequest(FromDictMixin):
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)

def assign_id(self, request_id: str) -> None:
Expand Down Expand Up @@ -1068,7 +1071,7 @@ class ScheduledServiceEquipmentData(FromDictMixin, DateLimitsMixin):
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=valid_reduction
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)
Expand Down Expand Up @@ -1287,7 +1290,7 @@ class UnscheduledServiceEquipmentData(FromDictMixin, DateLimitsMixin):
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=valid_reduction
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)
Expand Down
41 changes: 28 additions & 13 deletions wombat/core/repair_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -532,18 +532,23 @@ def invalidate_system(
self.systems_waiting_for_tow.index(system.id)
)

def interrupt_system(self, system: System | Cable) -> None:
def interrupt_system(
self, system: System | Cable, replacement: str | None = None
) -> None:
"""Sets the turbine status to be in servicing, and interrupts all the processes
to turn off operations.

Parameters
----------
system_id : str
The system to disable repairs.
replacement: str | None, optional
If a subassebly `id` is provided, this indicates the interruption is caused
by its replacement event. Defaults to None.
"""
if system.servicing.triggered and system.id in self.invalid_systems:
system.servicing = self.env.event()
system.interrupt_all_subassembly_processes()
system.interrupt_all_subassembly_processes(replacement=replacement)
else:
raise RuntimeError(
f"{self.env.simulation_time} {system.id} already being serviced"
Expand Down Expand Up @@ -671,19 +676,25 @@ def purge_subassembly_requests(
if not self.items:
return None

requests = [
# First check the system matches because we'll need these separated later to
# ensure we don't incorrectly remove towing requests from other subassemblies
system_requests = [
request
for request in self.items
if (
request.system_id == system_id
and request.subassembly_id == subassembly_id
and request.request_id not in exclude
)
if request.system_id == system_id and request.request_id not in exclude
]
if requests == []:
if system_requests == []:
return None

for request in requests:
subassembly_requests = [
request
for request in system_requests
if request.subassembly_id == subassembly_id
]
if subassembly_requests == []:
return None

for request in subassembly_requests:
which = "repair" if isinstance(request.details, Failure) else "maintenance"
self.env.log_action(
system_id=request.system_id,
Expand All @@ -702,14 +713,18 @@ def purge_subassembly_requests(
_ = self.get(lambda x: x is request) # pylint: disable=W0640
sid = request.system_id

# Ensure that if it was reset, and a tow was waiting, that it gets cleared
# Ensure that if it was reset, and a tow was waiting, that it gets cleared,
# unless a separate subassembly required the tow
if sid in self.systems_waiting_for_tow:
if sid not in self.systems_in_tow:
other_subassembly_match = [
r for r in system_requests if "TOW" in r.details.service_equipment
]
if sid not in self.systems_in_tow and other_subassembly_match == []:
_ = self.systems_waiting_for_tow.pop(
self.systems_waiting_for_tow.index(sid)
)

return requests
return subassembly_requests

@property
def request_map(self) -> dict[str, int]:
Expand Down
16 changes: 12 additions & 4 deletions wombat/core/service_equipment.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,8 +489,12 @@
)
subassembly.recreate_processes()
elif operation_reduction == 1:
subassembly.operating_level = starting_operating_level
subassembly.broken.succeed()
subassembly.operating_level = repair.prior_operating_level
try:
subassembly.broken.succeed()
except RuntimeError as e:
print(subassembly.system.id, repair.details.description)
raise e

Check warning on line 497 in wombat/core/service_equipment.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/service_equipment.py#L495-L497

Added lines #L495 - L497 were not covered by tests
elif operation_reduction == 0:
subassembly.operating_level = starting_operating_level
else:
Expand Down Expand Up @@ -1467,7 +1471,10 @@
raise RuntimeError(f"{self.settings.name} is lost!")

if initial:
self.manager.interrupt_system(system)
replacement = (
request.subassembly_id if request.details.replacement else None
)
self.manager.interrupt_system(system, replacement=replacement)
yield self.env.process(
self.crew_transfer(system, subassembly, request, to_system=True)
)
Expand Down Expand Up @@ -1885,7 +1892,8 @@
)

# Turn off the turbine
self.manager.interrupt_system(system)
replacement = request.subassembly_id if request.details.replacement else None
self.manager.interrupt_system(system, replacement=replacement)

Check warning on line 1896 in wombat/core/service_equipment.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/service_equipment.py#L1895-L1896

Added lines #L1895 - L1896 were not covered by tests

# Unmoor the turbine and tow it back to port
yield self.env.process(self.mooring_connection(system, request, which="unmoor"))
Expand Down
52 changes: 31 additions & 21 deletions wombat/windfarm/system/cable.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,25 +169,41 @@
"""
self.processes = dict(self._create_processes())

def interrupt_processes(self) -> None:
def interrupt_processes(self, replacement: str | None = None) -> None:
"""Interrupts all of the running processes within the subassembly except for the
process associated with failure that triggers the catastrophic failure.

Parameters
----------
subassembly : Subassembly
The subassembly that should have all processes interrupted.
replacement: bool, optional
If a subassebly `id` is provided, this indicates the interruption is caused
by its replacement event. Defaults to None.
"""
cause = "failure"
if self.id == replacement:
cause = "replacement"

Check warning on line 186 in wombat/windfarm/system/cable.py

View check run for this annotation

Codecov / codecov/patch

wombat/windfarm/system/cable.py#L186

Added line #L186 was not covered by tests

for _, process in self.processes.items():
try:
process.interrupt()
process.interrupt(cause=cause)
except RuntimeError:
# This error occurs for the process halting all other processes.
pass

def interrupt_all_subassembly_processes(self) -> None:
"""Thin wrapper for ``interrupt_processes`` for consistent usage with system."""
self.interrupt_processes()
def interrupt_all_subassembly_processes(
self, replacement: str | None = None
) -> None:
"""Thin wrapper for ``interrupt_processes`` for consistent usage with system.

Parameters
----------
replacement: bool, optional
If a subassebly `id` is provided, this indicates the interruption is caused
by its replacement event. Defaults to None.
"""
self.interrupt_processes(replacement=replacement)

def stop_all_upstream_processes(self, failure: Failure | Maintenance) -> None:
"""Stops all upstream turbines and cables from producing power by creating a
Expand Down Expand Up @@ -266,6 +282,7 @@
The maintenance or failure event that triggers a ``RepairRequest``.
"""
which = "maintenance" if isinstance(action, Maintenance) else "repair"
current_ol = self.operating_level
self.operating_level *= 1 - action.operation_reduction

# Automatically submit a repair request
Expand All @@ -280,6 +297,7 @@
cable=True,
upstream_turbines=self.upstream_nodes,
upstream_cables=self.upstream_cables,
prior_operating_level=current_ol,
)
repair_request = self.system.repair_manager.register_request(repair_request)
self.env.log_action(
Expand Down Expand Up @@ -341,14 +359,10 @@
yield self.env.timeout(hours_to_next)
hours_to_next = 0
self.trigger_request(maintenance)
except simpy.Interrupt:
if not self.broken.triggered:
# The subassembly had to restart the maintenance cycle
hours_to_next = 0
else:
# A different process failed, so subtract the elapsed time
# only if it had started to be processed
hours_to_next -= 0 if start == -1 else self.env.now - start
except simpy.Interrupt as i:

Check warning on line 362 in wombat/windfarm/system/cable.py

View check run for this annotation

Codecov / codecov/patch

wombat/windfarm/system/cable.py#L362

Added line #L362 was not covered by tests
if i.cause == "replacement":
return
hours_to_next -= 0 if start == -1 else self.env.now - start

Check warning on line 365 in wombat/windfarm/system/cable.py

View check run for this annotation

Codecov / codecov/patch

wombat/windfarm/system/cable.py#L364-L365

Added lines #L364 - L365 were not covered by tests

def run_single_failure(self, failure: Failure) -> Generator:
"""Runs a process to trigger one type of failure repair request throughout the
Expand Down Expand Up @@ -385,11 +399,7 @@
yield self.env.timeout(hours_to_next)
hours_to_next = 0
self.trigger_request(failure)
except simpy.Interrupt:
if not self.broken.triggered:
# Restart after fixing
hours_to_next = 0
else:
# A different process failed, so subtract the elapsed time
# only if it had started to be processed
hours_to_next -= 0 if start == -1 else self.env.now - start
except simpy.Interrupt as i:
if i.cause == "replacement":
return

Check warning on line 404 in wombat/windfarm/system/cable.py

View check run for this annotation

Codecov / codecov/patch

wombat/windfarm/system/cable.py#L404

Added line #L404 was not covered by tests
hours_to_next -= 0 if start == -1 else self.env.now - start
Loading
Loading