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/18] 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 f07fbab2..be444dda 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 0a567b1c..fb4010fa 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 00000000..e085b22a --- /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/18] 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 be444dda..f07fbab2 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/18] 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 e085b22a..766d97ba 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/18] 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 fb4010fa..499a4b85 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 766d97ba..15bb15bb 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/18] 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 15bb15bb..0180a848 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/18] 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 93f71664..dda93b3d 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/18] 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 dda93b3d..3a422ffe 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 984e4abf..3b670478 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 2c9a17df..91ad1684 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 0180a848..2e096df7 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 b1926dc6..9d6aa419 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/18] 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 f07fbab2..fd16a8a1 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 3a422ffe..e450fcc7 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 2e096df7..090da1b6 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/18] 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 090da1b6..80772c13 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 00000000..f36a6c03 --- /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/18] 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 fd16a8a1..5c3a5f9b 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 f36a6c03..bebc07fe 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/18] 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 5c3a5f9b..b0d68074 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 e450fcc7..e6825603 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 80772c13..76ffc2ff 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 bebc07fe..538f93d3 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 d61c1719..7a106ba6 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 9d6aa419..38ba790c 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/18] 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 b0d68074..6893a2eb 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 e6825603..c09ae7b5 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 538f93d3..e9837057 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 98fe1ae0..ba4b2d5a 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/18] 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 6893a2eb..269d77e1 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 91ad1684..2c9a17df 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 76ffc2ff..97696a21 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 e9837057..c10e9d90 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 ba4b2d5a..1a734ba7 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/18] [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 3b670478..984e4abf 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 38ba790c..f0514e93 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: From c04246cf18f7954fed6b3d137460c35e39077bd7 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 16 Jan 2026 09:03:36 +0100 Subject: [PATCH 15/18] propagating general pre-departure problem --- src/virtualship/cli/_run.py | 2 +- src/virtualship/errors.py | 6 + .../make_realistic/problems/simulator.py | 25 ++++- src/virtualship/models/checkpoint.py | 103 ++++++++++-------- 4 files changed, 86 insertions(+), 50 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index 269d77e1..bad70809 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -86,7 +86,7 @@ def _run( if checkpoint is None: checkpoint = Checkpoint(past_schedule=Schedule(waypoints=[])) - # verify that schedule and checkpoint match + # verify that schedule and checkpoint match, and that problems have been resolved checkpoint.verify(expedition.schedule, expedition_dir) print("\n---- WAYPOINT VERIFICATION ----") diff --git a/src/virtualship/errors.py b/src/virtualship/errors.py index ac1aa8a1..60a4b0ef 100644 --- a/src/virtualship/errors.py +++ b/src/virtualship/errors.py @@ -50,3 +50,9 @@ class CopernicusCatalogueError(Exception): """Error raised when a relevant product is not found in the Copernicus Catalogue.""" pass + + +class ProblemEncountered(Exception): + """Error raised when a problem is encountered during simulation.""" + + pass diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index c10e9d90..a779ec29 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -2,6 +2,7 @@ import hashlib import os +import sys import time from pathlib import Path from typing import TYPE_CHECKING @@ -32,7 +33,7 @@ "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", + "pre_departure_delay": "This problem will cause a delay of **{delay_duration} hours** to the whole 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", } @@ -76,7 +77,6 @@ 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) @@ -96,7 +96,7 @@ def execute( / f"problems_encountered/problem_{gproblem_hash}.json" ) if hash_path.exists(): - continue # problem * waypoint combination has already occurred + continue # problem * waypoint combination has already occurred; don't repeat else: self._hash_to_json( gproblem, gproblem_hash, failed_waypoint_i, hash_path @@ -209,17 +209,26 @@ def _log_problem( f"\nNot enough contingency time scheduled to avoid delay of {problem.delay_duration.total_seconds() / 3600.0} hours.\n" ) + # save checkpoint checkpoint = self._make_checkpoint(failed_waypoint_i) _save_checkpoint(checkpoint, self.expedition_dir) - return + # cache original schedule for reference and/or restoring later if needed + schedule_original_path = ( + self.expedition_dir / "problems_encountered" / "schedule_original.yaml" + ) + if os.path.exists(schedule_original_path) is False: + self._cache_original_schedule(self.schedule, schedule_original_path) + + # pause simulation + sys.exit(0) def _make_checkpoint(self, failed_waypoint_i: int | float = np.nan): """Make checkpoint, also handling pre-departure.""" 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? + ) # use full schedule as past schedule else: checkpoint = Checkpoint( past_schedule=Schedule( @@ -253,3 +262,9 @@ def _hash_to_json( } with open(hash_path, "w") as f: json.dump(hash_data, f, indent=4) + + def _cache_original_schedule(self, schedule: Schedule, path: Path | str): + """Cache original schedule to file for reference, as a checkpoint object.""" + schedule_original = Checkpoint(past_schedule=schedule) + schedule_original.to_yaml(path) + print(f"\nOriginal schedule cached to {path}.\n") diff --git a/src/virtualship/models/checkpoint.py b/src/virtualship/models/checkpoint.py index 1a734ba7..d565087c 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -55,8 +55,12 @@ def from_yaml(cls, file_path: str | Path) -> Checkpoint: return Checkpoint(**data) 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 ( + """Verify that the given schedule matches the checkpoint's past schedule , and/or that any problem has been resolved.""" + # 1) check that past waypoints have not been changed, unless is a pre-departure problem + if len(self.past_schedule.waypoints) == len(schedule.waypoints): + pass # pre-departure problem checkpoint will match len of current schedule; no past waypoints to compare (any checkpoint file generated because user defined schedule with not enough time between waypoints will always have fewer waypoints than current schedule) + elif ( + # TODO: double check this still works as intended for the user defined schedule with not enough time between waypoints case not schedule.waypoints[: len(self.past_schedule.waypoints)] == self.past_schedule.waypoints ): @@ -64,55 +68,66 @@ def verify(self, schedule: Schedule, expedition_dir: Path) -> None: "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. + breakpoint() - # check that problems have been resolved in the new schedule + # 2) 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"]) - if type(problem["failed_waypoint_i"]) is int - else np.nan - ) - - time_deltas = [ - schedule.waypoints[i].time - - self.past_schedule.waypoints[i].time - for i in range( - failed_waypoint_i, len(self.past_schedule.waypoints) + if len(hash_fpaths) > 0: + 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"]) + if type(problem["failed_waypoint_i"]) is int + else np.nan ) - ] # 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" + waypoint_range = ( + range(len(self.past_schedule.waypoints)) + if np.isnan(failed_waypoint_i) + else range( + failed_waypoint_i, len(self.past_schedule.waypoints) + ) ) - - # 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 + time_deltas = [ + schedule.waypoints[i].time + - self.past_schedule.waypoints[i].time + for i in waypoint_range + ] # 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: + affected_waypoints = ( + "all waypoints" + if np.isnan(failed_waypoint_i) + else f"waypoints from {failed_waypoint_i + 1} onwards" + ) + 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 the problem.", + f"The problem was associated with a delay duration of {problem['delay_duration_hours']} hours affecting {affected_waypoints}.", + ) + + # only handle the first unresolved problem found; others will be handled in subsequent runs but are not yet known to the user + break From 9adec5864a238cb9b29874329164b1b744ede609 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 20 Jan 2026 15:03:37 +0100 Subject: [PATCH 16/18] propagate general problem during expedition --- src/virtualship/cli/_run.py | 24 ++- .../make_realistic/problems/scenarios.py | 5 +- .../make_realistic/problems/simulator.py | 171 ++++++++++-------- src/virtualship/models/checkpoint.py | 37 ++-- src/virtualship/utils.py | 4 +- 5 files changed, 136 insertions(+), 105 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index bad70809..6fadf494 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -19,6 +19,7 @@ from virtualship.models import Checkpoint, Schedule from virtualship.utils import ( CHECKPOINT, + PROBLEMS_ENCOUNTERED_DIR, _get_expedition, _save_checkpoint, expedition_cost, @@ -109,11 +110,8 @@ def _run( ) _save_checkpoint( Checkpoint( - past_schedule=Schedule( - waypoints=expedition.schedule.waypoints[ - : schedule_results.failed_waypoint_i - ] - ) + past_schedule=expedition.schedule, + failed_waypoint_i=schedule_results.failed_waypoint_i, ), expedition_dir, ) @@ -145,12 +143,13 @@ def _run( instruments_in_expedition = expedition.get_instruments() - for i, itype in enumerate(instruments_in_expedition): - # propagate problems (pre-departure problems will only occur in first iteration) + for itype in instruments_in_expedition: + #! TODO: move this to before the loop; determine problem selection based on instruments_in_expedition to ensure only relevant problems are selected; and then instrument problems are propagated to within the loop + # TODO: instrument-specific problems at different waypoints are where see if can get time savings by not re-simulating everything from scratch... but if it's too complex than just leave for now + # propagate problems if problems: problem_simulator.execute( problems=problems, - pre_departure=True if i == 0 else False, instrument_type=itype, ) @@ -182,6 +181,13 @@ def _run( print( f"Your measurements can be found in the '{expedition_dir}/results' directory." ) + + if problems: + print("\n----- RECORD OF PROBLEMS ENCOUNTERED ------") + print( + f"\nA record of problems encountered during the expedition is saved in: {expedition_dir.joinpath(PROBLEMS_ENCOUNTERED_DIR)}" + ) + print("\n------------- END -------------\n") # end timing @@ -199,7 +205,7 @@ def _load_checkpoint(expedition_dir: Path) -> Checkpoint | None: def _load_hashes(expedition_dir: Path) -> set[str]: - hashes_path = expedition_dir.joinpath("problems_encountered") + hashes_path = expedition_dir.joinpath(PROBLEMS_ENCOUNTERED_DIR) if not hashes_path.exists(): return set() hash_files = glob.glob(str(hashes_path / "problem_*.txt")) diff --git a/src/virtualship/make_realistic/problems/scenarios.py b/src/virtualship/make_realistic/problems/scenarios.py index 97696a21..a7bc6a84 100644 --- a/src/virtualship/make_realistic/problems/scenarios.py +++ b/src/virtualship/make_realistic/problems/scenarios.py @@ -16,6 +16,7 @@ # ===================================================== +# TODO: pydantic model to ensure correct types? @dataclass class GeneralProblem(abc.ABC): """ @@ -107,8 +108,8 @@ class CaptainSafetyDrill(GeneralProblem): "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: False - delay_duration: 2.0 + can_reoccur = False + delay_duration = timedelta(hours=2.0) base_probability = 0.1 pre_departure = False diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index a779ec29..e713f263 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -16,24 +16,29 @@ FoodDeliveryDelayed, ) from virtualship.models.checkpoint import Checkpoint -from virtualship.utils import CHECKPOINT, _save_checkpoint +from virtualship.models.expedition import Schedule +from virtualship.utils import ( + CHECKPOINT, + EXPEDITION, + PROBLEMS_ENCOUNTERED_DIR, + SCHEDULE_ORIGINAL, + _save_checkpoint, +) if TYPE_CHECKING: from virtualship.make_realistic.problems.scenarios import ( GeneralProblem, InstrumentProblem, ) - from virtualship.models import Schedule import json +import random LOG_MESSAGING = { - "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 whole 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", + "pre_departure": "Hang on! There could be a pre-departure problem in-port...", + "during_expedition": "Oh no, a problem has occurred during the expedition, at waypoint {waypoint_i}...!", + "simulation_paused": "Please update your schedule (`virtualship plan` or directly in {expedition_yaml}) to account for the delay at waypoint {waypoint_i} 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 shortly...\n", + "pre_departure_delay": "This problem will cause a delay of {delay_duration} hours to the whole expedition schedule. Please account for this for all waypoints in your schedule (`virtualship plan` or directly in {expedition_yaml}), then continue the expedition by executing the `virtualship run` command again.\n", } @@ -64,7 +69,6 @@ def select_problems( def execute( self, problems: dict[str, list[GeneralProblem | InstrumentProblem]], - pre_departure: bool, instrument_type: InstrumentType | None = None, log_delay: float = 7.0, ): @@ -74,26 +78,51 @@ 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: re: prob levels: + # 0 = no problems + # 1 = only one problem in expedition (either pre-departure or during expedition, general or instrument) [and set this to DEFAULT prob level] + # 2 = multiple problems can occur (general and instrument), but only one pre-departure problem allowed + + # TODO: what to do about fact that students can avoid all problems by just scheduling in enough contingency time?? + # this should probably be a learning point though, so maybe it's fine... + #! though could then ensure that if they pass because of contingency time, they definitely get a pre-depature problem...? + # this would all probably have to be a bit asynchronous, which might make things more complicated... + #! 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 = problems["general"] + instrument_problems = problems["instrument"] + + # allow only one pre-departure problem to occur + pre_departure_problems = [p for p in general_problems if p.pre_departure] + if len(pre_departure_problems) > 1: + to_keep = random.choice(pre_departure_problems) + general_problems = [ + p for p in general_problems if not p.pre_departure or p is to_keep + ] + # ensure any pre-departure problem is first in list + general_problems.sort(key=lambda x: x.pre_departure, reverse=True) + # TODO: make the log output stand out more visually # general problems - for i, gproblem in enumerate(problems["general"]): + for i, gproblem in enumerate(general_problems): # 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) + None + if gproblem.pre_departure + else np.random.randint( + 0, len(self.schedule.waypoints) - 1 + ) # last waypoint excluded ) - # 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? + # TODO: delete checkpoint file once final expedition simulation has been completed successfully? # 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" + / f"{PROBLEMS_ENCOUNTERED_DIR}/problem_{gproblem_hash}.json" ) if hash_path.exists(): continue # problem * waypoint combination has already occurred; don't repeat @@ -102,26 +131,13 @@ def execute( gproblem, gproblem_hash, failed_waypoint_i, hash_path ) - if pre_departure and gproblem.pre_departure: - alert_msg = ( - LOG_MESSAGING["first_pre_departure"] - if i == 0 - else LOG_MESSAGING["subsequent_pre_departure"] - ) - - 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 - ) - ) + if gproblem.pre_departure: + alert_msg = LOG_MESSAGING["pre_departure"] else: - continue # problem does not occur (e.g. wrong combination of pre-departure vs. problem can only occur during expedition) + alert_msg = LOG_MESSAGING["during_expedition"].format( + waypoint_i=int(failed_waypoint_i) + 1 + ) # log problem occurrence, save to checkpoint, and pause simulation self._log_problem(gproblem, failed_waypoint_i, alert_msg, log_delay) @@ -154,7 +170,9 @@ def _calc_prob(self) -> float: 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 [FoodDeliveryDelayed] # TODO: temporary placeholder!! + return [ + FoodDeliveryDelayed, + ] # TODO: temporary placeholder!! def _instrument_problem_select(self, probability) -> list[InstrumentProblem]: """Select which problems. Higher probability (tied to expedition duration) means more problems are likely to occur.""" @@ -168,7 +186,7 @@ def _instrument_problem_select(self, probability) -> list[InstrumentProblem]: def _log_problem( self, problem: GeneralProblem | InstrumentProblem, - failed_waypoint_i: int | float, + failed_waypoint_i: int | None, alert_msg: str, log_delay: float, ): @@ -178,44 +196,60 @@ def _log_problem( time.sleep(log_delay) spinner.ok("💥 ") - print("\nPROBLEM ENCOUNTERED: " + problem.message) + print("\nPROBLEM ENCOUNTERED: " + problem.message + "\n") - if np.isnan(failed_waypoint_i): # pre-departure problem + if failed_waypoint_i is None: # pre-departure problem print( "\nRESULT: " + LOG_MESSAGING["pre_departure_delay"].format( - delay_duration=problem.delay_duration.total_seconds() / 3600.0 + delay_duration=problem.delay_duration.total_seconds() / 3600.0, + expedition_yaml=EXPEDITION, ) ) + else: # problem occurring during expedition - print( - "\nRESULT: " - + LOG_MESSAGING["simulation_paused"].format( - checkpoint_path=self.expedition_dir.joinpath(CHECKPOINT) - ) + result_msg = "\nRESULT: " + LOG_MESSAGING["simulation_paused"].format( + waypoint_i=int(failed_waypoint_i) + 1, + expedition_yaml=EXPEDITION, + 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 + + # handle first waypoint separately (no previous waypoint to provide contingency time, or rather the previous waypoint ends up being the -1th waypoint which is non-sensical) + if failed_waypoint_i == 0: + print(result_msg) + + # all other waypoints else: - print( - f"\nNot enough contingency time scheduled to avoid delay of {problem.delay_duration.total_seconds() / 3600.0} hours.\n" - ) + # check if enough contingency time has been scheduled to avoid delay + with yaspin(text="Assessing impact on expedition schedule..."): + time.sleep(5.0) + 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 # [hours] + if time_diff >= problem.delay_duration.total_seconds() / 3600.0: + print(LOG_MESSAGING["problem_avoided"]) + # give users time to read message before simulation continues + with yaspin(): + time.sleep(7.0) + return + + else: + print( + f"\nNot enough contingency time scheduled to avoid delay of {problem.delay_duration.total_seconds() / 3600.0} hours at waypoint {failed_waypoint_i + 1} (future waypoints would be reached too late).\n" + ) + print(result_msg) # save checkpoint checkpoint = self._make_checkpoint(failed_waypoint_i) _save_checkpoint(checkpoint, self.expedition_dir) - # cache original schedule for reference and/or restoring later if needed + # cache original schedule for reference and/or restoring later if needed (checkpoint can be overwritten if multiple problems occur so is not a persistent record of original schedule) schedule_original_path = ( - self.expedition_dir / "problems_encountered" / "schedule_original.yaml" + self.expedition_dir / PROBLEMS_ENCOUNTERED_DIR / SCHEDULE_ORIGINAL ) if os.path.exists(schedule_original_path) is False: self._cache_original_schedule(self.schedule, schedule_original_path) @@ -223,19 +257,10 @@ def _log_problem( # pause simulation sys.exit(0) - def _make_checkpoint(self, failed_waypoint_i: int | float = np.nan): + def _make_checkpoint(self, failed_waypoint_i: int | None = None) -> Checkpoint: """Make checkpoint, also handling pre-departure.""" - if np.isnan(failed_waypoint_i): # handles pre-departure problems - checkpoint = Checkpoint( - past_schedule=self.schedule - ) # use full schedule as past schedule - else: - checkpoint = Checkpoint( - past_schedule=Schedule( - waypoints=self.schedule.waypoints[:failed_waypoint_i] - ) - ) - return checkpoint + fpi = None if failed_waypoint_i is None else failed_waypoint_i + return Checkpoint(past_schedule=self.schedule, failed_waypoint_i=fpi) def _make_hash(self, s: str, length: int) -> str: """Make unique hash for problem occurrence.""" @@ -247,11 +272,11 @@ def _hash_to_json( self, problem: InstrumentProblem | GeneralProblem, problem_hash: str, - failed_waypoint_i: int | float, + failed_waypoint_i: int | None, hash_path: Path, ) -> dict: """Convert problem details + hash to json.""" - os.makedirs(self.expedition_dir / "problems_encountered", exist_ok=True) + os.makedirs(self.expedition_dir / PROBLEMS_ENCOUNTERED_DIR, exist_ok=True) hash_data = { "problem_hash": problem_hash, "message": problem.message, diff --git a/src/virtualship/models/checkpoint.py b/src/virtualship/models/checkpoint.py index d565087c..93b12824 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -6,13 +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.expedition import Schedule +from virtualship.utils import EXPEDITION, PROBLEMS_ENCOUNTERED_DIR class _YamlDumper(yaml.SafeDumper): @@ -32,6 +32,7 @@ class Checkpoint(pydantic.BaseModel): """ past_schedule: Schedule + failed_waypoint_i: int | None = None def to_yaml(self, file_path: str | Path) -> None: """ @@ -56,24 +57,27 @@ def from_yaml(cls, file_path: str | Path) -> Checkpoint: def verify(self, schedule: Schedule, expedition_dir: Path) -> None: """Verify that the given schedule matches the checkpoint's past schedule , and/or that any problem has been resolved.""" + # TODO: + #! TODO: needs adapting for new checkpoints model + # 1) check that past waypoints have not been changed, unless is a pre-departure problem - if len(self.past_schedule.waypoints) == len(schedule.waypoints): - pass # pre-departure problem checkpoint will match len of current schedule; no past waypoints to compare (any checkpoint file generated because user defined schedule with not enough time between waypoints will always have fewer waypoints than current schedule) + if ( + self.failed_waypoint_i is None + ): # pre-departure problem or empty checkpoint file + pass elif ( # TODO: double check this still works as intended for the user defined schedule with not enough time between waypoints case - not schedule.waypoints[: len(self.past_schedule.waypoints)] - == self.past_schedule.waypoints + not schedule.waypoints[: int(self.failed_waypoint_i)] + == self.past_schedule.waypoints[: int(self.failed_waypoint_i)] ): raise CheckpointError( "Past waypoints in schedule have been changed! Restore past schedule and only change future waypoints." ) - breakpoint() - # 2) check that problems have been resolved in the new schedule hash_fpaths = [ str(path.resolve()) - for path in Path(expedition_dir, "problems_encountered").glob( + for path in Path(expedition_dir, PROBLEMS_ENCOUNTERED_DIR).glob( "problem_*.json" ) ] @@ -88,18 +92,11 @@ def verify(self, schedule: Schedule, expedition_dir: Path) -> None: delay_duration = timedelta( hours=float(problem["delay_duration_hours"]) ) # delay associated with the problem - - failed_waypoint_i = ( - int(problem["failed_waypoint_i"]) - if type(problem["failed_waypoint_i"]) is int - else np.nan - ) - waypoint_range = ( range(len(self.past_schedule.waypoints)) - if np.isnan(failed_waypoint_i) + if self.failed_waypoint_i is None else range( - failed_waypoint_i, len(self.past_schedule.waypoints) + int(self.failed_waypoint_i), len(schedule.waypoints) ) ) time_deltas = [ @@ -121,11 +118,11 @@ def verify(self, schedule: Schedule, expedition_dir: Path) -> None: else: affected_waypoints = ( "all waypoints" - if np.isnan(failed_waypoint_i) - else f"waypoints from {failed_waypoint_i + 1} onwards" + if self.failed_waypoint_i is None + else f"waypoint {int(self.failed_waypoint_i) + 1} onwards" ) 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 the problem.", + f"The problem encountered in previous simulation has not been resolved in the schedule! Please adjust the schedule to account for delays caused by the problem (by using `virtualship plan` or directly editing the {EXPEDITION} file).\n" f"The problem was associated with a delay duration of {problem['delay_duration_hours']} hours affecting {affected_waypoints}.", ) diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index d5e28120..79387187 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: @@ -31,6 +31,8 @@ EXPEDITION = "expedition.yaml" CHECKPOINT = "checkpoint.yaml" +SCHEDULE_ORIGINAL = "schedule_original.yaml" +PROBLEMS_ENCOUNTERED_DIR = "problems_encountered" def load_static_file(name: str) -> str: From 221179a2c277c22081d832d5f1fee07a87f0d172 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 14:04:02 +0000 Subject: [PATCH 17/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/virtualship/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 79387187..3894e384 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: From 790c5092802f0bde1b393f77156789c6274ba46d Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:57:25 +0100 Subject: [PATCH 18/18] fix waypoint index selection for problem waypoint vs waypoint where scheduling will fail --- src/virtualship/cli/_run.py | 2 + .../expedition/simulate_schedule.py | 5 +- .../make_realistic/problems/simulator.py | 55 +++++++++++-------- src/virtualship/models/checkpoint.py | 15 +++-- 4 files changed, 41 insertions(+), 36 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index 6fadf494..d2934e59 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -182,6 +182,8 @@ def _run( f"Your measurements can be found in the '{expedition_dir}/results' directory." ) + # TODO: delete checkpoint file at the end of successful expedition? [it inteferes with ability to re-run expedition] + if problems: print("\n----- RECORD OF PROBLEMS ENCOUNTERED ------") print( diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index c09ae7b5..656a2722 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 TYPE_CHECKING, ClassVar +from typing import ClassVar import pyproj @@ -21,9 +21,6 @@ Waypoint, ) -if TYPE_CHECKING: - pass - @dataclass class ScheduleOk: diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index e713f263..a74b4604 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -12,8 +12,8 @@ from virtualship.instruments.types import InstrumentType from virtualship.make_realistic.problems.scenarios import ( + CaptainSafetyDrill, CTDCableJammed, - FoodDeliveryDelayed, ) from virtualship.models.checkpoint import Checkpoint from virtualship.models.expedition import Schedule @@ -72,7 +72,11 @@ def execute( instrument_type: InstrumentType | None = None, log_delay: float = 7.0, ): - """Execute the selected problems, returning messaging and delay times.""" + """ + Execute the selected problems, returning messaging and delay times. + + N.B. the problem_waypoint_i is different to the failed_waypoint_i defined in the Checkpoint class; the failed_waypoint_i is the waypoint index after the problem_waypoint_i where the problem occurred, as this is when scheduling issues would be encountered. + """ # 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 @@ -90,6 +94,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: N.B. there is not logic currently controlling how many problems can occur in total during an expedition; at the moment it can happen every time the expedition is run if it's a different waypoint / problem combination + general_problems = problems["general"] instrument_problems = problems["instrument"] @@ -105,20 +111,19 @@ def execute( # TODO: make the log output stand out more visually # general problems - for i, gproblem in enumerate(general_problems): - # determine failed waypoint index (random if during expedition) - failed_waypoint_i = ( + for gproblem in general_problems: + # determine problem waypoint index (random if during expedition) + problem_waypoint_i = ( None if gproblem.pre_departure else np.random.randint( 0, len(self.schedule.waypoints) - 1 - ) # last waypoint excluded + ) # last waypoint excluded (would not impact any future scheduling) ) - # 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? - # TODO: delete checkpoint file once final expedition simulation has been completed successfully? + # 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 + gproblem.message + str(problem_waypoint_i), 8 ) hash_path = Path( self.expedition_dir @@ -128,7 +133,7 @@ def execute( continue # problem * waypoint combination has already occurred; don't repeat else: self._hash_to_json( - gproblem, gproblem_hash, failed_waypoint_i, hash_path + gproblem, gproblem_hash, problem_waypoint_i, hash_path ) if gproblem.pre_departure: @@ -136,11 +141,11 @@ def execute( else: alert_msg = LOG_MESSAGING["during_expedition"].format( - waypoint_i=int(failed_waypoint_i) + 1 + waypoint_i=int(problem_waypoint_i) + 1 ) # log problem occurrence, save to checkpoint, and pause simulation - self._log_problem(gproblem, failed_waypoint_i, alert_msg, log_delay) + self._log_problem(gproblem, problem_waypoint_i, alert_msg, log_delay) # instrument problems for i, problem in enumerate(problems["instrument"]): @@ -171,7 +176,7 @@ def _calc_prob(self) -> float: 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 [ - FoodDeliveryDelayed, + CaptainSafetyDrill, ] # TODO: temporary placeholder!! def _instrument_problem_select(self, probability) -> list[InstrumentProblem]: @@ -186,7 +191,7 @@ def _instrument_problem_select(self, probability) -> list[InstrumentProblem]: def _log_problem( self, problem: GeneralProblem | InstrumentProblem, - failed_waypoint_i: int | None, + problem_waypoint_i: int | None, alert_msg: str, log_delay: float, ): @@ -198,7 +203,7 @@ def _log_problem( print("\nPROBLEM ENCOUNTERED: " + problem.message + "\n") - if failed_waypoint_i is None: # pre-departure problem + if problem_waypoint_i is None: # pre-departure problem print( "\nRESULT: " + LOG_MESSAGING["pre_departure_delay"].format( @@ -209,26 +214,26 @@ def _log_problem( else: # problem occurring during expedition result_msg = "\nRESULT: " + LOG_MESSAGING["simulation_paused"].format( - waypoint_i=int(failed_waypoint_i) + 1, + waypoint_i=int(problem_waypoint_i) + 1, expedition_yaml=EXPEDITION, checkpoint_path=self.expedition_dir.joinpath(CHECKPOINT), ) # handle first waypoint separately (no previous waypoint to provide contingency time, or rather the previous waypoint ends up being the -1th waypoint which is non-sensical) - if failed_waypoint_i == 0: + if problem_waypoint_i == 0: print(result_msg) # all other waypoints else: - # check if enough contingency time has been scheduled to avoid delay + # check if enough contingency time has been scheduled to avoid delay affecting future waypoints with yaspin(text="Assessing impact on expedition schedule..."): time.sleep(5.0) - failed_waypoint_time = self.schedule.waypoints[failed_waypoint_i].time - previous_waypoint_time = self.schedule.waypoints[ - failed_waypoint_i - 1 + problem_waypoint_time = self.schedule.waypoints[problem_waypoint_i].time + next_waypoint_time = self.schedule.waypoints[ + problem_waypoint_i + 1 ].time time_diff = ( - failed_waypoint_time - previous_waypoint_time + next_waypoint_time - problem_waypoint_time ).total_seconds() / 3600.0 # [hours] if time_diff >= problem.delay_duration.total_seconds() / 3600.0: print(LOG_MESSAGING["problem_avoided"]) @@ -239,12 +244,14 @@ def _log_problem( else: print( - f"\nNot enough contingency time scheduled to avoid delay of {problem.delay_duration.total_seconds() / 3600.0} hours at waypoint {failed_waypoint_i + 1} (future waypoints would be reached too late).\n" + f"\nNot enough contingency time scheduled to mitigate delay of {problem.delay_duration.total_seconds() / 3600.0} hours occuring at waypoint {problem_waypoint_i + 1} (future waypoints would be reached too late).\n" ) print(result_msg) # save checkpoint - checkpoint = self._make_checkpoint(failed_waypoint_i) + checkpoint = self._make_checkpoint( + failed_waypoint_i=problem_waypoint_i + 1 + ) # failed waypoint index then becomes the one after the one where the problem occurred; this is when scheduling issues would be run into _save_checkpoint(checkpoint, self.expedition_dir) # cache original schedule for reference and/or restoring later if needed (checkpoint can be overwritten if multiple problems occur so is not a persistent record of original schedule) diff --git a/src/virtualship/models/checkpoint.py b/src/virtualship/models/checkpoint.py index 93b12824..cbea7a5b 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -56,14 +56,13 @@ def from_yaml(cls, file_path: str | Path) -> Checkpoint: return Checkpoint(**data) def verify(self, schedule: Schedule, expedition_dir: Path) -> None: - """Verify that the given schedule matches the checkpoint's past schedule , and/or that any problem has been resolved.""" - # TODO: - #! TODO: needs adapting for new checkpoints model + """ + Verify that the given schedule matches the checkpoint's past schedule , and/or that any problem has been resolved. + Addresses changes made by the user in response to both i) scheduling issues arising for not enough time for the ship to travel between waypoints, and ii) problems encountered during simulation. + """ # 1) check that past waypoints have not been changed, unless is a pre-departure problem - if ( - self.failed_waypoint_i is None - ): # pre-departure problem or empty checkpoint file + if self.failed_waypoint_i is None: pass elif ( # TODO: double check this still works as intended for the user defined schedule with not enough time between waypoints case @@ -71,7 +70,7 @@ def verify(self, schedule: Schedule, expedition_dir: Path) -> None: == self.past_schedule.waypoints[: int(self.failed_waypoint_i)] ): raise CheckpointError( - "Past waypoints in schedule have been changed! Restore past schedule and only change future waypoints." + f"Past waypoints in schedule have been changed! Restore past schedule and only change future waypoints (waypoint {int(self.failed_waypoint_i) + 1} onwards)." ) # 2) check that problems have been resolved in the new schedule @@ -107,7 +106,7 @@ def verify(self, schedule: Schedule, expedition_dir: Path) -> None: if all(td >= delay_duration for td in time_deltas): print( - "\n\nPrevious problem has been resolved in the schedule.\n" + "\n\n🎉 Previous problem has been resolved in the schedule.\n" ) # save back to json file changing the resolved status to True