From b537b3ba0abb3b422d9408b2da87cccd4d7ee4d3 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 25 Nov 2025 12:11:03 +0100 Subject: [PATCH 01/14] psuedo code --- src/virtualship/cli/_run.py | 21 ++++++++ .../expedition/simulate_schedule.py | 21 ++++++++ src/virtualship/make_realistic/problems.py | 48 +++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 src/virtualship/make_realistic/problems.py diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index f07fbab27..be444ddaf 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -112,6 +112,27 @@ def _run(expedition_dir: str | Path, from_data: Path | None = None) -> None: ) return + """ + PROBLEMS: + - Post verification of schedule: + - There will be a problem in the expedition, and the problem is tied to an instrument type + - JAMIE DETERMINES **WHERE** THE LOGIC OF WHETHER THE PROBLEM IS INITIATED LIVES (probably simulate_schedule .simulate() ) + - assume it's assigned to be a problem at waypoint e.g. 4 (can get more creative with this later; and e.g. some that are only before waypoiny 1, delays with food, fuel) + - extract from schedule, way the time absolutley it should take to get to the next waypoint (based on speed and distance) + - compare to the extracted value of what the user has scheduled, + - if they have not scheduled enough time for the time associated with the specific problem, then we have a problem + - if they have scheduled enough time then can continue and give a message that there was a problem but they had enough time scheduled to deal with it - well done etc. + - return to `virtualship plan` [with adequate messaging to say waypoint N AND BEYOND need updating to account for x hour/days delay] + for user to update schedule (or directly in YAML) + - once updated, run `virtualship run` again, will check from the checkpoint and check that the new schedule is suitable (do checkpoint.verify methods need updating?) + - if not suitable, return to `virtualship plan` again etc. + - Also give error + messaging if the user has made changes to waypoints PREVIOUS to the problem waypoint + - proceed with run + + + - Ability to turn on and off problems + """ + # delete and create results directory if os.path.exists(expedition_dir.joinpath("results")): shutil.rmtree(expedition_dir.joinpath("results")) diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index 0a567b1c6..fb4010fa2 100644 --- a/src/virtualship/expedition/simulate_schedule.py +++ b/src/virtualship/expedition/simulate_schedule.py @@ -114,8 +114,29 @@ def __init__(self, projection: pyproj.Geod, expedition: Expedition) -> None: self._next_adcp_time = self._time self._next_ship_underwater_st_time = self._time + def _calc_prob(self, waypoint: Waypoint, wp_instruments) -> float: + """ + Calcuates the probability of a problem occurring at a given waypoint based on the instruments being used. + + 1) check if want a problem before waypoint 0 + 2) then by waypoint + """ + + def _return_specific_problem(self): + """ + Return the problem class (e.g. CTDPRoblem_Winch_Failure) based on the instrument type causing the problem OR if general problem (e.g. EngineProblem_FuelLeak). + + With instructions for re-processing the schedule afterwards. + + """ + def simulate(self) -> ScheduleOk | ScheduleProblem: for wp_i, waypoint in enumerate(self._expedition.schedule.waypoints): + probability_of_problem = self._calc_prob(waypoint, wp_instruments) # noqa: F821 + + if probability_of_problem > 1.0: + return self._return_specific_problem() + # sail towards waypoint self._progress_time_traveling_towards(waypoint.location) diff --git a/src/virtualship/make_realistic/problems.py b/src/virtualship/make_realistic/problems.py new file mode 100644 index 000000000..e085b22ae --- /dev/null +++ b/src/virtualship/make_realistic/problems.py @@ -0,0 +1,48 @@ +"""This can be where we house both genreal and instrument-specific probelems.""" # noqa: D404 + +from dataclasses import dataclass + +import pydantic + +from virtualship.instruments.ctd import CTD + +# base classes + + +class GeneralProblem(pydantic.BaseModel): + """Base class for general problems.""" + + message: str + can_reoccur: bool + delay_duration: float # in hours + + +class InstrumentProblem(pydantic.BaseModel): + """Base class for instrument-specific problems.""" + + instrument_dataclass: type + message: str + can_reoccur: bool + delay_duration: float # in hours + + +# Genreral problems + + +@dataclass +class EngineProblem_FuelLeak(GeneralProblem): ... # noqa: D101 + + +@dataclass +class FoodDelivery_Delayed(GeneralProblem): ... # noqa: D101 + + +# Instrument-specific problems + + +@dataclass +class CTDPRoblem_Winch_Failure(InstrumentProblem): # noqa: D101 + instrument_dataclass = CTD + message: str = ... + can_reoccur: bool = ... + delay_duration: float = ... # in hours From a53b61d0f0c7a38fd49ba42e323d4e351ef606e6 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:14:06 +0100 Subject: [PATCH 02/14] remove notes --- src/virtualship/cli/_run.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index be444ddaf..f07fbab27 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -112,27 +112,6 @@ def _run(expedition_dir: str | Path, from_data: Path | None = None) -> None: ) return - """ - PROBLEMS: - - Post verification of schedule: - - There will be a problem in the expedition, and the problem is tied to an instrument type - - JAMIE DETERMINES **WHERE** THE LOGIC OF WHETHER THE PROBLEM IS INITIATED LIVES (probably simulate_schedule .simulate() ) - - assume it's assigned to be a problem at waypoint e.g. 4 (can get more creative with this later; and e.g. some that are only before waypoiny 1, delays with food, fuel) - - extract from schedule, way the time absolutley it should take to get to the next waypoint (based on speed and distance) - - compare to the extracted value of what the user has scheduled, - - if they have not scheduled enough time for the time associated with the specific problem, then we have a problem - - if they have scheduled enough time then can continue and give a message that there was a problem but they had enough time scheduled to deal with it - well done etc. - - return to `virtualship plan` [with adequate messaging to say waypoint N AND BEYOND need updating to account for x hour/days delay] - for user to update schedule (or directly in YAML) - - once updated, run `virtualship run` again, will check from the checkpoint and check that the new schedule is suitable (do checkpoint.verify methods need updating?) - - if not suitable, return to `virtualship plan` again etc. - - Also give error + messaging if the user has made changes to waypoints PREVIOUS to the problem waypoint - - proceed with run - - - - Ability to turn on and off problems - """ - # delete and create results directory if os.path.exists(expedition_dir.joinpath("results")): shutil.rmtree(expedition_dir.joinpath("results")) From 51ab3086aa73dcdfb9b010a6919d96c8a029355c Mon Sep 17 00:00:00 2001 From: Emma Daniels Date: Wed, 26 Nov 2025 09:40:22 +0100 Subject: [PATCH 03/14] fill some problems --- src/virtualship/make_realistic/problems.py | 200 ++++++++++++++++++++- 1 file changed, 192 insertions(+), 8 deletions(-) diff --git a/src/virtualship/make_realistic/problems.py b/src/virtualship/make_realistic/problems.py index e085b22ae..766d97bae 100644 --- a/src/virtualship/make_realistic/problems.py +++ b/src/virtualship/make_realistic/problems.py @@ -1,10 +1,13 @@ -"""This can be where we house both genreal and instrument-specific probelems.""" # noqa: D404 +"""This can be where we house both general and instrument-specific problems.""" # noqa: D404 from dataclasses import dataclass import pydantic from virtualship.instruments.ctd import CTD +from virtualship.instruments.adcp import ADCP +from virtualship.instruments.drifter import Drifter +from virtualship.instruments.argo_float import ArgoFloat # base classes @@ -26,23 +29,204 @@ class InstrumentProblem(pydantic.BaseModel): delay_duration: float # in hours -# Genreral problems +# General problems +@dataclass +class VenomousCentipedeOnboard: + message: str = ( + "A venomous centipede is discovered onboard while operating in tropical waters. " + "One crew member becomes ill after contact with the creature and receives medical attention, " + "prompting a full search of the vessel to ensure no further danger. " + "The medical response and search efforts cause an operational delay of about 2 hours." + ) + can_reoccur: bool = False + delay_duration: float = 2.0 + +@dataclass +class CaptainSafetyDrill: + message: str = ( + "A miscommunication with the ship’s captain results in the sudden initiation of a mandatory safety drill. " + "The emergency vessel must be lowered and tested while the ship remains stationary, pausing all scientific " + "operations for the duration of the exercise. The drill introduces a delay of approximately 2 hours." + ) + can_reoccur: bool = False + delay_duration: float = 2. @dataclass -class EngineProblem_FuelLeak(GeneralProblem): ... # noqa: D101 +class FoodDeliveryDelayed: + message: str = ( + "The scheduled food delivery prior to departure has not arrived. Until the supply truck reaches the pier, " + "we cannot leave. Once it arrives, unloading and stowing the provisions in the ship’s cold storage " + "will also take additional time. These combined delays postpone departure by approximately 5 hours." + ) + can_reoccur: bool = False + delay_duration: float = 5.0 + +# @dataclass +# class FuelDeliveryIssue: +# message: str = ( +# "The fuel tanker expected to deliver fuel has not arrived. Port authorities are unable to provide " +# "a clear estimate for when the delivery might occur. You may choose to [w]ait for the tanker or [g]et a " +# "harbor pilot to guide the vessel to an available bunker dock instead. This decision may need to be " +# "revisited periodically depending on circumstances." +# ) +# can_reoccur: bool = False +# delay_duration: float = 0.0 # dynamic delays based on repeated choices + +# @dataclass +# class EngineOverheating: +# message: str = ( +# "One of the main engines has overheated. To prevent further damage, the engineering team orders a reduction " +# "in vessel speed until the engine can be inspected and repaired in port. The ship will now operate at a " +# "reduced cruising speed of 8.5 knots for the remainder of the transit." +# ) +# can_reoccur: bool = False +# delay_duration: None = None # speed reduction affects ETA instead of fixed delay +# ship_speed_knots: float = 8.5 +@dataclass +class MarineMammalInDeploymentArea: + message: str = ( + "A pod of dolphins is observed swimming directly beneath the planned deployment area. " + "To avoid risk to wildlife and comply with environmental protocols, all in-water operations " + "must pause until the animals move away from the vicinity. This results in a delay of about 30 minutes." + ) + can_reoccur: bool = True + delay_duration: float = 0.5 @dataclass -class FoodDelivery_Delayed(GeneralProblem): ... # noqa: D101 +class BallastPumpFailure: + message: str = ( + "One of the ship’s ballast pumps suddenly stops responding during routine ballasting operations. " + "Without the pump, the vessel cannot safely adjust trim or compensate for equipment movements on deck. " + "Engineering isolates the faulty pump and performs a rapid inspection. Temporary repairs allow limited " + "functionality, but the interruption causes a delay of approximately 1 hour." + ) + can_reoccur: bool = True + delay_duration: float = 1.0 +@dataclass +class ThrusterConverterFault: + message: str = ( + "The bow thruster's power converter reports a fault during station-keeping operations. " + "Dynamic positioning becomes less stable, forcing a temporary suspension of high-precision sampling. " + "Engineers troubleshoot the converter and perform a reset, resulting in a delay of around 1 hour." + ) + can_reoccur: bool = False + delay_duration: float = 1.0 + +@dataclass +class AFrameHydraulicLeak: + message: str = ( + "A crew member notices hydraulic fluid leaking from the A-frame actuator during equipment checks. " + "The leak must be isolated immediately to prevent environmental contamination or mechanical failure. " + "Engineering replaces a faulty hose and repressurizes the system. This repair causes a delay of about 2 hours." + ) + can_reoccur: bool = True + delay_duration: float = 2.0 + +@dataclass +class CoolingWaterIntakeBlocked: + message: str = ( + "The main engine's cooling water intake alarms indicate reduced flow, likely caused by marine debris " + "or biological fouling. The vessel must temporarily slow down while engineering clears the obstruction " + "and flushes the intake. This results in a delay of approximately 1 hour." + ) + can_reoccur: bool = True + delay_duration: float = 1.0 # Instrument-specific problems +@dataclass +class CTDCableJammed: + message: str = ( + "During preparation for the next CTD cast, the CTD cable becomes jammed in the winch drum. " + "Attempts to free it are unsuccessful, and the crew determines that the entire cable must be " + "replaced before deployment can continue. This repair is time-consuming and results in a delay " + "of approximately 3 hours." + ) + can_reoccur: bool = True + delay_duration: float = 3.0 + instrument_dataclass = CTD @dataclass -class CTDPRoblem_Winch_Failure(InstrumentProblem): # noqa: D101 +class CTDTemperatureSensorFailure: + message: str = ( + "The primary temperature sensor on the CTD begins returning inconsistent readings. " + "Troubleshooting confirms that the sensor has malfunctioned. A spare unit can be installed, " + "but integrating and verifying the replacement will pause operations. " + "This procedure leads to an estimated delay of around 2 hours." + ) + can_reoccur: bool = True + delay_duration: float = 2.0 instrument_dataclass = CTD - message: str = ... - can_reoccur: bool = ... - delay_duration: float = ... # in hours + +@dataclass +class CTDSalinitySensorFailureWithCalibration: + message: str = ( + "The CTD’s primary salinity sensor fails and must be replaced with a backup. After installation, " + "a mandatory calibration cast to a minimum depth of 1000 meters is required to verify sensor accuracy. " + "Both the replacement and calibration activities result in a total delay of roughly 4 hours." + ) + can_reoccur: bool = True + delay_duration: float = 4.0 + instrument_dataclass = CTD + +@dataclass +class WinchHydraulicPressureDrop: + message: str = ( + "The CTD winch begins to lose hydraulic pressure during routine checks prior to deployment. " + "The engineering crew must stop operations to diagnose the hydraulic pump and replenish or repair " + "the system. Until pressure is restored to operational levels, the winch cannot safely be used. " + "This results in an estimated delay of 1.5 hours." + ) + can_reoccur: bool = True + delay_duration: float = 1.5 + instrument_dataclass = CTD + +@dataclass +class RosetteTriggerFailure: + message: str = ( + "During a CTD cast, the rosette's bottle-triggering mechanism fails to actuate. " + "No discrete water samples can be collected during this cast. The rosette must be brought back " + "on deck for inspection and manual testing of the trigger system. This results in an operational " + "delay of approximately 2.5 hours." + ) + can_reoccur: bool = True + delay_duration: float = 2.5 + +@dataclass +class ADCPMalfunction: + message: str = ( + "The hull-mounted ADCP begins returning invalid velocity data. Engineering suspects damage to the cable " + "from recent maintenance activities. The ship must hold position while a technician enters the cable " + "compartment to perform an inspection and continuity test. This diagnostic procedure results in a delay " + "of around 1 hour." + ) + can_reoccur: bool = True + delay_duration: float = 1.0 + instrument_dataclass = ADCP + +@dataclass +class DrifterSatelliteConnectionDelay: + message: str = ( + "The drifter scheduled for deployment fails to establish a satellite connection during " + "pre-launch checks. To improve signal acquisition, the float must be moved to a higher location on deck " + "with fewer obstructions. The team waits for the satellite fix to come through, resulting in a delay " + "of approximately 2 hours." + ) + can_reoccur: bool = True + delay_duration: float = 2.0 + instrument_dataclass = Drifter + +@dataclass +class ArgoSatelliteConnectionDelay: + message: str = ( + "The Argo float scheduled for deployment fails to establish a satellite connection during " + "pre-launch checks. To improve signal acquisition, the float must be moved to a higher location on deck " + "with fewer obstructions. The team waits for the satellite fix to come through, resulting in a delay " + "of approximately 2 hours." + ) + can_reoccur: bool = True + delay_duration: float = 2.0 + instrument_dataclass = ArgoFloat \ No newline at end of file From a55e6f722f7c5382ab9d949ed93f161620ddb1b3 Mon Sep 17 00:00:00 2001 From: Emma Daniels Date: Wed, 26 Nov 2025 11:41:13 +0100 Subject: [PATCH 04/14] nicks changes --- src/virtualship/expedition/simulate_schedule.py | 6 +++--- src/virtualship/make_realistic/problems.py | 17 +++++++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index fb4010fa2..499a4b85d 100644 --- a/src/virtualship/expedition/simulate_schedule.py +++ b/src/virtualship/expedition/simulate_schedule.py @@ -132,10 +132,10 @@ def _return_specific_problem(self): def simulate(self) -> ScheduleOk | ScheduleProblem: for wp_i, waypoint in enumerate(self._expedition.schedule.waypoints): - probability_of_problem = self._calc_prob(waypoint, wp_instruments) # noqa: F821 + # probability_of_problem = self._calc_prob(waypoint, wp_instruments) # noqa: F821 - if probability_of_problem > 1.0: - return self._return_specific_problem() + # if probability_of_problem > 1.0: + # return self._return_specific_problem() # sail towards waypoint self._progress_time_traveling_towards(waypoint.location) diff --git a/src/virtualship/make_realistic/problems.py b/src/virtualship/make_realistic/problems.py index 766d97bae..15bb15bb1 100644 --- a/src/virtualship/make_realistic/problems.py +++ b/src/virtualship/make_realistic/problems.py @@ -9,26 +9,35 @@ from virtualship.instruments.drifter import Drifter from virtualship.instruments.argo_float import ArgoFloat -# base classes +from abc import ABC -class GeneralProblem(pydantic.BaseModel): - """Base class for general problems.""" + +class GeneralProblem: + """Base class for general problems. + + Problems occur at each waypoint.""" message: str can_reoccur: bool + base_probability: float # Probability is a function of time - the longer the expedition the more likely something is to go wrong (not a function of waypoints) delay_duration: float # in hours -class InstrumentProblem(pydantic.BaseModel): + + +class InstrumentProblem: """Base class for instrument-specific problems.""" instrument_dataclass: type message: str can_reoccur: bool + base_probability: float # Probability is a function of time - the longer the expedition the more likely something is to go wrong (not a function of waypoints) delay_duration: float # in hours + + # General problems @dataclass From d81efeb4fc7fea01fd4b161bcdb4bc2ed5040e02 Mon Sep 17 00:00:00 2001 From: Emma Daniels Date: Tue, 2 Dec 2025 08:38:25 +0100 Subject: [PATCH 05/14] still pseudo-code --- src/virtualship/make_realistic/problems.py | 86 ++++++++++++++++++---- 1 file changed, 72 insertions(+), 14 deletions(-) diff --git a/src/virtualship/make_realistic/problems.py b/src/virtualship/make_realistic/problems.py index 15bb15bb1..0180a848d 100644 --- a/src/virtualship/make_realistic/problems.py +++ b/src/virtualship/make_realistic/problems.py @@ -2,16 +2,59 @@ from dataclasses import dataclass -import pydantic - from virtualship.instruments.ctd import CTD from virtualship.instruments.adcp import ADCP from virtualship.instruments.drifter import Drifter from virtualship.instruments.argo_float import ArgoFloat +from virtualship.models import Waypoint -from abc import ABC +@dataclass +class ProblemConfig: + message: str + can_reoccur: bool + base_probability: float # Probability is a function of time - the longer the expedition the more likely something is to go wrong (not a function of waypoints) + delay_duration: float # in hours +def general_proba_function(config: ProblemConfig, waypoint: Waypoint) -> bool: + """Determine if a general problem should occur at a given waypoint.""" + # some random calculation based on the base_probability + return True + +# Pseudo-code for problem probability functions +# def instrument_specific_proba( +# instrument: type, +# ) -> Callable([ProblemConfig, Waypoint], bool): +# """Return a function to determine if an instrument-specific problem should occur.""" + +# def should_occur(config: ProblemConfig, waypoint) -> bool: +# if instrument not in waypoint.instruments: +# return False + +# return general_proba_function(config, waypoint) + +# return should_occur + +# PROBLEMS: list[Tuple[ProblemConfig, Callable[[ProblemConfig, Waypoint], bool]]] = [ +# ( +# ProblemConfig( +# message="General problem occurred", +# can_reoccur=True, +# base_probability=0.1, +# delay_duration=2.0, +# ), +# general_proba_function, +# ), +# ( +# ProblemConfig( +# message="Instrument-specific problem occurred", +# can_reoccur=False, +# base_probability=0.05, +# delay_duration=4.0, +# ), +# instrument_specific_proba(CTD), +# ), +# ] class GeneralProblem: """Base class for general problems. @@ -21,8 +64,7 @@ class GeneralProblem: message: str can_reoccur: bool base_probability: float # Probability is a function of time - the longer the expedition the more likely something is to go wrong (not a function of waypoints) - delay_duration: float # in hours - + delay_duration: float # in hours @@ -50,6 +92,7 @@ class VenomousCentipedeOnboard: ) can_reoccur: bool = False delay_duration: float = 2.0 + base_probability: float = 0.05 @dataclass class CaptainSafetyDrill: @@ -60,16 +103,17 @@ class CaptainSafetyDrill: ) can_reoccur: bool = False delay_duration: float = 2. + base_probability: float = 0.1 -@dataclass -class FoodDeliveryDelayed: - message: str = ( - "The scheduled food delivery prior to departure has not arrived. Until the supply truck reaches the pier, " - "we cannot leave. Once it arrives, unloading and stowing the provisions in the ship’s cold storage " - "will also take additional time. These combined delays postpone departure by approximately 5 hours." - ) - can_reoccur: bool = False - delay_duration: float = 5.0 +# @dataclass +# class FoodDeliveryDelayed: +# message: str = ( +# "The scheduled food delivery prior to departure has not arrived. Until the supply truck reaches the pier, " +# "we cannot leave. Once it arrives, unloading and stowing the provisions in the ship’s cold storage " +# "will also take additional time. These combined delays postpone departure by approximately 5 hours." +# ) +# can_reoccur: bool = False +# delay_duration: float = 5.0 # @dataclass # class FuelDeliveryIssue: @@ -102,6 +146,7 @@ class MarineMammalInDeploymentArea: ) can_reoccur: bool = True delay_duration: float = 0.5 + base_probability: float = 0.1 @dataclass class BallastPumpFailure: @@ -113,6 +158,7 @@ class BallastPumpFailure: ) can_reoccur: bool = True delay_duration: float = 1.0 + base_probability: float = 0.1 @dataclass class ThrusterConverterFault: @@ -123,6 +169,7 @@ class ThrusterConverterFault: ) can_reoccur: bool = False delay_duration: float = 1.0 + base_probability: float = 0.1 @dataclass class AFrameHydraulicLeak: @@ -133,6 +180,7 @@ class AFrameHydraulicLeak: ) can_reoccur: bool = True delay_duration: float = 2.0 + base_probability: float = 0.1 @dataclass class CoolingWaterIntakeBlocked: @@ -143,6 +191,7 @@ class CoolingWaterIntakeBlocked: ) can_reoccur: bool = True delay_duration: float = 1.0 + base_probability: float = 0.1 # Instrument-specific problems @@ -156,6 +205,7 @@ class CTDCableJammed: ) can_reoccur: bool = True delay_duration: float = 3.0 + base_probability: float = 0.1 instrument_dataclass = CTD @dataclass @@ -168,6 +218,7 @@ class CTDTemperatureSensorFailure: ) can_reoccur: bool = True delay_duration: float = 2.0 + base_probability: float = 0.1 instrument_dataclass = CTD @dataclass @@ -179,6 +230,7 @@ class CTDSalinitySensorFailureWithCalibration: ) can_reoccur: bool = True delay_duration: float = 4.0 + base_probability: float = 0.1 instrument_dataclass = CTD @dataclass @@ -191,6 +243,7 @@ class WinchHydraulicPressureDrop: ) can_reoccur: bool = True delay_duration: float = 1.5 + base_probability: float = 0.1 instrument_dataclass = CTD @dataclass @@ -203,6 +256,8 @@ class RosetteTriggerFailure: ) can_reoccur: bool = True delay_duration: float = 2.5 + base_probability: float = 0.1 + instrument_dataclass = CTD @dataclass class ADCPMalfunction: @@ -214,6 +269,7 @@ class ADCPMalfunction: ) can_reoccur: bool = True delay_duration: float = 1.0 + base_probability: float = 0.1 instrument_dataclass = ADCP @dataclass @@ -226,6 +282,7 @@ class DrifterSatelliteConnectionDelay: ) can_reoccur: bool = True delay_duration: float = 2.0 + base_probability: float = 0.1 instrument_dataclass = Drifter @dataclass @@ -238,4 +295,5 @@ class ArgoSatelliteConnectionDelay: ) can_reoccur: bool = True delay_duration: float = 2.0 + base_probability: float = 0.1 instrument_dataclass = ArgoFloat \ No newline at end of file From 07df2696a5ae2d13257d9409bcc279c0d8c0fca6 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 9 Jan 2026 14:07:14 +0100 Subject: [PATCH 06/14] prepare for problem handling logic --- src/virtualship/expedition/simulate_schedule.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index 93f71664d..dda93b3db 100644 --- a/src/virtualship/expedition/simulate_schedule.py +++ b/src/virtualship/expedition/simulate_schedule.py @@ -132,7 +132,11 @@ def _return_specific_problem(self): def simulate(self) -> ScheduleOk | ScheduleProblem: for wp_i, waypoint in enumerate(self._expedition.schedule.waypoints): - # probability_of_problem = self._calc_prob(waypoint, wp_instruments) # noqa: F821 + # TODO: + # TODO: insert method/class here which ingests waypoint model, and handles the logic for determing which problem is occuring at this waypoint/point of this loop + # TODO: this method/class should definitely be housed AWAY from this simulate_schedule.py, and with the rest of the problems logic/classes + + probability_of_problem = self._calc_prob(waypoint, wp_instruments) # noqa: F821 F841 # if probability_of_problem > 1.0: # return self._return_specific_problem() From 2c41f28edca8e07d34f61f8bc4c0b375d96e5d54 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 9 Jan 2026 17:28:18 +0100 Subject: [PATCH 07/14] work in progress... --- .../expedition/simulate_schedule.py | 83 +++++-- src/virtualship/instruments/base.py | 2 +- src/virtualship/make_realistic/__init__.py | 1 + src/virtualship/make_realistic/problems.py | 207 +++++++++++++----- src/virtualship/utils.py | 23 +- 5 files changed, 244 insertions(+), 72 deletions(-) diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index dda93b3db..3a422ffe4 100644 --- a/src/virtualship/expedition/simulate_schedule.py +++ b/src/virtualship/expedition/simulate_schedule.py @@ -3,10 +3,11 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import datetime, timedelta +from datetime import datetime, time, timedelta from typing import ClassVar import pyproj +from yaspin import yaspin from virtualship.instruments.argo_float import ArgoFloat from virtualship.instruments.ctd import CTD @@ -14,6 +15,7 @@ from virtualship.instruments.drifter import Drifter from virtualship.instruments.types import InstrumentType from virtualship.instruments.xbt import XBT +from virtualship.make_realistic.problems import general_problem_select from virtualship.models import ( Expedition, Location, @@ -114,32 +116,87 @@ def __init__(self, projection: pyproj.Geod, expedition: Expedition) -> None: self._next_adcp_time = self._time self._next_ship_underwater_st_time = self._time - def _calc_prob(self, waypoint: Waypoint, wp_instruments) -> float: - """ - Calcuates the probability of a problem occurring at a given waypoint based on the instruments being used. + #! TODO: + # TODO: ... + #! TODO: could all these methods be wrapped up more nicely into a ProblemsSimulator class or similar, imported from problems.py...? + #! TODO: not sure how it would intereact with the pre and post departure logic though and per waypoint logic... - 1) check if want a problem before waypoint 0 - 2) then by waypoint + def _calc_general_prob(self, expedition: Expedition, prob_level: int) -> float: """ + Calcuates probability of a general problem as function of expedition duration and prob-level. - def _return_specific_problem(self): + TODO: and more factors? If not then could combine with _calc_instrument_prob? """ - Return the problem class (e.g. CTDPRoblem_Winch_Failure) based on the instrument type causing the problem OR if general problem (e.g. EngineProblem_FuelLeak). + if prob_level == 0: + return 0.0 - With instructions for re-processing the schedule afterwards. + def _calc_instrument_prob(self, expedition: Expedition, prob_level: int) -> float: + """ + Calcuates probability of instrument-specific problems as function of expedition duration and prob-level. + TODO: and more factors? If not then could combine with _calc_general_prob? """ + if prob_level == 0: + return 0.0 + + def _general_problem_occurrence(self, probability: float, delay: float = 7): + problems_to_execute = general_problem_select(probability=probability) + if len(problems_to_execute) > 0: + for i, problem in enumerate(problems_to_execute): + if problem.pre_departure: + print( + "\nHang on! There could be a pre-departure problem in-port...\n\n" + if i == 0 + else "\nOh no, another pre-departure problem has occurred...!\n\n" + ) + + with yaspin(): + time.sleep(delay) + + problem.execute() + else: + print( + "\nOh no! A problem has occurred during the expedition...\n\n" + if i == 0 + else "\nOh no, another problem has occurred...!\n\n" + ) + + with yaspin(): + time.sleep(delay) + + return problem.delay_duration def simulate(self) -> ScheduleOk | ScheduleProblem: + # TODO: still need to incorp can_reoccur logic somewhere + + # expedition problem probabilities (one probability per expedition, not waypoint) + general_proba = self._calc_general_prob(self._expedition) + instrument_proba = self._calc_instrument_prob(self._expedition) + + #! PRE-EXPEDITION PROBLEMS (general problems only) + if general_proba > 0.0: + # TODO: need to rethink this logic a bit; i.e. needs to feed through that only pre-departure problems are possible here!!!!! + delay_duration = self._general_problem_occurrence(general_proba, delay=7) + for wp_i, waypoint in enumerate(self._expedition.schedule.waypoints): + ##### PROBLEM LOGIC GOES HERE ##### + ##### PROBLEM LOGIC GOES HERE ##### + ##### PROBLEM LOGIC GOES HERE ##### + + if general_proba > 0.0: + delay_duration = self._general_problem_occurrence( + general_proba, delay=7 + ) + + if instrument_proba > 0.0: + ... + # TODO: # TODO: insert method/class here which ingests waypoint model, and handles the logic for determing which problem is occuring at this waypoint/point of this loop # TODO: this method/class should definitely be housed AWAY from this simulate_schedule.py, and with the rest of the problems logic/classes - probability_of_problem = self._calc_prob(waypoint, wp_instruments) # noqa: F821 F841 - - # if probability_of_problem > 1.0: - # return self._return_specific_problem() + #! TODO: do we want the messaging to appear whilst the spinners are running though?! Is it clunky to have it pre all the analysis is actually performed...? + # TODO: think of a way to artificially add the instruments as not occuring until part way through simulations...and during the specific instrument's simulation step if it's an instrument-specific problem # sail towards waypoint self._progress_time_traveling_towards(waypoint.location) diff --git a/src/virtualship/instruments/base.py b/src/virtualship/instruments/base.py index 984e4abf5..3b670478a 100644 --- a/src/virtualship/instruments/base.py +++ b/src/virtualship/instruments/base.py @@ -9,9 +9,9 @@ import copernicusmarine import xarray as xr -from parcels import FieldSet from yaspin import yaspin +from parcels import FieldSet from virtualship.errors import CopernicusCatalogueError from virtualship.utils import ( COPERNICUSMARINE_PHYS_VARIABLES, diff --git a/src/virtualship/make_realistic/__init__.py b/src/virtualship/make_realistic/__init__.py index 2c9a17df7..91ad16847 100644 --- a/src/virtualship/make_realistic/__init__.py +++ b/src/virtualship/make_realistic/__init__.py @@ -2,5 +2,6 @@ from .adcp_make_realistic import adcp_make_realistic from .ctd_make_realistic import ctd_make_realistic +from .problems impor __all__ = ["adcp_make_realistic", "ctd_make_realistic"] diff --git a/src/virtualship/make_realistic/problems.py b/src/virtualship/make_realistic/problems.py index 0180a848d..2e096df7b 100644 --- a/src/virtualship/make_realistic/problems.py +++ b/src/virtualship/make_realistic/problems.py @@ -1,26 +1,43 @@ """This can be where we house both general and instrument-specific problems.""" # noqa: D404 +from __future__ import annotations + +import abc from dataclasses import dataclass +from typing import TYPE_CHECKING -from virtualship.instruments.ctd import CTD from virtualship.instruments.adcp import ADCP -from virtualship.instruments.drifter import Drifter from virtualship.instruments.argo_float import ArgoFloat -from virtualship.models import Waypoint +from virtualship.instruments.ctd import CTD +from virtualship.instruments.drifter import Drifter +from virtualship.instruments.types import InstrumentType +from virtualship.utils import register_general_problem, register_instrument_problem -@dataclass -class ProblemConfig: - message: str - can_reoccur: bool - base_probability: float # Probability is a function of time - the longer the expedition the more likely something is to go wrong (not a function of waypoints) - delay_duration: float # in hours +if TYPE_CHECKING: + from virtualship.models import Waypoint + +# @dataclass +# class ProblemConfig: +# message: str +# can_reoccur: bool +# base_probability: float # Probability is a function of time - the longer the expedition the more likely something is to go wrong (not a function of waypoints) +# delay_duration: float # in hours -def general_proba_function(config: ProblemConfig, waypoint: Waypoint) -> bool: +def general_problem_select() -> bool: """Determine if a general problem should occur at a given waypoint.""" # some random calculation based on the base_probability return True + +def instrument_problem_select(probability: float, waypoint: Waypoint) -> bool: + """Determine if an instrument-specific problem should occur at a given waypoint.""" + # set: waypoint instruments vs. list of instrument-specific problems (automated registry) + # will deterimne which instrument-specific problems are possible at this waypoint + + wp_instruments = waypoint.instruments + + # Pseudo-code for problem probability functions # def instrument_specific_proba( # instrument: type, @@ -56,54 +73,90 @@ def general_proba_function(config: ProblemConfig, waypoint: Waypoint) -> bool: # ), # ] -class GeneralProblem: - """Base class for general problems. - - Problems occur at each waypoint.""" + +##### BASE CLASSES FOR PROBLEMS ##### + + +@dataclass +class GeneralProblem(abc.ABC): + """ + Base class for general problems. + + Problems occur at each waypoint. + """ message: str can_reoccur: bool - base_probability: float # Probability is a function of time - the longer the expedition the more likely something is to go wrong (not a function of waypoints) - delay_duration: float # in hours + base_probability: float # Probability is a function of time - the longer the expedition the more likely something is to go wrong (not a function of waypoints) + delay_duration: float # in hours + pre_departure: bool # True if problem occurs before expedition departure, False if during expedition + @abc.abstractmethod + def is_valid() -> bool: + """Check if the problem can occur based on e.g. waypoint location and/or datetime etc.""" + ... -class InstrumentProblem: +@dataclass +class InstrumentProblem(abc.ABC): """Base class for instrument-specific problems.""" instrument_dataclass: type message: str can_reoccur: bool - base_probability: float # Probability is a function of time - the longer the expedition the more likely something is to go wrong (not a function of waypoints) + base_probability: float # Probability is a function of time - the longer the expedition the more likely something is to go wrong (not a function of waypoints) delay_duration: float # in hours + pre_departure: bool # True if problem can occur before expedition departure, False if during expedition + @abc.abstractmethod + def is_valid() -> bool: + """Check if the problem can occur based on e.g. waypoint location and/or datetime etc.""" + ... +##### Specific Problems ##### -# General problems -@dataclass -class VenomousCentipedeOnboard: +### General problems + + +@register_general_problem +class VenomousCentipedeOnboard(GeneralProblem): + """Problem: Venomous centipede discovered onboard in tropical waters.""" + + # TODO: this needs logic added to the is_valid() method to check if waypoint is in tropical waters + message: str = ( "A venomous centipede is discovered onboard while operating in tropical waters. " "One crew member becomes ill after contact with the creature and receives medical attention, " "prompting a full search of the vessel to ensure no further danger. " "The medical response and search efforts cause an operational delay of about 2 hours." ) - can_reoccur: bool = False - delay_duration: float = 2.0 - base_probability: float = 0.05 + can_reoccur = False + delay_duration = 2.0 + base_probability = 0.05 + pre_departure = False + + def is_valid(self, waypoint: Waypoint) -> bool: + """Check if the waypoint is in tropical waters.""" + lat_limit = 23.5 # [degrees] + return abs(waypoint.latitude) <= lat_limit + + +@register_general_problem +class CaptainSafetyDrill(GeneralProblem): + """Problem: Sudden initiation of a mandatory safety drill.""" -@dataclass -class CaptainSafetyDrill: message: str = ( "A miscommunication with the ship’s captain results in the sudden initiation of a mandatory safety drill. " "The emergency vessel must be lowered and tested while the ship remains stationary, pausing all scientific " "operations for the duration of the exercise. The drill introduces a delay of approximately 2 hours." ) - can_reoccur: bool = False - delay_duration: float = 2. - base_probability: float = 0.1 + can_reoccur: False + delay_duration: 2.0 + base_probability = 0.1 + pre_departure = False + # @dataclass # class FoodDeliveryDelayed: @@ -137,8 +190,11 @@ class CaptainSafetyDrill: # delay_duration: None = None # speed reduction affects ETA instead of fixed delay # ship_speed_knots: float = 8.5 -@dataclass -class MarineMammalInDeploymentArea: + +@register_general_problem +class MarineMammalInDeploymentArea(GeneralProblem): + """Problem: Marine mammals observed in deployment area, causing delay.""" + message: str = ( "A pod of dolphins is observed swimming directly beneath the planned deployment area. " "To avoid risk to wildlife and comply with environmental protocols, all in-water operations " @@ -148,8 +204,11 @@ class MarineMammalInDeploymentArea: delay_duration: float = 0.5 base_probability: float = 0.1 -@dataclass -class BallastPumpFailure: + +@register_general_problem +class BallastPumpFailure(GeneralProblem): + """Problem: Ballast pump failure during ballasting operations.""" + message: str = ( "One of the ship’s ballast pumps suddenly stops responding during routine ballasting operations. " "Without the pump, the vessel cannot safely adjust trim or compensate for equipment movements on deck. " @@ -160,8 +219,11 @@ class BallastPumpFailure: delay_duration: float = 1.0 base_probability: float = 0.1 -@dataclass -class ThrusterConverterFault: + +@register_general_problem +class ThrusterConverterFault(GeneralProblem): + """Problem: Bow thruster's power converter fault during station-keeping.""" + message: str = ( "The bow thruster's power converter reports a fault during station-keeping operations. " "Dynamic positioning becomes less stable, forcing a temporary suspension of high-precision sampling. " @@ -171,8 +233,11 @@ class ThrusterConverterFault: delay_duration: float = 1.0 base_probability: float = 0.1 -@dataclass -class AFrameHydraulicLeak: + +@register_general_problem +class AFrameHydraulicLeak(GeneralProblem): + """Problem: Hydraulic fluid leak from A-frame actuator.""" + message: str = ( "A crew member notices hydraulic fluid leaking from the A-frame actuator during equipment checks. " "The leak must be isolated immediately to prevent environmental contamination or mechanical failure. " @@ -182,8 +247,11 @@ class AFrameHydraulicLeak: delay_duration: float = 2.0 base_probability: float = 0.1 -@dataclass -class CoolingWaterIntakeBlocked: + +@register_general_problem +class CoolingWaterIntakeBlocked(GeneralProblem): + """Problem: Main engine's cooling water intake blocked.""" + message: str = ( "The main engine's cooling water intake alarms indicate reduced flow, likely caused by marine debris " "or biological fouling. The vessel must temporarily slow down while engineering clears the obstruction " @@ -193,10 +261,14 @@ class CoolingWaterIntakeBlocked: delay_duration: float = 1.0 base_probability: float = 0.1 -# Instrument-specific problems -@dataclass -class CTDCableJammed: +### Instrument-specific problems + + +@register_instrument_problem(InstrumentType.CTD) +class CTDCableJammed(InstrumentProblem): + """Problem: CTD cable jammed in winch drum, requiring replacement.""" + message: str = ( "During preparation for the next CTD cast, the CTD cable becomes jammed in the winch drum. " "Attempts to free it are unsuccessful, and the crew determines that the entire cable must be " @@ -208,8 +280,11 @@ class CTDCableJammed: base_probability: float = 0.1 instrument_dataclass = CTD -@dataclass -class CTDTemperatureSensorFailure: + +@register_instrument_problem(InstrumentType.CTD) +class CTDTemperatureSensorFailure(InstrumentProblem): + """Problem: CTD temperature sensor failure, requiring replacement.""" + message: str = ( "The primary temperature sensor on the CTD begins returning inconsistent readings. " "Troubleshooting confirms that the sensor has malfunctioned. A spare unit can be installed, " @@ -221,8 +296,11 @@ class CTDTemperatureSensorFailure: base_probability: float = 0.1 instrument_dataclass = CTD -@dataclass -class CTDSalinitySensorFailureWithCalibration: + +@register_instrument_problem(InstrumentType.CTD) +class CTDSalinitySensorFailureWithCalibration(InstrumentProblem): + """Problem: CTD salinity sensor failure, requiring replacement and calibration.""" + message: str = ( "The CTD’s primary salinity sensor fails and must be replaced with a backup. After installation, " "a mandatory calibration cast to a minimum depth of 1000 meters is required to verify sensor accuracy. " @@ -233,8 +311,11 @@ class CTDSalinitySensorFailureWithCalibration: base_probability: float = 0.1 instrument_dataclass = CTD -@dataclass -class WinchHydraulicPressureDrop: + +@register_instrument_problem(InstrumentType.CTD) +class WinchHydraulicPressureDrop(InstrumentProblem): + """Problem: CTD winch hydraulic pressure drop, requiring repair.""" + message: str = ( "The CTD winch begins to lose hydraulic pressure during routine checks prior to deployment. " "The engineering crew must stop operations to diagnose the hydraulic pump and replenish or repair " @@ -246,8 +327,11 @@ class WinchHydraulicPressureDrop: base_probability: float = 0.1 instrument_dataclass = CTD -@dataclass -class RosetteTriggerFailure: + +@register_instrument_problem(InstrumentType.CTD) +class RosetteTriggerFailure(InstrumentProblem): + """Problem: CTD rosette trigger failure, requiring inspection.""" + message: str = ( "During a CTD cast, the rosette's bottle-triggering mechanism fails to actuate. " "No discrete water samples can be collected during this cast. The rosette must be brought back " @@ -259,8 +343,11 @@ class RosetteTriggerFailure: base_probability: float = 0.1 instrument_dataclass = CTD -@dataclass -class ADCPMalfunction: + +@register_instrument_problem(InstrumentType.ADCP) +class ADCPMalfunction(InstrumentProblem): + """Problem: ADCP returns invalid data, requiring inspection.""" + message: str = ( "The hull-mounted ADCP begins returning invalid velocity data. Engineering suspects damage to the cable " "from recent maintenance activities. The ship must hold position while a technician enters the cable " @@ -272,8 +359,11 @@ class ADCPMalfunction: base_probability: float = 0.1 instrument_dataclass = ADCP -@dataclass -class DrifterSatelliteConnectionDelay: + +@register_instrument_problem(InstrumentType.DRIFTER) +class DrifterSatelliteConnectionDelay(InstrumentProblem): + """Problem: Drifter fails to establish satellite connection before deployment.""" + message: str = ( "The drifter scheduled for deployment fails to establish a satellite connection during " "pre-launch checks. To improve signal acquisition, the float must be moved to a higher location on deck " @@ -285,8 +375,11 @@ class DrifterSatelliteConnectionDelay: base_probability: float = 0.1 instrument_dataclass = Drifter -@dataclass -class ArgoSatelliteConnectionDelay: + +@register_instrument_problem(InstrumentType.ARGO_FLOAT) +class ArgoSatelliteConnectionDelay(InstrumentProblem): + """Problem: Argo float fails to establish satellite connection before deployment.""" + message: str = ( "The Argo float scheduled for deployment fails to establish a satellite connection during " "pre-launch checks. To improve signal acquisition, the float must be moved to a higher location on deck " @@ -296,4 +389,4 @@ class ArgoSatelliteConnectionDelay: can_reoccur: bool = True delay_duration: float = 2.0 base_probability: float = 0.1 - instrument_dataclass = ArgoFloat \ No newline at end of file + instrument_dataclass = ArgoFloat diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index b1926dc65..9d6aa4194 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -13,8 +13,8 @@ import copernicusmarine import numpy as np import xarray as xr -from parcels import FieldSet +from parcels import FieldSet from virtualship.errors import CopernicusCatalogueError if TYPE_CHECKING: @@ -272,6 +272,27 @@ def add_dummy_UV(fieldset: FieldSet): ) from None +# problems inventory registry and registration utilities +INSTRUMENT_PROBLEM_MAP = [] +GENERAL_PROBLEM_REG = [] + + +def register_instrument_problem(instrument_type): + def decorator(cls): + INSTRUMENT_PROBLEM_MAP[instrument_type] = cls + return cls + + return decorator + + +def register_general_problem(): + def decorator(cls): + GENERAL_PROBLEM_REG.append(cls) + return cls + + return decorator + + # Copernicus Marine product IDs PRODUCT_IDS = { From 164789389e7cd3d95c4ac654a2a27ec654327d71 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 12 Jan 2026 14:51:05 +0100 Subject: [PATCH 08/14] moving logic to _run --- src/virtualship/cli/_run.py | 24 ++ .../expedition/simulate_schedule.py | 84 +---- src/virtualship/make_realistic/problems.py | 296 +++++++++++------- 3 files changed, 204 insertions(+), 200 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index f07fbab27..fd16a8a10 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -14,6 +14,7 @@ ScheduleProblem, simulate_schedule, ) +from virtualship.make_realistic.problems import ProblemSimulator from virtualship.models import Schedule from virtualship.models.checkpoint import Checkpoint from virtualship.utils import ( @@ -129,7 +130,30 @@ def _run(expedition_dir: str | Path, from_data: Path | None = None) -> None: instruments_in_expedition = expedition.get_instruments() + # TODO: overview: + # 1) determine all the general AND instrument problems which will occur across the whole expedition + # 2) make a checker function which can be called at various points in the simulation to see if a problem occurs at that point + # - e.g. at pre-departure, at each instrument measurement step, etc. + # - each time a problem occurs, propagate its effects (e.g. delay), log it, save to checkpoint etc. + # TODO: still need to incorp can_reoccur logic somewhere + + # TODO: prob_level needs to be parsed from CLI args + problem_simulator = ProblemSimulator(expedition.schedule, prob_level=...) + problems = problem_simulator.select_problems() + + # check for and execute pre-departure problems + if problems: + problem_simulator.execute(problems, pre_departure=True) + for itype in instruments_in_expedition: + # simulate problems (N.B. it's still possible for general problems to occur during the expedition) + if problems: + problem_simulator.execute( + problems=problems, + pre_departure=False, + instrument_type=itype, + ) + # get instrument class instrument_class = get_instrument_class(itype) if instrument_class is None: diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index 3a422ffe4..e450fcc7c 100644 --- a/src/virtualship/expedition/simulate_schedule.py +++ b/src/virtualship/expedition/simulate_schedule.py @@ -3,11 +3,10 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import datetime, time, timedelta +from datetime import datetime, timedelta from typing import ClassVar import pyproj -from yaspin import yaspin from virtualship.instruments.argo_float import ArgoFloat from virtualship.instruments.ctd import CTD @@ -15,7 +14,6 @@ from virtualship.instruments.drifter import Drifter from virtualship.instruments.types import InstrumentType from virtualship.instruments.xbt import XBT -from virtualship.make_realistic.problems import general_problem_select from virtualship.models import ( Expedition, Location, @@ -116,88 +114,8 @@ def __init__(self, projection: pyproj.Geod, expedition: Expedition) -> None: self._next_adcp_time = self._time self._next_ship_underwater_st_time = self._time - #! TODO: - # TODO: ... - #! TODO: could all these methods be wrapped up more nicely into a ProblemsSimulator class or similar, imported from problems.py...? - #! TODO: not sure how it would intereact with the pre and post departure logic though and per waypoint logic... - - def _calc_general_prob(self, expedition: Expedition, prob_level: int) -> float: - """ - Calcuates probability of a general problem as function of expedition duration and prob-level. - - TODO: and more factors? If not then could combine with _calc_instrument_prob? - """ - if prob_level == 0: - return 0.0 - - def _calc_instrument_prob(self, expedition: Expedition, prob_level: int) -> float: - """ - Calcuates probability of instrument-specific problems as function of expedition duration and prob-level. - - TODO: and more factors? If not then could combine with _calc_general_prob? - """ - if prob_level == 0: - return 0.0 - - def _general_problem_occurrence(self, probability: float, delay: float = 7): - problems_to_execute = general_problem_select(probability=probability) - if len(problems_to_execute) > 0: - for i, problem in enumerate(problems_to_execute): - if problem.pre_departure: - print( - "\nHang on! There could be a pre-departure problem in-port...\n\n" - if i == 0 - else "\nOh no, another pre-departure problem has occurred...!\n\n" - ) - - with yaspin(): - time.sleep(delay) - - problem.execute() - else: - print( - "\nOh no! A problem has occurred during the expedition...\n\n" - if i == 0 - else "\nOh no, another problem has occurred...!\n\n" - ) - - with yaspin(): - time.sleep(delay) - - return problem.delay_duration - def simulate(self) -> ScheduleOk | ScheduleProblem: - # TODO: still need to incorp can_reoccur logic somewhere - - # expedition problem probabilities (one probability per expedition, not waypoint) - general_proba = self._calc_general_prob(self._expedition) - instrument_proba = self._calc_instrument_prob(self._expedition) - - #! PRE-EXPEDITION PROBLEMS (general problems only) - if general_proba > 0.0: - # TODO: need to rethink this logic a bit; i.e. needs to feed through that only pre-departure problems are possible here!!!!! - delay_duration = self._general_problem_occurrence(general_proba, delay=7) - for wp_i, waypoint in enumerate(self._expedition.schedule.waypoints): - ##### PROBLEM LOGIC GOES HERE ##### - ##### PROBLEM LOGIC GOES HERE ##### - ##### PROBLEM LOGIC GOES HERE ##### - - if general_proba > 0.0: - delay_duration = self._general_problem_occurrence( - general_proba, delay=7 - ) - - if instrument_proba > 0.0: - ... - - # TODO: - # TODO: insert method/class here which ingests waypoint model, and handles the logic for determing which problem is occuring at this waypoint/point of this loop - # TODO: this method/class should definitely be housed AWAY from this simulate_schedule.py, and with the rest of the problems logic/classes - - #! TODO: do we want the messaging to appear whilst the spinners are running though?! Is it clunky to have it pre all the analysis is actually performed...? - # TODO: think of a way to artificially add the instruments as not occuring until part way through simulations...and during the specific instrument's simulation step if it's an instrument-specific problem - # sail towards waypoint self._progress_time_traveling_towards(waypoint.location) diff --git a/src/virtualship/make_realistic/problems.py b/src/virtualship/make_realistic/problems.py index 2e096df7b..090da1b69 100644 --- a/src/virtualship/make_realistic/problems.py +++ b/src/virtualship/make_realistic/problems.py @@ -3,75 +3,135 @@ from __future__ import annotations import abc +import time from dataclasses import dataclass +from pathlib import Path from typing import TYPE_CHECKING -from virtualship.instruments.adcp import ADCP -from virtualship.instruments.argo_float import ArgoFloat -from virtualship.instruments.ctd import CTD -from virtualship.instruments.drifter import Drifter +from yaspin import yaspin + +from virtualship.cli._run import _save_checkpoint from virtualship.instruments.types import InstrumentType -from virtualship.utils import register_general_problem, register_instrument_problem +from virtualship.models.checkpoint import Checkpoint +from virtualship.utils import ( + CHECKPOINT, + register_general_problem, + register_instrument_problem, +) if TYPE_CHECKING: - from virtualship.models import Waypoint - -# @dataclass -# class ProblemConfig: -# message: str -# can_reoccur: bool -# base_probability: float # Probability is a function of time - the longer the expedition the more likely something is to go wrong (not a function of waypoints) -# delay_duration: float # in hours - - -def general_problem_select() -> bool: - """Determine if a general problem should occur at a given waypoint.""" - # some random calculation based on the base_probability - return True - - -def instrument_problem_select(probability: float, waypoint: Waypoint) -> bool: - """Determine if an instrument-specific problem should occur at a given waypoint.""" - # set: waypoint instruments vs. list of instrument-specific problems (automated registry) - # will deterimne which instrument-specific problems are possible at this waypoint - - wp_instruments = waypoint.instruments - - -# Pseudo-code for problem probability functions -# def instrument_specific_proba( -# instrument: type, -# ) -> Callable([ProblemConfig, Waypoint], bool): -# """Return a function to determine if an instrument-specific problem should occur.""" - -# def should_occur(config: ProblemConfig, waypoint) -> bool: -# if instrument not in waypoint.instruments: -# return False - -# return general_proba_function(config, waypoint) - -# return should_occur - -# PROBLEMS: list[Tuple[ProblemConfig, Callable[[ProblemConfig, Waypoint], bool]]] = [ -# ( -# ProblemConfig( -# message="General problem occurred", -# can_reoccur=True, -# base_probability=0.1, -# delay_duration=2.0, -# ), -# general_proba_function, -# ), -# ( -# ProblemConfig( -# message="Instrument-specific problem occurred", -# can_reoccur=False, -# base_probability=0.05, -# delay_duration=4.0, -# ), -# instrument_specific_proba(CTD), -# ), -# ] + from virtualship.models import Schedule, Waypoint + + +LOG_MESSAGING = { + "first_pre_departure": "\nHang on! There could be a pre-departure problem in-port...\n\n", + "subsequent_pre_departure": "\nOh no, another pre-departure problem has occurred...!\n\n", + "first_during_expedition": "\nOh no, a problem has occurred during the expedition...!\n\n", + "subsequent_during_expedition": "\nAnother problem has occurred during the expedition...!\n\n", +} + + +class ProblemSimulator: + """Handle problem simulation during expedition.""" + + # TODO: incorporate some kind of knowledge of at which waypoint the problems occur to provide this feedback to the user and also to save in the checkpoint yaml! + + def __init__(self, schedule: Schedule, prob_level: int, expedition_dir: str | Path): + """Initialise ProblemSimulator with a schedule and probability level.""" + self.schedule = schedule + self.prob_level = prob_level + self.expedition_dir = Path(expedition_dir) + + def select_problems( + self, + ) -> dict[str, list[GeneralProblem | InstrumentProblem]] | None: + """Propagate both general and instrument problems.""" + probability = self._calc_prob() + if probability > 0.0: + problems = {} + problems["general"] = self._general_problem_select(probability) + problems["instrument"] = self._instrument_problem_select(probability) + return problems + else: + return None + + def execute( + self, + problems: dict[str, list[GeneralProblem | InstrumentProblem]], + pre_departure: bool, + instrument_type: InstrumentType | None = None, + log_delay: float = 7.0, + ): + """Execute the selected problems, returning messaging and delay times.""" + for i, problem in enumerate(problems["general"]): + if pre_departure and problem.pre_departure: + print( + LOG_MESSAGING["first_pre_departure"] + if i == 0 + else LOG_MESSAGING["subsequent_pre_departure"] + ) + else: + if not pre_departure and not problem.pre_departure: + print( + LOG_MESSAGING["first_during_expedition"] + if i == 0 + else LOG_MESSAGING["subsequent_during_expedition"] + ) + with yaspin(): + time.sleep(log_delay) + + # provide problem-specific messaging + print(problem.message) + + # save to pause expedition and save to checkpoint + print( + f"\n\nSIMULATION PAUSED: update your schedule (`virtualship plan`) and continue the expedition by executing the `virtualship run` command again.\nCheckpoint has been saved to {self.expedition_dir.joinpath(CHECKPOINT)}." + ) + _save_checkpoint( + Checkpoint( + past_schedule=Schedule( + waypoints=self.schedule.waypoints[ + : schedule_results.failed_waypoint_i + ] + ) + ), + self.expedition_dir, + ) + + def _propagate_general_problems(self): + """Propagate general problems based on probability.""" + probability = self._calc_general_prob(self.schedule, prob_level=self.prob_level) + return self._general_problem_select(probability) + + def _propagate_instrument_problems(self): + """Propagate instrument problems based on probability.""" + probability = self._calc_instrument_prob( + self.schedule, prob_level=self.prob_level + ) + return self._instrument_problem_select(probability) + + def _calc_prob(self) -> float: + """ + Calcuates probability of a general problem as function of expedition duration and prob-level. + + TODO: for now, general and instrument-specific problems have the same probability of occurence. Separating this out and allowing their probabilities to be set independently may be useful in future. + """ + if self.prob_level == 0: + return 0.0 + + def _general_problem_select(self) -> list[GeneralProblem]: + """Select which problems. Higher probability (tied to expedition duration) means more problems are likely to occur.""" + ... + return [] + + def _instrument_problem_select(self) -> list[InstrumentProblem]: + """Select which problems. Higher probability (tied to expedition duration) means more problems are likely to occur.""" + # set: waypoint instruments vs. list of instrument-specific problems (automated registry) + # will deterimne which instrument-specific problems are possible at this waypoint + + wp_instruments = self.schedule.waypoints.instruments + + return [] ##### BASE CLASSES FOR PROBLEMS ##### @@ -158,37 +218,39 @@ class CaptainSafetyDrill(GeneralProblem): pre_departure = False -# @dataclass -# class FoodDeliveryDelayed: -# message: str = ( -# "The scheduled food delivery prior to departure has not arrived. Until the supply truck reaches the pier, " -# "we cannot leave. Once it arrives, unloading and stowing the provisions in the ship’s cold storage " -# "will also take additional time. These combined delays postpone departure by approximately 5 hours." -# ) -# can_reoccur: bool = False -# delay_duration: float = 5.0 - -# @dataclass -# class FuelDeliveryIssue: -# message: str = ( -# "The fuel tanker expected to deliver fuel has not arrived. Port authorities are unable to provide " -# "a clear estimate for when the delivery might occur. You may choose to [w]ait for the tanker or [g]et a " -# "harbor pilot to guide the vessel to an available bunker dock instead. This decision may need to be " -# "revisited periodically depending on circumstances." -# ) -# can_reoccur: bool = False -# delay_duration: float = 0.0 # dynamic delays based on repeated choices - -# @dataclass -# class EngineOverheating: -# message: str = ( -# "One of the main engines has overheated. To prevent further damage, the engineering team orders a reduction " -# "in vessel speed until the engine can be inspected and repaired in port. The ship will now operate at a " -# "reduced cruising speed of 8.5 knots for the remainder of the transit." -# ) -# can_reoccur: bool = False -# delay_duration: None = None # speed reduction affects ETA instead of fixed delay -# ship_speed_knots: float = 8.5 +@dataclass +class FoodDeliveryDelayed: + message: str = ( + "The scheduled food delivery prior to departure has not arrived. Until the supply truck reaches the pier, " + "we cannot leave. Once it arrives, unloading and stowing the provisions in the ship’s cold storage " + "will also take additional time. These combined delays postpone departure by approximately 5 hours." + ) + can_reoccur: bool = False + delay_duration: float = 5.0 + + +@dataclass +class FuelDeliveryIssue: + message: str = ( + "The fuel tanker expected to deliver fuel has not arrived. Port authorities are unable to provide " + "a clear estimate for when the delivery might occur. You may choose to [w]ait for the tanker or [g]et a " + "harbor pilot to guide the vessel to an available bunker dock instead. This decision may need to be " + "revisited periodically depending on circumstances." + ) + can_reoccur: bool = False + delay_duration: float = 0.0 # dynamic delays based on repeated choices + + +@dataclass +class EngineOverheating: + message: str = ( + "One of the main engines has overheated. To prevent further damage, the engineering team orders a reduction " + "in vessel speed until the engine can be inspected and repaired in port. The ship will now operate at a " + "reduced cruising speed of 8.5 knots for the remainder of the transit." + ) + can_reoccur: bool = False + delay_duration: None = None # speed reduction affects ETA instead of fixed delay + ship_speed_knots: float = 8.5 @register_general_problem @@ -278,7 +340,23 @@ class CTDCableJammed(InstrumentProblem): can_reoccur: bool = True delay_duration: float = 3.0 base_probability: float = 0.1 - instrument_dataclass = CTD + instrument_type = InstrumentType.CTD + + +@register_instrument_problem(InstrumentType.ADCP) +class ADCPMalfunction(InstrumentProblem): + """Problem: ADCP returns invalid data, requiring inspection.""" + + message: str = ( + "The hull-mounted ADCP begins returning invalid velocity data. Engineering suspects damage to the cable " + "from recent maintenance activities. The ship must hold position while a technician enters the cable " + "compartment to perform an inspection and continuity test. This diagnostic procedure results in a delay " + "of around 1 hour." + ) + can_reoccur: bool = True + delay_duration: float = 1.0 + base_probability: float = 0.1 + instrument_type = InstrumentType.ADCP @register_instrument_problem(InstrumentType.CTD) @@ -294,7 +372,7 @@ class CTDTemperatureSensorFailure(InstrumentProblem): can_reoccur: bool = True delay_duration: float = 2.0 base_probability: float = 0.1 - instrument_dataclass = CTD + instrument_type = InstrumentType.CTD @register_instrument_problem(InstrumentType.CTD) @@ -309,7 +387,7 @@ class CTDSalinitySensorFailureWithCalibration(InstrumentProblem): can_reoccur: bool = True delay_duration: float = 4.0 base_probability: float = 0.1 - instrument_dataclass = CTD + instrument_type = InstrumentType.CTD @register_instrument_problem(InstrumentType.CTD) @@ -325,7 +403,7 @@ class WinchHydraulicPressureDrop(InstrumentProblem): can_reoccur: bool = True delay_duration: float = 1.5 base_probability: float = 0.1 - instrument_dataclass = CTD + instrument_type = InstrumentType.CTD @register_instrument_problem(InstrumentType.CTD) @@ -341,23 +419,7 @@ class RosetteTriggerFailure(InstrumentProblem): can_reoccur: bool = True delay_duration: float = 2.5 base_probability: float = 0.1 - instrument_dataclass = CTD - - -@register_instrument_problem(InstrumentType.ADCP) -class ADCPMalfunction(InstrumentProblem): - """Problem: ADCP returns invalid data, requiring inspection.""" - - message: str = ( - "The hull-mounted ADCP begins returning invalid velocity data. Engineering suspects damage to the cable " - "from recent maintenance activities. The ship must hold position while a technician enters the cable " - "compartment to perform an inspection and continuity test. This diagnostic procedure results in a delay " - "of around 1 hour." - ) - can_reoccur: bool = True - delay_duration: float = 1.0 - base_probability: float = 0.1 - instrument_dataclass = ADCP + instrument_type = InstrumentType.CTD @register_instrument_problem(InstrumentType.DRIFTER) @@ -373,7 +435,7 @@ class DrifterSatelliteConnectionDelay(InstrumentProblem): can_reoccur: bool = True delay_duration: float = 2.0 base_probability: float = 0.1 - instrument_dataclass = Drifter + instrument_type = InstrumentType.DRIFTER @register_instrument_problem(InstrumentType.ARGO_FLOAT) @@ -389,4 +451,4 @@ class ArgoSatelliteConnectionDelay(InstrumentProblem): can_reoccur: bool = True delay_duration: float = 2.0 base_probability: float = 0.1 - instrument_dataclass = ArgoFloat + instrument_type = InstrumentType.ARGO_FLOAT From 4b6f1258dc2888e83a7341e295226eaaae76b1c3 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:10:54 +0100 Subject: [PATCH 09/14] separate problem classes and separation logic --- src/virtualship/make_realistic/problems.py | 137 ++------------------ src/virtualship/make_realistic/simulator.py | 127 ++++++++++++++++++ 2 files changed, 137 insertions(+), 127 deletions(-) create mode 100644 src/virtualship/make_realistic/simulator.py diff --git a/src/virtualship/make_realistic/problems.py b/src/virtualship/make_realistic/problems.py index 090da1b69..80772c13c 100644 --- a/src/virtualship/make_realistic/problems.py +++ b/src/virtualship/make_realistic/problems.py @@ -1,140 +1,22 @@ -"""This can be where we house both general and instrument-specific problems.""" # noqa: D404 - from __future__ import annotations import abc -import time from dataclasses import dataclass -from pathlib import Path from typing import TYPE_CHECKING -from yaspin import yaspin - -from virtualship.cli._run import _save_checkpoint from virtualship.instruments.types import InstrumentType -from virtualship.models.checkpoint import Checkpoint from virtualship.utils import ( - CHECKPOINT, register_general_problem, register_instrument_problem, ) if TYPE_CHECKING: - from virtualship.models import Schedule, Waypoint - - -LOG_MESSAGING = { - "first_pre_departure": "\nHang on! There could be a pre-departure problem in-port...\n\n", - "subsequent_pre_departure": "\nOh no, another pre-departure problem has occurred...!\n\n", - "first_during_expedition": "\nOh no, a problem has occurred during the expedition...!\n\n", - "subsequent_during_expedition": "\nAnother problem has occurred during the expedition...!\n\n", -} - - -class ProblemSimulator: - """Handle problem simulation during expedition.""" - - # TODO: incorporate some kind of knowledge of at which waypoint the problems occur to provide this feedback to the user and also to save in the checkpoint yaml! - - def __init__(self, schedule: Schedule, prob_level: int, expedition_dir: str | Path): - """Initialise ProblemSimulator with a schedule and probability level.""" - self.schedule = schedule - self.prob_level = prob_level - self.expedition_dir = Path(expedition_dir) - - def select_problems( - self, - ) -> dict[str, list[GeneralProblem | InstrumentProblem]] | None: - """Propagate both general and instrument problems.""" - probability = self._calc_prob() - if probability > 0.0: - problems = {} - problems["general"] = self._general_problem_select(probability) - problems["instrument"] = self._instrument_problem_select(probability) - return problems - else: - return None - - def execute( - self, - problems: dict[str, list[GeneralProblem | InstrumentProblem]], - pre_departure: bool, - instrument_type: InstrumentType | None = None, - log_delay: float = 7.0, - ): - """Execute the selected problems, returning messaging and delay times.""" - for i, problem in enumerate(problems["general"]): - if pre_departure and problem.pre_departure: - print( - LOG_MESSAGING["first_pre_departure"] - if i == 0 - else LOG_MESSAGING["subsequent_pre_departure"] - ) - else: - if not pre_departure and not problem.pre_departure: - print( - LOG_MESSAGING["first_during_expedition"] - if i == 0 - else LOG_MESSAGING["subsequent_during_expedition"] - ) - with yaspin(): - time.sleep(log_delay) - - # provide problem-specific messaging - print(problem.message) - - # save to pause expedition and save to checkpoint - print( - f"\n\nSIMULATION PAUSED: update your schedule (`virtualship plan`) and continue the expedition by executing the `virtualship run` command again.\nCheckpoint has been saved to {self.expedition_dir.joinpath(CHECKPOINT)}." - ) - _save_checkpoint( - Checkpoint( - past_schedule=Schedule( - waypoints=self.schedule.waypoints[ - : schedule_results.failed_waypoint_i - ] - ) - ), - self.expedition_dir, - ) - - def _propagate_general_problems(self): - """Propagate general problems based on probability.""" - probability = self._calc_general_prob(self.schedule, prob_level=self.prob_level) - return self._general_problem_select(probability) - - def _propagate_instrument_problems(self): - """Propagate instrument problems based on probability.""" - probability = self._calc_instrument_prob( - self.schedule, prob_level=self.prob_level - ) - return self._instrument_problem_select(probability) - - def _calc_prob(self) -> float: - """ - Calcuates probability of a general problem as function of expedition duration and prob-level. - - TODO: for now, general and instrument-specific problems have the same probability of occurence. Separating this out and allowing their probabilities to be set independently may be useful in future. - """ - if self.prob_level == 0: - return 0.0 - - def _general_problem_select(self) -> list[GeneralProblem]: - """Select which problems. Higher probability (tied to expedition duration) means more problems are likely to occur.""" - ... - return [] - - def _instrument_problem_select(self) -> list[InstrumentProblem]: - """Select which problems. Higher probability (tied to expedition duration) means more problems are likely to occur.""" - # set: waypoint instruments vs. list of instrument-specific problems (automated registry) - # will deterimne which instrument-specific problems are possible at this waypoint + from virtualship.models import Waypoint - wp_instruments = self.schedule.waypoints.instruments - return [] - - -##### BASE CLASSES FOR PROBLEMS ##### +# ===================================================== +# SECTION: Base Classes +# ===================================================== @dataclass @@ -174,10 +56,9 @@ def is_valid() -> bool: ... -##### Specific Problems ##### - - -### General problems +# ===================================================== +# SECTION: General Problem Classes +# ===================================================== @register_general_problem @@ -324,7 +205,9 @@ class CoolingWaterIntakeBlocked(GeneralProblem): base_probability: float = 0.1 -### Instrument-specific problems +# ===================================================== +# SECTION: Instrument-specific Problem Classes +# ===================================================== @register_instrument_problem(InstrumentType.CTD) diff --git a/src/virtualship/make_realistic/simulator.py b/src/virtualship/make_realistic/simulator.py new file mode 100644 index 000000000..f36a6c03c --- /dev/null +++ b/src/virtualship/make_realistic/simulator.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import time +from pathlib import Path +from typing import TYPE_CHECKING + +from yaspin import yaspin + +from virtualship.cli._run import _save_checkpoint +from virtualship.instruments.types import InstrumentType +from virtualship.make_realistic.problems import GeneralProblem, InstrumentProblem +from virtualship.models.checkpoint import Checkpoint +from virtualship.utils import ( + CHECKPOINT, +) + +if TYPE_CHECKING: + from virtualship.models import Schedule + + +LOG_MESSAGING = { + "first_pre_departure": "\nHang on! There could be a pre-departure problem in-port...\n\n", + "subsequent_pre_departure": "\nOh no, another pre-departure problem has occurred...!\n\n", + "first_during_expedition": "\nOh no, a problem has occurred during the expedition...!\n\n", + "subsequent_during_expedition": "\nAnother problem has occurred during the expedition...!\n\n", +} + + +class ProblemSimulator: + """Handle problem simulation during expedition.""" + + def __init__(self, schedule: Schedule, prob_level: int, expedition_dir: str | Path): + """Initialise ProblemSimulator with a schedule and probability level.""" + self.schedule = schedule + self.prob_level = prob_level + self.expedition_dir = Path(expedition_dir) + + def select_problems( + self, + ) -> dict[str, list[GeneralProblem | InstrumentProblem]] | None: + """Propagate both general and instrument problems.""" + probability = self._calc_prob() + if probability > 0.0: + problems = {} + problems["general"] = self._general_problem_select(probability) + problems["instrument"] = self._instrument_problem_select(probability) + return problems + else: + return None + + def execute( + self, + problems: dict[str, list[GeneralProblem | InstrumentProblem]], + pre_departure: bool, + instrument_type: InstrumentType | None = None, + log_delay: float = 7.0, + ): + """Execute the selected problems, returning messaging and delay times.""" + for i, problem in enumerate(problems["general"]): + if pre_departure and problem.pre_departure: + print( + LOG_MESSAGING["first_pre_departure"] + if i == 0 + else LOG_MESSAGING["subsequent_pre_departure"] + ) + else: + if not pre_departure and not problem.pre_departure: + print( + LOG_MESSAGING["first_during_expedition"] + if i == 0 + else LOG_MESSAGING["subsequent_during_expedition"] + ) + with yaspin(): + time.sleep(log_delay) + + # provide problem-specific messaging + print(problem.message) + + # save to pause expedition and save to checkpoint + print( + f"\n\nSIMULATION PAUSED: update your schedule (`virtualship plan`) and continue the expedition by executing the `virtualship run` command again.\nCheckpoint has been saved to {self.expedition_dir.joinpath(CHECKPOINT)}." + ) + _save_checkpoint( + Checkpoint( + past_schedule=Schedule( + waypoints=self.schedule.waypoints[ + : schedule_results.failed_waypoint_i + ] + ) + ), + self.expedition_dir, + ) + + def _propagate_general_problems(self): + """Propagate general problems based on probability.""" + probability = self._calc_general_prob(self.schedule, prob_level=self.prob_level) + return self._general_problem_select(probability) + + def _propagate_instrument_problems(self): + """Propagate instrument problems based on probability.""" + probability = self._calc_instrument_prob( + self.schedule, prob_level=self.prob_level + ) + return self._instrument_problem_select(probability) + + def _calc_prob(self) -> float: + """ + Calcuates probability of a general problem as function of expedition duration and prob-level. + + TODO: for now, general and instrument-specific problems have the same probability of occurence. Separating this out and allowing their probabilities to be set independently may be useful in future. + """ + if self.prob_level == 0: + return 0.0 + + def _general_problem_select(self) -> list[GeneralProblem]: + """Select which problems. Higher probability (tied to expedition duration) means more problems are likely to occur.""" + ... + return [] + + def _instrument_problem_select(self) -> list[InstrumentProblem]: + """Select which problems. Higher probability (tied to expedition duration) means more problems are likely to occur.""" + # set: waypoint instruments vs. list of instrument-specific problems (automated registry) + # will deterimne which instrument-specific problems are possible at this waypoint + + wp_instruments = self.schedule.waypoints.instruments + + return [] From d68738df95791a5d65055399ae0ba5368626f675 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 13 Jan 2026 11:37:35 +0100 Subject: [PATCH 10/14] re-structure --- src/virtualship/cli/_run.py | 4 +-- .../{problems.py => problems/scenarios.py} | 0 .../{ => problems}/simulator.py | 29 ++++++++++--------- 3 files changed, 17 insertions(+), 16 deletions(-) rename src/virtualship/make_realistic/{problems.py => problems/scenarios.py} (100%) rename src/virtualship/make_realistic/{ => problems}/simulator.py (78%) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index fd16a8a10..5c3a5f9b5 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -14,7 +14,7 @@ ScheduleProblem, simulate_schedule, ) -from virtualship.make_realistic.problems import ProblemSimulator +from virtualship.make_realistic.problems.simulator import ProblemSimulator from virtualship.models import Schedule from virtualship.models.checkpoint import Checkpoint from virtualship.utils import ( @@ -74,7 +74,7 @@ def _run(expedition_dir: str | Path, from_data: Path | None = None) -> None: expedition = _get_expedition(expedition_dir) - # Verify instruments_config file is consistent with schedule + # verify instruments_config file is consistent with schedule expedition.instruments_config.verify(expedition) # load last checkpoint diff --git a/src/virtualship/make_realistic/problems.py b/src/virtualship/make_realistic/problems/scenarios.py similarity index 100% rename from src/virtualship/make_realistic/problems.py rename to src/virtualship/make_realistic/problems/scenarios.py diff --git a/src/virtualship/make_realistic/simulator.py b/src/virtualship/make_realistic/problems/simulator.py similarity index 78% rename from src/virtualship/make_realistic/simulator.py rename to src/virtualship/make_realistic/problems/simulator.py index f36a6c03c..bebc07feb 100644 --- a/src/virtualship/make_realistic/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -6,15 +6,16 @@ from yaspin import yaspin -from virtualship.cli._run import _save_checkpoint from virtualship.instruments.types import InstrumentType -from virtualship.make_realistic.problems import GeneralProblem, InstrumentProblem -from virtualship.models.checkpoint import Checkpoint from virtualship.utils import ( CHECKPOINT, ) if TYPE_CHECKING: + from virtualship.make_realistic.problems.scenarios import ( + GeneralProblem, + InstrumentProblem, + ) from virtualship.models import Schedule @@ -23,6 +24,8 @@ "subsequent_pre_departure": "\nOh no, another pre-departure problem has occurred...!\n\n", "first_during_expedition": "\nOh no, a problem has occurred during the expedition...!\n\n", "subsequent_during_expedition": "\nAnother problem has occurred during the expedition...!\n\n", + "simulation_paused": "\nSIMULATION PAUSED: update your schedule (`virtualship plan`) and continue the expedition by executing the `virtualship run` command again.\nCheckpoint has been saved to {checkpoint_path}.\n", + "problem_avoided:": "\nPhew! You had enough contingency time scheduled to avoid delays from this problem. The expedition can carry on!\n", } @@ -77,20 +80,18 @@ def execute( print(problem.message) # save to pause expedition and save to checkpoint + print( - f"\n\nSIMULATION PAUSED: update your schedule (`virtualship plan`) and continue the expedition by executing the `virtualship run` command again.\nCheckpoint has been saved to {self.expedition_dir.joinpath(CHECKPOINT)}." - ) - _save_checkpoint( - Checkpoint( - past_schedule=Schedule( - waypoints=self.schedule.waypoints[ - : schedule_results.failed_waypoint_i - ] - ) - ), - self.expedition_dir, + LOG_MESSAGING["simulation_paused"].format( + checkpoint_path=self.expedition_dir.joinpath(CHECKPOINT) + ) ) + # TODO: integration with which zarr files have been written so far + # TODO: plus a checkpoint file to assess whether the user has indeed also made the necessary changes to the schedule as required by the problem's delay_duration + # - in here also comes the logic for whether the user has already scheduled in enough contingency time to account for the problem's delay_duration, and they get a well done message if so + #! - may have to be that make a note of it during the simulate_schedule (and feed it forward), otherwise won't know which waypoint(s)... + def _propagate_general_problems(self): """Propagate general problems based on probability.""" probability = self._calc_general_prob(self.schedule, prob_level=self.prob_level) From d27bbc53c6364c51f695cbab5becec932fdddb59 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Wed, 14 Jan 2026 09:19:37 +0100 Subject: [PATCH 11/14] progressing changes across simuate_schedule and _run --- src/virtualship/cli/_run.py | 42 +++--- .../expedition/simulate_schedule.py | 5 +- .../make_realistic/problems/scenarios.py | 53 +++---- .../make_realistic/problems/simulator.py | 129 ++++++++++++++---- src/virtualship/models/__init__.py | 2 + src/virtualship/utils.py | 11 +- 6 files changed, 162 insertions(+), 80 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index 5c3a5f9b5..b0d680749 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -15,11 +15,11 @@ simulate_schedule, ) from virtualship.make_realistic.problems.simulator import ProblemSimulator -from virtualship.models import Schedule -from virtualship.models.checkpoint import Checkpoint +from virtualship.models import Checkpoint, Schedule from virtualship.utils import ( CHECKPOINT, _get_expedition, + _save_checkpoint, expedition_cost, get_instrument_class, ) @@ -92,10 +92,22 @@ def _run(expedition_dir: str | Path, from_data: Path | None = None) -> None: from_data=Path(from_data) if from_data else None, ) + # TODO: overview: + # 1) determine all the general AND instrument problems which will occur across the whole expedition + # 2) make a checker function which can be called at various points in the simulation to see if a problem occurs at that point + # - e.g. at pre-departure, at each instrument measurement step, etc. + # - each time a problem occurs, propagate its effects (e.g. delay), log it, save to checkpoint etc. + # TODO: still need to incorp can_reoccur logic somewhere + + # TODO: prob_level needs to be parsed from CLI args + problem_simulator = ProblemSimulator(expedition.schedule, prob_level=...) + problems = problem_simulator.select_problems() + # simulate the schedule schedule_results = simulate_schedule( projection=projection, expedition=expedition, + problems=problems if problems else None, ) if isinstance(schedule_results, ScheduleProblem): print( @@ -130,27 +142,12 @@ def _run(expedition_dir: str | Path, from_data: Path | None = None) -> None: instruments_in_expedition = expedition.get_instruments() - # TODO: overview: - # 1) determine all the general AND instrument problems which will occur across the whole expedition - # 2) make a checker function which can be called at various points in the simulation to see if a problem occurs at that point - # - e.g. at pre-departure, at each instrument measurement step, etc. - # - each time a problem occurs, propagate its effects (e.g. delay), log it, save to checkpoint etc. - # TODO: still need to incorp can_reoccur logic somewhere - - # TODO: prob_level needs to be parsed from CLI args - problem_simulator = ProblemSimulator(expedition.schedule, prob_level=...) - problems = problem_simulator.select_problems() - - # check for and execute pre-departure problems - if problems: - problem_simulator.execute(problems, pre_departure=True) - - for itype in instruments_in_expedition: - # simulate problems (N.B. it's still possible for general problems to occur during the expedition) + for i, itype in enumerate(instruments_in_expedition): + # propagate problems (pre-departure problems will only occur in first iteration) if problems: problem_simulator.execute( problems=problems, - pre_departure=False, + pre_departure=True if i == 0 else False, instrument_type=itype, ) @@ -198,11 +195,6 @@ def _load_checkpoint(expedition_dir: Path) -> Checkpoint | None: return None -def _save_checkpoint(checkpoint: Checkpoint, expedition_dir: Path) -> None: - file_path = expedition_dir.joinpath(CHECKPOINT) - checkpoint.to_yaml(file_path) - - def _write_expedition_cost(expedition, schedule_results, expedition_dir): """Calculate the expedition cost, write it to a file, and print summary.""" assert expedition.schedule.waypoints[0].time is not None, ( diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index e450fcc7c..e6825603e 100644 --- a/src/virtualship/expedition/simulate_schedule.py +++ b/src/virtualship/expedition/simulate_schedule.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from datetime import datetime, timedelta -from typing import ClassVar +from typing import TYPE_CHECKING, ClassVar import pyproj @@ -21,6 +21,9 @@ Waypoint, ) +if TYPE_CHECKING: + pass + @dataclass class ScheduleOk: diff --git a/src/virtualship/make_realistic/problems/scenarios.py b/src/virtualship/make_realistic/problems/scenarios.py index 80772c13c..76ffc2ffa 100644 --- a/src/virtualship/make_realistic/problems/scenarios.py +++ b/src/virtualship/make_realistic/problems/scenarios.py @@ -2,6 +2,7 @@ import abc from dataclasses import dataclass +from datetime import timedelta from typing import TYPE_CHECKING from virtualship.instruments.types import InstrumentType @@ -30,7 +31,7 @@ class GeneralProblem(abc.ABC): message: str can_reoccur: bool base_probability: float # Probability is a function of time - the longer the expedition the more likely something is to go wrong (not a function of waypoints) - delay_duration: float # in hours + delay_duration: timedelta pre_departure: bool # True if problem occurs before expedition departure, False if during expedition @abc.abstractmethod @@ -47,7 +48,7 @@ class InstrumentProblem(abc.ABC): message: str can_reoccur: bool base_probability: float # Probability is a function of time - the longer the expedition the more likely something is to go wrong (not a function of waypoints) - delay_duration: float # in hours + delay_duration: timedelta pre_departure: bool # True if problem can occur before expedition departure, False if during expedition @abc.abstractmethod @@ -57,10 +58,27 @@ def is_valid() -> bool: # ===================================================== -# SECTION: General Problem Classes +# SECTION: General Problems # ===================================================== +@dataclass +@register_general_problem +class FoodDeliveryDelayed: + """Problem: Scheduled food delivery is delayed, causing a postponement of departure.""" + + message: str = ( + "The scheduled food delivery prior to departure has not arrived. Until the supply truck reaches the pier, " + "we cannot leave. Once it arrives, unloading and stowing the provisions in the ship’s cold storage " + "will also take additional time. These combined delays postpone departure by approximately 5 hours." + ) + can_reoccur = False + delay_duration = timedelta(hours=5.0) + base_probability = 0.1 + pre_departure = True + + +@dataclass @register_general_problem class VenomousCentipedeOnboard(GeneralProblem): """Problem: Venomous centipede discovered onboard in tropical waters.""" @@ -74,7 +92,7 @@ class VenomousCentipedeOnboard(GeneralProblem): "The medical response and search efforts cause an operational delay of about 2 hours." ) can_reoccur = False - delay_duration = 2.0 + delay_duration = timedelta(hours=2.0) base_probability = 0.05 pre_departure = False @@ -99,17 +117,6 @@ class CaptainSafetyDrill(GeneralProblem): pre_departure = False -@dataclass -class FoodDeliveryDelayed: - message: str = ( - "The scheduled food delivery prior to departure has not arrived. Until the supply truck reaches the pier, " - "we cannot leave. Once it arrives, unloading and stowing the provisions in the ship’s cold storage " - "will also take additional time. These combined delays postpone departure by approximately 5 hours." - ) - can_reoccur: bool = False - delay_duration: float = 5.0 - - @dataclass class FuelDeliveryIssue: message: str = ( @@ -206,7 +213,7 @@ class CoolingWaterIntakeBlocked(GeneralProblem): # ===================================================== -# SECTION: Instrument-specific Problem Classes +# SECTION: Instrument-specific Problems # ===================================================== @@ -214,15 +221,15 @@ class CoolingWaterIntakeBlocked(GeneralProblem): class CTDCableJammed(InstrumentProblem): """Problem: CTD cable jammed in winch drum, requiring replacement.""" - message: str = ( + message = ( "During preparation for the next CTD cast, the CTD cable becomes jammed in the winch drum. " "Attempts to free it are unsuccessful, and the crew determines that the entire cable must be " "replaced before deployment can continue. This repair is time-consuming and results in a delay " "of approximately 3 hours." ) - can_reoccur: bool = True - delay_duration: float = 3.0 - base_probability: float = 0.1 + can_reoccur = True + delay_duration = timedelta(hours=3.0) + base_probability = 0.1 instrument_type = InstrumentType.CTD @@ -236,9 +243,9 @@ class ADCPMalfunction(InstrumentProblem): "compartment to perform an inspection and continuity test. This diagnostic procedure results in a delay " "of around 1 hour." ) - can_reoccur: bool = True - delay_duration: float = 1.0 - base_probability: float = 0.1 + can_reoccur = True + delay_duration = timedelta(hours=1.0) + base_probability = 0.1 instrument_type = InstrumentType.ADCP diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index bebc07feb..538f93d3c 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -1,15 +1,15 @@ from __future__ import annotations -import time from pathlib import Path +from time import time from typing import TYPE_CHECKING +import numpy as np from yaspin import yaspin from virtualship.instruments.types import InstrumentType -from virtualship.utils import ( - CHECKPOINT, -) +from virtualship.models.checkpoint import Checkpoint +from virtualship.utils import CHECKPOINT, _save_checkpoint if TYPE_CHECKING: from virtualship.make_realistic.problems.scenarios import ( @@ -22,10 +22,11 @@ LOG_MESSAGING = { "first_pre_departure": "\nHang on! There could be a pre-departure problem in-port...\n\n", "subsequent_pre_departure": "\nOh no, another pre-departure problem has occurred...!\n\n", - "first_during_expedition": "\nOh no, a problem has occurred during the expedition...!\n\n", - "subsequent_during_expedition": "\nAnother problem has occurred during the expedition...!\n\n", + "first_during_expedition": "\nOh no, a problem has occurred during at waypoint {waypoint_i}...!\n\n", + "subsequent_during_expedition": "\nAnother problem has occurred during the expedition... at waypoint {waypoint_i}!\n\n", "simulation_paused": "\nSIMULATION PAUSED: update your schedule (`virtualship plan`) and continue the expedition by executing the `virtualship run` command again.\nCheckpoint has been saved to {checkpoint_path}.\n", - "problem_avoided:": "\nPhew! You had enough contingency time scheduled to avoid delays from this problem. The expedition can carry on!\n", + "problem_avoided": "\nPhew! You had enough contingency time scheduled to avoid delays from this problem. The expedition can carry on. \n", + "pre_departure_delay": "\nThis problem will cause a delay of {delay_duration} hours to the expedition schedule. Please add this time to your schedule (`virtualship plan`) and continue the expedition by executing the `virtualship run` command again.\n", } @@ -59,38 +60,50 @@ def execute( log_delay: float = 7.0, ): """Execute the selected problems, returning messaging and delay times.""" - for i, problem in enumerate(problems["general"]): - if pre_departure and problem.pre_departure: - print( + # TODO: integration with which zarr files have been written so far? + # TODO: logic to determine whether user has made the necessary changes to the schedule to account for the problem's delay_duration when next running the simulation... (does this come in here or _run?) + # TODO: logic for whether the user has already scheduled in enough contingency time to account for the problem's delay_duration, and they get a well done message if so + # TODO: need logic for if the problem can reoccur or not / and or that it has already occurred and has been addressed + + # general problems + for i, gproblem in enumerate(problems["general"]): + if pre_departure and gproblem.pre_departure: + alert_msg = ( LOG_MESSAGING["first_pre_departure"] if i == 0 else LOG_MESSAGING["subsequent_pre_departure"] ) - else: - if not pre_departure and not problem.pre_departure: - print( - LOG_MESSAGING["first_during_expedition"] - if i == 0 - else LOG_MESSAGING["subsequent_during_expedition"] + + elif not pre_departure and not gproblem.pre_departure: + alert_msg = ( + LOG_MESSAGING["first_during_expedition"].format( + waypoint_i=gproblem.waypoint_i + ) + if i == 0 + else LOG_MESSAGING["subsequent_during_expedition"].format( + waypoint_i=gproblem.waypoint_i ) - with yaspin(): - time.sleep(log_delay) + ) - # provide problem-specific messaging - print(problem.message) + else: + continue # problem does not occur at this time - # save to pause expedition and save to checkpoint + # alert user + print(alert_msg) - print( - LOG_MESSAGING["simulation_paused"].format( - checkpoint_path=self.expedition_dir.joinpath(CHECKPOINT) - ) + # determine failed waypoint index (random if during expedition) + failed_waypoint_i = ( + np.nan + if pre_departure + else np.random.randint(0, len(self.schedule.waypoints) - 1) ) - # TODO: integration with which zarr files have been written so far - # TODO: plus a checkpoint file to assess whether the user has indeed also made the necessary changes to the schedule as required by the problem's delay_duration - # - in here also comes the logic for whether the user has already scheduled in enough contingency time to account for the problem's delay_duration, and they get a well done message if so - #! - may have to be that make a note of it during the simulate_schedule (and feed it forward), otherwise won't know which waypoint(s)... + # log problem occurrence, save to checkpoint, and pause simulation + self._log_problem(gproblem, failed_waypoint_i, log_delay) + + # instrument problems + for i, problem in enumerate(problems["instrument"]): + ... def _propagate_general_problems(self): """Propagate general problems based on probability.""" @@ -126,3 +139,61 @@ def _instrument_problem_select(self) -> list[InstrumentProblem]: wp_instruments = self.schedule.waypoints.instruments return [] + + def _log_problem( + self, + problem: GeneralProblem | InstrumentProblem, + failed_waypoint_i: int, + log_delay: float, + ): + """Log problem occurrence with spinner and delay, save to checkpoint.""" + with yaspin(): + time.sleep(log_delay) + + print(problem.message) + + print("\n\nAssessing impact on expedition schedule...\n") + + # check if enough contingency time has been scheduled to avoid delay + failed_waypoint_time = self.schedule.waypoints[failed_waypoint_i].time + previous_waypoint_time = self.schedule.waypoints[failed_waypoint_i - 1].time + time_diff = ( + failed_waypoint_time - previous_waypoint_time + ).total_seconds() / 3600.0 # in hours + if time_diff >= problem.delay_duration.total_seconds() / 3600.0: + print(LOG_MESSAGING["problem_avoided"]) + return + else: + print( + f"\nNot enough contingency time scheduled to avoid delay of {problem.delay_duration.total_seconds() / 3600.0} hours.\n" + ) + + checkpoint = self._make_checkpoint(failed_waypoint_i) + _save_checkpoint(checkpoint, self.expedition_dir) + + if np.isnan(failed_waypoint_i): + print( + LOG_MESSAGING["pre_departure_delay"].format( + delay_duration=problem.delay_duration.total_seconds() / 3600.0 + ) + ) + else: + print( + LOG_MESSAGING["simulation_paused"].format( + checkpoint_path=self.expedition_dir.joinpath(CHECKPOINT) + ) + ) + + def _make_checkpoint(self, failed_waypoint_i: int | float = np.nan): + """Make checkpoint, also handling pre-departure.""" + if np.isnan(failed_waypoint_i): + checkpoint = Checkpoint( + past_schedule=self.schedule + ) # TODO: and then when it comes to verify checkpoint later, can determine whether the changes have been made to the schedule accordingly? + else: + checkpoint = Checkpoint( + past_schedule=Schedule( + waypoints=self.schedule.waypoints[:failed_waypoint_i] + ) + ) + return checkpoint diff --git a/src/virtualship/models/__init__.py b/src/virtualship/models/__init__.py index d61c17194..7a106ba60 100644 --- a/src/virtualship/models/__init__.py +++ b/src/virtualship/models/__init__.py @@ -1,5 +1,6 @@ """Pydantic models and data classes used to configure virtualship (i.e., in the configuration files or settings).""" +from .checkpoint import Checkpoint from .expedition import ( ADCPConfig, ArgoFloatConfig, @@ -34,4 +35,5 @@ "Spacetime", "Expedition", "InstrumentsConfig", + "Checkpoint", ] diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 9d6aa4194..38ba790cb 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -18,9 +18,11 @@ from virtualship.errors import CopernicusCatalogueError if TYPE_CHECKING: - from virtualship.expedition.simulate_schedule import ScheduleOk + from virtualship.expedition.simulate_schedule import ( + ScheduleOk, + ) from virtualship.models import Expedition - + from virtualship.models.checkpoint import Checkpoint import pandas as pd import yaml @@ -574,3 +576,8 @@ def _get_waypoint_latlons(waypoints): strict=True, ) return wp_lats, wp_lons + + +def _save_checkpoint(checkpoint: Checkpoint, expedition_dir: Path) -> None: + file_path = expedition_dir.joinpath(CHECKPOINT) + checkpoint.to_yaml(file_path) From 49e506feb975aaa0b6cd9af4e07bdebccebc8d90 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Wed, 14 Jan 2026 17:00:41 +0100 Subject: [PATCH 12/14] checkpoint and reverification logic --- src/virtualship/cli/_run.py | 33 +++++---- .../expedition/simulate_schedule.py | 3 +- .../make_realistic/problems/simulator.py | 71 +++++++++++++++---- src/virtualship/models/checkpoint.py | 67 +++++++++++++---- 4 files changed, 134 insertions(+), 40 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index b0d680749..6893a2eb0 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -1,5 +1,6 @@ """do_expedition function.""" +import glob import logging import os import shutil @@ -83,7 +84,7 @@ def _run(expedition_dir: str | Path, from_data: Path | None = None) -> None: checkpoint = Checkpoint(past_schedule=Schedule(waypoints=[])) # verify that schedule and checkpoint match - checkpoint.verify(expedition.schedule) + checkpoint.verify(expedition.schedule, expedition_dir) print("\n---- WAYPOINT VERIFICATION ----") @@ -92,23 +93,13 @@ def _run(expedition_dir: str | Path, from_data: Path | None = None) -> None: from_data=Path(from_data) if from_data else None, ) - # TODO: overview: - # 1) determine all the general AND instrument problems which will occur across the whole expedition - # 2) make a checker function which can be called at various points in the simulation to see if a problem occurs at that point - # - e.g. at pre-departure, at each instrument measurement step, etc. - # - each time a problem occurs, propagate its effects (e.g. delay), log it, save to checkpoint etc. - # TODO: still need to incorp can_reoccur logic somewhere - - # TODO: prob_level needs to be parsed from CLI args - problem_simulator = ProblemSimulator(expedition.schedule, prob_level=...) - problems = problem_simulator.select_problems() - # simulate the schedule schedule_results = simulate_schedule( projection=projection, expedition=expedition, - problems=problems if problems else None, ) + + # handle cases where user defined schedule is incompatible (i.e. not enough time between waypoints, not problems) if isinstance(schedule_results, ScheduleProblem): print( f"SIMULATION PAUSED: update your schedule (`virtualship plan`) and continue the expedition by executing the `virtualship run` command again.\nCheckpoint has been saved to {expedition_dir.joinpath(CHECKPOINT)}." @@ -137,9 +128,16 @@ def _run(expedition_dir: str | Path, from_data: Path | None = None) -> None: print("\n--- MEASUREMENT SIMULATIONS ---") + # identify problems + # TODO: prob_level needs to be parsed from CLI args + problem_simulator = ProblemSimulator(expedition.schedule, prob_level=...) + problems = problem_simulator.select_problems() + # simulate measurements print("\nSimulating measurements. This may take a while...\n") + # TODO: logic for getting simulations to carry on from last checkpoint! Building on .zarr files already created... + instruments_in_expedition = expedition.get_instruments() for i, itype in enumerate(instruments_in_expedition): @@ -195,6 +193,15 @@ def _load_checkpoint(expedition_dir: Path) -> Checkpoint | None: return None +def _load_hashes(expedition_dir: Path) -> set[str]: + hashes_path = expedition_dir.joinpath("problems_encountered") + if not hashes_path.exists(): + return set() + hash_files = glob.glob(str(hashes_path / "problem_*.txt")) + hashes = {Path(f).stem.split("_")[1] for f in hash_files} + return hashes + + def _write_expedition_cost(expedition, schedule_results, expedition_dir): """Calculate the expedition cost, write it to a file, and print summary.""" assert expedition.schedule.waypoints[0].time is not None, ( diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index e6825603e..c09ae7b56 100644 --- a/src/virtualship/expedition/simulate_schedule.py +++ b/src/virtualship/expedition/simulate_schedule.py @@ -127,7 +127,8 @@ def simulate(self) -> ScheduleOk | ScheduleProblem: print( f"Waypoint {wp_i + 1} could not be reached in time. Current time: {self._time}. Waypoint time: {waypoint.time}." "\n\nHave you ensured that your schedule includes sufficient time for taking measurements, e.g. CTD casts (in addition to the time it takes to sail between waypoints)?\n" - "**Note**, the `virtualship plan` tool will not account for measurement times when verifying the schedule, only the time it takes to sail between waypoints.\n" + "**Hint #1**, the `virtualship plan` tool will not account for measurement times when verifying the schedule, only the time it takes to sail between waypoints.\n" + "**Hint #2**: if you previously encountered any unforeseen delays (e.g. equipment failure, pre-departure delays) during your expedition, you will need to adjust the timings of **all** waypoints after the affected waypoint, not just the next one." ) return ScheduleProblem(self._time, wp_i) else: diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index 538f93d3c..e9837057b 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -1,5 +1,7 @@ from __future__ import annotations +import hashlib +import os from pathlib import Path from time import time from typing import TYPE_CHECKING @@ -17,7 +19,7 @@ InstrumentProblem, ) from virtualship.models import Schedule - +import json LOG_MESSAGING = { "first_pre_departure": "\nHang on! There could be a pre-departure problem in-port...\n\n", @@ -26,7 +28,7 @@ "subsequent_during_expedition": "\nAnother problem has occurred during the expedition... at waypoint {waypoint_i}!\n\n", "simulation_paused": "\nSIMULATION PAUSED: update your schedule (`virtualship plan`) and continue the expedition by executing the `virtualship run` command again.\nCheckpoint has been saved to {checkpoint_path}.\n", "problem_avoided": "\nPhew! You had enough contingency time scheduled to avoid delays from this problem. The expedition can carry on. \n", - "pre_departure_delay": "\nThis problem will cause a delay of {delay_duration} hours to the expedition schedule. Please add this time to your schedule (`virtualship plan`) and continue the expedition by executing the `virtualship run` command again.\n", + "pre_departure_delay": "\nThis problem will cause a delay of {delay_duration} hours to the expedition schedule. Please account for this for all waypoints in your schedule (`virtualship plan`), then continue the expedition by executing the `virtualship run` command again.\n", } @@ -43,6 +45,7 @@ def select_problems( self, ) -> dict[str, list[GeneralProblem | InstrumentProblem]] | None: """Propagate both general and instrument problems.""" + # TODO: whether a problem can reoccur or not needs to be handled here too! probability = self._calc_prob() if probability > 0.0: problems = {} @@ -65,8 +68,32 @@ def execute( # TODO: logic for whether the user has already scheduled in enough contingency time to account for the problem's delay_duration, and they get a well done message if so # TODO: need logic for if the problem can reoccur or not / and or that it has already occurred and has been addressed + #! TODO: logic as well for case where problem can reoccur but it can only reoccur at a waypoint different to the one it has already occurred at + # general problems for i, gproblem in enumerate(problems["general"]): + # determine failed waypoint index (random if during expedition) + failed_waypoint_i = ( + np.nan + if pre_departure + else np.random.randint(0, len(self.schedule.waypoints) - 1) + ) + + # mark problem by unique hash and log to json, use to assess whether problem has already occurred + gproblem_hash = self._make_hash( + gproblem.message + str(failed_waypoint_i), 8 + ) + hash_path = Path( + self.expedition_dir + / f"problems_encountered/problem_{gproblem_hash}.json" + ) + if hash_path.exists(): + continue # problem * waypoint combination has already occurred + else: + self._hash_to_json( + gproblem, gproblem_hash, failed_waypoint_i, hash_path + ) + if pre_departure and gproblem.pre_departure: alert_msg = ( LOG_MESSAGING["first_pre_departure"] @@ -86,24 +113,18 @@ def execute( ) else: - continue # problem does not occur at this time + continue # problem does not occur (e.g. wrong combination of pre-departure vs. problem can only occur during expedition) # alert user print(alert_msg) - # determine failed waypoint index (random if during expedition) - failed_waypoint_i = ( - np.nan - if pre_departure - else np.random.randint(0, len(self.schedule.waypoints) - 1) - ) - # log problem occurrence, save to checkpoint, and pause simulation self._log_problem(gproblem, failed_waypoint_i, log_delay) # instrument problems for i, problem in enumerate(problems["instrument"]): ... + # TODO: similar logic to above for instrument-specific problems... or combine? def _propagate_general_problems(self): """Propagate general problems based on probability.""" @@ -146,7 +167,7 @@ def _log_problem( failed_waypoint_i: int, log_delay: float, ): - """Log problem occurrence with spinner and delay, save to checkpoint.""" + """Log problem occurrence with spinner and delay, save to checkpoint, write hash.""" with yaspin(): time.sleep(log_delay) @@ -186,7 +207,7 @@ def _log_problem( def _make_checkpoint(self, failed_waypoint_i: int | float = np.nan): """Make checkpoint, also handling pre-departure.""" - if np.isnan(failed_waypoint_i): + if np.isnan(failed_waypoint_i): # handles pre-departure problems checkpoint = Checkpoint( past_schedule=self.schedule ) # TODO: and then when it comes to verify checkpoint later, can determine whether the changes have been made to the schedule accordingly? @@ -197,3 +218,29 @@ def _make_checkpoint(self, failed_waypoint_i: int | float = np.nan): ) ) return checkpoint + + def _make_hash(s: str, length: int) -> str: + """Make unique hash for problem occurrence.""" + assert length % 2 == 0, "Length must be even." + half_length = length // 2 + return hashlib.shake_128(s.encode("utf-8")).hexdigest(half_length) + + def _hash_to_json( + self, + problem: InstrumentProblem | GeneralProblem, + problem_hash: str, + failed_waypoint_i: int | float, + hash_path: Path, + ) -> dict: + """Convert problem details + hash to json.""" + os.makedirs(self.expedition_dir / "problems_encountered", exist_ok=True) + hash_data = { + "problem_hash": problem_hash, + "message": problem.message, + "failed_waypoint_i": failed_waypoint_i, + "delay_duration_hours": problem.delay_duration.total_seconds() / 3600.0, + "timestamp": time.time(), + "resolved": False, + } + with open(hash_path, "w") as f: + json.dump(hash_data, f, indent=4) diff --git a/src/virtualship/models/checkpoint.py b/src/virtualship/models/checkpoint.py index 98fe1ae0a..ba4b2d5a5 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -2,6 +2,8 @@ from __future__ import annotations +import json +from datetime import timedelta from pathlib import Path import pydantic @@ -51,20 +53,8 @@ def from_yaml(cls, file_path: str | Path) -> Checkpoint: data = yaml.safe_load(file) return Checkpoint(**data) - def verify(self, schedule: Schedule) -> None: - """ - Verify that the given schedule matches the checkpoint's past schedule. - - This method checks if the waypoints in the given schedule match the waypoints - in the checkpoint's past schedule up to the length of the past schedule. - If there's a mismatch, it raises a CheckpointError. - - :param schedule: The schedule to verify against the checkpoint. - :type schedule: Schedule - :raises CheckpointError: If the past waypoints in the given schedule - have been changed compared to the checkpoint. - :return: None - """ + def verify(self, schedule: Schedule, expedition_dir: Path) -> None: + """Verify that the given schedule matches the checkpoint's past schedule, and that problems have been resolved.""" if ( not schedule.waypoints[: len(self.past_schedule.waypoints)] == self.past_schedule.waypoints @@ -72,3 +62,52 @@ def verify(self, schedule: Schedule) -> None: raise CheckpointError( "Past waypoints in schedule have been changed! Restore past schedule and only change future waypoints." ) + + # TODO: how does this handle pre-departure problems that caused delays? Old schedule will be a complete mismatch then. + + # check that problems have been resolved in the new schedule + hash_fpaths = [ + str(path.resolve()) + for path in Path(expedition_dir, "problems_encountered").glob( + "problem_*.json" + ) + ] + + for file in hash_fpaths: + with open(file) as f: + problem = json.load(f) + if problem["resolved"]: + continue + elif not problem["resolved"]: + # check if delay has been accounted for in the schedule + delay_duration = timedelta( + hours=float(problem["delay_duration_hours"]) + ) # delay associated with the problem + + failed_waypoint_i = int(problem["failed_waypoint_i"]) + + time_deltas = [ + schedule.waypoints[i].time + - self.past_schedule.waypoints[i].time + for i in range( + failed_waypoint_i, len(self.past_schedule.waypoints) + ) + ] # difference in time between the two schedules from the failed waypoint onwards + + if all(td >= delay_duration for td in time_deltas): + print( + "\n\nPrevious problem has been resolved in the schedule.\n" + ) + + # save back to json file changing the resolved status to True + problem["resolved"] = True + with open(file, "w") as f_out: + json.dump(problem, f_out, indent=4) + + else: + raise CheckpointError( + "The problem encountered in previous simulation has not been resolved in the schedule! Please adjust the schedule to account for delays caused by problem.", + f"The problem was associated with a delay duration of {problem['delay_duration_hours']} hours starting from waypoint {failed_waypoint_i + 1}.", + ) + + break # only handle the first unresolved problem found; others will be handled in subsequent runs but are not yet known to the user From 5e7889f3702dbb64a71bb95f5470a8ac377aa363 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:27:23 +0100 Subject: [PATCH 13/14] tidy up some logging etc --- src/virtualship/cli/_run.py | 9 +- src/virtualship/make_realistic/__init__.py | 1 - .../make_realistic/problems/scenarios.py | 70 ++++++------ .../make_realistic/problems/simulator.py | 103 ++++++++++-------- src/virtualship/models/checkpoint.py | 9 +- 5 files changed, 103 insertions(+), 89 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index 6893a2eb0..269d77e15 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -37,7 +37,10 @@ logging.getLogger("copernicusmarine").setLevel("ERROR") -def _run(expedition_dir: str | Path, from_data: Path | None = None) -> None: +# TODO: prob-level needs to be parsed from CLI args; currently set to 1 override for testing purposes +def _run( + expedition_dir: str | Path, from_data: Path | None = None, prob_level: int = 1 +) -> None: """ Perform an expedition, providing terminal feedback and file output. @@ -130,7 +133,9 @@ def _run(expedition_dir: str | Path, from_data: Path | None = None) -> None: # identify problems # TODO: prob_level needs to be parsed from CLI args - problem_simulator = ProblemSimulator(expedition.schedule, prob_level=...) + problem_simulator = ProblemSimulator( + expedition.schedule, prob_level, expedition_dir + ) problems = problem_simulator.select_problems() # simulate measurements diff --git a/src/virtualship/make_realistic/__init__.py b/src/virtualship/make_realistic/__init__.py index 91ad16847..2c9a17df7 100644 --- a/src/virtualship/make_realistic/__init__.py +++ b/src/virtualship/make_realistic/__init__.py @@ -2,6 +2,5 @@ from .adcp_make_realistic import adcp_make_realistic from .ctd_make_realistic import ctd_make_realistic -from .problems impor __all__ = ["adcp_make_realistic", "ctd_make_realistic"] diff --git a/src/virtualship/make_realistic/problems/scenarios.py b/src/virtualship/make_realistic/problems/scenarios.py index 76ffc2ffa..97696a21e 100644 --- a/src/virtualship/make_realistic/problems/scenarios.py +++ b/src/virtualship/make_realistic/problems/scenarios.py @@ -6,10 +6,6 @@ from typing import TYPE_CHECKING from virtualship.instruments.types import InstrumentType -from virtualship.utils import ( - register_general_problem, - register_instrument_problem, -) if TYPE_CHECKING: from virtualship.models import Waypoint @@ -63,11 +59,11 @@ def is_valid() -> bool: @dataclass -@register_general_problem +# @register_general_problem class FoodDeliveryDelayed: """Problem: Scheduled food delivery is delayed, causing a postponement of departure.""" - message: str = ( + message = ( "The scheduled food delivery prior to departure has not arrived. Until the supply truck reaches the pier, " "we cannot leave. Once it arrives, unloading and stowing the provisions in the ship’s cold storage " "will also take additional time. These combined delays postpone departure by approximately 5 hours." @@ -79,13 +75,13 @@ class FoodDeliveryDelayed: @dataclass -@register_general_problem +# @register_general_problem class VenomousCentipedeOnboard(GeneralProblem): """Problem: Venomous centipede discovered onboard in tropical waters.""" # TODO: this needs logic added to the is_valid() method to check if waypoint is in tropical waters - message: str = ( + message = ( "A venomous centipede is discovered onboard while operating in tropical waters. " "One crew member becomes ill after contact with the creature and receives medical attention, " "prompting a full search of the vessel to ensure no further danger. " @@ -102,11 +98,11 @@ def is_valid(self, waypoint: Waypoint) -> bool: return abs(waypoint.latitude) <= lat_limit -@register_general_problem +# @register_general_problem class CaptainSafetyDrill(GeneralProblem): """Problem: Sudden initiation of a mandatory safety drill.""" - message: str = ( + message = ( "A miscommunication with the ship’s captain results in the sudden initiation of a mandatory safety drill. " "The emergency vessel must be lowered and tested while the ship remains stationary, pausing all scientific " "operations for the duration of the exercise. The drill introduces a delay of approximately 2 hours." @@ -119,7 +115,7 @@ class CaptainSafetyDrill(GeneralProblem): @dataclass class FuelDeliveryIssue: - message: str = ( + message = ( "The fuel tanker expected to deliver fuel has not arrived. Port authorities are unable to provide " "a clear estimate for when the delivery might occur. You may choose to [w]ait for the tanker or [g]et a " "harbor pilot to guide the vessel to an available bunker dock instead. This decision may need to be " @@ -131,7 +127,7 @@ class FuelDeliveryIssue: @dataclass class EngineOverheating: - message: str = ( + message = ( "One of the main engines has overheated. To prevent further damage, the engineering team orders a reduction " "in vessel speed until the engine can be inspected and repaired in port. The ship will now operate at a " "reduced cruising speed of 8.5 knots for the remainder of the transit." @@ -141,11 +137,11 @@ class EngineOverheating: ship_speed_knots: float = 8.5 -@register_general_problem +# @register_general_problem class MarineMammalInDeploymentArea(GeneralProblem): """Problem: Marine mammals observed in deployment area, causing delay.""" - message: str = ( + message = ( "A pod of dolphins is observed swimming directly beneath the planned deployment area. " "To avoid risk to wildlife and comply with environmental protocols, all in-water operations " "must pause until the animals move away from the vicinity. This results in a delay of about 30 minutes." @@ -155,11 +151,11 @@ class MarineMammalInDeploymentArea(GeneralProblem): base_probability: float = 0.1 -@register_general_problem +# @register_general_problem class BallastPumpFailure(GeneralProblem): """Problem: Ballast pump failure during ballasting operations.""" - message: str = ( + message = ( "One of the ship’s ballast pumps suddenly stops responding during routine ballasting operations. " "Without the pump, the vessel cannot safely adjust trim or compensate for equipment movements on deck. " "Engineering isolates the faulty pump and performs a rapid inspection. Temporary repairs allow limited " @@ -170,11 +166,11 @@ class BallastPumpFailure(GeneralProblem): base_probability: float = 0.1 -@register_general_problem +# @register_general_problem class ThrusterConverterFault(GeneralProblem): """Problem: Bow thruster's power converter fault during station-keeping.""" - message: str = ( + message = ( "The bow thruster's power converter reports a fault during station-keeping operations. " "Dynamic positioning becomes less stable, forcing a temporary suspension of high-precision sampling. " "Engineers troubleshoot the converter and perform a reset, resulting in a delay of around 1 hour." @@ -184,11 +180,11 @@ class ThrusterConverterFault(GeneralProblem): base_probability: float = 0.1 -@register_general_problem +# @register_general_problem class AFrameHydraulicLeak(GeneralProblem): """Problem: Hydraulic fluid leak from A-frame actuator.""" - message: str = ( + message = ( "A crew member notices hydraulic fluid leaking from the A-frame actuator during equipment checks. " "The leak must be isolated immediately to prevent environmental contamination or mechanical failure. " "Engineering replaces a faulty hose and repressurizes the system. This repair causes a delay of about 2 hours." @@ -198,11 +194,11 @@ class AFrameHydraulicLeak(GeneralProblem): base_probability: float = 0.1 -@register_general_problem +# @register_general_problem class CoolingWaterIntakeBlocked(GeneralProblem): """Problem: Main engine's cooling water intake blocked.""" - message: str = ( + message = ( "The main engine's cooling water intake alarms indicate reduced flow, likely caused by marine debris " "or biological fouling. The vessel must temporarily slow down while engineering clears the obstruction " "and flushes the intake. This results in a delay of approximately 1 hour." @@ -217,7 +213,7 @@ class CoolingWaterIntakeBlocked(GeneralProblem): # ===================================================== -@register_instrument_problem(InstrumentType.CTD) +# @register_instrument_problem(InstrumentType.CTD) class CTDCableJammed(InstrumentProblem): """Problem: CTD cable jammed in winch drum, requiring replacement.""" @@ -233,11 +229,11 @@ class CTDCableJammed(InstrumentProblem): instrument_type = InstrumentType.CTD -@register_instrument_problem(InstrumentType.ADCP) +# @register_instrument_problem(InstrumentType.ADCP) class ADCPMalfunction(InstrumentProblem): """Problem: ADCP returns invalid data, requiring inspection.""" - message: str = ( + message = ( "The hull-mounted ADCP begins returning invalid velocity data. Engineering suspects damage to the cable " "from recent maintenance activities. The ship must hold position while a technician enters the cable " "compartment to perform an inspection and continuity test. This diagnostic procedure results in a delay " @@ -249,11 +245,11 @@ class ADCPMalfunction(InstrumentProblem): instrument_type = InstrumentType.ADCP -@register_instrument_problem(InstrumentType.CTD) +# @register_instrument_problem(InstrumentType.CTD) class CTDTemperatureSensorFailure(InstrumentProblem): """Problem: CTD temperature sensor failure, requiring replacement.""" - message: str = ( + message = ( "The primary temperature sensor on the CTD begins returning inconsistent readings. " "Troubleshooting confirms that the sensor has malfunctioned. A spare unit can be installed, " "but integrating and verifying the replacement will pause operations. " @@ -265,11 +261,11 @@ class CTDTemperatureSensorFailure(InstrumentProblem): instrument_type = InstrumentType.CTD -@register_instrument_problem(InstrumentType.CTD) +# @register_instrument_problem(InstrumentType.CTD) class CTDSalinitySensorFailureWithCalibration(InstrumentProblem): """Problem: CTD salinity sensor failure, requiring replacement and calibration.""" - message: str = ( + message = ( "The CTD’s primary salinity sensor fails and must be replaced with a backup. After installation, " "a mandatory calibration cast to a minimum depth of 1000 meters is required to verify sensor accuracy. " "Both the replacement and calibration activities result in a total delay of roughly 4 hours." @@ -280,11 +276,11 @@ class CTDSalinitySensorFailureWithCalibration(InstrumentProblem): instrument_type = InstrumentType.CTD -@register_instrument_problem(InstrumentType.CTD) +# @register_instrument_problem(InstrumentType.CTD) class WinchHydraulicPressureDrop(InstrumentProblem): """Problem: CTD winch hydraulic pressure drop, requiring repair.""" - message: str = ( + message = ( "The CTD winch begins to lose hydraulic pressure during routine checks prior to deployment. " "The engineering crew must stop operations to diagnose the hydraulic pump and replenish or repair " "the system. Until pressure is restored to operational levels, the winch cannot safely be used. " @@ -296,11 +292,11 @@ class WinchHydraulicPressureDrop(InstrumentProblem): instrument_type = InstrumentType.CTD -@register_instrument_problem(InstrumentType.CTD) +# @register_instrument_problem(InstrumentType.CTD) class RosetteTriggerFailure(InstrumentProblem): """Problem: CTD rosette trigger failure, requiring inspection.""" - message: str = ( + message = ( "During a CTD cast, the rosette's bottle-triggering mechanism fails to actuate. " "No discrete water samples can be collected during this cast. The rosette must be brought back " "on deck for inspection and manual testing of the trigger system. This results in an operational " @@ -312,11 +308,11 @@ class RosetteTriggerFailure(InstrumentProblem): instrument_type = InstrumentType.CTD -@register_instrument_problem(InstrumentType.DRIFTER) +# @register_instrument_problem(InstrumentType.DRIFTER) class DrifterSatelliteConnectionDelay(InstrumentProblem): """Problem: Drifter fails to establish satellite connection before deployment.""" - message: str = ( + message = ( "The drifter scheduled for deployment fails to establish a satellite connection during " "pre-launch checks. To improve signal acquisition, the float must be moved to a higher location on deck " "with fewer obstructions. The team waits for the satellite fix to come through, resulting in a delay " @@ -328,11 +324,11 @@ class DrifterSatelliteConnectionDelay(InstrumentProblem): instrument_type = InstrumentType.DRIFTER -@register_instrument_problem(InstrumentType.ARGO_FLOAT) +# @register_instrument_problem(InstrumentType.ARGO_FLOAT) class ArgoSatelliteConnectionDelay(InstrumentProblem): """Problem: Argo float fails to establish satellite connection before deployment.""" - message: str = ( + message = ( "The Argo float scheduled for deployment fails to establish a satellite connection during " "pre-launch checks. To improve signal acquisition, the float must be moved to a higher location on deck " "with fewer obstructions. The team waits for the satellite fix to come through, resulting in a delay " diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index e9837057b..c10e9d909 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -2,14 +2,18 @@ import hashlib import os +import time from pathlib import Path -from time import time from typing import TYPE_CHECKING import numpy as np from yaspin import yaspin from virtualship.instruments.types import InstrumentType +from virtualship.make_realistic.problems.scenarios import ( + CTDCableJammed, + FoodDeliveryDelayed, +) from virtualship.models.checkpoint import Checkpoint from virtualship.utils import CHECKPOINT, _save_checkpoint @@ -22,13 +26,13 @@ import json LOG_MESSAGING = { - "first_pre_departure": "\nHang on! There could be a pre-departure problem in-port...\n\n", - "subsequent_pre_departure": "\nOh no, another pre-departure problem has occurred...!\n\n", - "first_during_expedition": "\nOh no, a problem has occurred during at waypoint {waypoint_i}...!\n\n", - "subsequent_during_expedition": "\nAnother problem has occurred during the expedition... at waypoint {waypoint_i}!\n\n", - "simulation_paused": "\nSIMULATION PAUSED: update your schedule (`virtualship plan`) and continue the expedition by executing the `virtualship run` command again.\nCheckpoint has been saved to {checkpoint_path}.\n", - "problem_avoided": "\nPhew! You had enough contingency time scheduled to avoid delays from this problem. The expedition can carry on. \n", - "pre_departure_delay": "\nThis problem will cause a delay of {delay_duration} hours to the expedition schedule. Please account for this for all waypoints in your schedule (`virtualship plan`), then continue the expedition by executing the `virtualship run` command again.\n", + "first_pre_departure": "Hang on! There could be a pre-departure problem in-port...", + "subsequent_pre_departure": "Oh no, another pre-departure problem has occurred...!\n", + "first_during_expedition": "Oh no, a problem has occurred during at waypoint {waypoint_i}...!\n", + "subsequent_during_expedition": "Another problem has occurred during the expedition... at waypoint {waypoint_i}!\n", + "simulation_paused": "SIMULATION PAUSED: update your schedule (`virtualship plan`) and continue the expedition by executing the `virtualship run` command again.\nCheckpoint has been saved to {checkpoint_path}.\n", + "problem_avoided": "Phew! You had enough contingency time scheduled to avoid delays from this problem. The expedition can carry on.\n", + "pre_departure_delay": "This problem will cause a delay of **{delay_duration} hours** to the expedition schedule. \n\nPlease account for this for **ALL** waypoints in your schedule (`virtualship plan`), then continue the expedition by executing the `virtualship run` command again.\n", } @@ -47,6 +51,7 @@ def select_problems( """Propagate both general and instrument problems.""" # TODO: whether a problem can reoccur or not needs to be handled here too! probability = self._calc_prob() + probability = 1.0 # TODO: temporary override for testing!! if probability > 0.0: problems = {} problems["general"] = self._general_problem_select(probability) @@ -70,6 +75,8 @@ def execute( #! TODO: logic as well for case where problem can reoccur but it can only reoccur at a waypoint different to the one it has already occurred at + # TODO: make the log output stand out more visually + # general problems for i, gproblem in enumerate(problems["general"]): # determine failed waypoint index (random if during expedition) @@ -79,6 +86,7 @@ def execute( else np.random.randint(0, len(self.schedule.waypoints) - 1) ) + # TODO: some kind of handling for deleting directory if ... simulation encounters error? or just leave it to user to delete manually if they want to restart from scratch? # mark problem by unique hash and log to json, use to assess whether problem has already occurred gproblem_hash = self._make_hash( gproblem.message + str(failed_waypoint_i), 8 @@ -115,15 +123,12 @@ def execute( else: continue # problem does not occur (e.g. wrong combination of pre-departure vs. problem can only occur during expedition) - # alert user - print(alert_msg) - # log problem occurrence, save to checkpoint, and pause simulation - self._log_problem(gproblem, failed_waypoint_i, log_delay) + self._log_problem(gproblem, failed_waypoint_i, alert_msg, log_delay) # instrument problems for i, problem in enumerate(problems["instrument"]): - ... + pass # TODO: implement!! # TODO: similar logic to above for instrument-specific problems... or combine? def _propagate_general_problems(self): @@ -147,63 +152,67 @@ def _calc_prob(self) -> float: if self.prob_level == 0: return 0.0 - def _general_problem_select(self) -> list[GeneralProblem]: + def _general_problem_select(self, probability) -> list[GeneralProblem]: """Select which problems. Higher probability (tied to expedition duration) means more problems are likely to occur.""" - ... - return [] + return [FoodDeliveryDelayed] # TODO: temporary placeholder!! - def _instrument_problem_select(self) -> list[InstrumentProblem]: + def _instrument_problem_select(self, probability) -> list[InstrumentProblem]: """Select which problems. Higher probability (tied to expedition duration) means more problems are likely to occur.""" # set: waypoint instruments vs. list of instrument-specific problems (automated registry) # will deterimne which instrument-specific problems are possible at this waypoint - wp_instruments = self.schedule.waypoints.instruments + # wp_instruments = self.schedule.waypoints.instruments - return [] + return [CTDCableJammed] def _log_problem( self, problem: GeneralProblem | InstrumentProblem, - failed_waypoint_i: int, + failed_waypoint_i: int | float, + alert_msg: str, log_delay: float, ): """Log problem occurrence with spinner and delay, save to checkpoint, write hash.""" - with yaspin(): + time.sleep(3.0) # brief pause before spinner + with yaspin(text=alert_msg) as spinner: time.sleep(log_delay) + spinner.ok("💥 ") - print(problem.message) - - print("\n\nAssessing impact on expedition schedule...\n") - - # check if enough contingency time has been scheduled to avoid delay - failed_waypoint_time = self.schedule.waypoints[failed_waypoint_i].time - previous_waypoint_time = self.schedule.waypoints[failed_waypoint_i - 1].time - time_diff = ( - failed_waypoint_time - previous_waypoint_time - ).total_seconds() / 3600.0 # in hours - if time_diff >= problem.delay_duration.total_seconds() / 3600.0: - print(LOG_MESSAGING["problem_avoided"]) - return - else: - print( - f"\nNot enough contingency time scheduled to avoid delay of {problem.delay_duration.total_seconds() / 3600.0} hours.\n" - ) - - checkpoint = self._make_checkpoint(failed_waypoint_i) - _save_checkpoint(checkpoint, self.expedition_dir) + print("\nPROBLEM ENCOUNTERED: " + problem.message) - if np.isnan(failed_waypoint_i): + if np.isnan(failed_waypoint_i): # pre-departure problem print( - LOG_MESSAGING["pre_departure_delay"].format( + "\nRESULT: " + + LOG_MESSAGING["pre_departure_delay"].format( delay_duration=problem.delay_duration.total_seconds() / 3600.0 ) ) - else: + else: # problem occurring during expedition print( - LOG_MESSAGING["simulation_paused"].format( + "\nRESULT: " + + LOG_MESSAGING["simulation_paused"].format( checkpoint_path=self.expedition_dir.joinpath(CHECKPOINT) ) ) + # check if enough contingency time has been scheduled to avoid delay + print("\nAssessing impact on expedition schedule...\n") + failed_waypoint_time = self.schedule.waypoints[failed_waypoint_i].time + previous_waypoint_time = self.schedule.waypoints[failed_waypoint_i - 1].time + time_diff = ( + failed_waypoint_time - previous_waypoint_time + ).total_seconds() / 3600.0 # in hours + if time_diff >= problem.delay_duration.total_seconds() / 3600.0: + print(LOG_MESSAGING["problem_avoided"]) + return + else: + print( + f"\nNot enough contingency time scheduled to avoid delay of {problem.delay_duration.total_seconds() / 3600.0} hours.\n" + ) + + checkpoint = self._make_checkpoint(failed_waypoint_i) + _save_checkpoint(checkpoint, self.expedition_dir) + + return def _make_checkpoint(self, failed_waypoint_i: int | float = np.nan): """Make checkpoint, also handling pre-departure.""" @@ -219,7 +228,7 @@ def _make_checkpoint(self, failed_waypoint_i: int | float = np.nan): ) return checkpoint - def _make_hash(s: str, length: int) -> str: + def _make_hash(self, s: str, length: int) -> str: """Make unique hash for problem occurrence.""" assert length % 2 == 0, "Length must be even." half_length = length // 2 @@ -239,7 +248,7 @@ def _hash_to_json( "message": problem.message, "failed_waypoint_i": failed_waypoint_i, "delay_duration_hours": problem.delay_duration.total_seconds() / 3600.0, - "timestamp": time.time(), + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), "resolved": False, } with open(hash_path, "w") as f: diff --git a/src/virtualship/models/checkpoint.py b/src/virtualship/models/checkpoint.py index ba4b2d5a5..1a734ba74 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -6,12 +6,13 @@ from datetime import timedelta from pathlib import Path +import numpy as np import pydantic import yaml from virtualship.errors import CheckpointError from virtualship.instruments.types import InstrumentType -from virtualship.models import Schedule +from virtualship.models.expedition import Schedule class _YamlDumper(yaml.SafeDumper): @@ -84,7 +85,11 @@ def verify(self, schedule: Schedule, expedition_dir: Path) -> None: hours=float(problem["delay_duration_hours"]) ) # delay associated with the problem - failed_waypoint_i = int(problem["failed_waypoint_i"]) + failed_waypoint_i = ( + int(problem["failed_waypoint_i"]) + if type(problem["failed_waypoint_i"]) is int + else np.nan + ) time_deltas = [ schedule.waypoints[i].time From 3c7e975333b83aba242b876c546fce9cb04a9384 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:31:51 +0000 Subject: [PATCH 14/14] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/virtualship/instruments/base.py | 2 +- src/virtualship/utils.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/virtualship/instruments/base.py b/src/virtualship/instruments/base.py index 3b670478a..984e4abf5 100644 --- a/src/virtualship/instruments/base.py +++ b/src/virtualship/instruments/base.py @@ -9,9 +9,9 @@ import copernicusmarine import xarray as xr +from parcels import FieldSet from yaspin import yaspin -from parcels import FieldSet from virtualship.errors import CopernicusCatalogueError from virtualship.utils import ( COPERNICUSMARINE_PHYS_VARIABLES, diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 38ba790cb..f0514e938 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -13,8 +13,8 @@ import copernicusmarine import numpy as np import xarray as xr - from parcels import FieldSet + from virtualship.errors import CopernicusCatalogueError if TYPE_CHECKING: