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/52] psuedo code --- src/virtualship/cli/_run.py | 21 ++++++++ .../expedition/simulate_schedule.py | 21 ++++++++ src/virtualship/make_realistic/problems.py | 48 +++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 src/virtualship/make_realistic/problems.py diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index f07fbab27..be444ddaf 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -112,6 +112,27 @@ def _run(expedition_dir: str | Path, from_data: Path | None = None) -> None: ) return + """ + PROBLEMS: + - Post verification of schedule: + - There will be a problem in the expedition, and the problem is tied to an instrument type + - JAMIE DETERMINES **WHERE** THE LOGIC OF WHETHER THE PROBLEM IS INITIATED LIVES (probably simulate_schedule .simulate() ) + - assume it's assigned to be a problem at waypoint e.g. 4 (can get more creative with this later; and e.g. some that are only before waypoiny 1, delays with food, fuel) + - extract from schedule, way the time absolutley it should take to get to the next waypoint (based on speed and distance) + - compare to the extracted value of what the user has scheduled, + - if they have not scheduled enough time for the time associated with the specific problem, then we have a problem + - if they have scheduled enough time then can continue and give a message that there was a problem but they had enough time scheduled to deal with it - well done etc. + - return to `virtualship plan` [with adequate messaging to say waypoint N AND BEYOND need updating to account for x hour/days delay] + for user to update schedule (or directly in YAML) + - once updated, run `virtualship run` again, will check from the checkpoint and check that the new schedule is suitable (do checkpoint.verify methods need updating?) + - if not suitable, return to `virtualship plan` again etc. + - Also give error + messaging if the user has made changes to waypoints PREVIOUS to the problem waypoint + - proceed with run + + + - Ability to turn on and off problems + """ + # delete and create results directory if os.path.exists(expedition_dir.joinpath("results")): shutil.rmtree(expedition_dir.joinpath("results")) diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index 0a567b1c6..fb4010fa2 100644 --- a/src/virtualship/expedition/simulate_schedule.py +++ b/src/virtualship/expedition/simulate_schedule.py @@ -114,8 +114,29 @@ def __init__(self, projection: pyproj.Geod, expedition: Expedition) -> None: self._next_adcp_time = self._time self._next_ship_underwater_st_time = self._time + def _calc_prob(self, waypoint: Waypoint, wp_instruments) -> float: + """ + Calcuates the probability of a problem occurring at a given waypoint based on the instruments being used. + + 1) check if want a problem before waypoint 0 + 2) then by waypoint + """ + + def _return_specific_problem(self): + """ + Return the problem class (e.g. CTDPRoblem_Winch_Failure) based on the instrument type causing the problem OR if general problem (e.g. EngineProblem_FuelLeak). + + With instructions for re-processing the schedule afterwards. + + """ + def simulate(self) -> ScheduleOk | ScheduleProblem: for wp_i, waypoint in enumerate(self._expedition.schedule.waypoints): + probability_of_problem = self._calc_prob(waypoint, wp_instruments) # noqa: F821 + + if probability_of_problem > 1.0: + return self._return_specific_problem() + # sail towards waypoint self._progress_time_traveling_towards(waypoint.location) diff --git a/src/virtualship/make_realistic/problems.py b/src/virtualship/make_realistic/problems.py new file mode 100644 index 000000000..e085b22ae --- /dev/null +++ b/src/virtualship/make_realistic/problems.py @@ -0,0 +1,48 @@ +"""This can be where we house both genreal and instrument-specific probelems.""" # noqa: D404 + +from dataclasses import dataclass + +import pydantic + +from virtualship.instruments.ctd import CTD + +# base classes + + +class GeneralProblem(pydantic.BaseModel): + """Base class for general problems.""" + + message: str + can_reoccur: bool + delay_duration: float # in hours + + +class InstrumentProblem(pydantic.BaseModel): + """Base class for instrument-specific problems.""" + + instrument_dataclass: type + message: str + can_reoccur: bool + delay_duration: float # in hours + + +# Genreral problems + + +@dataclass +class EngineProblem_FuelLeak(GeneralProblem): ... # noqa: D101 + + +@dataclass +class FoodDelivery_Delayed(GeneralProblem): ... # noqa: D101 + + +# Instrument-specific problems + + +@dataclass +class CTDPRoblem_Winch_Failure(InstrumentProblem): # noqa: D101 + instrument_dataclass = CTD + message: str = ... + can_reoccur: bool = ... + delay_duration: float = ... # in hours From a53b61d0f0c7a38fd49ba42e323d4e351ef606e6 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:14:06 +0100 Subject: [PATCH 02/52] remove notes --- src/virtualship/cli/_run.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index be444ddaf..f07fbab27 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -112,27 +112,6 @@ def _run(expedition_dir: str | Path, from_data: Path | None = None) -> None: ) return - """ - PROBLEMS: - - Post verification of schedule: - - There will be a problem in the expedition, and the problem is tied to an instrument type - - JAMIE DETERMINES **WHERE** THE LOGIC OF WHETHER THE PROBLEM IS INITIATED LIVES (probably simulate_schedule .simulate() ) - - assume it's assigned to be a problem at waypoint e.g. 4 (can get more creative with this later; and e.g. some that are only before waypoiny 1, delays with food, fuel) - - extract from schedule, way the time absolutley it should take to get to the next waypoint (based on speed and distance) - - compare to the extracted value of what the user has scheduled, - - if they have not scheduled enough time for the time associated with the specific problem, then we have a problem - - if they have scheduled enough time then can continue and give a message that there was a problem but they had enough time scheduled to deal with it - well done etc. - - return to `virtualship plan` [with adequate messaging to say waypoint N AND BEYOND need updating to account for x hour/days delay] - for user to update schedule (or directly in YAML) - - once updated, run `virtualship run` again, will check from the checkpoint and check that the new schedule is suitable (do checkpoint.verify methods need updating?) - - if not suitable, return to `virtualship plan` again etc. - - Also give error + messaging if the user has made changes to waypoints PREVIOUS to the problem waypoint - - proceed with run - - - - Ability to turn on and off problems - """ - # delete and create results directory if os.path.exists(expedition_dir.joinpath("results")): shutil.rmtree(expedition_dir.joinpath("results")) From 51ab3086aa73dcdfb9b010a6919d96c8a029355c Mon Sep 17 00:00:00 2001 From: Emma Daniels Date: Wed, 26 Nov 2025 09:40:22 +0100 Subject: [PATCH 03/52] fill some problems --- src/virtualship/make_realistic/problems.py | 200 ++++++++++++++++++++- 1 file changed, 192 insertions(+), 8 deletions(-) diff --git a/src/virtualship/make_realistic/problems.py b/src/virtualship/make_realistic/problems.py index e085b22ae..766d97bae 100644 --- a/src/virtualship/make_realistic/problems.py +++ b/src/virtualship/make_realistic/problems.py @@ -1,10 +1,13 @@ -"""This can be where we house both genreal and instrument-specific probelems.""" # noqa: D404 +"""This can be where we house both general and instrument-specific problems.""" # noqa: D404 from dataclasses import dataclass import pydantic from virtualship.instruments.ctd import CTD +from virtualship.instruments.adcp import ADCP +from virtualship.instruments.drifter import Drifter +from virtualship.instruments.argo_float import ArgoFloat # base classes @@ -26,23 +29,204 @@ class InstrumentProblem(pydantic.BaseModel): delay_duration: float # in hours -# Genreral problems +# General problems +@dataclass +class VenomousCentipedeOnboard: + message: str = ( + "A venomous centipede is discovered onboard while operating in tropical waters. " + "One crew member becomes ill after contact with the creature and receives medical attention, " + "prompting a full search of the vessel to ensure no further danger. " + "The medical response and search efforts cause an operational delay of about 2 hours." + ) + can_reoccur: bool = False + delay_duration: float = 2.0 + +@dataclass +class CaptainSafetyDrill: + message: str = ( + "A miscommunication with the ship’s captain results in the sudden initiation of a mandatory safety drill. " + "The emergency vessel must be lowered and tested while the ship remains stationary, pausing all scientific " + "operations for the duration of the exercise. The drill introduces a delay of approximately 2 hours." + ) + can_reoccur: bool = False + delay_duration: float = 2. @dataclass -class EngineProblem_FuelLeak(GeneralProblem): ... # noqa: D101 +class FoodDeliveryDelayed: + message: str = ( + "The scheduled food delivery prior to departure has not arrived. Until the supply truck reaches the pier, " + "we cannot leave. Once it arrives, unloading and stowing the provisions in the ship’s cold storage " + "will also take additional time. These combined delays postpone departure by approximately 5 hours." + ) + can_reoccur: bool = False + delay_duration: float = 5.0 + +# @dataclass +# class FuelDeliveryIssue: +# message: str = ( +# "The fuel tanker expected to deliver fuel has not arrived. Port authorities are unable to provide " +# "a clear estimate for when the delivery might occur. You may choose to [w]ait for the tanker or [g]et a " +# "harbor pilot to guide the vessel to an available bunker dock instead. This decision may need to be " +# "revisited periodically depending on circumstances." +# ) +# can_reoccur: bool = False +# delay_duration: float = 0.0 # dynamic delays based on repeated choices + +# @dataclass +# class EngineOverheating: +# message: str = ( +# "One of the main engines has overheated. To prevent further damage, the engineering team orders a reduction " +# "in vessel speed until the engine can be inspected and repaired in port. The ship will now operate at a " +# "reduced cruising speed of 8.5 knots for the remainder of the transit." +# ) +# can_reoccur: bool = False +# delay_duration: None = None # speed reduction affects ETA instead of fixed delay +# ship_speed_knots: float = 8.5 +@dataclass +class MarineMammalInDeploymentArea: + message: str = ( + "A pod of dolphins is observed swimming directly beneath the planned deployment area. " + "To avoid risk to wildlife and comply with environmental protocols, all in-water operations " + "must pause until the animals move away from the vicinity. This results in a delay of about 30 minutes." + ) + can_reoccur: bool = True + delay_duration: float = 0.5 @dataclass -class FoodDelivery_Delayed(GeneralProblem): ... # noqa: D101 +class BallastPumpFailure: + message: str = ( + "One of the ship’s ballast pumps suddenly stops responding during routine ballasting operations. " + "Without the pump, the vessel cannot safely adjust trim or compensate for equipment movements on deck. " + "Engineering isolates the faulty pump and performs a rapid inspection. Temporary repairs allow limited " + "functionality, but the interruption causes a delay of approximately 1 hour." + ) + can_reoccur: bool = True + delay_duration: float = 1.0 +@dataclass +class ThrusterConverterFault: + message: str = ( + "The bow thruster's power converter reports a fault during station-keeping operations. " + "Dynamic positioning becomes less stable, forcing a temporary suspension of high-precision sampling. " + "Engineers troubleshoot the converter and perform a reset, resulting in a delay of around 1 hour." + ) + can_reoccur: bool = False + delay_duration: float = 1.0 + +@dataclass +class AFrameHydraulicLeak: + message: str = ( + "A crew member notices hydraulic fluid leaking from the A-frame actuator during equipment checks. " + "The leak must be isolated immediately to prevent environmental contamination or mechanical failure. " + "Engineering replaces a faulty hose and repressurizes the system. This repair causes a delay of about 2 hours." + ) + can_reoccur: bool = True + delay_duration: float = 2.0 + +@dataclass +class CoolingWaterIntakeBlocked: + message: str = ( + "The main engine's cooling water intake alarms indicate reduced flow, likely caused by marine debris " + "or biological fouling. The vessel must temporarily slow down while engineering clears the obstruction " + "and flushes the intake. This results in a delay of approximately 1 hour." + ) + can_reoccur: bool = True + delay_duration: float = 1.0 # Instrument-specific problems +@dataclass +class CTDCableJammed: + message: str = ( + "During preparation for the next CTD cast, the CTD cable becomes jammed in the winch drum. " + "Attempts to free it are unsuccessful, and the crew determines that the entire cable must be " + "replaced before deployment can continue. This repair is time-consuming and results in a delay " + "of approximately 3 hours." + ) + can_reoccur: bool = True + delay_duration: float = 3.0 + instrument_dataclass = CTD @dataclass -class CTDPRoblem_Winch_Failure(InstrumentProblem): # noqa: D101 +class CTDTemperatureSensorFailure: + message: str = ( + "The primary temperature sensor on the CTD begins returning inconsistent readings. " + "Troubleshooting confirms that the sensor has malfunctioned. A spare unit can be installed, " + "but integrating and verifying the replacement will pause operations. " + "This procedure leads to an estimated delay of around 2 hours." + ) + can_reoccur: bool = True + delay_duration: float = 2.0 instrument_dataclass = CTD - message: str = ... - can_reoccur: bool = ... - delay_duration: float = ... # in hours + +@dataclass +class CTDSalinitySensorFailureWithCalibration: + message: str = ( + "The CTD’s primary salinity sensor fails and must be replaced with a backup. After installation, " + "a mandatory calibration cast to a minimum depth of 1000 meters is required to verify sensor accuracy. " + "Both the replacement and calibration activities result in a total delay of roughly 4 hours." + ) + can_reoccur: bool = True + delay_duration: float = 4.0 + instrument_dataclass = CTD + +@dataclass +class WinchHydraulicPressureDrop: + message: str = ( + "The CTD winch begins to lose hydraulic pressure during routine checks prior to deployment. " + "The engineering crew must stop operations to diagnose the hydraulic pump and replenish or repair " + "the system. Until pressure is restored to operational levels, the winch cannot safely be used. " + "This results in an estimated delay of 1.5 hours." + ) + can_reoccur: bool = True + delay_duration: float = 1.5 + instrument_dataclass = CTD + +@dataclass +class RosetteTriggerFailure: + message: str = ( + "During a CTD cast, the rosette's bottle-triggering mechanism fails to actuate. " + "No discrete water samples can be collected during this cast. The rosette must be brought back " + "on deck for inspection and manual testing of the trigger system. This results in an operational " + "delay of approximately 2.5 hours." + ) + can_reoccur: bool = True + delay_duration: float = 2.5 + +@dataclass +class ADCPMalfunction: + message: str = ( + "The hull-mounted ADCP begins returning invalid velocity data. Engineering suspects damage to the cable " + "from recent maintenance activities. The ship must hold position while a technician enters the cable " + "compartment to perform an inspection and continuity test. This diagnostic procedure results in a delay " + "of around 1 hour." + ) + can_reoccur: bool = True + delay_duration: float = 1.0 + instrument_dataclass = ADCP + +@dataclass +class DrifterSatelliteConnectionDelay: + message: str = ( + "The drifter scheduled for deployment fails to establish a satellite connection during " + "pre-launch checks. To improve signal acquisition, the float must be moved to a higher location on deck " + "with fewer obstructions. The team waits for the satellite fix to come through, resulting in a delay " + "of approximately 2 hours." + ) + can_reoccur: bool = True + delay_duration: float = 2.0 + instrument_dataclass = Drifter + +@dataclass +class ArgoSatelliteConnectionDelay: + message: str = ( + "The Argo float scheduled for deployment fails to establish a satellite connection during " + "pre-launch checks. To improve signal acquisition, the float must be moved to a higher location on deck " + "with fewer obstructions. The team waits for the satellite fix to come through, resulting in a delay " + "of approximately 2 hours." + ) + can_reoccur: bool = True + delay_duration: float = 2.0 + instrument_dataclass = ArgoFloat \ No newline at end of file From a55e6f722f7c5382ab9d949ed93f161620ddb1b3 Mon Sep 17 00:00:00 2001 From: Emma Daniels Date: Wed, 26 Nov 2025 11:41:13 +0100 Subject: [PATCH 04/52] nicks changes --- src/virtualship/expedition/simulate_schedule.py | 6 +++--- src/virtualship/make_realistic/problems.py | 17 +++++++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index fb4010fa2..499a4b85d 100644 --- a/src/virtualship/expedition/simulate_schedule.py +++ b/src/virtualship/expedition/simulate_schedule.py @@ -132,10 +132,10 @@ def _return_specific_problem(self): def simulate(self) -> ScheduleOk | ScheduleProblem: for wp_i, waypoint in enumerate(self._expedition.schedule.waypoints): - probability_of_problem = self._calc_prob(waypoint, wp_instruments) # noqa: F821 + # probability_of_problem = self._calc_prob(waypoint, wp_instruments) # noqa: F821 - if probability_of_problem > 1.0: - return self._return_specific_problem() + # if probability_of_problem > 1.0: + # return self._return_specific_problem() # sail towards waypoint self._progress_time_traveling_towards(waypoint.location) diff --git a/src/virtualship/make_realistic/problems.py b/src/virtualship/make_realistic/problems.py index 766d97bae..15bb15bb1 100644 --- a/src/virtualship/make_realistic/problems.py +++ b/src/virtualship/make_realistic/problems.py @@ -9,26 +9,35 @@ from virtualship.instruments.drifter import Drifter from virtualship.instruments.argo_float import ArgoFloat -# base classes +from abc import ABC -class GeneralProblem(pydantic.BaseModel): - """Base class for general problems.""" + +class GeneralProblem: + """Base class for general problems. + + Problems occur at each waypoint.""" message: str can_reoccur: bool + base_probability: float # Probability is a function of time - the longer the expedition the more likely something is to go wrong (not a function of waypoints) delay_duration: float # in hours -class InstrumentProblem(pydantic.BaseModel): + + +class InstrumentProblem: """Base class for instrument-specific problems.""" instrument_dataclass: type message: str can_reoccur: bool + base_probability: float # Probability is a function of time - the longer the expedition the more likely something is to go wrong (not a function of waypoints) delay_duration: float # in hours + + # General problems @dataclass From d81efeb4fc7fea01fd4b161bcdb4bc2ed5040e02 Mon Sep 17 00:00:00 2001 From: Emma Daniels Date: Tue, 2 Dec 2025 08:38:25 +0100 Subject: [PATCH 05/52] still pseudo-code --- src/virtualship/make_realistic/problems.py | 86 ++++++++++++++++++---- 1 file changed, 72 insertions(+), 14 deletions(-) diff --git a/src/virtualship/make_realistic/problems.py b/src/virtualship/make_realistic/problems.py index 15bb15bb1..0180a848d 100644 --- a/src/virtualship/make_realistic/problems.py +++ b/src/virtualship/make_realistic/problems.py @@ -2,16 +2,59 @@ from dataclasses import dataclass -import pydantic - from virtualship.instruments.ctd import CTD from virtualship.instruments.adcp import ADCP from virtualship.instruments.drifter import Drifter from virtualship.instruments.argo_float import ArgoFloat +from virtualship.models import Waypoint -from abc import ABC +@dataclass +class ProblemConfig: + message: str + can_reoccur: bool + base_probability: float # Probability is a function of time - the longer the expedition the more likely something is to go wrong (not a function of waypoints) + delay_duration: float # in hours +def general_proba_function(config: ProblemConfig, waypoint: Waypoint) -> bool: + """Determine if a general problem should occur at a given waypoint.""" + # some random calculation based on the base_probability + return True + +# Pseudo-code for problem probability functions +# def instrument_specific_proba( +# instrument: type, +# ) -> Callable([ProblemConfig, Waypoint], bool): +# """Return a function to determine if an instrument-specific problem should occur.""" + +# def should_occur(config: ProblemConfig, waypoint) -> bool: +# if instrument not in waypoint.instruments: +# return False + +# return general_proba_function(config, waypoint) + +# return should_occur + +# PROBLEMS: list[Tuple[ProblemConfig, Callable[[ProblemConfig, Waypoint], bool]]] = [ +# ( +# ProblemConfig( +# message="General problem occurred", +# can_reoccur=True, +# base_probability=0.1, +# delay_duration=2.0, +# ), +# general_proba_function, +# ), +# ( +# ProblemConfig( +# message="Instrument-specific problem occurred", +# can_reoccur=False, +# base_probability=0.05, +# delay_duration=4.0, +# ), +# instrument_specific_proba(CTD), +# ), +# ] class GeneralProblem: """Base class for general problems. @@ -21,8 +64,7 @@ class GeneralProblem: message: str can_reoccur: bool base_probability: float # Probability is a function of time - the longer the expedition the more likely something is to go wrong (not a function of waypoints) - delay_duration: float # in hours - + delay_duration: float # in hours @@ -50,6 +92,7 @@ class VenomousCentipedeOnboard: ) can_reoccur: bool = False delay_duration: float = 2.0 + base_probability: float = 0.05 @dataclass class CaptainSafetyDrill: @@ -60,16 +103,17 @@ class CaptainSafetyDrill: ) can_reoccur: bool = False delay_duration: float = 2. + base_probability: float = 0.1 -@dataclass -class FoodDeliveryDelayed: - message: str = ( - "The scheduled food delivery prior to departure has not arrived. Until the supply truck reaches the pier, " - "we cannot leave. Once it arrives, unloading and stowing the provisions in the ship’s cold storage " - "will also take additional time. These combined delays postpone departure by approximately 5 hours." - ) - can_reoccur: bool = False - delay_duration: float = 5.0 +# @dataclass +# class FoodDeliveryDelayed: +# message: str = ( +# "The scheduled food delivery prior to departure has not arrived. Until the supply truck reaches the pier, " +# "we cannot leave. Once it arrives, unloading and stowing the provisions in the ship’s cold storage " +# "will also take additional time. These combined delays postpone departure by approximately 5 hours." +# ) +# can_reoccur: bool = False +# delay_duration: float = 5.0 # @dataclass # class FuelDeliveryIssue: @@ -102,6 +146,7 @@ class MarineMammalInDeploymentArea: ) can_reoccur: bool = True delay_duration: float = 0.5 + base_probability: float = 0.1 @dataclass class BallastPumpFailure: @@ -113,6 +158,7 @@ class BallastPumpFailure: ) can_reoccur: bool = True delay_duration: float = 1.0 + base_probability: float = 0.1 @dataclass class ThrusterConverterFault: @@ -123,6 +169,7 @@ class ThrusterConverterFault: ) can_reoccur: bool = False delay_duration: float = 1.0 + base_probability: float = 0.1 @dataclass class AFrameHydraulicLeak: @@ -133,6 +180,7 @@ class AFrameHydraulicLeak: ) can_reoccur: bool = True delay_duration: float = 2.0 + base_probability: float = 0.1 @dataclass class CoolingWaterIntakeBlocked: @@ -143,6 +191,7 @@ class CoolingWaterIntakeBlocked: ) can_reoccur: bool = True delay_duration: float = 1.0 + base_probability: float = 0.1 # Instrument-specific problems @@ -156,6 +205,7 @@ class CTDCableJammed: ) can_reoccur: bool = True delay_duration: float = 3.0 + base_probability: float = 0.1 instrument_dataclass = CTD @dataclass @@ -168,6 +218,7 @@ class CTDTemperatureSensorFailure: ) can_reoccur: bool = True delay_duration: float = 2.0 + base_probability: float = 0.1 instrument_dataclass = CTD @dataclass @@ -179,6 +230,7 @@ class CTDSalinitySensorFailureWithCalibration: ) can_reoccur: bool = True delay_duration: float = 4.0 + base_probability: float = 0.1 instrument_dataclass = CTD @dataclass @@ -191,6 +243,7 @@ class WinchHydraulicPressureDrop: ) can_reoccur: bool = True delay_duration: float = 1.5 + base_probability: float = 0.1 instrument_dataclass = CTD @dataclass @@ -203,6 +256,8 @@ class RosetteTriggerFailure: ) can_reoccur: bool = True delay_duration: float = 2.5 + base_probability: float = 0.1 + instrument_dataclass = CTD @dataclass class ADCPMalfunction: @@ -214,6 +269,7 @@ class ADCPMalfunction: ) can_reoccur: bool = True delay_duration: float = 1.0 + base_probability: float = 0.1 instrument_dataclass = ADCP @dataclass @@ -226,6 +282,7 @@ class DrifterSatelliteConnectionDelay: ) can_reoccur: bool = True delay_duration: float = 2.0 + base_probability: float = 0.1 instrument_dataclass = Drifter @dataclass @@ -238,4 +295,5 @@ class ArgoSatelliteConnectionDelay: ) can_reoccur: bool = True delay_duration: float = 2.0 + base_probability: float = 0.1 instrument_dataclass = ArgoFloat \ No newline at end of file From 07df2696a5ae2d13257d9409bcc279c0d8c0fca6 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 9 Jan 2026 14:07:14 +0100 Subject: [PATCH 06/52] prepare for problem handling logic --- src/virtualship/expedition/simulate_schedule.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index 93f71664d..dda93b3db 100644 --- a/src/virtualship/expedition/simulate_schedule.py +++ b/src/virtualship/expedition/simulate_schedule.py @@ -132,7 +132,11 @@ def _return_specific_problem(self): def simulate(self) -> ScheduleOk | ScheduleProblem: for wp_i, waypoint in enumerate(self._expedition.schedule.waypoints): - # probability_of_problem = self._calc_prob(waypoint, wp_instruments) # noqa: F821 + # TODO: + # TODO: insert method/class here which ingests waypoint model, and handles the logic for determing which problem is occuring at this waypoint/point of this loop + # TODO: this method/class should definitely be housed AWAY from this simulate_schedule.py, and with the rest of the problems logic/classes + + probability_of_problem = self._calc_prob(waypoint, wp_instruments) # noqa: F821 F841 # if probability_of_problem > 1.0: # return self._return_specific_problem() From 2c41f28edca8e07d34f61f8bc4c0b375d96e5d54 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 9 Jan 2026 17:28:18 +0100 Subject: [PATCH 07/52] work in progress... --- .../expedition/simulate_schedule.py | 83 +++++-- src/virtualship/instruments/base.py | 2 +- src/virtualship/make_realistic/__init__.py | 1 + src/virtualship/make_realistic/problems.py | 207 +++++++++++++----- src/virtualship/utils.py | 23 +- 5 files changed, 244 insertions(+), 72 deletions(-) diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index dda93b3db..3a422ffe4 100644 --- a/src/virtualship/expedition/simulate_schedule.py +++ b/src/virtualship/expedition/simulate_schedule.py @@ -3,10 +3,11 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import datetime, timedelta +from datetime import datetime, time, timedelta from typing import ClassVar import pyproj +from yaspin import yaspin from virtualship.instruments.argo_float import ArgoFloat from virtualship.instruments.ctd import CTD @@ -14,6 +15,7 @@ from virtualship.instruments.drifter import Drifter from virtualship.instruments.types import InstrumentType from virtualship.instruments.xbt import XBT +from virtualship.make_realistic.problems import general_problem_select from virtualship.models import ( Expedition, Location, @@ -114,32 +116,87 @@ def __init__(self, projection: pyproj.Geod, expedition: Expedition) -> None: self._next_adcp_time = self._time self._next_ship_underwater_st_time = self._time - def _calc_prob(self, waypoint: Waypoint, wp_instruments) -> float: - """ - Calcuates the probability of a problem occurring at a given waypoint based on the instruments being used. + #! TODO: + # TODO: ... + #! TODO: could all these methods be wrapped up more nicely into a ProblemsSimulator class or similar, imported from problems.py...? + #! TODO: not sure how it would intereact with the pre and post departure logic though and per waypoint logic... - 1) check if want a problem before waypoint 0 - 2) then by waypoint + def _calc_general_prob(self, expedition: Expedition, prob_level: int) -> float: """ + Calcuates probability of a general problem as function of expedition duration and prob-level. - def _return_specific_problem(self): + TODO: and more factors? If not then could combine with _calc_instrument_prob? """ - Return the problem class (e.g. CTDPRoblem_Winch_Failure) based on the instrument type causing the problem OR if general problem (e.g. EngineProblem_FuelLeak). + if prob_level == 0: + return 0.0 - With instructions for re-processing the schedule afterwards. + def _calc_instrument_prob(self, expedition: Expedition, prob_level: int) -> float: + """ + Calcuates probability of instrument-specific problems as function of expedition duration and prob-level. + TODO: and more factors? If not then could combine with _calc_general_prob? """ + if prob_level == 0: + return 0.0 + + def _general_problem_occurrence(self, probability: float, delay: float = 7): + problems_to_execute = general_problem_select(probability=probability) + if len(problems_to_execute) > 0: + for i, problem in enumerate(problems_to_execute): + if problem.pre_departure: + print( + "\nHang on! There could be a pre-departure problem in-port...\n\n" + if i == 0 + else "\nOh no, another pre-departure problem has occurred...!\n\n" + ) + + with yaspin(): + time.sleep(delay) + + problem.execute() + else: + print( + "\nOh no! A problem has occurred during the expedition...\n\n" + if i == 0 + else "\nOh no, another problem has occurred...!\n\n" + ) + + with yaspin(): + time.sleep(delay) + + return problem.delay_duration def simulate(self) -> ScheduleOk | ScheduleProblem: + # TODO: still need to incorp can_reoccur logic somewhere + + # expedition problem probabilities (one probability per expedition, not waypoint) + general_proba = self._calc_general_prob(self._expedition) + instrument_proba = self._calc_instrument_prob(self._expedition) + + #! PRE-EXPEDITION PROBLEMS (general problems only) + if general_proba > 0.0: + # TODO: need to rethink this logic a bit; i.e. needs to feed through that only pre-departure problems are possible here!!!!! + delay_duration = self._general_problem_occurrence(general_proba, delay=7) + for wp_i, waypoint in enumerate(self._expedition.schedule.waypoints): + ##### PROBLEM LOGIC GOES HERE ##### + ##### PROBLEM LOGIC GOES HERE ##### + ##### PROBLEM LOGIC GOES HERE ##### + + if general_proba > 0.0: + delay_duration = self._general_problem_occurrence( + general_proba, delay=7 + ) + + if instrument_proba > 0.0: + ... + # TODO: # TODO: insert method/class here which ingests waypoint model, and handles the logic for determing which problem is occuring at this waypoint/point of this loop # TODO: this method/class should definitely be housed AWAY from this simulate_schedule.py, and with the rest of the problems logic/classes - probability_of_problem = self._calc_prob(waypoint, wp_instruments) # noqa: F821 F841 - - # if probability_of_problem > 1.0: - # return self._return_specific_problem() + #! TODO: do we want the messaging to appear whilst the spinners are running though?! Is it clunky to have it pre all the analysis is actually performed...? + # TODO: think of a way to artificially add the instruments as not occuring until part way through simulations...and during the specific instrument's simulation step if it's an instrument-specific problem # sail towards waypoint self._progress_time_traveling_towards(waypoint.location) diff --git a/src/virtualship/instruments/base.py b/src/virtualship/instruments/base.py index 984e4abf5..3b670478a 100644 --- a/src/virtualship/instruments/base.py +++ b/src/virtualship/instruments/base.py @@ -9,9 +9,9 @@ import copernicusmarine import xarray as xr -from parcels import FieldSet from yaspin import yaspin +from parcels import FieldSet from virtualship.errors import CopernicusCatalogueError from virtualship.utils import ( COPERNICUSMARINE_PHYS_VARIABLES, diff --git a/src/virtualship/make_realistic/__init__.py b/src/virtualship/make_realistic/__init__.py index 2c9a17df7..91ad16847 100644 --- a/src/virtualship/make_realistic/__init__.py +++ b/src/virtualship/make_realistic/__init__.py @@ -2,5 +2,6 @@ from .adcp_make_realistic import adcp_make_realistic from .ctd_make_realistic import ctd_make_realistic +from .problems impor __all__ = ["adcp_make_realistic", "ctd_make_realistic"] diff --git a/src/virtualship/make_realistic/problems.py b/src/virtualship/make_realistic/problems.py index 0180a848d..2e096df7b 100644 --- a/src/virtualship/make_realistic/problems.py +++ b/src/virtualship/make_realistic/problems.py @@ -1,26 +1,43 @@ """This can be where we house both general and instrument-specific problems.""" # noqa: D404 +from __future__ import annotations + +import abc from dataclasses import dataclass +from typing import TYPE_CHECKING -from virtualship.instruments.ctd import CTD from virtualship.instruments.adcp import ADCP -from virtualship.instruments.drifter import Drifter from virtualship.instruments.argo_float import ArgoFloat -from virtualship.models import Waypoint +from virtualship.instruments.ctd import CTD +from virtualship.instruments.drifter import Drifter +from virtualship.instruments.types import InstrumentType +from virtualship.utils import register_general_problem, register_instrument_problem -@dataclass -class ProblemConfig: - message: str - can_reoccur: bool - base_probability: float # Probability is a function of time - the longer the expedition the more likely something is to go wrong (not a function of waypoints) - delay_duration: float # in hours +if TYPE_CHECKING: + from virtualship.models import Waypoint + +# @dataclass +# class ProblemConfig: +# message: str +# can_reoccur: bool +# base_probability: float # Probability is a function of time - the longer the expedition the more likely something is to go wrong (not a function of waypoints) +# delay_duration: float # in hours -def general_proba_function(config: ProblemConfig, waypoint: Waypoint) -> bool: +def general_problem_select() -> bool: """Determine if a general problem should occur at a given waypoint.""" # some random calculation based on the base_probability return True + +def instrument_problem_select(probability: float, waypoint: Waypoint) -> bool: + """Determine if an instrument-specific problem should occur at a given waypoint.""" + # set: waypoint instruments vs. list of instrument-specific problems (automated registry) + # will deterimne which instrument-specific problems are possible at this waypoint + + wp_instruments = waypoint.instruments + + # Pseudo-code for problem probability functions # def instrument_specific_proba( # instrument: type, @@ -56,54 +73,90 @@ def general_proba_function(config: ProblemConfig, waypoint: Waypoint) -> bool: # ), # ] -class GeneralProblem: - """Base class for general problems. - - Problems occur at each waypoint.""" + +##### BASE CLASSES FOR PROBLEMS ##### + + +@dataclass +class GeneralProblem(abc.ABC): + """ + Base class for general problems. + + Problems occur at each waypoint. + """ message: str can_reoccur: bool - base_probability: float # Probability is a function of time - the longer the expedition the more likely something is to go wrong (not a function of waypoints) - delay_duration: float # in hours + base_probability: float # Probability is a function of time - the longer the expedition the more likely something is to go wrong (not a function of waypoints) + delay_duration: float # in hours + pre_departure: bool # True if problem occurs before expedition departure, False if during expedition + @abc.abstractmethod + def is_valid() -> bool: + """Check if the problem can occur based on e.g. waypoint location and/or datetime etc.""" + ... -class InstrumentProblem: +@dataclass +class InstrumentProblem(abc.ABC): """Base class for instrument-specific problems.""" instrument_dataclass: type message: str can_reoccur: bool - base_probability: float # Probability is a function of time - the longer the expedition the more likely something is to go wrong (not a function of waypoints) + base_probability: float # Probability is a function of time - the longer the expedition the more likely something is to go wrong (not a function of waypoints) delay_duration: float # in hours + pre_departure: bool # True if problem can occur before expedition departure, False if during expedition + @abc.abstractmethod + def is_valid() -> bool: + """Check if the problem can occur based on e.g. waypoint location and/or datetime etc.""" + ... +##### Specific Problems ##### -# General problems -@dataclass -class VenomousCentipedeOnboard: +### General problems + + +@register_general_problem +class VenomousCentipedeOnboard(GeneralProblem): + """Problem: Venomous centipede discovered onboard in tropical waters.""" + + # TODO: this needs logic added to the is_valid() method to check if waypoint is in tropical waters + message: str = ( "A venomous centipede is discovered onboard while operating in tropical waters. " "One crew member becomes ill after contact with the creature and receives medical attention, " "prompting a full search of the vessel to ensure no further danger. " "The medical response and search efforts cause an operational delay of about 2 hours." ) - can_reoccur: bool = False - delay_duration: float = 2.0 - base_probability: float = 0.05 + can_reoccur = False + delay_duration = 2.0 + base_probability = 0.05 + pre_departure = False + + def is_valid(self, waypoint: Waypoint) -> bool: + """Check if the waypoint is in tropical waters.""" + lat_limit = 23.5 # [degrees] + return abs(waypoint.latitude) <= lat_limit + + +@register_general_problem +class CaptainSafetyDrill(GeneralProblem): + """Problem: Sudden initiation of a mandatory safety drill.""" -@dataclass -class CaptainSafetyDrill: message: str = ( "A miscommunication with the ship’s captain results in the sudden initiation of a mandatory safety drill. " "The emergency vessel must be lowered and tested while the ship remains stationary, pausing all scientific " "operations for the duration of the exercise. The drill introduces a delay of approximately 2 hours." ) - can_reoccur: bool = False - delay_duration: float = 2. - base_probability: float = 0.1 + can_reoccur: False + delay_duration: 2.0 + base_probability = 0.1 + pre_departure = False + # @dataclass # class FoodDeliveryDelayed: @@ -137,8 +190,11 @@ class CaptainSafetyDrill: # delay_duration: None = None # speed reduction affects ETA instead of fixed delay # ship_speed_knots: float = 8.5 -@dataclass -class MarineMammalInDeploymentArea: + +@register_general_problem +class MarineMammalInDeploymentArea(GeneralProblem): + """Problem: Marine mammals observed in deployment area, causing delay.""" + message: str = ( "A pod of dolphins is observed swimming directly beneath the planned deployment area. " "To avoid risk to wildlife and comply with environmental protocols, all in-water operations " @@ -148,8 +204,11 @@ class MarineMammalInDeploymentArea: delay_duration: float = 0.5 base_probability: float = 0.1 -@dataclass -class BallastPumpFailure: + +@register_general_problem +class BallastPumpFailure(GeneralProblem): + """Problem: Ballast pump failure during ballasting operations.""" + message: str = ( "One of the ship’s ballast pumps suddenly stops responding during routine ballasting operations. " "Without the pump, the vessel cannot safely adjust trim or compensate for equipment movements on deck. " @@ -160,8 +219,11 @@ class BallastPumpFailure: delay_duration: float = 1.0 base_probability: float = 0.1 -@dataclass -class ThrusterConverterFault: + +@register_general_problem +class ThrusterConverterFault(GeneralProblem): + """Problem: Bow thruster's power converter fault during station-keeping.""" + message: str = ( "The bow thruster's power converter reports a fault during station-keeping operations. " "Dynamic positioning becomes less stable, forcing a temporary suspension of high-precision sampling. " @@ -171,8 +233,11 @@ class ThrusterConverterFault: delay_duration: float = 1.0 base_probability: float = 0.1 -@dataclass -class AFrameHydraulicLeak: + +@register_general_problem +class AFrameHydraulicLeak(GeneralProblem): + """Problem: Hydraulic fluid leak from A-frame actuator.""" + message: str = ( "A crew member notices hydraulic fluid leaking from the A-frame actuator during equipment checks. " "The leak must be isolated immediately to prevent environmental contamination or mechanical failure. " @@ -182,8 +247,11 @@ class AFrameHydraulicLeak: delay_duration: float = 2.0 base_probability: float = 0.1 -@dataclass -class CoolingWaterIntakeBlocked: + +@register_general_problem +class CoolingWaterIntakeBlocked(GeneralProblem): + """Problem: Main engine's cooling water intake blocked.""" + message: str = ( "The main engine's cooling water intake alarms indicate reduced flow, likely caused by marine debris " "or biological fouling. The vessel must temporarily slow down while engineering clears the obstruction " @@ -193,10 +261,14 @@ class CoolingWaterIntakeBlocked: delay_duration: float = 1.0 base_probability: float = 0.1 -# Instrument-specific problems -@dataclass -class CTDCableJammed: +### Instrument-specific problems + + +@register_instrument_problem(InstrumentType.CTD) +class CTDCableJammed(InstrumentProblem): + """Problem: CTD cable jammed in winch drum, requiring replacement.""" + message: str = ( "During preparation for the next CTD cast, the CTD cable becomes jammed in the winch drum. " "Attempts to free it are unsuccessful, and the crew determines that the entire cable must be " @@ -208,8 +280,11 @@ class CTDCableJammed: base_probability: float = 0.1 instrument_dataclass = CTD -@dataclass -class CTDTemperatureSensorFailure: + +@register_instrument_problem(InstrumentType.CTD) +class CTDTemperatureSensorFailure(InstrumentProblem): + """Problem: CTD temperature sensor failure, requiring replacement.""" + message: str = ( "The primary temperature sensor on the CTD begins returning inconsistent readings. " "Troubleshooting confirms that the sensor has malfunctioned. A spare unit can be installed, " @@ -221,8 +296,11 @@ class CTDTemperatureSensorFailure: base_probability: float = 0.1 instrument_dataclass = CTD -@dataclass -class CTDSalinitySensorFailureWithCalibration: + +@register_instrument_problem(InstrumentType.CTD) +class CTDSalinitySensorFailureWithCalibration(InstrumentProblem): + """Problem: CTD salinity sensor failure, requiring replacement and calibration.""" + message: str = ( "The CTD’s primary salinity sensor fails and must be replaced with a backup. After installation, " "a mandatory calibration cast to a minimum depth of 1000 meters is required to verify sensor accuracy. " @@ -233,8 +311,11 @@ class CTDSalinitySensorFailureWithCalibration: base_probability: float = 0.1 instrument_dataclass = CTD -@dataclass -class WinchHydraulicPressureDrop: + +@register_instrument_problem(InstrumentType.CTD) +class WinchHydraulicPressureDrop(InstrumentProblem): + """Problem: CTD winch hydraulic pressure drop, requiring repair.""" + message: str = ( "The CTD winch begins to lose hydraulic pressure during routine checks prior to deployment. " "The engineering crew must stop operations to diagnose the hydraulic pump and replenish or repair " @@ -246,8 +327,11 @@ class WinchHydraulicPressureDrop: base_probability: float = 0.1 instrument_dataclass = CTD -@dataclass -class RosetteTriggerFailure: + +@register_instrument_problem(InstrumentType.CTD) +class RosetteTriggerFailure(InstrumentProblem): + """Problem: CTD rosette trigger failure, requiring inspection.""" + message: str = ( "During a CTD cast, the rosette's bottle-triggering mechanism fails to actuate. " "No discrete water samples can be collected during this cast. The rosette must be brought back " @@ -259,8 +343,11 @@ class RosetteTriggerFailure: base_probability: float = 0.1 instrument_dataclass = CTD -@dataclass -class ADCPMalfunction: + +@register_instrument_problem(InstrumentType.ADCP) +class ADCPMalfunction(InstrumentProblem): + """Problem: ADCP returns invalid data, requiring inspection.""" + message: str = ( "The hull-mounted ADCP begins returning invalid velocity data. Engineering suspects damage to the cable " "from recent maintenance activities. The ship must hold position while a technician enters the cable " @@ -272,8 +359,11 @@ class ADCPMalfunction: base_probability: float = 0.1 instrument_dataclass = ADCP -@dataclass -class DrifterSatelliteConnectionDelay: + +@register_instrument_problem(InstrumentType.DRIFTER) +class DrifterSatelliteConnectionDelay(InstrumentProblem): + """Problem: Drifter fails to establish satellite connection before deployment.""" + message: str = ( "The drifter scheduled for deployment fails to establish a satellite connection during " "pre-launch checks. To improve signal acquisition, the float must be moved to a higher location on deck " @@ -285,8 +375,11 @@ class DrifterSatelliteConnectionDelay: base_probability: float = 0.1 instrument_dataclass = Drifter -@dataclass -class ArgoSatelliteConnectionDelay: + +@register_instrument_problem(InstrumentType.ARGO_FLOAT) +class ArgoSatelliteConnectionDelay(InstrumentProblem): + """Problem: Argo float fails to establish satellite connection before deployment.""" + message: str = ( "The Argo float scheduled for deployment fails to establish a satellite connection during " "pre-launch checks. To improve signal acquisition, the float must be moved to a higher location on deck " @@ -296,4 +389,4 @@ class ArgoSatelliteConnectionDelay: can_reoccur: bool = True delay_duration: float = 2.0 base_probability: float = 0.1 - instrument_dataclass = ArgoFloat \ No newline at end of file + instrument_dataclass = ArgoFloat diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index b1926dc65..9d6aa4194 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -13,8 +13,8 @@ import copernicusmarine import numpy as np import xarray as xr -from parcels import FieldSet +from parcels import FieldSet from virtualship.errors import CopernicusCatalogueError if TYPE_CHECKING: @@ -272,6 +272,27 @@ def add_dummy_UV(fieldset: FieldSet): ) from None +# problems inventory registry and registration utilities +INSTRUMENT_PROBLEM_MAP = [] +GENERAL_PROBLEM_REG = [] + + +def register_instrument_problem(instrument_type): + def decorator(cls): + INSTRUMENT_PROBLEM_MAP[instrument_type] = cls + return cls + + return decorator + + +def register_general_problem(): + def decorator(cls): + GENERAL_PROBLEM_REG.append(cls) + return cls + + return decorator + + # Copernicus Marine product IDs PRODUCT_IDS = { From 164789389e7cd3d95c4ac654a2a27ec654327d71 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 12 Jan 2026 14:51:05 +0100 Subject: [PATCH 08/52] moving logic to _run --- src/virtualship/cli/_run.py | 24 ++ .../expedition/simulate_schedule.py | 84 +---- src/virtualship/make_realistic/problems.py | 296 +++++++++++------- 3 files changed, 204 insertions(+), 200 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index f07fbab27..fd16a8a10 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -14,6 +14,7 @@ ScheduleProblem, simulate_schedule, ) +from virtualship.make_realistic.problems import ProblemSimulator from virtualship.models import Schedule from virtualship.models.checkpoint import Checkpoint from virtualship.utils import ( @@ -129,7 +130,30 @@ def _run(expedition_dir: str | Path, from_data: Path | None = None) -> None: instruments_in_expedition = expedition.get_instruments() + # TODO: overview: + # 1) determine all the general AND instrument problems which will occur across the whole expedition + # 2) make a checker function which can be called at various points in the simulation to see if a problem occurs at that point + # - e.g. at pre-departure, at each instrument measurement step, etc. + # - each time a problem occurs, propagate its effects (e.g. delay), log it, save to checkpoint etc. + # TODO: still need to incorp can_reoccur logic somewhere + + # TODO: prob_level needs to be parsed from CLI args + problem_simulator = ProblemSimulator(expedition.schedule, prob_level=...) + problems = problem_simulator.select_problems() + + # check for and execute pre-departure problems + if problems: + problem_simulator.execute(problems, pre_departure=True) + for itype in instruments_in_expedition: + # simulate problems (N.B. it's still possible for general problems to occur during the expedition) + if problems: + problem_simulator.execute( + problems=problems, + pre_departure=False, + instrument_type=itype, + ) + # get instrument class instrument_class = get_instrument_class(itype) if instrument_class is None: diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index 3a422ffe4..e450fcc7c 100644 --- a/src/virtualship/expedition/simulate_schedule.py +++ b/src/virtualship/expedition/simulate_schedule.py @@ -3,11 +3,10 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import datetime, time, timedelta +from datetime import datetime, timedelta from typing import ClassVar import pyproj -from yaspin import yaspin from virtualship.instruments.argo_float import ArgoFloat from virtualship.instruments.ctd import CTD @@ -15,7 +14,6 @@ from virtualship.instruments.drifter import Drifter from virtualship.instruments.types import InstrumentType from virtualship.instruments.xbt import XBT -from virtualship.make_realistic.problems import general_problem_select from virtualship.models import ( Expedition, Location, @@ -116,88 +114,8 @@ def __init__(self, projection: pyproj.Geod, expedition: Expedition) -> None: self._next_adcp_time = self._time self._next_ship_underwater_st_time = self._time - #! TODO: - # TODO: ... - #! TODO: could all these methods be wrapped up more nicely into a ProblemsSimulator class or similar, imported from problems.py...? - #! TODO: not sure how it would intereact with the pre and post departure logic though and per waypoint logic... - - def _calc_general_prob(self, expedition: Expedition, prob_level: int) -> float: - """ - Calcuates probability of a general problem as function of expedition duration and prob-level. - - TODO: and more factors? If not then could combine with _calc_instrument_prob? - """ - if prob_level == 0: - return 0.0 - - def _calc_instrument_prob(self, expedition: Expedition, prob_level: int) -> float: - """ - Calcuates probability of instrument-specific problems as function of expedition duration and prob-level. - - TODO: and more factors? If not then could combine with _calc_general_prob? - """ - if prob_level == 0: - return 0.0 - - def _general_problem_occurrence(self, probability: float, delay: float = 7): - problems_to_execute = general_problem_select(probability=probability) - if len(problems_to_execute) > 0: - for i, problem in enumerate(problems_to_execute): - if problem.pre_departure: - print( - "\nHang on! There could be a pre-departure problem in-port...\n\n" - if i == 0 - else "\nOh no, another pre-departure problem has occurred...!\n\n" - ) - - with yaspin(): - time.sleep(delay) - - problem.execute() - else: - print( - "\nOh no! A problem has occurred during the expedition...\n\n" - if i == 0 - else "\nOh no, another problem has occurred...!\n\n" - ) - - with yaspin(): - time.sleep(delay) - - return problem.delay_duration - def simulate(self) -> ScheduleOk | ScheduleProblem: - # TODO: still need to incorp can_reoccur logic somewhere - - # expedition problem probabilities (one probability per expedition, not waypoint) - general_proba = self._calc_general_prob(self._expedition) - instrument_proba = self._calc_instrument_prob(self._expedition) - - #! PRE-EXPEDITION PROBLEMS (general problems only) - if general_proba > 0.0: - # TODO: need to rethink this logic a bit; i.e. needs to feed through that only pre-departure problems are possible here!!!!! - delay_duration = self._general_problem_occurrence(general_proba, delay=7) - for wp_i, waypoint in enumerate(self._expedition.schedule.waypoints): - ##### PROBLEM LOGIC GOES HERE ##### - ##### PROBLEM LOGIC GOES HERE ##### - ##### PROBLEM LOGIC GOES HERE ##### - - if general_proba > 0.0: - delay_duration = self._general_problem_occurrence( - general_proba, delay=7 - ) - - if instrument_proba > 0.0: - ... - - # TODO: - # TODO: insert method/class here which ingests waypoint model, and handles the logic for determing which problem is occuring at this waypoint/point of this loop - # TODO: this method/class should definitely be housed AWAY from this simulate_schedule.py, and with the rest of the problems logic/classes - - #! TODO: do we want the messaging to appear whilst the spinners are running though?! Is it clunky to have it pre all the analysis is actually performed...? - # TODO: think of a way to artificially add the instruments as not occuring until part way through simulations...and during the specific instrument's simulation step if it's an instrument-specific problem - # sail towards waypoint self._progress_time_traveling_towards(waypoint.location) diff --git a/src/virtualship/make_realistic/problems.py b/src/virtualship/make_realistic/problems.py index 2e096df7b..090da1b69 100644 --- a/src/virtualship/make_realistic/problems.py +++ b/src/virtualship/make_realistic/problems.py @@ -3,75 +3,135 @@ from __future__ import annotations import abc +import time from dataclasses import dataclass +from pathlib import Path from typing import TYPE_CHECKING -from virtualship.instruments.adcp import ADCP -from virtualship.instruments.argo_float import ArgoFloat -from virtualship.instruments.ctd import CTD -from virtualship.instruments.drifter import Drifter +from yaspin import yaspin + +from virtualship.cli._run import _save_checkpoint from virtualship.instruments.types import InstrumentType -from virtualship.utils import register_general_problem, register_instrument_problem +from virtualship.models.checkpoint import Checkpoint +from virtualship.utils import ( + CHECKPOINT, + register_general_problem, + register_instrument_problem, +) if TYPE_CHECKING: - from virtualship.models import Waypoint - -# @dataclass -# class ProblemConfig: -# message: str -# can_reoccur: bool -# base_probability: float # Probability is a function of time - the longer the expedition the more likely something is to go wrong (not a function of waypoints) -# delay_duration: float # in hours - - -def general_problem_select() -> bool: - """Determine if a general problem should occur at a given waypoint.""" - # some random calculation based on the base_probability - return True - - -def instrument_problem_select(probability: float, waypoint: Waypoint) -> bool: - """Determine if an instrument-specific problem should occur at a given waypoint.""" - # set: waypoint instruments vs. list of instrument-specific problems (automated registry) - # will deterimne which instrument-specific problems are possible at this waypoint - - wp_instruments = waypoint.instruments - - -# Pseudo-code for problem probability functions -# def instrument_specific_proba( -# instrument: type, -# ) -> Callable([ProblemConfig, Waypoint], bool): -# """Return a function to determine if an instrument-specific problem should occur.""" - -# def should_occur(config: ProblemConfig, waypoint) -> bool: -# if instrument not in waypoint.instruments: -# return False - -# return general_proba_function(config, waypoint) - -# return should_occur - -# PROBLEMS: list[Tuple[ProblemConfig, Callable[[ProblemConfig, Waypoint], bool]]] = [ -# ( -# ProblemConfig( -# message="General problem occurred", -# can_reoccur=True, -# base_probability=0.1, -# delay_duration=2.0, -# ), -# general_proba_function, -# ), -# ( -# ProblemConfig( -# message="Instrument-specific problem occurred", -# can_reoccur=False, -# base_probability=0.05, -# delay_duration=4.0, -# ), -# instrument_specific_proba(CTD), -# ), -# ] + from virtualship.models import Schedule, Waypoint + + +LOG_MESSAGING = { + "first_pre_departure": "\nHang on! There could be a pre-departure problem in-port...\n\n", + "subsequent_pre_departure": "\nOh no, another pre-departure problem has occurred...!\n\n", + "first_during_expedition": "\nOh no, a problem has occurred during the expedition...!\n\n", + "subsequent_during_expedition": "\nAnother problem has occurred during the expedition...!\n\n", +} + + +class ProblemSimulator: + """Handle problem simulation during expedition.""" + + # TODO: incorporate some kind of knowledge of at which waypoint the problems occur to provide this feedback to the user and also to save in the checkpoint yaml! + + def __init__(self, schedule: Schedule, prob_level: int, expedition_dir: str | Path): + """Initialise ProblemSimulator with a schedule and probability level.""" + self.schedule = schedule + self.prob_level = prob_level + self.expedition_dir = Path(expedition_dir) + + def select_problems( + self, + ) -> dict[str, list[GeneralProblem | InstrumentProblem]] | None: + """Propagate both general and instrument problems.""" + probability = self._calc_prob() + if probability > 0.0: + problems = {} + problems["general"] = self._general_problem_select(probability) + problems["instrument"] = self._instrument_problem_select(probability) + return problems + else: + return None + + def execute( + self, + problems: dict[str, list[GeneralProblem | InstrumentProblem]], + pre_departure: bool, + instrument_type: InstrumentType | None = None, + log_delay: float = 7.0, + ): + """Execute the selected problems, returning messaging and delay times.""" + for i, problem in enumerate(problems["general"]): + if pre_departure and problem.pre_departure: + print( + LOG_MESSAGING["first_pre_departure"] + if i == 0 + else LOG_MESSAGING["subsequent_pre_departure"] + ) + else: + if not pre_departure and not problem.pre_departure: + print( + LOG_MESSAGING["first_during_expedition"] + if i == 0 + else LOG_MESSAGING["subsequent_during_expedition"] + ) + with yaspin(): + time.sleep(log_delay) + + # provide problem-specific messaging + print(problem.message) + + # save to pause expedition and save to checkpoint + print( + f"\n\nSIMULATION PAUSED: update your schedule (`virtualship plan`) and continue the expedition by executing the `virtualship run` command again.\nCheckpoint has been saved to {self.expedition_dir.joinpath(CHECKPOINT)}." + ) + _save_checkpoint( + Checkpoint( + past_schedule=Schedule( + waypoints=self.schedule.waypoints[ + : schedule_results.failed_waypoint_i + ] + ) + ), + self.expedition_dir, + ) + + def _propagate_general_problems(self): + """Propagate general problems based on probability.""" + probability = self._calc_general_prob(self.schedule, prob_level=self.prob_level) + return self._general_problem_select(probability) + + def _propagate_instrument_problems(self): + """Propagate instrument problems based on probability.""" + probability = self._calc_instrument_prob( + self.schedule, prob_level=self.prob_level + ) + return self._instrument_problem_select(probability) + + def _calc_prob(self) -> float: + """ + Calcuates probability of a general problem as function of expedition duration and prob-level. + + TODO: for now, general and instrument-specific problems have the same probability of occurence. Separating this out and allowing their probabilities to be set independently may be useful in future. + """ + if self.prob_level == 0: + return 0.0 + + def _general_problem_select(self) -> list[GeneralProblem]: + """Select which problems. Higher probability (tied to expedition duration) means more problems are likely to occur.""" + ... + return [] + + def _instrument_problem_select(self) -> list[InstrumentProblem]: + """Select which problems. Higher probability (tied to expedition duration) means more problems are likely to occur.""" + # set: waypoint instruments vs. list of instrument-specific problems (automated registry) + # will deterimne which instrument-specific problems are possible at this waypoint + + wp_instruments = self.schedule.waypoints.instruments + + return [] ##### BASE CLASSES FOR PROBLEMS ##### @@ -158,37 +218,39 @@ class CaptainSafetyDrill(GeneralProblem): pre_departure = False -# @dataclass -# class FoodDeliveryDelayed: -# message: str = ( -# "The scheduled food delivery prior to departure has not arrived. Until the supply truck reaches the pier, " -# "we cannot leave. Once it arrives, unloading and stowing the provisions in the ship’s cold storage " -# "will also take additional time. These combined delays postpone departure by approximately 5 hours." -# ) -# can_reoccur: bool = False -# delay_duration: float = 5.0 - -# @dataclass -# class FuelDeliveryIssue: -# message: str = ( -# "The fuel tanker expected to deliver fuel has not arrived. Port authorities are unable to provide " -# "a clear estimate for when the delivery might occur. You may choose to [w]ait for the tanker or [g]et a " -# "harbor pilot to guide the vessel to an available bunker dock instead. This decision may need to be " -# "revisited periodically depending on circumstances." -# ) -# can_reoccur: bool = False -# delay_duration: float = 0.0 # dynamic delays based on repeated choices - -# @dataclass -# class EngineOverheating: -# message: str = ( -# "One of the main engines has overheated. To prevent further damage, the engineering team orders a reduction " -# "in vessel speed until the engine can be inspected and repaired in port. The ship will now operate at a " -# "reduced cruising speed of 8.5 knots for the remainder of the transit." -# ) -# can_reoccur: bool = False -# delay_duration: None = None # speed reduction affects ETA instead of fixed delay -# ship_speed_knots: float = 8.5 +@dataclass +class FoodDeliveryDelayed: + message: str = ( + "The scheduled food delivery prior to departure has not arrived. Until the supply truck reaches the pier, " + "we cannot leave. Once it arrives, unloading and stowing the provisions in the ship’s cold storage " + "will also take additional time. These combined delays postpone departure by approximately 5 hours." + ) + can_reoccur: bool = False + delay_duration: float = 5.0 + + +@dataclass +class FuelDeliveryIssue: + message: str = ( + "The fuel tanker expected to deliver fuel has not arrived. Port authorities are unable to provide " + "a clear estimate for when the delivery might occur. You may choose to [w]ait for the tanker or [g]et a " + "harbor pilot to guide the vessel to an available bunker dock instead. This decision may need to be " + "revisited periodically depending on circumstances." + ) + can_reoccur: bool = False + delay_duration: float = 0.0 # dynamic delays based on repeated choices + + +@dataclass +class EngineOverheating: + message: str = ( + "One of the main engines has overheated. To prevent further damage, the engineering team orders a reduction " + "in vessel speed until the engine can be inspected and repaired in port. The ship will now operate at a " + "reduced cruising speed of 8.5 knots for the remainder of the transit." + ) + can_reoccur: bool = False + delay_duration: None = None # speed reduction affects ETA instead of fixed delay + ship_speed_knots: float = 8.5 @register_general_problem @@ -278,7 +340,23 @@ class CTDCableJammed(InstrumentProblem): can_reoccur: bool = True delay_duration: float = 3.0 base_probability: float = 0.1 - instrument_dataclass = CTD + instrument_type = InstrumentType.CTD + + +@register_instrument_problem(InstrumentType.ADCP) +class ADCPMalfunction(InstrumentProblem): + """Problem: ADCP returns invalid data, requiring inspection.""" + + message: str = ( + "The hull-mounted ADCP begins returning invalid velocity data. Engineering suspects damage to the cable " + "from recent maintenance activities. The ship must hold position while a technician enters the cable " + "compartment to perform an inspection and continuity test. This diagnostic procedure results in a delay " + "of around 1 hour." + ) + can_reoccur: bool = True + delay_duration: float = 1.0 + base_probability: float = 0.1 + instrument_type = InstrumentType.ADCP @register_instrument_problem(InstrumentType.CTD) @@ -294,7 +372,7 @@ class CTDTemperatureSensorFailure(InstrumentProblem): can_reoccur: bool = True delay_duration: float = 2.0 base_probability: float = 0.1 - instrument_dataclass = CTD + instrument_type = InstrumentType.CTD @register_instrument_problem(InstrumentType.CTD) @@ -309,7 +387,7 @@ class CTDSalinitySensorFailureWithCalibration(InstrumentProblem): can_reoccur: bool = True delay_duration: float = 4.0 base_probability: float = 0.1 - instrument_dataclass = CTD + instrument_type = InstrumentType.CTD @register_instrument_problem(InstrumentType.CTD) @@ -325,7 +403,7 @@ class WinchHydraulicPressureDrop(InstrumentProblem): can_reoccur: bool = True delay_duration: float = 1.5 base_probability: float = 0.1 - instrument_dataclass = CTD + instrument_type = InstrumentType.CTD @register_instrument_problem(InstrumentType.CTD) @@ -341,23 +419,7 @@ class RosetteTriggerFailure(InstrumentProblem): can_reoccur: bool = True delay_duration: float = 2.5 base_probability: float = 0.1 - instrument_dataclass = CTD - - -@register_instrument_problem(InstrumentType.ADCP) -class ADCPMalfunction(InstrumentProblem): - """Problem: ADCP returns invalid data, requiring inspection.""" - - message: str = ( - "The hull-mounted ADCP begins returning invalid velocity data. Engineering suspects damage to the cable " - "from recent maintenance activities. The ship must hold position while a technician enters the cable " - "compartment to perform an inspection and continuity test. This diagnostic procedure results in a delay " - "of around 1 hour." - ) - can_reoccur: bool = True - delay_duration: float = 1.0 - base_probability: float = 0.1 - instrument_dataclass = ADCP + instrument_type = InstrumentType.CTD @register_instrument_problem(InstrumentType.DRIFTER) @@ -373,7 +435,7 @@ class DrifterSatelliteConnectionDelay(InstrumentProblem): can_reoccur: bool = True delay_duration: float = 2.0 base_probability: float = 0.1 - instrument_dataclass = Drifter + instrument_type = InstrumentType.DRIFTER @register_instrument_problem(InstrumentType.ARGO_FLOAT) @@ -389,4 +451,4 @@ class ArgoSatelliteConnectionDelay(InstrumentProblem): can_reoccur: bool = True delay_duration: float = 2.0 base_probability: float = 0.1 - instrument_dataclass = ArgoFloat + instrument_type = InstrumentType.ARGO_FLOAT From 4b6f1258dc2888e83a7341e295226eaaae76b1c3 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:10:54 +0100 Subject: [PATCH 09/52] separate problem classes and separation logic --- src/virtualship/make_realistic/problems.py | 137 ++------------------ src/virtualship/make_realistic/simulator.py | 127 ++++++++++++++++++ 2 files changed, 137 insertions(+), 127 deletions(-) create mode 100644 src/virtualship/make_realistic/simulator.py diff --git a/src/virtualship/make_realistic/problems.py b/src/virtualship/make_realistic/problems.py index 090da1b69..80772c13c 100644 --- a/src/virtualship/make_realistic/problems.py +++ b/src/virtualship/make_realistic/problems.py @@ -1,140 +1,22 @@ -"""This can be where we house both general and instrument-specific problems.""" # noqa: D404 - from __future__ import annotations import abc -import time from dataclasses import dataclass -from pathlib import Path from typing import TYPE_CHECKING -from yaspin import yaspin - -from virtualship.cli._run import _save_checkpoint from virtualship.instruments.types import InstrumentType -from virtualship.models.checkpoint import Checkpoint from virtualship.utils import ( - CHECKPOINT, register_general_problem, register_instrument_problem, ) if TYPE_CHECKING: - from virtualship.models import Schedule, Waypoint - - -LOG_MESSAGING = { - "first_pre_departure": "\nHang on! There could be a pre-departure problem in-port...\n\n", - "subsequent_pre_departure": "\nOh no, another pre-departure problem has occurred...!\n\n", - "first_during_expedition": "\nOh no, a problem has occurred during the expedition...!\n\n", - "subsequent_during_expedition": "\nAnother problem has occurred during the expedition...!\n\n", -} - - -class ProblemSimulator: - """Handle problem simulation during expedition.""" - - # TODO: incorporate some kind of knowledge of at which waypoint the problems occur to provide this feedback to the user and also to save in the checkpoint yaml! - - def __init__(self, schedule: Schedule, prob_level: int, expedition_dir: str | Path): - """Initialise ProblemSimulator with a schedule and probability level.""" - self.schedule = schedule - self.prob_level = prob_level - self.expedition_dir = Path(expedition_dir) - - def select_problems( - self, - ) -> dict[str, list[GeneralProblem | InstrumentProblem]] | None: - """Propagate both general and instrument problems.""" - probability = self._calc_prob() - if probability > 0.0: - problems = {} - problems["general"] = self._general_problem_select(probability) - problems["instrument"] = self._instrument_problem_select(probability) - return problems - else: - return None - - def execute( - self, - problems: dict[str, list[GeneralProblem | InstrumentProblem]], - pre_departure: bool, - instrument_type: InstrumentType | None = None, - log_delay: float = 7.0, - ): - """Execute the selected problems, returning messaging and delay times.""" - for i, problem in enumerate(problems["general"]): - if pre_departure and problem.pre_departure: - print( - LOG_MESSAGING["first_pre_departure"] - if i == 0 - else LOG_MESSAGING["subsequent_pre_departure"] - ) - else: - if not pre_departure and not problem.pre_departure: - print( - LOG_MESSAGING["first_during_expedition"] - if i == 0 - else LOG_MESSAGING["subsequent_during_expedition"] - ) - with yaspin(): - time.sleep(log_delay) - - # provide problem-specific messaging - print(problem.message) - - # save to pause expedition and save to checkpoint - print( - f"\n\nSIMULATION PAUSED: update your schedule (`virtualship plan`) and continue the expedition by executing the `virtualship run` command again.\nCheckpoint has been saved to {self.expedition_dir.joinpath(CHECKPOINT)}." - ) - _save_checkpoint( - Checkpoint( - past_schedule=Schedule( - waypoints=self.schedule.waypoints[ - : schedule_results.failed_waypoint_i - ] - ) - ), - self.expedition_dir, - ) - - def _propagate_general_problems(self): - """Propagate general problems based on probability.""" - probability = self._calc_general_prob(self.schedule, prob_level=self.prob_level) - return self._general_problem_select(probability) - - def _propagate_instrument_problems(self): - """Propagate instrument problems based on probability.""" - probability = self._calc_instrument_prob( - self.schedule, prob_level=self.prob_level - ) - return self._instrument_problem_select(probability) - - def _calc_prob(self) -> float: - """ - Calcuates probability of a general problem as function of expedition duration and prob-level. - - TODO: for now, general and instrument-specific problems have the same probability of occurence. Separating this out and allowing their probabilities to be set independently may be useful in future. - """ - if self.prob_level == 0: - return 0.0 - - def _general_problem_select(self) -> list[GeneralProblem]: - """Select which problems. Higher probability (tied to expedition duration) means more problems are likely to occur.""" - ... - return [] - - def _instrument_problem_select(self) -> list[InstrumentProblem]: - """Select which problems. Higher probability (tied to expedition duration) means more problems are likely to occur.""" - # set: waypoint instruments vs. list of instrument-specific problems (automated registry) - # will deterimne which instrument-specific problems are possible at this waypoint + from virtualship.models import Waypoint - wp_instruments = self.schedule.waypoints.instruments - return [] - - -##### BASE CLASSES FOR PROBLEMS ##### +# ===================================================== +# SECTION: Base Classes +# ===================================================== @dataclass @@ -174,10 +56,9 @@ def is_valid() -> bool: ... -##### Specific Problems ##### - - -### General problems +# ===================================================== +# SECTION: General Problem Classes +# ===================================================== @register_general_problem @@ -324,7 +205,9 @@ class CoolingWaterIntakeBlocked(GeneralProblem): base_probability: float = 0.1 -### Instrument-specific problems +# ===================================================== +# SECTION: Instrument-specific Problem Classes +# ===================================================== @register_instrument_problem(InstrumentType.CTD) diff --git a/src/virtualship/make_realistic/simulator.py b/src/virtualship/make_realistic/simulator.py new file mode 100644 index 000000000..f36a6c03c --- /dev/null +++ b/src/virtualship/make_realistic/simulator.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import time +from pathlib import Path +from typing import TYPE_CHECKING + +from yaspin import yaspin + +from virtualship.cli._run import _save_checkpoint +from virtualship.instruments.types import InstrumentType +from virtualship.make_realistic.problems import GeneralProblem, InstrumentProblem +from virtualship.models.checkpoint import Checkpoint +from virtualship.utils import ( + CHECKPOINT, +) + +if TYPE_CHECKING: + from virtualship.models import Schedule + + +LOG_MESSAGING = { + "first_pre_departure": "\nHang on! There could be a pre-departure problem in-port...\n\n", + "subsequent_pre_departure": "\nOh no, another pre-departure problem has occurred...!\n\n", + "first_during_expedition": "\nOh no, a problem has occurred during the expedition...!\n\n", + "subsequent_during_expedition": "\nAnother problem has occurred during the expedition...!\n\n", +} + + +class ProblemSimulator: + """Handle problem simulation during expedition.""" + + def __init__(self, schedule: Schedule, prob_level: int, expedition_dir: str | Path): + """Initialise ProblemSimulator with a schedule and probability level.""" + self.schedule = schedule + self.prob_level = prob_level + self.expedition_dir = Path(expedition_dir) + + def select_problems( + self, + ) -> dict[str, list[GeneralProblem | InstrumentProblem]] | None: + """Propagate both general and instrument problems.""" + probability = self._calc_prob() + if probability > 0.0: + problems = {} + problems["general"] = self._general_problem_select(probability) + problems["instrument"] = self._instrument_problem_select(probability) + return problems + else: + return None + + def execute( + self, + problems: dict[str, list[GeneralProblem | InstrumentProblem]], + pre_departure: bool, + instrument_type: InstrumentType | None = None, + log_delay: float = 7.0, + ): + """Execute the selected problems, returning messaging and delay times.""" + for i, problem in enumerate(problems["general"]): + if pre_departure and problem.pre_departure: + print( + LOG_MESSAGING["first_pre_departure"] + if i == 0 + else LOG_MESSAGING["subsequent_pre_departure"] + ) + else: + if not pre_departure and not problem.pre_departure: + print( + LOG_MESSAGING["first_during_expedition"] + if i == 0 + else LOG_MESSAGING["subsequent_during_expedition"] + ) + with yaspin(): + time.sleep(log_delay) + + # provide problem-specific messaging + print(problem.message) + + # save to pause expedition and save to checkpoint + print( + f"\n\nSIMULATION PAUSED: update your schedule (`virtualship plan`) and continue the expedition by executing the `virtualship run` command again.\nCheckpoint has been saved to {self.expedition_dir.joinpath(CHECKPOINT)}." + ) + _save_checkpoint( + Checkpoint( + past_schedule=Schedule( + waypoints=self.schedule.waypoints[ + : schedule_results.failed_waypoint_i + ] + ) + ), + self.expedition_dir, + ) + + def _propagate_general_problems(self): + """Propagate general problems based on probability.""" + probability = self._calc_general_prob(self.schedule, prob_level=self.prob_level) + return self._general_problem_select(probability) + + def _propagate_instrument_problems(self): + """Propagate instrument problems based on probability.""" + probability = self._calc_instrument_prob( + self.schedule, prob_level=self.prob_level + ) + return self._instrument_problem_select(probability) + + def _calc_prob(self) -> float: + """ + Calcuates probability of a general problem as function of expedition duration and prob-level. + + TODO: for now, general and instrument-specific problems have the same probability of occurence. Separating this out and allowing their probabilities to be set independently may be useful in future. + """ + if self.prob_level == 0: + return 0.0 + + def _general_problem_select(self) -> list[GeneralProblem]: + """Select which problems. Higher probability (tied to expedition duration) means more problems are likely to occur.""" + ... + return [] + + def _instrument_problem_select(self) -> list[InstrumentProblem]: + """Select which problems. Higher probability (tied to expedition duration) means more problems are likely to occur.""" + # set: waypoint instruments vs. list of instrument-specific problems (automated registry) + # will deterimne which instrument-specific problems are possible at this waypoint + + wp_instruments = self.schedule.waypoints.instruments + + return [] From d68738df95791a5d65055399ae0ba5368626f675 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 13 Jan 2026 11:37:35 +0100 Subject: [PATCH 10/52] re-structure --- src/virtualship/cli/_run.py | 4 +-- .../{problems.py => problems/scenarios.py} | 0 .../{ => problems}/simulator.py | 29 ++++++++++--------- 3 files changed, 17 insertions(+), 16 deletions(-) rename src/virtualship/make_realistic/{problems.py => problems/scenarios.py} (100%) rename src/virtualship/make_realistic/{ => problems}/simulator.py (78%) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index fd16a8a10..5c3a5f9b5 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -14,7 +14,7 @@ ScheduleProblem, simulate_schedule, ) -from virtualship.make_realistic.problems import ProblemSimulator +from virtualship.make_realistic.problems.simulator import ProblemSimulator from virtualship.models import Schedule from virtualship.models.checkpoint import Checkpoint from virtualship.utils import ( @@ -74,7 +74,7 @@ def _run(expedition_dir: str | Path, from_data: Path | None = None) -> None: expedition = _get_expedition(expedition_dir) - # Verify instruments_config file is consistent with schedule + # verify instruments_config file is consistent with schedule expedition.instruments_config.verify(expedition) # load last checkpoint diff --git a/src/virtualship/make_realistic/problems.py b/src/virtualship/make_realistic/problems/scenarios.py similarity index 100% rename from src/virtualship/make_realistic/problems.py rename to src/virtualship/make_realistic/problems/scenarios.py diff --git a/src/virtualship/make_realistic/simulator.py b/src/virtualship/make_realistic/problems/simulator.py similarity index 78% rename from src/virtualship/make_realistic/simulator.py rename to src/virtualship/make_realistic/problems/simulator.py index f36a6c03c..bebc07feb 100644 --- a/src/virtualship/make_realistic/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -6,15 +6,16 @@ from yaspin import yaspin -from virtualship.cli._run import _save_checkpoint from virtualship.instruments.types import InstrumentType -from virtualship.make_realistic.problems import GeneralProblem, InstrumentProblem -from virtualship.models.checkpoint import Checkpoint from virtualship.utils import ( CHECKPOINT, ) if TYPE_CHECKING: + from virtualship.make_realistic.problems.scenarios import ( + GeneralProblem, + InstrumentProblem, + ) from virtualship.models import Schedule @@ -23,6 +24,8 @@ "subsequent_pre_departure": "\nOh no, another pre-departure problem has occurred...!\n\n", "first_during_expedition": "\nOh no, a problem has occurred during the expedition...!\n\n", "subsequent_during_expedition": "\nAnother problem has occurred during the expedition...!\n\n", + "simulation_paused": "\nSIMULATION PAUSED: update your schedule (`virtualship plan`) and continue the expedition by executing the `virtualship run` command again.\nCheckpoint has been saved to {checkpoint_path}.\n", + "problem_avoided:": "\nPhew! You had enough contingency time scheduled to avoid delays from this problem. The expedition can carry on!\n", } @@ -77,20 +80,18 @@ def execute( print(problem.message) # save to pause expedition and save to checkpoint + print( - f"\n\nSIMULATION PAUSED: update your schedule (`virtualship plan`) and continue the expedition by executing the `virtualship run` command again.\nCheckpoint has been saved to {self.expedition_dir.joinpath(CHECKPOINT)}." - ) - _save_checkpoint( - Checkpoint( - past_schedule=Schedule( - waypoints=self.schedule.waypoints[ - : schedule_results.failed_waypoint_i - ] - ) - ), - self.expedition_dir, + LOG_MESSAGING["simulation_paused"].format( + checkpoint_path=self.expedition_dir.joinpath(CHECKPOINT) + ) ) + # TODO: integration with which zarr files have been written so far + # TODO: plus a checkpoint file to assess whether the user has indeed also made the necessary changes to the schedule as required by the problem's delay_duration + # - in here also comes the logic for whether the user has already scheduled in enough contingency time to account for the problem's delay_duration, and they get a well done message if so + #! - may have to be that make a note of it during the simulate_schedule (and feed it forward), otherwise won't know which waypoint(s)... + def _propagate_general_problems(self): """Propagate general problems based on probability.""" probability = self._calc_general_prob(self.schedule, prob_level=self.prob_level) From d27bbc53c6364c51f695cbab5becec932fdddb59 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Wed, 14 Jan 2026 09:19:37 +0100 Subject: [PATCH 11/52] progressing changes across simuate_schedule and _run --- src/virtualship/cli/_run.py | 42 +++--- .../expedition/simulate_schedule.py | 5 +- .../make_realistic/problems/scenarios.py | 53 +++---- .../make_realistic/problems/simulator.py | 129 ++++++++++++++---- src/virtualship/models/__init__.py | 2 + src/virtualship/utils.py | 11 +- 6 files changed, 162 insertions(+), 80 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index 5c3a5f9b5..b0d680749 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -15,11 +15,11 @@ simulate_schedule, ) from virtualship.make_realistic.problems.simulator import ProblemSimulator -from virtualship.models import Schedule -from virtualship.models.checkpoint import Checkpoint +from virtualship.models import Checkpoint, Schedule from virtualship.utils import ( CHECKPOINT, _get_expedition, + _save_checkpoint, expedition_cost, get_instrument_class, ) @@ -92,10 +92,22 @@ def _run(expedition_dir: str | Path, from_data: Path | None = None) -> None: from_data=Path(from_data) if from_data else None, ) + # TODO: overview: + # 1) determine all the general AND instrument problems which will occur across the whole expedition + # 2) make a checker function which can be called at various points in the simulation to see if a problem occurs at that point + # - e.g. at pre-departure, at each instrument measurement step, etc. + # - each time a problem occurs, propagate its effects (e.g. delay), log it, save to checkpoint etc. + # TODO: still need to incorp can_reoccur logic somewhere + + # TODO: prob_level needs to be parsed from CLI args + problem_simulator = ProblemSimulator(expedition.schedule, prob_level=...) + problems = problem_simulator.select_problems() + # simulate the schedule schedule_results = simulate_schedule( projection=projection, expedition=expedition, + problems=problems if problems else None, ) if isinstance(schedule_results, ScheduleProblem): print( @@ -130,27 +142,12 @@ def _run(expedition_dir: str | Path, from_data: Path | None = None) -> None: instruments_in_expedition = expedition.get_instruments() - # TODO: overview: - # 1) determine all the general AND instrument problems which will occur across the whole expedition - # 2) make a checker function which can be called at various points in the simulation to see if a problem occurs at that point - # - e.g. at pre-departure, at each instrument measurement step, etc. - # - each time a problem occurs, propagate its effects (e.g. delay), log it, save to checkpoint etc. - # TODO: still need to incorp can_reoccur logic somewhere - - # TODO: prob_level needs to be parsed from CLI args - problem_simulator = ProblemSimulator(expedition.schedule, prob_level=...) - problems = problem_simulator.select_problems() - - # check for and execute pre-departure problems - if problems: - problem_simulator.execute(problems, pre_departure=True) - - for itype in instruments_in_expedition: - # simulate problems (N.B. it's still possible for general problems to occur during the expedition) + for i, itype in enumerate(instruments_in_expedition): + # propagate problems (pre-departure problems will only occur in first iteration) if problems: problem_simulator.execute( problems=problems, - pre_departure=False, + pre_departure=True if i == 0 else False, instrument_type=itype, ) @@ -198,11 +195,6 @@ def _load_checkpoint(expedition_dir: Path) -> Checkpoint | None: return None -def _save_checkpoint(checkpoint: Checkpoint, expedition_dir: Path) -> None: - file_path = expedition_dir.joinpath(CHECKPOINT) - checkpoint.to_yaml(file_path) - - def _write_expedition_cost(expedition, schedule_results, expedition_dir): """Calculate the expedition cost, write it to a file, and print summary.""" assert expedition.schedule.waypoints[0].time is not None, ( diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index e450fcc7c..e6825603e 100644 --- a/src/virtualship/expedition/simulate_schedule.py +++ b/src/virtualship/expedition/simulate_schedule.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from datetime import datetime, timedelta -from typing import ClassVar +from typing import TYPE_CHECKING, ClassVar import pyproj @@ -21,6 +21,9 @@ Waypoint, ) +if TYPE_CHECKING: + pass + @dataclass class ScheduleOk: diff --git a/src/virtualship/make_realistic/problems/scenarios.py b/src/virtualship/make_realistic/problems/scenarios.py index 80772c13c..76ffc2ffa 100644 --- a/src/virtualship/make_realistic/problems/scenarios.py +++ b/src/virtualship/make_realistic/problems/scenarios.py @@ -2,6 +2,7 @@ import abc from dataclasses import dataclass +from datetime import timedelta from typing import TYPE_CHECKING from virtualship.instruments.types import InstrumentType @@ -30,7 +31,7 @@ class GeneralProblem(abc.ABC): message: str can_reoccur: bool base_probability: float # Probability is a function of time - the longer the expedition the more likely something is to go wrong (not a function of waypoints) - delay_duration: float # in hours + delay_duration: timedelta pre_departure: bool # True if problem occurs before expedition departure, False if during expedition @abc.abstractmethod @@ -47,7 +48,7 @@ class InstrumentProblem(abc.ABC): message: str can_reoccur: bool base_probability: float # Probability is a function of time - the longer the expedition the more likely something is to go wrong (not a function of waypoints) - delay_duration: float # in hours + delay_duration: timedelta pre_departure: bool # True if problem can occur before expedition departure, False if during expedition @abc.abstractmethod @@ -57,10 +58,27 @@ def is_valid() -> bool: # ===================================================== -# SECTION: General Problem Classes +# SECTION: General Problems # ===================================================== +@dataclass +@register_general_problem +class FoodDeliveryDelayed: + """Problem: Scheduled food delivery is delayed, causing a postponement of departure.""" + + message: str = ( + "The scheduled food delivery prior to departure has not arrived. Until the supply truck reaches the pier, " + "we cannot leave. Once it arrives, unloading and stowing the provisions in the ship’s cold storage " + "will also take additional time. These combined delays postpone departure by approximately 5 hours." + ) + can_reoccur = False + delay_duration = timedelta(hours=5.0) + base_probability = 0.1 + pre_departure = True + + +@dataclass @register_general_problem class VenomousCentipedeOnboard(GeneralProblem): """Problem: Venomous centipede discovered onboard in tropical waters.""" @@ -74,7 +92,7 @@ class VenomousCentipedeOnboard(GeneralProblem): "The medical response and search efforts cause an operational delay of about 2 hours." ) can_reoccur = False - delay_duration = 2.0 + delay_duration = timedelta(hours=2.0) base_probability = 0.05 pre_departure = False @@ -99,17 +117,6 @@ class CaptainSafetyDrill(GeneralProblem): pre_departure = False -@dataclass -class FoodDeliveryDelayed: - message: str = ( - "The scheduled food delivery prior to departure has not arrived. Until the supply truck reaches the pier, " - "we cannot leave. Once it arrives, unloading and stowing the provisions in the ship’s cold storage " - "will also take additional time. These combined delays postpone departure by approximately 5 hours." - ) - can_reoccur: bool = False - delay_duration: float = 5.0 - - @dataclass class FuelDeliveryIssue: message: str = ( @@ -206,7 +213,7 @@ class CoolingWaterIntakeBlocked(GeneralProblem): # ===================================================== -# SECTION: Instrument-specific Problem Classes +# SECTION: Instrument-specific Problems # ===================================================== @@ -214,15 +221,15 @@ class CoolingWaterIntakeBlocked(GeneralProblem): class CTDCableJammed(InstrumentProblem): """Problem: CTD cable jammed in winch drum, requiring replacement.""" - message: str = ( + message = ( "During preparation for the next CTD cast, the CTD cable becomes jammed in the winch drum. " "Attempts to free it are unsuccessful, and the crew determines that the entire cable must be " "replaced before deployment can continue. This repair is time-consuming and results in a delay " "of approximately 3 hours." ) - can_reoccur: bool = True - delay_duration: float = 3.0 - base_probability: float = 0.1 + can_reoccur = True + delay_duration = timedelta(hours=3.0) + base_probability = 0.1 instrument_type = InstrumentType.CTD @@ -236,9 +243,9 @@ class ADCPMalfunction(InstrumentProblem): "compartment to perform an inspection and continuity test. This diagnostic procedure results in a delay " "of around 1 hour." ) - can_reoccur: bool = True - delay_duration: float = 1.0 - base_probability: float = 0.1 + can_reoccur = True + delay_duration = timedelta(hours=1.0) + base_probability = 0.1 instrument_type = InstrumentType.ADCP diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index bebc07feb..538f93d3c 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -1,15 +1,15 @@ from __future__ import annotations -import time from pathlib import Path +from time import time from typing import TYPE_CHECKING +import numpy as np from yaspin import yaspin from virtualship.instruments.types import InstrumentType -from virtualship.utils import ( - CHECKPOINT, -) +from virtualship.models.checkpoint import Checkpoint +from virtualship.utils import CHECKPOINT, _save_checkpoint if TYPE_CHECKING: from virtualship.make_realistic.problems.scenarios import ( @@ -22,10 +22,11 @@ LOG_MESSAGING = { "first_pre_departure": "\nHang on! There could be a pre-departure problem in-port...\n\n", "subsequent_pre_departure": "\nOh no, another pre-departure problem has occurred...!\n\n", - "first_during_expedition": "\nOh no, a problem has occurred during the expedition...!\n\n", - "subsequent_during_expedition": "\nAnother problem has occurred during the expedition...!\n\n", + "first_during_expedition": "\nOh no, a problem has occurred during at waypoint {waypoint_i}...!\n\n", + "subsequent_during_expedition": "\nAnother problem has occurred during the expedition... at waypoint {waypoint_i}!\n\n", "simulation_paused": "\nSIMULATION PAUSED: update your schedule (`virtualship plan`) and continue the expedition by executing the `virtualship run` command again.\nCheckpoint has been saved to {checkpoint_path}.\n", - "problem_avoided:": "\nPhew! You had enough contingency time scheduled to avoid delays from this problem. The expedition can carry on!\n", + "problem_avoided": "\nPhew! You had enough contingency time scheduled to avoid delays from this problem. The expedition can carry on. \n", + "pre_departure_delay": "\nThis problem will cause a delay of {delay_duration} hours to the expedition schedule. Please add this time to your schedule (`virtualship plan`) and continue the expedition by executing the `virtualship run` command again.\n", } @@ -59,38 +60,50 @@ def execute( log_delay: float = 7.0, ): """Execute the selected problems, returning messaging and delay times.""" - for i, problem in enumerate(problems["general"]): - if pre_departure and problem.pre_departure: - print( + # TODO: integration with which zarr files have been written so far? + # TODO: logic to determine whether user has made the necessary changes to the schedule to account for the problem's delay_duration when next running the simulation... (does this come in here or _run?) + # TODO: logic for whether the user has already scheduled in enough contingency time to account for the problem's delay_duration, and they get a well done message if so + # TODO: need logic for if the problem can reoccur or not / and or that it has already occurred and has been addressed + + # general problems + for i, gproblem in enumerate(problems["general"]): + if pre_departure and gproblem.pre_departure: + alert_msg = ( LOG_MESSAGING["first_pre_departure"] if i == 0 else LOG_MESSAGING["subsequent_pre_departure"] ) - else: - if not pre_departure and not problem.pre_departure: - print( - LOG_MESSAGING["first_during_expedition"] - if i == 0 - else LOG_MESSAGING["subsequent_during_expedition"] + + elif not pre_departure and not gproblem.pre_departure: + alert_msg = ( + LOG_MESSAGING["first_during_expedition"].format( + waypoint_i=gproblem.waypoint_i + ) + if i == 0 + else LOG_MESSAGING["subsequent_during_expedition"].format( + waypoint_i=gproblem.waypoint_i ) - with yaspin(): - time.sleep(log_delay) + ) - # provide problem-specific messaging - print(problem.message) + else: + continue # problem does not occur at this time - # save to pause expedition and save to checkpoint + # alert user + print(alert_msg) - print( - LOG_MESSAGING["simulation_paused"].format( - checkpoint_path=self.expedition_dir.joinpath(CHECKPOINT) - ) + # determine failed waypoint index (random if during expedition) + failed_waypoint_i = ( + np.nan + if pre_departure + else np.random.randint(0, len(self.schedule.waypoints) - 1) ) - # TODO: integration with which zarr files have been written so far - # TODO: plus a checkpoint file to assess whether the user has indeed also made the necessary changes to the schedule as required by the problem's delay_duration - # - in here also comes the logic for whether the user has already scheduled in enough contingency time to account for the problem's delay_duration, and they get a well done message if so - #! - may have to be that make a note of it during the simulate_schedule (and feed it forward), otherwise won't know which waypoint(s)... + # log problem occurrence, save to checkpoint, and pause simulation + self._log_problem(gproblem, failed_waypoint_i, log_delay) + + # instrument problems + for i, problem in enumerate(problems["instrument"]): + ... def _propagate_general_problems(self): """Propagate general problems based on probability.""" @@ -126,3 +139,61 @@ def _instrument_problem_select(self) -> list[InstrumentProblem]: wp_instruments = self.schedule.waypoints.instruments return [] + + def _log_problem( + self, + problem: GeneralProblem | InstrumentProblem, + failed_waypoint_i: int, + log_delay: float, + ): + """Log problem occurrence with spinner and delay, save to checkpoint.""" + with yaspin(): + time.sleep(log_delay) + + print(problem.message) + + print("\n\nAssessing impact on expedition schedule...\n") + + # check if enough contingency time has been scheduled to avoid delay + failed_waypoint_time = self.schedule.waypoints[failed_waypoint_i].time + previous_waypoint_time = self.schedule.waypoints[failed_waypoint_i - 1].time + time_diff = ( + failed_waypoint_time - previous_waypoint_time + ).total_seconds() / 3600.0 # in hours + if time_diff >= problem.delay_duration.total_seconds() / 3600.0: + print(LOG_MESSAGING["problem_avoided"]) + return + else: + print( + f"\nNot enough contingency time scheduled to avoid delay of {problem.delay_duration.total_seconds() / 3600.0} hours.\n" + ) + + checkpoint = self._make_checkpoint(failed_waypoint_i) + _save_checkpoint(checkpoint, self.expedition_dir) + + if np.isnan(failed_waypoint_i): + print( + LOG_MESSAGING["pre_departure_delay"].format( + delay_duration=problem.delay_duration.total_seconds() / 3600.0 + ) + ) + else: + print( + LOG_MESSAGING["simulation_paused"].format( + checkpoint_path=self.expedition_dir.joinpath(CHECKPOINT) + ) + ) + + def _make_checkpoint(self, failed_waypoint_i: int | float = np.nan): + """Make checkpoint, also handling pre-departure.""" + if np.isnan(failed_waypoint_i): + checkpoint = Checkpoint( + past_schedule=self.schedule + ) # TODO: and then when it comes to verify checkpoint later, can determine whether the changes have been made to the schedule accordingly? + else: + checkpoint = Checkpoint( + past_schedule=Schedule( + waypoints=self.schedule.waypoints[:failed_waypoint_i] + ) + ) + return checkpoint diff --git a/src/virtualship/models/__init__.py b/src/virtualship/models/__init__.py index d61c17194..7a106ba60 100644 --- a/src/virtualship/models/__init__.py +++ b/src/virtualship/models/__init__.py @@ -1,5 +1,6 @@ """Pydantic models and data classes used to configure virtualship (i.e., in the configuration files or settings).""" +from .checkpoint import Checkpoint from .expedition import ( ADCPConfig, ArgoFloatConfig, @@ -34,4 +35,5 @@ "Spacetime", "Expedition", "InstrumentsConfig", + "Checkpoint", ] diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 9d6aa4194..38ba790cb 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -18,9 +18,11 @@ from virtualship.errors import CopernicusCatalogueError if TYPE_CHECKING: - from virtualship.expedition.simulate_schedule import ScheduleOk + from virtualship.expedition.simulate_schedule import ( + ScheduleOk, + ) from virtualship.models import Expedition - + from virtualship.models.checkpoint import Checkpoint import pandas as pd import yaml @@ -574,3 +576,8 @@ def _get_waypoint_latlons(waypoints): strict=True, ) return wp_lats, wp_lons + + +def _save_checkpoint(checkpoint: Checkpoint, expedition_dir: Path) -> None: + file_path = expedition_dir.joinpath(CHECKPOINT) + checkpoint.to_yaml(file_path) From 49e506feb975aaa0b6cd9af4e07bdebccebc8d90 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Wed, 14 Jan 2026 17:00:41 +0100 Subject: [PATCH 12/52] checkpoint and reverification logic --- src/virtualship/cli/_run.py | 33 +++++---- .../expedition/simulate_schedule.py | 3 +- .../make_realistic/problems/simulator.py | 71 +++++++++++++++---- src/virtualship/models/checkpoint.py | 67 +++++++++++++---- 4 files changed, 134 insertions(+), 40 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index b0d680749..6893a2eb0 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -1,5 +1,6 @@ """do_expedition function.""" +import glob import logging import os import shutil @@ -83,7 +84,7 @@ def _run(expedition_dir: str | Path, from_data: Path | None = None) -> None: checkpoint = Checkpoint(past_schedule=Schedule(waypoints=[])) # verify that schedule and checkpoint match - checkpoint.verify(expedition.schedule) + checkpoint.verify(expedition.schedule, expedition_dir) print("\n---- WAYPOINT VERIFICATION ----") @@ -92,23 +93,13 @@ def _run(expedition_dir: str | Path, from_data: Path | None = None) -> None: from_data=Path(from_data) if from_data else None, ) - # TODO: overview: - # 1) determine all the general AND instrument problems which will occur across the whole expedition - # 2) make a checker function which can be called at various points in the simulation to see if a problem occurs at that point - # - e.g. at pre-departure, at each instrument measurement step, etc. - # - each time a problem occurs, propagate its effects (e.g. delay), log it, save to checkpoint etc. - # TODO: still need to incorp can_reoccur logic somewhere - - # TODO: prob_level needs to be parsed from CLI args - problem_simulator = ProblemSimulator(expedition.schedule, prob_level=...) - problems = problem_simulator.select_problems() - # simulate the schedule schedule_results = simulate_schedule( projection=projection, expedition=expedition, - problems=problems if problems else None, ) + + # handle cases where user defined schedule is incompatible (i.e. not enough time between waypoints, not problems) if isinstance(schedule_results, ScheduleProblem): print( f"SIMULATION PAUSED: update your schedule (`virtualship plan`) and continue the expedition by executing the `virtualship run` command again.\nCheckpoint has been saved to {expedition_dir.joinpath(CHECKPOINT)}." @@ -137,9 +128,16 @@ def _run(expedition_dir: str | Path, from_data: Path | None = None) -> None: print("\n--- MEASUREMENT SIMULATIONS ---") + # identify problems + # TODO: prob_level needs to be parsed from CLI args + problem_simulator = ProblemSimulator(expedition.schedule, prob_level=...) + problems = problem_simulator.select_problems() + # simulate measurements print("\nSimulating measurements. This may take a while...\n") + # TODO: logic for getting simulations to carry on from last checkpoint! Building on .zarr files already created... + instruments_in_expedition = expedition.get_instruments() for i, itype in enumerate(instruments_in_expedition): @@ -195,6 +193,15 @@ def _load_checkpoint(expedition_dir: Path) -> Checkpoint | None: return None +def _load_hashes(expedition_dir: Path) -> set[str]: + hashes_path = expedition_dir.joinpath("problems_encountered") + if not hashes_path.exists(): + return set() + hash_files = glob.glob(str(hashes_path / "problem_*.txt")) + hashes = {Path(f).stem.split("_")[1] for f in hash_files} + return hashes + + def _write_expedition_cost(expedition, schedule_results, expedition_dir): """Calculate the expedition cost, write it to a file, and print summary.""" assert expedition.schedule.waypoints[0].time is not None, ( diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index e6825603e..c09ae7b56 100644 --- a/src/virtualship/expedition/simulate_schedule.py +++ b/src/virtualship/expedition/simulate_schedule.py @@ -127,7 +127,8 @@ def simulate(self) -> ScheduleOk | ScheduleProblem: print( f"Waypoint {wp_i + 1} could not be reached in time. Current time: {self._time}. Waypoint time: {waypoint.time}." "\n\nHave you ensured that your schedule includes sufficient time for taking measurements, e.g. CTD casts (in addition to the time it takes to sail between waypoints)?\n" - "**Note**, the `virtualship plan` tool will not account for measurement times when verifying the schedule, only the time it takes to sail between waypoints.\n" + "**Hint #1**, the `virtualship plan` tool will not account for measurement times when verifying the schedule, only the time it takes to sail between waypoints.\n" + "**Hint #2**: if you previously encountered any unforeseen delays (e.g. equipment failure, pre-departure delays) during your expedition, you will need to adjust the timings of **all** waypoints after the affected waypoint, not just the next one." ) return ScheduleProblem(self._time, wp_i) else: diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index 538f93d3c..e9837057b 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -1,5 +1,7 @@ from __future__ import annotations +import hashlib +import os from pathlib import Path from time import time from typing import TYPE_CHECKING @@ -17,7 +19,7 @@ InstrumentProblem, ) from virtualship.models import Schedule - +import json LOG_MESSAGING = { "first_pre_departure": "\nHang on! There could be a pre-departure problem in-port...\n\n", @@ -26,7 +28,7 @@ "subsequent_during_expedition": "\nAnother problem has occurred during the expedition... at waypoint {waypoint_i}!\n\n", "simulation_paused": "\nSIMULATION PAUSED: update your schedule (`virtualship plan`) and continue the expedition by executing the `virtualship run` command again.\nCheckpoint has been saved to {checkpoint_path}.\n", "problem_avoided": "\nPhew! You had enough contingency time scheduled to avoid delays from this problem. The expedition can carry on. \n", - "pre_departure_delay": "\nThis problem will cause a delay of {delay_duration} hours to the expedition schedule. Please add this time to your schedule (`virtualship plan`) and continue the expedition by executing the `virtualship run` command again.\n", + "pre_departure_delay": "\nThis problem will cause a delay of {delay_duration} hours to the expedition schedule. Please account for this for all waypoints in your schedule (`virtualship plan`), then continue the expedition by executing the `virtualship run` command again.\n", } @@ -43,6 +45,7 @@ def select_problems( self, ) -> dict[str, list[GeneralProblem | InstrumentProblem]] | None: """Propagate both general and instrument problems.""" + # TODO: whether a problem can reoccur or not needs to be handled here too! probability = self._calc_prob() if probability > 0.0: problems = {} @@ -65,8 +68,32 @@ def execute( # TODO: logic for whether the user has already scheduled in enough contingency time to account for the problem's delay_duration, and they get a well done message if so # TODO: need logic for if the problem can reoccur or not / and or that it has already occurred and has been addressed + #! TODO: logic as well for case where problem can reoccur but it can only reoccur at a waypoint different to the one it has already occurred at + # general problems for i, gproblem in enumerate(problems["general"]): + # determine failed waypoint index (random if during expedition) + failed_waypoint_i = ( + np.nan + if pre_departure + else np.random.randint(0, len(self.schedule.waypoints) - 1) + ) + + # mark problem by unique hash and log to json, use to assess whether problem has already occurred + gproblem_hash = self._make_hash( + gproblem.message + str(failed_waypoint_i), 8 + ) + hash_path = Path( + self.expedition_dir + / f"problems_encountered/problem_{gproblem_hash}.json" + ) + if hash_path.exists(): + continue # problem * waypoint combination has already occurred + else: + self._hash_to_json( + gproblem, gproblem_hash, failed_waypoint_i, hash_path + ) + if pre_departure and gproblem.pre_departure: alert_msg = ( LOG_MESSAGING["first_pre_departure"] @@ -86,24 +113,18 @@ def execute( ) else: - continue # problem does not occur at this time + continue # problem does not occur (e.g. wrong combination of pre-departure vs. problem can only occur during expedition) # alert user print(alert_msg) - # determine failed waypoint index (random if during expedition) - failed_waypoint_i = ( - np.nan - if pre_departure - else np.random.randint(0, len(self.schedule.waypoints) - 1) - ) - # log problem occurrence, save to checkpoint, and pause simulation self._log_problem(gproblem, failed_waypoint_i, log_delay) # instrument problems for i, problem in enumerate(problems["instrument"]): ... + # TODO: similar logic to above for instrument-specific problems... or combine? def _propagate_general_problems(self): """Propagate general problems based on probability.""" @@ -146,7 +167,7 @@ def _log_problem( failed_waypoint_i: int, log_delay: float, ): - """Log problem occurrence with spinner and delay, save to checkpoint.""" + """Log problem occurrence with spinner and delay, save to checkpoint, write hash.""" with yaspin(): time.sleep(log_delay) @@ -186,7 +207,7 @@ def _log_problem( def _make_checkpoint(self, failed_waypoint_i: int | float = np.nan): """Make checkpoint, also handling pre-departure.""" - if np.isnan(failed_waypoint_i): + if np.isnan(failed_waypoint_i): # handles pre-departure problems checkpoint = Checkpoint( past_schedule=self.schedule ) # TODO: and then when it comes to verify checkpoint later, can determine whether the changes have been made to the schedule accordingly? @@ -197,3 +218,29 @@ def _make_checkpoint(self, failed_waypoint_i: int | float = np.nan): ) ) return checkpoint + + def _make_hash(s: str, length: int) -> str: + """Make unique hash for problem occurrence.""" + assert length % 2 == 0, "Length must be even." + half_length = length // 2 + return hashlib.shake_128(s.encode("utf-8")).hexdigest(half_length) + + def _hash_to_json( + self, + problem: InstrumentProblem | GeneralProblem, + problem_hash: str, + failed_waypoint_i: int | float, + hash_path: Path, + ) -> dict: + """Convert problem details + hash to json.""" + os.makedirs(self.expedition_dir / "problems_encountered", exist_ok=True) + hash_data = { + "problem_hash": problem_hash, + "message": problem.message, + "failed_waypoint_i": failed_waypoint_i, + "delay_duration_hours": problem.delay_duration.total_seconds() / 3600.0, + "timestamp": time.time(), + "resolved": False, + } + with open(hash_path, "w") as f: + json.dump(hash_data, f, indent=4) diff --git a/src/virtualship/models/checkpoint.py b/src/virtualship/models/checkpoint.py index 98fe1ae0a..ba4b2d5a5 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -2,6 +2,8 @@ from __future__ import annotations +import json +from datetime import timedelta from pathlib import Path import pydantic @@ -51,20 +53,8 @@ def from_yaml(cls, file_path: str | Path) -> Checkpoint: data = yaml.safe_load(file) return Checkpoint(**data) - def verify(self, schedule: Schedule) -> None: - """ - Verify that the given schedule matches the checkpoint's past schedule. - - This method checks if the waypoints in the given schedule match the waypoints - in the checkpoint's past schedule up to the length of the past schedule. - If there's a mismatch, it raises a CheckpointError. - - :param schedule: The schedule to verify against the checkpoint. - :type schedule: Schedule - :raises CheckpointError: If the past waypoints in the given schedule - have been changed compared to the checkpoint. - :return: None - """ + def verify(self, schedule: Schedule, expedition_dir: Path) -> None: + """Verify that the given schedule matches the checkpoint's past schedule, and that problems have been resolved.""" if ( not schedule.waypoints[: len(self.past_schedule.waypoints)] == self.past_schedule.waypoints @@ -72,3 +62,52 @@ def verify(self, schedule: Schedule) -> None: raise CheckpointError( "Past waypoints in schedule have been changed! Restore past schedule and only change future waypoints." ) + + # TODO: how does this handle pre-departure problems that caused delays? Old schedule will be a complete mismatch then. + + # check that problems have been resolved in the new schedule + hash_fpaths = [ + str(path.resolve()) + for path in Path(expedition_dir, "problems_encountered").glob( + "problem_*.json" + ) + ] + + for file in hash_fpaths: + with open(file) as f: + problem = json.load(f) + if problem["resolved"]: + continue + elif not problem["resolved"]: + # check if delay has been accounted for in the schedule + delay_duration = timedelta( + hours=float(problem["delay_duration_hours"]) + ) # delay associated with the problem + + failed_waypoint_i = int(problem["failed_waypoint_i"]) + + time_deltas = [ + schedule.waypoints[i].time + - self.past_schedule.waypoints[i].time + for i in range( + failed_waypoint_i, len(self.past_schedule.waypoints) + ) + ] # difference in time between the two schedules from the failed waypoint onwards + + if all(td >= delay_duration for td in time_deltas): + print( + "\n\nPrevious problem has been resolved in the schedule.\n" + ) + + # save back to json file changing the resolved status to True + problem["resolved"] = True + with open(file, "w") as f_out: + json.dump(problem, f_out, indent=4) + + else: + raise CheckpointError( + "The problem encountered in previous simulation has not been resolved in the schedule! Please adjust the schedule to account for delays caused by problem.", + f"The problem was associated with a delay duration of {problem['delay_duration_hours']} hours starting from waypoint {failed_waypoint_i + 1}.", + ) + + break # only handle the first unresolved problem found; others will be handled in subsequent runs but are not yet known to the user From 5e7889f3702dbb64a71bb95f5470a8ac377aa363 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:27:23 +0100 Subject: [PATCH 13/52] tidy up some logging etc --- src/virtualship/cli/_run.py | 9 +- src/virtualship/make_realistic/__init__.py | 1 - .../make_realistic/problems/scenarios.py | 70 ++++++------ .../make_realistic/problems/simulator.py | 103 ++++++++++-------- src/virtualship/models/checkpoint.py | 9 +- 5 files changed, 103 insertions(+), 89 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index 6893a2eb0..269d77e15 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -37,7 +37,10 @@ logging.getLogger("copernicusmarine").setLevel("ERROR") -def _run(expedition_dir: str | Path, from_data: Path | None = None) -> None: +# TODO: prob-level needs to be parsed from CLI args; currently set to 1 override for testing purposes +def _run( + expedition_dir: str | Path, from_data: Path | None = None, prob_level: int = 1 +) -> None: """ Perform an expedition, providing terminal feedback and file output. @@ -130,7 +133,9 @@ def _run(expedition_dir: str | Path, from_data: Path | None = None) -> None: # identify problems # TODO: prob_level needs to be parsed from CLI args - problem_simulator = ProblemSimulator(expedition.schedule, prob_level=...) + problem_simulator = ProblemSimulator( + expedition.schedule, prob_level, expedition_dir + ) problems = problem_simulator.select_problems() # simulate measurements diff --git a/src/virtualship/make_realistic/__init__.py b/src/virtualship/make_realistic/__init__.py index 91ad16847..2c9a17df7 100644 --- a/src/virtualship/make_realistic/__init__.py +++ b/src/virtualship/make_realistic/__init__.py @@ -2,6 +2,5 @@ from .adcp_make_realistic import adcp_make_realistic from .ctd_make_realistic import ctd_make_realistic -from .problems impor __all__ = ["adcp_make_realistic", "ctd_make_realistic"] diff --git a/src/virtualship/make_realistic/problems/scenarios.py b/src/virtualship/make_realistic/problems/scenarios.py index 76ffc2ffa..97696a21e 100644 --- a/src/virtualship/make_realistic/problems/scenarios.py +++ b/src/virtualship/make_realistic/problems/scenarios.py @@ -6,10 +6,6 @@ from typing import TYPE_CHECKING from virtualship.instruments.types import InstrumentType -from virtualship.utils import ( - register_general_problem, - register_instrument_problem, -) if TYPE_CHECKING: from virtualship.models import Waypoint @@ -63,11 +59,11 @@ def is_valid() -> bool: @dataclass -@register_general_problem +# @register_general_problem class FoodDeliveryDelayed: """Problem: Scheduled food delivery is delayed, causing a postponement of departure.""" - message: str = ( + message = ( "The scheduled food delivery prior to departure has not arrived. Until the supply truck reaches the pier, " "we cannot leave. Once it arrives, unloading and stowing the provisions in the ship’s cold storage " "will also take additional time. These combined delays postpone departure by approximately 5 hours." @@ -79,13 +75,13 @@ class FoodDeliveryDelayed: @dataclass -@register_general_problem +# @register_general_problem class VenomousCentipedeOnboard(GeneralProblem): """Problem: Venomous centipede discovered onboard in tropical waters.""" # TODO: this needs logic added to the is_valid() method to check if waypoint is in tropical waters - message: str = ( + message = ( "A venomous centipede is discovered onboard while operating in tropical waters. " "One crew member becomes ill after contact with the creature and receives medical attention, " "prompting a full search of the vessel to ensure no further danger. " @@ -102,11 +98,11 @@ def is_valid(self, waypoint: Waypoint) -> bool: return abs(waypoint.latitude) <= lat_limit -@register_general_problem +# @register_general_problem class CaptainSafetyDrill(GeneralProblem): """Problem: Sudden initiation of a mandatory safety drill.""" - message: str = ( + message = ( "A miscommunication with the ship’s captain results in the sudden initiation of a mandatory safety drill. " "The emergency vessel must be lowered and tested while the ship remains stationary, pausing all scientific " "operations for the duration of the exercise. The drill introduces a delay of approximately 2 hours." @@ -119,7 +115,7 @@ class CaptainSafetyDrill(GeneralProblem): @dataclass class FuelDeliveryIssue: - message: str = ( + message = ( "The fuel tanker expected to deliver fuel has not arrived. Port authorities are unable to provide " "a clear estimate for when the delivery might occur. You may choose to [w]ait for the tanker or [g]et a " "harbor pilot to guide the vessel to an available bunker dock instead. This decision may need to be " @@ -131,7 +127,7 @@ class FuelDeliveryIssue: @dataclass class EngineOverheating: - message: str = ( + message = ( "One of the main engines has overheated. To prevent further damage, the engineering team orders a reduction " "in vessel speed until the engine can be inspected and repaired in port. The ship will now operate at a " "reduced cruising speed of 8.5 knots for the remainder of the transit." @@ -141,11 +137,11 @@ class EngineOverheating: ship_speed_knots: float = 8.5 -@register_general_problem +# @register_general_problem class MarineMammalInDeploymentArea(GeneralProblem): """Problem: Marine mammals observed in deployment area, causing delay.""" - message: str = ( + message = ( "A pod of dolphins is observed swimming directly beneath the planned deployment area. " "To avoid risk to wildlife and comply with environmental protocols, all in-water operations " "must pause until the animals move away from the vicinity. This results in a delay of about 30 minutes." @@ -155,11 +151,11 @@ class MarineMammalInDeploymentArea(GeneralProblem): base_probability: float = 0.1 -@register_general_problem +# @register_general_problem class BallastPumpFailure(GeneralProblem): """Problem: Ballast pump failure during ballasting operations.""" - message: str = ( + message = ( "One of the ship’s ballast pumps suddenly stops responding during routine ballasting operations. " "Without the pump, the vessel cannot safely adjust trim or compensate for equipment movements on deck. " "Engineering isolates the faulty pump and performs a rapid inspection. Temporary repairs allow limited " @@ -170,11 +166,11 @@ class BallastPumpFailure(GeneralProblem): base_probability: float = 0.1 -@register_general_problem +# @register_general_problem class ThrusterConverterFault(GeneralProblem): """Problem: Bow thruster's power converter fault during station-keeping.""" - message: str = ( + message = ( "The bow thruster's power converter reports a fault during station-keeping operations. " "Dynamic positioning becomes less stable, forcing a temporary suspension of high-precision sampling. " "Engineers troubleshoot the converter and perform a reset, resulting in a delay of around 1 hour." @@ -184,11 +180,11 @@ class ThrusterConverterFault(GeneralProblem): base_probability: float = 0.1 -@register_general_problem +# @register_general_problem class AFrameHydraulicLeak(GeneralProblem): """Problem: Hydraulic fluid leak from A-frame actuator.""" - message: str = ( + message = ( "A crew member notices hydraulic fluid leaking from the A-frame actuator during equipment checks. " "The leak must be isolated immediately to prevent environmental contamination or mechanical failure. " "Engineering replaces a faulty hose and repressurizes the system. This repair causes a delay of about 2 hours." @@ -198,11 +194,11 @@ class AFrameHydraulicLeak(GeneralProblem): base_probability: float = 0.1 -@register_general_problem +# @register_general_problem class CoolingWaterIntakeBlocked(GeneralProblem): """Problem: Main engine's cooling water intake blocked.""" - message: str = ( + message = ( "The main engine's cooling water intake alarms indicate reduced flow, likely caused by marine debris " "or biological fouling. The vessel must temporarily slow down while engineering clears the obstruction " "and flushes the intake. This results in a delay of approximately 1 hour." @@ -217,7 +213,7 @@ class CoolingWaterIntakeBlocked(GeneralProblem): # ===================================================== -@register_instrument_problem(InstrumentType.CTD) +# @register_instrument_problem(InstrumentType.CTD) class CTDCableJammed(InstrumentProblem): """Problem: CTD cable jammed in winch drum, requiring replacement.""" @@ -233,11 +229,11 @@ class CTDCableJammed(InstrumentProblem): instrument_type = InstrumentType.CTD -@register_instrument_problem(InstrumentType.ADCP) +# @register_instrument_problem(InstrumentType.ADCP) class ADCPMalfunction(InstrumentProblem): """Problem: ADCP returns invalid data, requiring inspection.""" - message: str = ( + message = ( "The hull-mounted ADCP begins returning invalid velocity data. Engineering suspects damage to the cable " "from recent maintenance activities. The ship must hold position while a technician enters the cable " "compartment to perform an inspection and continuity test. This diagnostic procedure results in a delay " @@ -249,11 +245,11 @@ class ADCPMalfunction(InstrumentProblem): instrument_type = InstrumentType.ADCP -@register_instrument_problem(InstrumentType.CTD) +# @register_instrument_problem(InstrumentType.CTD) class CTDTemperatureSensorFailure(InstrumentProblem): """Problem: CTD temperature sensor failure, requiring replacement.""" - message: str = ( + message = ( "The primary temperature sensor on the CTD begins returning inconsistent readings. " "Troubleshooting confirms that the sensor has malfunctioned. A spare unit can be installed, " "but integrating and verifying the replacement will pause operations. " @@ -265,11 +261,11 @@ class CTDTemperatureSensorFailure(InstrumentProblem): instrument_type = InstrumentType.CTD -@register_instrument_problem(InstrumentType.CTD) +# @register_instrument_problem(InstrumentType.CTD) class CTDSalinitySensorFailureWithCalibration(InstrumentProblem): """Problem: CTD salinity sensor failure, requiring replacement and calibration.""" - message: str = ( + message = ( "The CTD’s primary salinity sensor fails and must be replaced with a backup. After installation, " "a mandatory calibration cast to a minimum depth of 1000 meters is required to verify sensor accuracy. " "Both the replacement and calibration activities result in a total delay of roughly 4 hours." @@ -280,11 +276,11 @@ class CTDSalinitySensorFailureWithCalibration(InstrumentProblem): instrument_type = InstrumentType.CTD -@register_instrument_problem(InstrumentType.CTD) +# @register_instrument_problem(InstrumentType.CTD) class WinchHydraulicPressureDrop(InstrumentProblem): """Problem: CTD winch hydraulic pressure drop, requiring repair.""" - message: str = ( + message = ( "The CTD winch begins to lose hydraulic pressure during routine checks prior to deployment. " "The engineering crew must stop operations to diagnose the hydraulic pump and replenish or repair " "the system. Until pressure is restored to operational levels, the winch cannot safely be used. " @@ -296,11 +292,11 @@ class WinchHydraulicPressureDrop(InstrumentProblem): instrument_type = InstrumentType.CTD -@register_instrument_problem(InstrumentType.CTD) +# @register_instrument_problem(InstrumentType.CTD) class RosetteTriggerFailure(InstrumentProblem): """Problem: CTD rosette trigger failure, requiring inspection.""" - message: str = ( + message = ( "During a CTD cast, the rosette's bottle-triggering mechanism fails to actuate. " "No discrete water samples can be collected during this cast. The rosette must be brought back " "on deck for inspection and manual testing of the trigger system. This results in an operational " @@ -312,11 +308,11 @@ class RosetteTriggerFailure(InstrumentProblem): instrument_type = InstrumentType.CTD -@register_instrument_problem(InstrumentType.DRIFTER) +# @register_instrument_problem(InstrumentType.DRIFTER) class DrifterSatelliteConnectionDelay(InstrumentProblem): """Problem: Drifter fails to establish satellite connection before deployment.""" - message: str = ( + message = ( "The drifter scheduled for deployment fails to establish a satellite connection during " "pre-launch checks. To improve signal acquisition, the float must be moved to a higher location on deck " "with fewer obstructions. The team waits for the satellite fix to come through, resulting in a delay " @@ -328,11 +324,11 @@ class DrifterSatelliteConnectionDelay(InstrumentProblem): instrument_type = InstrumentType.DRIFTER -@register_instrument_problem(InstrumentType.ARGO_FLOAT) +# @register_instrument_problem(InstrumentType.ARGO_FLOAT) class ArgoSatelliteConnectionDelay(InstrumentProblem): """Problem: Argo float fails to establish satellite connection before deployment.""" - message: str = ( + message = ( "The Argo float scheduled for deployment fails to establish a satellite connection during " "pre-launch checks. To improve signal acquisition, the float must be moved to a higher location on deck " "with fewer obstructions. The team waits for the satellite fix to come through, resulting in a delay " diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index e9837057b..c10e9d909 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -2,14 +2,18 @@ import hashlib import os +import time from pathlib import Path -from time import time from typing import TYPE_CHECKING import numpy as np from yaspin import yaspin from virtualship.instruments.types import InstrumentType +from virtualship.make_realistic.problems.scenarios import ( + CTDCableJammed, + FoodDeliveryDelayed, +) from virtualship.models.checkpoint import Checkpoint from virtualship.utils import CHECKPOINT, _save_checkpoint @@ -22,13 +26,13 @@ import json LOG_MESSAGING = { - "first_pre_departure": "\nHang on! There could be a pre-departure problem in-port...\n\n", - "subsequent_pre_departure": "\nOh no, another pre-departure problem has occurred...!\n\n", - "first_during_expedition": "\nOh no, a problem has occurred during at waypoint {waypoint_i}...!\n\n", - "subsequent_during_expedition": "\nAnother problem has occurred during the expedition... at waypoint {waypoint_i}!\n\n", - "simulation_paused": "\nSIMULATION PAUSED: update your schedule (`virtualship plan`) and continue the expedition by executing the `virtualship run` command again.\nCheckpoint has been saved to {checkpoint_path}.\n", - "problem_avoided": "\nPhew! You had enough contingency time scheduled to avoid delays from this problem. The expedition can carry on. \n", - "pre_departure_delay": "\nThis problem will cause a delay of {delay_duration} hours to the expedition schedule. Please account for this for all waypoints in your schedule (`virtualship plan`), then continue the expedition by executing the `virtualship run` command again.\n", + "first_pre_departure": "Hang on! There could be a pre-departure problem in-port...", + "subsequent_pre_departure": "Oh no, another pre-departure problem has occurred...!\n", + "first_during_expedition": "Oh no, a problem has occurred during at waypoint {waypoint_i}...!\n", + "subsequent_during_expedition": "Another problem has occurred during the expedition... at waypoint {waypoint_i}!\n", + "simulation_paused": "SIMULATION PAUSED: update your schedule (`virtualship plan`) and continue the expedition by executing the `virtualship run` command again.\nCheckpoint has been saved to {checkpoint_path}.\n", + "problem_avoided": "Phew! You had enough contingency time scheduled to avoid delays from this problem. The expedition can carry on.\n", + "pre_departure_delay": "This problem will cause a delay of **{delay_duration} hours** to the expedition schedule. \n\nPlease account for this for **ALL** waypoints in your schedule (`virtualship plan`), then continue the expedition by executing the `virtualship run` command again.\n", } @@ -47,6 +51,7 @@ def select_problems( """Propagate both general and instrument problems.""" # TODO: whether a problem can reoccur or not needs to be handled here too! probability = self._calc_prob() + probability = 1.0 # TODO: temporary override for testing!! if probability > 0.0: problems = {} problems["general"] = self._general_problem_select(probability) @@ -70,6 +75,8 @@ def execute( #! TODO: logic as well for case where problem can reoccur but it can only reoccur at a waypoint different to the one it has already occurred at + # TODO: make the log output stand out more visually + # general problems for i, gproblem in enumerate(problems["general"]): # determine failed waypoint index (random if during expedition) @@ -79,6 +86,7 @@ def execute( else np.random.randint(0, len(self.schedule.waypoints) - 1) ) + # TODO: some kind of handling for deleting directory if ... simulation encounters error? or just leave it to user to delete manually if they want to restart from scratch? # mark problem by unique hash and log to json, use to assess whether problem has already occurred gproblem_hash = self._make_hash( gproblem.message + str(failed_waypoint_i), 8 @@ -115,15 +123,12 @@ def execute( else: continue # problem does not occur (e.g. wrong combination of pre-departure vs. problem can only occur during expedition) - # alert user - print(alert_msg) - # log problem occurrence, save to checkpoint, and pause simulation - self._log_problem(gproblem, failed_waypoint_i, log_delay) + self._log_problem(gproblem, failed_waypoint_i, alert_msg, log_delay) # instrument problems for i, problem in enumerate(problems["instrument"]): - ... + pass # TODO: implement!! # TODO: similar logic to above for instrument-specific problems... or combine? def _propagate_general_problems(self): @@ -147,63 +152,67 @@ def _calc_prob(self) -> float: if self.prob_level == 0: return 0.0 - def _general_problem_select(self) -> list[GeneralProblem]: + def _general_problem_select(self, probability) -> list[GeneralProblem]: """Select which problems. Higher probability (tied to expedition duration) means more problems are likely to occur.""" - ... - return [] + return [FoodDeliveryDelayed] # TODO: temporary placeholder!! - def _instrument_problem_select(self) -> list[InstrumentProblem]: + def _instrument_problem_select(self, probability) -> list[InstrumentProblem]: """Select which problems. Higher probability (tied to expedition duration) means more problems are likely to occur.""" # set: waypoint instruments vs. list of instrument-specific problems (automated registry) # will deterimne which instrument-specific problems are possible at this waypoint - wp_instruments = self.schedule.waypoints.instruments + # wp_instruments = self.schedule.waypoints.instruments - return [] + return [CTDCableJammed] def _log_problem( self, problem: GeneralProblem | InstrumentProblem, - failed_waypoint_i: int, + failed_waypoint_i: int | float, + alert_msg: str, log_delay: float, ): """Log problem occurrence with spinner and delay, save to checkpoint, write hash.""" - with yaspin(): + time.sleep(3.0) # brief pause before spinner + with yaspin(text=alert_msg) as spinner: time.sleep(log_delay) + spinner.ok("💥 ") - print(problem.message) - - print("\n\nAssessing impact on expedition schedule...\n") - - # check if enough contingency time has been scheduled to avoid delay - failed_waypoint_time = self.schedule.waypoints[failed_waypoint_i].time - previous_waypoint_time = self.schedule.waypoints[failed_waypoint_i - 1].time - time_diff = ( - failed_waypoint_time - previous_waypoint_time - ).total_seconds() / 3600.0 # in hours - if time_diff >= problem.delay_duration.total_seconds() / 3600.0: - print(LOG_MESSAGING["problem_avoided"]) - return - else: - print( - f"\nNot enough contingency time scheduled to avoid delay of {problem.delay_duration.total_seconds() / 3600.0} hours.\n" - ) - - checkpoint = self._make_checkpoint(failed_waypoint_i) - _save_checkpoint(checkpoint, self.expedition_dir) + print("\nPROBLEM ENCOUNTERED: " + problem.message) - if np.isnan(failed_waypoint_i): + if np.isnan(failed_waypoint_i): # pre-departure problem print( - LOG_MESSAGING["pre_departure_delay"].format( + "\nRESULT: " + + LOG_MESSAGING["pre_departure_delay"].format( delay_duration=problem.delay_duration.total_seconds() / 3600.0 ) ) - else: + else: # problem occurring during expedition print( - LOG_MESSAGING["simulation_paused"].format( + "\nRESULT: " + + LOG_MESSAGING["simulation_paused"].format( checkpoint_path=self.expedition_dir.joinpath(CHECKPOINT) ) ) + # check if enough contingency time has been scheduled to avoid delay + print("\nAssessing impact on expedition schedule...\n") + failed_waypoint_time = self.schedule.waypoints[failed_waypoint_i].time + previous_waypoint_time = self.schedule.waypoints[failed_waypoint_i - 1].time + time_diff = ( + failed_waypoint_time - previous_waypoint_time + ).total_seconds() / 3600.0 # in hours + if time_diff >= problem.delay_duration.total_seconds() / 3600.0: + print(LOG_MESSAGING["problem_avoided"]) + return + else: + print( + f"\nNot enough contingency time scheduled to avoid delay of {problem.delay_duration.total_seconds() / 3600.0} hours.\n" + ) + + checkpoint = self._make_checkpoint(failed_waypoint_i) + _save_checkpoint(checkpoint, self.expedition_dir) + + return def _make_checkpoint(self, failed_waypoint_i: int | float = np.nan): """Make checkpoint, also handling pre-departure.""" @@ -219,7 +228,7 @@ def _make_checkpoint(self, failed_waypoint_i: int | float = np.nan): ) return checkpoint - def _make_hash(s: str, length: int) -> str: + def _make_hash(self, s: str, length: int) -> str: """Make unique hash for problem occurrence.""" assert length % 2 == 0, "Length must be even." half_length = length // 2 @@ -239,7 +248,7 @@ def _hash_to_json( "message": problem.message, "failed_waypoint_i": failed_waypoint_i, "delay_duration_hours": problem.delay_duration.total_seconds() / 3600.0, - "timestamp": time.time(), + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), "resolved": False, } with open(hash_path, "w") as f: diff --git a/src/virtualship/models/checkpoint.py b/src/virtualship/models/checkpoint.py index ba4b2d5a5..1a734ba74 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -6,12 +6,13 @@ from datetime import timedelta from pathlib import Path +import numpy as np import pydantic import yaml from virtualship.errors import CheckpointError from virtualship.instruments.types import InstrumentType -from virtualship.models import Schedule +from virtualship.models.expedition import Schedule class _YamlDumper(yaml.SafeDumper): @@ -84,7 +85,11 @@ def verify(self, schedule: Schedule, expedition_dir: Path) -> None: hours=float(problem["delay_duration_hours"]) ) # delay associated with the problem - failed_waypoint_i = int(problem["failed_waypoint_i"]) + failed_waypoint_i = ( + int(problem["failed_waypoint_i"]) + if type(problem["failed_waypoint_i"]) is int + else np.nan + ) time_deltas = [ schedule.waypoints[i].time From 3c7e975333b83aba242b876c546fce9cb04a9384 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:31:51 +0000 Subject: [PATCH 14/52] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/virtualship/instruments/base.py | 2 +- src/virtualship/utils.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/virtualship/instruments/base.py b/src/virtualship/instruments/base.py index 3b670478a..984e4abf5 100644 --- a/src/virtualship/instruments/base.py +++ b/src/virtualship/instruments/base.py @@ -9,9 +9,9 @@ import copernicusmarine import xarray as xr +from parcels import FieldSet from yaspin import yaspin -from parcels import FieldSet from virtualship.errors import CopernicusCatalogueError from virtualship.utils import ( COPERNICUSMARINE_PHYS_VARIABLES, diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 38ba790cb..f0514e938 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -13,8 +13,8 @@ import copernicusmarine import numpy as np import xarray as xr - from parcels import FieldSet + from virtualship.errors import CopernicusCatalogueError if TYPE_CHECKING: 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/52] 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 269d77e15..bad708092 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 ac1aa8a1b..60a4b0ef2 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 c10e9d909..a779ec29a 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 1a734ba74..d565087c9 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/52] 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 bad708092..6fadf494c 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 97696a21e..a7bc6a840 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 a779ec29a..e713f2637 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 d565087c9..93b128241 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 d5e281205..79387187c 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/52] [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 79387187c..3894e384b 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/52] 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 6fadf494c..d2934e59d 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 c09ae7b56..656a27223 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 e713f2637..a74b4604e 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 93b128241..cbea7a5b6 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 From 54713a544b9050b2ba3eea86a2508eed143b1a44 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 23 Jan 2026 10:58:10 +0100 Subject: [PATCH 19/52] work towards propagating instrument problems --- src/virtualship/cli/_run.py | 29 ++--- .../make_realistic/problems/simulator.py | 123 +++++------------- 2 files changed, 49 insertions(+), 103 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index d2934e59d..6aec17ed1 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -56,8 +56,8 @@ def _run( print("╚═════════════════════════════════════════════════╝") if from_data is None: - # TODO: caution, if collaborative environments, will this mean everyone uses the same credentials file? - # TODO: need to think about how to deal with this for when using collaborative environments AND streaming data via copernicusmarine + # TODO: caution, if collaborative environments (or the same machine), this will mean that multiple users share the same copernicusmarine credentials file + # TODO: deal with this for if/when using collaborative environments (same machine) and streaming data from Copernicus Marine Service? COPERNICUS_CREDS_FILE = os.path.expandvars( "$HOME/.copernicusmarine/.copernicusmarine-credentials" ) @@ -129,28 +129,25 @@ def _run( print("\n--- MEASUREMENT SIMULATIONS ---") - # identify problems - # TODO: prob_level needs to be parsed from CLI args - problem_simulator = ProblemSimulator( - expedition.schedule, prob_level, expedition_dir - ) - 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() + # problems + # TODO: prob_level needs to be parsed from CLI args + problem_simulator = ProblemSimulator( + expedition.schedule, prob_level, expedition_dir + ) + problems = problem_simulator.select_problems(instruments_in_expedition) + 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 + #! TODO: need logic for skipping simulation of instruments which have already been simulated successfully in a previous run of the expedition + #! TODO: and new logic for not overwriting existing zarr files if they already exist from a previous successful simulation of that instrument if problems: problem_simulator.execute( - problems=problems, - instrument_type=itype, + problems, + instrumet_type=itype, ) # get instrument class diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index a74b4604e..5e5047124 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -12,7 +12,6 @@ from virtualship.instruments.types import InstrumentType from virtualship.make_realistic.problems.scenarios import ( - CaptainSafetyDrill, CTDCableJammed, ) from virtualship.models.checkpoint import Checkpoint @@ -37,8 +36,8 @@ "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", + "problem_avoided": "Phew! You had enough contingency time scheduled to avoid delays from this problem. The expedition can carry on shortly...\n", } @@ -53,90 +52,75 @@ def __init__(self, schedule: Schedule, prob_level: int, expedition_dir: str | Pa def select_problems( self, + prob_level, + instruments_in_expedition: set[InstrumentType], ) -> 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() - probability = 1.0 # TODO: temporary override for testing!! - if probability > 0.0: - problems = {} - problems["general"] = self._general_problem_select(probability) - problems["instrument"] = self._instrument_problem_select(probability) - return problems - else: - return None + if prob_level > 0: + return self._problem_select(prob_level, instruments_in_expedition) def execute( self, problems: dict[str, list[GeneralProblem | InstrumentProblem]], - instrument_type: InstrumentType | None = None, + instrument_type_validation: InstrumentType | None = None, log_delay: float = 7.0, ): """ 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. + N.B. a problem_waypoint_i is different to a failed_waypoint_i defined in the Checkpoint class; 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 - # 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 - # 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"] + #! TODO: what happens if students decide to re-run the expedition multiple times with slightly changed set-ups to try to e.g. get more measurements? Maybe it should be that problems are ignored if the exact expedition.yaml has been run before, and if there's any changes to the expedition.yaml + # TODO: for this reason, `problems_encountered` dir should be housed in `results` dir along with a cache of the expedition.yaml used for that run... + # TODO: and the results dir given a unique name which can be used to check against when re-running the expedition? # allow only one pre-departure problem to occur - pre_departure_problems = [p for p in general_problems if p.pre_departure] + pre_departure_problems = [p for p in problems if isinstance(p, GeneralProblem)] 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) + problems = [p for p in problems if not p.pre_departure or p is to_keep] + + problems.sort( + key=lambda x: x.pre_departure, reverse=True + ) # ensure any pre-departure problem is first # TODO: make the log output stand out more visually - # general problems - for gproblem in general_problems: - # determine problem waypoint index (random if during expedition) + for p in problems: + # skip if instrument problem but `p.instrument_type` does not match `instrument_type_validation` + if ( + isinstance(p, InstrumentProblem) + and p.instrument_type is not instrument_type_validation + ): + continue + problem_waypoint_i = ( None - if gproblem.pre_departure + if p.pre_departure else np.random.randint( 0, len(self.schedule.waypoints) - 1 ) # last waypoint excluded (would not impact any future scheduling) ) - # 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(problem_waypoint_i), 8 - ) + # TODO: double check the hashing still works as expected when problem_waypoint_i is None (i.e. pre-departure problem) + problem_hash = self._make_hash(p.message + str(problem_waypoint_i), 8) hash_path = Path( self.expedition_dir - / f"{PROBLEMS_ENCOUNTERED_DIR}/problem_{gproblem_hash}.json" + / f"{PROBLEMS_ENCOUNTERED_DIR}/problem_{problem_hash}.json" ) if hash_path.exists(): continue # problem * waypoint combination has already occurred; don't repeat else: - self._hash_to_json( - gproblem, gproblem_hash, problem_waypoint_i, hash_path - ) + self._hash_to_json(p, problem_hash, problem_waypoint_i, hash_path) - if gproblem.pre_departure: + if isinstance(p, GeneralProblem) and p.pre_departure: alert_msg = LOG_MESSAGING["pre_departure"] else: @@ -145,48 +129,13 @@ def execute( ) # log problem occurrence, save to checkpoint, and pause simulation - self._log_problem(gproblem, problem_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): - """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, probability) -> list[GeneralProblem]: - """Select which problems. Higher probability (tied to expedition duration) means more problems are likely to occur.""" - return [ - CaptainSafetyDrill, - ] # 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.""" - # 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 + self._log_problem(p, problem_waypoint_i, alert_msg, log_delay) - return [CTDCableJammed] + def _problem_select( + self, prob_level, instruments_in_schedule + ) -> list[GeneralProblem | InstrumentProblem]: + """Select which problems (selected from general or instrument problems). Higher probability (tied to expedition duration) means more problems are likely to occur.""" + return [CTDCableJammed] # TODO: temporary placeholder!! def _log_problem( self, From 93dc089f5163f655a0015f07dd365b3402f066cd Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 23 Jan 2026 14:25:34 +0100 Subject: [PATCH 20/52] fix contingency time checker, finish propagating instrument problem --- src/virtualship/cli/_run.py | 22 ++-- .../expedition/simulate_schedule.py | 6 +- .../make_realistic/problems/scenarios.py | 3 + .../make_realistic/problems/simulator.py | 103 ++++++++++-------- src/virtualship/models/checkpoint.py | 4 +- src/virtualship/models/expedition.py | 1 + src/virtualship/utils.py | 26 ++++- 7 files changed, 102 insertions(+), 63 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index 6aec17ed1..70dd40f6e 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -8,7 +8,6 @@ from pathlib import Path import copernicusmarine -import pyproj from virtualship.expedition.simulate_schedule import ( MeasurementsToSimulate, @@ -19,17 +18,15 @@ from virtualship.models import Checkpoint, Schedule from virtualship.utils import ( CHECKPOINT, + EXPEDITION, PROBLEMS_ENCOUNTERED_DIR, + PROJECTION, _get_expedition, _save_checkpoint, expedition_cost, get_instrument_class, ) -# projection used to sail between waypoints -projection = pyproj.Geod(ellps="WGS84") - - # parcels logger (suppress INFO messages to prevent log being flooded) external_logger = logging.getLogger("parcels.tools.loggers") external_logger.setLevel(logging.WARNING) @@ -99,14 +96,14 @@ def _run( # simulate the schedule schedule_results = simulate_schedule( - projection=projection, + projection=PROJECTION, expedition=expedition, ) # 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)}." + f"Please update your schedule (`virtualship plan` or directly in {EXPEDITION}) and continue the expedition by executing the `virtualship run` command again.\nCheckpoint has been saved to {expedition_dir.joinpath(CHECKPOINT)}." ) _save_checkpoint( Checkpoint( @@ -136,18 +133,19 @@ def _run( # problems # TODO: prob_level needs to be parsed from CLI args - problem_simulator = ProblemSimulator( - expedition.schedule, prob_level, expedition_dir - ) - problems = problem_simulator.select_problems(instruments_in_expedition) + problem_simulator = ProblemSimulator(expedition, prob_level, expedition_dir) + problems = problem_simulator.select_problems(prob_level, instruments_in_expedition) for itype in instruments_in_expedition: + if prob_level > 0: # only helpful if problems are being simulated + print(f"\033[4mUp next\033[0m: {itype.name} measurements...\n") + #! TODO: need logic for skipping simulation of instruments which have already been simulated successfully in a previous run of the expedition #! TODO: and new logic for not overwriting existing zarr files if they already exist from a previous successful simulation of that instrument if problems: problem_simulator.execute( problems, - instrumet_type=itype, + instrument_type_validation=itype, ) # get instrument class diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index 656a27223..c46503cdf 100644 --- a/src/virtualship/expedition/simulate_schedule.py +++ b/src/virtualship/expedition/simulate_schedule.py @@ -122,10 +122,9 @@ def simulate(self) -> ScheduleOk | ScheduleProblem: # check if waypoint was reached in time if waypoint.time is not None and self._time > waypoint.time: print( - f"Waypoint {wp_i + 1} could not be reached in time. Current time: {self._time}. Waypoint time: {waypoint.time}." + f"\nWaypoint {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" - "**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." + "\nHint: the `virtualship plan` tool will not account for measurement times when verifying the schedule, only the time it takes to sail between waypoints.\n" ) return ScheduleProblem(self._time, wp_i) else: @@ -141,6 +140,7 @@ def simulate(self) -> ScheduleOk | ScheduleProblem: return ScheduleOk(self._time, self._measurements_to_simulate) def _progress_time_traveling_towards(self, location: Location) -> None: + # TODO: this can be refactored to use _calc_sail_time function from utils.py geodinv: tuple[float, float, float] = self._projection.inv( lons1=self._location.lon, lats1=self._location.lat, diff --git a/src/virtualship/make_realistic/problems/scenarios.py b/src/virtualship/make_realistic/problems/scenarios.py index a7bc6a840..43a80bd25 100644 --- a/src/virtualship/make_realistic/problems/scenarios.py +++ b/src/virtualship/make_realistic/problems/scenarios.py @@ -16,6 +16,9 @@ # ===================================================== +# TODO: maybe make some of the problems longer duration; to make it more rare that enough contingency time has been planned...? + + # TODO: pydantic model to ensure correct types? @dataclass class GeneralProblem(abc.ABC): diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index 5e5047124..9bc33ebc5 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -1,7 +1,9 @@ from __future__ import annotations import hashlib +import json import os +import random import sys import time from pathlib import Path @@ -13,24 +15,22 @@ from virtualship.instruments.types import InstrumentType from virtualship.make_realistic.problems.scenarios import ( CTDCableJammed, + GeneralProblem, + InstrumentProblem, ) from virtualship.models.checkpoint import Checkpoint -from virtualship.models.expedition import Schedule from virtualship.utils import ( CHECKPOINT, EXPEDITION, PROBLEMS_ENCOUNTERED_DIR, + PROJECTION, SCHEDULE_ORIGINAL, + _calc_sail_time, _save_checkpoint, ) if TYPE_CHECKING: - from virtualship.make_realistic.problems.scenarios import ( - GeneralProblem, - InstrumentProblem, - ) -import json -import random + from virtualship.models.expedition import Expedition, Schedule LOG_MESSAGING = { "pre_departure": "Hang on! There could be a pre-departure problem in-port...", @@ -44,9 +44,11 @@ class ProblemSimulator: """Handle problem simulation during expedition.""" - def __init__(self, schedule: Schedule, prob_level: int, expedition_dir: str | Path): + def __init__( + self, expedition: Expedition, prob_level: int, expedition_dir: str | Path + ): """Initialise ProblemSimulator with a schedule and probability level.""" - self.schedule = schedule + self.expedition = expedition self.prob_level = prob_level self.expedition_dir = Path(expedition_dir) @@ -82,15 +84,18 @@ def execute( # TODO: for this reason, `problems_encountered` dir should be housed in `results` dir along with a cache of the expedition.yaml used for that run... # TODO: and the results dir given a unique name which can be used to check against when re-running the expedition? - # allow only one pre-departure problem to occur + # allow only one pre-departure problem to occur (only GeneralProblems can be pre-departure problems) pre_departure_problems = [p for p in problems if isinstance(p, GeneralProblem)] if len(pre_departure_problems) > 1: to_keep = random.choice(pre_departure_problems) - problems = [p for p in problems if not p.pre_departure or p is to_keep] - + problems = [ + p + for p in problems + if not getattr(p, "pre_departure", False) or p is to_keep + ] problems.sort( - key=lambda x: x.pre_departure, reverse=True - ) # ensure any pre-departure problem is first + key=lambda p: getattr(p, "pre_departure", False), reverse=True + ) # ensure any problem with pre_departure=True is first; default to pre_departure=False if attribute not present (as is the case for InstrumentProblem's) # TODO: make the log output stand out more visually for p in problems: @@ -103,9 +108,9 @@ def execute( problem_waypoint_i = ( None - if p.pre_departure + if getattr(p, "pre_departure", False) else np.random.randint( - 0, len(self.schedule.waypoints) - 1 + 0, len(self.expedition.schedule.waypoints) - 1 ) # last waypoint excluded (would not impact any future scheduling) ) @@ -168,34 +173,42 @@ def _log_problem( 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 problem_waypoint_i == 0: - print(result_msg) + # 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) + problem_waypoint_time = self.expedition.schedule.waypoints[ + problem_waypoint_i + ].time + next_waypoint_time = self.expedition.schedule.waypoints[ + problem_waypoint_i + 1 + ].time + time_diff = ( + next_waypoint_time - problem_waypoint_time + ).total_seconds() / 3600.0 # [hours] + sail_time = ( + _calc_sail_time( + self.expedition.schedule.waypoints[problem_waypoint_i], + self.expedition.schedule.waypoints[problem_waypoint_i + 1], + ship_speed_knots=self.expedition.ship_config.ship_speed_knots, + projection=PROJECTION, + ).total_seconds() + / 3600.0 + ) # [hours] + if ( + time_diff + >= (problem.delay_duration.total_seconds() / 3600.0) + sail_time + ): + print(LOG_MESSAGING["problem_avoided"]) + # give users time to read message before simulation continues + with yaspin(): + time.sleep(7.0) + return - # all other waypoints else: - # 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) - problem_waypoint_time = self.schedule.waypoints[problem_waypoint_i].time - next_waypoint_time = self.schedule.waypoints[ - problem_waypoint_i + 1 - ].time - time_diff = ( - 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"]) - # 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 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) + print( + 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( @@ -208,7 +221,9 @@ def _log_problem( 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) + self._cache_original_schedule( + self.expedition.schedule, schedule_original_path + ) # pause simulation sys.exit(0) @@ -216,7 +231,7 @@ def _log_problem( def _make_checkpoint(self, failed_waypoint_i: int | None = None) -> Checkpoint: """Make checkpoint, also handling pre-departure.""" fpi = None if failed_waypoint_i is None else failed_waypoint_i - return Checkpoint(past_schedule=self.schedule, failed_waypoint_i=fpi) + return Checkpoint(past_schedule=self.expedition.schedule, failed_waypoint_i=fpi) def _make_hash(self, s: str, length: int) -> str: """Make unique hash for problem occurrence.""" diff --git a/src/virtualship/models/checkpoint.py b/src/virtualship/models/checkpoint.py index cbea7a5b6..e87abd91c 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -103,7 +103,6 @@ def verify(self, schedule: Schedule, expedition_dir: Path) -> None: - 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\n🎉 Previous problem has been resolved in the schedule.\n" @@ -122,7 +121,8 @@ def verify(self, schedule: Schedule, expedition_dir: Path) -> None: ) raise CheckpointError( 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}.", + f"The problem was associated with a delay duration of {problem['delay_duration_hours']} hours at waypoint {self.failed_waypoint_i} (affecting scheduling for {affected_waypoints}).\n" + "Hint: you should ensure that the delay time has been added to ALL waypoints after the waypoint where the problem occurred." ) # only handle the first unresolved problem found; others will be handled in subsequent runs but are not yet known to the user diff --git a/src/virtualship/models/expedition.py b/src/virtualship/models/expedition.py index b8f65558f..6d7ad3174 100644 --- a/src/virtualship/models/expedition.py +++ b/src/virtualship/models/expedition.py @@ -165,6 +165,7 @@ def verify( if wp.instrument is InstrumentType.CTD: time += timedelta(minutes=20) + # TODO: this can be refactored to use _calc_sail_time function from utils.py geodinv: tuple[float, float, float] = projection.inv( wp.location.lon, wp.location.lat, diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 3894e384b..c7a42a24e 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -12,16 +12,17 @@ import copernicusmarine import numpy as np +import pyproj import xarray as xr -from parcels import FieldSet +from parcels import FieldSet from virtualship.errors import CopernicusCatalogueError if TYPE_CHECKING: from virtualship.expedition.simulate_schedule import ( ScheduleOk, ) - from virtualship.models import Expedition + from virtualship.models import Expedition, Waypoint from virtualship.models.checkpoint import Checkpoint import pandas as pd @@ -34,6 +35,9 @@ SCHEDULE_ORIGINAL = "schedule_original.yaml" PROBLEMS_ENCOUNTERED_DIR = "problems_encountered" +# projection used to sail between waypoints +PROJECTION = pyproj.Geod(ellps="WGS84") + def load_static_file(name: str) -> str: """Load static file from the ``virtualship.static`` module by file name.""" @@ -582,3 +586,21 @@ def _get_waypoint_latlons(waypoints): def _save_checkpoint(checkpoint: Checkpoint, expedition_dir: Path) -> None: file_path = expedition_dir.joinpath(CHECKPOINT) checkpoint.to_yaml(file_path) + + +def _calc_sail_time( + waypoint1: Waypoint, + waypoint2: Waypoint, + ship_speed_knots: float, + projection: pyproj.Geod, +): + """Calculate sail time between two waypoints (their locations) given ship speed in knots.""" + geodinv: tuple[float, float, float] = projection.inv( + lons1=waypoint1.location.longitude, + lats1=waypoint1.location.latitude, + lons2=waypoint2.location.longitude, + lats2=waypoint2.location.latitude, + ) + ship_speed_meter_per_second = ship_speed_knots * 1852 / 3600 + distance_to_next_waypoint = geodinv[2] + return timedelta(seconds=distance_to_next_waypoint / ship_speed_meter_per_second) From 2dc6298abbf24461e79302c8cb45b43ae222e0ed Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 23 Jan 2026 14:47:43 +0100 Subject: [PATCH 21/52] refactor --- .../expedition/simulate_schedule.py | 20 +++++------------ .../make_realistic/problems/simulator.py | 21 +++++++++--------- src/virtualship/models/expedition.py | 16 ++++++-------- src/virtualship/utils.py | 22 +++++++++++-------- 4 files changed, 37 insertions(+), 42 deletions(-) diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index c46503cdf..4bfab1f9c 100644 --- a/src/virtualship/expedition/simulate_schedule.py +++ b/src/virtualship/expedition/simulate_schedule.py @@ -20,6 +20,7 @@ Spacetime, Waypoint, ) +from virtualship.utils import _calc_sail_time @dataclass @@ -140,20 +141,11 @@ def simulate(self) -> ScheduleOk | ScheduleProblem: return ScheduleOk(self._time, self._measurements_to_simulate) def _progress_time_traveling_towards(self, location: Location) -> None: - # TODO: this can be refactored to use _calc_sail_time function from utils.py - geodinv: tuple[float, float, float] = self._projection.inv( - lons1=self._location.lon, - lats1=self._location.lat, - lons2=location.lon, - lats2=location.lat, - ) - ship_speed_meter_per_second = ( - self._expedition.ship_config.ship_speed_knots * 1852 / 3600 - ) - azimuth1 = geodinv[0] - distance_to_next_waypoint = geodinv[2] - time_to_reach = timedelta( - seconds=distance_to_next_waypoint / ship_speed_meter_per_second + time_to_reach, azimuth1, ship_speed_meter_per_second = _calc_sail_time( + self._location, + location, + self._expedition.ship_config.ship_speed_knots, + self._projection, ) end_time = self._time + time_to_reach diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index 9bc33ebc5..f37c2098a 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -176,24 +176,25 @@ def _log_problem( # 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) - problem_waypoint_time = self.expedition.schedule.waypoints[ - problem_waypoint_i - ].time - next_waypoint_time = self.expedition.schedule.waypoints[ - problem_waypoint_i + 1 - ].time + + problem_wp = self.expedition.schedule.waypoints[problem_waypoint_i] + next_wp = self.expedition.schedule.waypoints[problem_waypoint_i + 1] + + problem_waypoint_time = problem_wp.time + next_waypoint_time = next_wp.time time_diff = ( next_waypoint_time - problem_waypoint_time ).total_seconds() / 3600.0 # [hours] sail_time = ( _calc_sail_time( - self.expedition.schedule.waypoints[problem_waypoint_i], - self.expedition.schedule.waypoints[problem_waypoint_i + 1], + problem_wp.location, + next_wp.location, ship_speed_knots=self.expedition.ship_config.ship_speed_knots, projection=PROJECTION, - ).total_seconds() + )[0].total_seconds() / 3600.0 ) # [hours] + if ( time_diff >= (problem.delay_duration.total_seconds() / 3600.0) + sail_time @@ -213,7 +214,7 @@ def _log_problem( # save checkpoint 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 + ) # failed waypoint index then becomes the one after the one where the problem occurred; as 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/expedition.py b/src/virtualship/models/expedition.py index 6d7ad3174..5359f27ca 100644 --- a/src/virtualship/models/expedition.py +++ b/src/virtualship/models/expedition.py @@ -12,6 +12,7 @@ from virtualship.errors import InstrumentsConfigError, ScheduleError from virtualship.instruments.types import InstrumentType from virtualship.utils import ( + _calc_sail_time, _get_bathy_data, _get_waypoint_latlons, _validate_numeric_to_timedelta, @@ -165,16 +166,13 @@ def verify( if wp.instrument is InstrumentType.CTD: time += timedelta(minutes=20) - # TODO: this can be refactored to use _calc_sail_time function from utils.py - geodinv: tuple[float, float, float] = projection.inv( - wp.location.lon, - wp.location.lat, - wp_next.location.lon, - wp_next.location.lat, - ) - distance = geodinv[2] + time_to_reach = _calc_sail_time( + wp.location, + wp_next.location, + ship_speed, + projection, + )[0] - time_to_reach = timedelta(seconds=distance / ship_speed * 3600 / 1852) arrival_time = time + time_to_reach if wp_next.time is None: diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index c7a42a24e..e0340b90a 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -22,7 +22,7 @@ from virtualship.expedition.simulate_schedule import ( ScheduleOk, ) - from virtualship.models import Expedition, Waypoint + from virtualship.models import Expedition, Location from virtualship.models.checkpoint import Checkpoint import pandas as pd @@ -589,18 +589,22 @@ def _save_checkpoint(checkpoint: Checkpoint, expedition_dir: Path) -> None: def _calc_sail_time( - waypoint1: Waypoint, - waypoint2: Waypoint, + location1: Location, + location2: Location, ship_speed_knots: float, projection: pyproj.Geod, -): +) -> tuple[timedelta, tuple[float, float, float], float]: """Calculate sail time between two waypoints (their locations) given ship speed in knots.""" geodinv: tuple[float, float, float] = projection.inv( - lons1=waypoint1.location.longitude, - lats1=waypoint1.location.latitude, - lons2=waypoint2.location.longitude, - lats2=waypoint2.location.latitude, + lons1=location1.longitude, + lats1=location1.latitude, + lons2=location2.longitude, + lats2=location2.latitude, ) ship_speed_meter_per_second = ship_speed_knots * 1852 / 3600 distance_to_next_waypoint = geodinv[2] - return timedelta(seconds=distance_to_next_waypoint / ship_speed_meter_per_second) + return ( + timedelta(seconds=distance_to_next_waypoint / ship_speed_meter_per_second), + geodinv, + ship_speed_meter_per_second, + ) From 3c089815aa25cd17345323a784de933911ad386f Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 23 Jan 2026 15:29:52 +0100 Subject: [PATCH 22/52] propagating multiple problems per expedition, bugs still to fix --- .../make_realistic/problems/scenarios.py | 2 +- .../make_realistic/problems/simulator.py | 72 +++++++++++-------- 2 files changed, 44 insertions(+), 30 deletions(-) diff --git a/src/virtualship/make_realistic/problems/scenarios.py b/src/virtualship/make_realistic/problems/scenarios.py index 43a80bd25..7cc6a0c36 100644 --- a/src/virtualship/make_realistic/problems/scenarios.py +++ b/src/virtualship/make_realistic/problems/scenarios.py @@ -64,7 +64,7 @@ def is_valid() -> bool: @dataclass # @register_general_problem -class FoodDeliveryDelayed: +class FoodDeliveryDelayed(GeneralProblem): """Problem: Scheduled food delivery is delayed, causing a postponement of departure.""" message = ( diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index f37c2098a..537004ab4 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -14,7 +14,9 @@ from virtualship.instruments.types import InstrumentType from virtualship.make_realistic.problems.scenarios import ( + CaptainSafetyDrill, CTDCableJammed, + FoodDeliveryDelayed, GeneralProblem, InstrumentProblem, ) @@ -56,11 +58,15 @@ def select_problems( self, prob_level, instruments_in_expedition: set[InstrumentType], - ) -> dict[str, list[GeneralProblem | InstrumentProblem]] | None: + ) -> list[GeneralProblem | InstrumentProblem] | None: """Propagate both general and instrument problems.""" # TODO: whether a problem can reoccur or not needs to be handled here too! if prob_level > 0: - return self._problem_select(prob_level, instruments_in_expedition) + return [ + CTDCableJammed, + FoodDeliveryDelayed, + CaptainSafetyDrill, + ] # TODO: temporary placeholder!! def execute( self, @@ -76,7 +82,7 @@ def execute( # 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 + # 2 = multiple problems can occur (general and instrument; total determined by the length of the expedition), but only one pre-departure problem allowed # 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 @@ -85,37 +91,51 @@ def execute( # TODO: and the results dir given a unique name which can be used to check against when re-running the expedition? # allow only one pre-departure problem to occur (only GeneralProblems can be pre-departure problems) - pre_departure_problems = [p for p in problems if isinstance(p, GeneralProblem)] - if len(pre_departure_problems) > 1: - to_keep = random.choice(pre_departure_problems) + + pre_departure_problems = [ + p for p in problems if issubclass(p, GeneralProblem) and p.pre_departure + ] + if len(pre_departure_problems) > 1: # keep only one pre-departure problem + to_keep = random.choice(pre_departure_problems) # pick one at random problems = [ p for p in problems if not getattr(p, "pre_departure", False) or p is to_keep ] - problems.sort( - key=lambda p: getattr(p, "pre_departure", False), reverse=True - ) # ensure any problem with pre_departure=True is first; default to pre_departure=False if attribute not present (as is the case for InstrumentProblem's) - # TODO: make the log output stand out more visually + # map each problem to a [random] waypoint (or None if pre-departure) + waypoint_idxs = [] for p in problems: - # skip if instrument problem but `p.instrument_type` does not match `instrument_type_validation` + if getattr(p, "pre_departure", False): + waypoint_idxs.append(None) + else: + waypoint_idxs.append( + np.random.randint(0, len(self.expedition.schedule.waypoints) - 1) + ) # last waypoint excluded (would not impact any future scheduling) + + # air problems with their waypoint indices and sort by waypoint index (pre-departure first) + paired = sorted( + zip(problems, waypoint_idxs, strict=True), + key=lambda x: (x[1] is not None, x[1] if x[1] is not None else -1), + ) + problems_sorted = { + "problem_class": [p for p, _ in paired], + "waypoint_i": [w for _, w in paired], + } + + # TODO: make the log output stand out more visually + for problem, problem_waypoint_i in zip( + problems_sorted["problem_class"], problems_sorted["waypoint_i"], strict=True + ): + # skip if instrument problem but `p.instrument_type` does not match `instrument_type_validation` (i.e. the current instrument being simulated in the expedition, e.g. from _run.py) if ( - isinstance(p, InstrumentProblem) - and p.instrument_type is not instrument_type_validation + issubclass(problem, InstrumentProblem) + and problem.instrument_type is not instrument_type_validation ): continue - problem_waypoint_i = ( - None - if getattr(p, "pre_departure", False) - else np.random.randint( - 0, len(self.expedition.schedule.waypoints) - 1 - ) # last waypoint excluded (would not impact any future scheduling) - ) - # TODO: double check the hashing still works as expected when problem_waypoint_i is None (i.e. pre-departure problem) - problem_hash = self._make_hash(p.message + str(problem_waypoint_i), 8) + problem_hash = self._make_hash(problem.message + str(problem_waypoint_i), 8) hash_path = Path( self.expedition_dir / f"{PROBLEMS_ENCOUNTERED_DIR}/problem_{problem_hash}.json" @@ -125,7 +145,7 @@ def execute( else: self._hash_to_json(p, problem_hash, problem_waypoint_i, hash_path) - if isinstance(p, GeneralProblem) and p.pre_departure: + if issubclass(p, GeneralProblem) and p.pre_departure: alert_msg = LOG_MESSAGING["pre_departure"] else: @@ -136,12 +156,6 @@ def execute( # log problem occurrence, save to checkpoint, and pause simulation self._log_problem(p, problem_waypoint_i, alert_msg, log_delay) - def _problem_select( - self, prob_level, instruments_in_schedule - ) -> list[GeneralProblem | InstrumentProblem]: - """Select which problems (selected from general or instrument problems). Higher probability (tied to expedition duration) means more problems are likely to occur.""" - return [CTDCableJammed] # TODO: temporary placeholder!! - def _log_problem( self, problem: GeneralProblem | InstrumentProblem, From 854ba49749037f890f4d32933bf95fdab44e105c Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:16:46 +0100 Subject: [PATCH 23/52] propagate multiple problems across a single expedition --- .../make_realistic/problems/simulator.py | 25 ++++++++++++++----- src/virtualship/models/checkpoint.py | 4 +-- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index 537004ab4..268591b2d 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -85,6 +85,7 @@ def execute( # 2 = multiple problems can occur (general and instrument; total determined by the length of the expedition), but only one pre-departure problem allowed # 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 + #! TODO: may want to ensure duplicate problem types are removed; even if they could theoretically occur at different waypoints, so as not to inundate users... #! TODO: what happens if students decide to re-run the expedition multiple times with slightly changed set-ups to try to e.g. get more measurements? Maybe it should be that problems are ignored if the exact expedition.yaml has been run before, and if there's any changes to the expedition.yaml # TODO: for this reason, `problems_encountered` dir should be housed in `results` dir along with a cache of the expedition.yaml used for that run... @@ -143,9 +144,9 @@ def execute( if hash_path.exists(): continue # problem * waypoint combination has already occurred; don't repeat else: - self._hash_to_json(p, problem_hash, problem_waypoint_i, hash_path) + self._hash_to_json(problem, problem_hash, problem_waypoint_i, hash_path) - if issubclass(p, GeneralProblem) and p.pre_departure: + if issubclass(problem, GeneralProblem) and problem.pre_departure: alert_msg = LOG_MESSAGING["pre_departure"] else: @@ -154,13 +155,16 @@ def execute( ) # log problem occurrence, save to checkpoint, and pause simulation - self._log_problem(p, problem_waypoint_i, alert_msg, log_delay) + self._log_problem( + problem, problem_waypoint_i, alert_msg, hash_path, log_delay + ) def _log_problem( self, problem: GeneralProblem | InstrumentProblem, problem_waypoint_i: int | None, alert_msg: str, + hash_path: Path, log_delay: float, ): """Log problem occurrence with spinner and delay, save to checkpoint, write hash.""" @@ -214,8 +218,15 @@ def _log_problem( >= (problem.delay_duration.total_seconds() / 3600.0) + sail_time ): print(LOG_MESSAGING["problem_avoided"]) - # give users time to read message before simulation continues - with yaspin(): + + # update problem json to resolved = True + with open(hash_path, encoding="utf-8") as f: + problem_json = json.load(f) + problem_json["resolved"] = True + with open(hash_path, "w", encoding="utf-8") as f_out: + json.dump(problem_json, f_out, indent=4) + + with yaspin(): # time to read message before simulation continues time.sleep(7.0) return @@ -228,6 +239,8 @@ def _log_problem( # save checkpoint checkpoint = self._make_checkpoint( failed_waypoint_i=problem_waypoint_i + 1 + if problem_waypoint_i is not None + else None ) # failed waypoint index then becomes the one after the one where the problem occurred; as this is when scheduling issues would be run into _save_checkpoint(checkpoint, self.expedition_dir) @@ -271,7 +284,7 @@ def _hash_to_json( "timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), "resolved": False, } - with open(hash_path, "w") as f: + with open(hash_path, "w", encoding="utf-8") as f: json.dump(hash_data, f, indent=4) def _cache_original_schedule(self, schedule: Schedule, path: Path | str): diff --git a/src/virtualship/models/checkpoint.py b/src/virtualship/models/checkpoint.py index e87abd91c..b9f8a396b 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -82,7 +82,7 @@ def verify(self, schedule: Schedule, expedition_dir: Path) -> None: ] if len(hash_fpaths) > 0: for file in hash_fpaths: - with open(file) as f: + with open(file, encoding="utf-8") as f: problem = json.load(f) if problem["resolved"]: continue @@ -110,7 +110,7 @@ def verify(self, schedule: Schedule, expedition_dir: Path) -> None: # save back to json file changing the resolved status to True problem["resolved"] = True - with open(file, "w") as f_out: + with open(file, "w", encoding="utf-8") as f_out: json.dump(problem, f_out, indent=4) else: From edb819513016d28eb7ad7d06b19a9342f797d9dc Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 27 Jan 2026 09:42:30 +0100 Subject: [PATCH 24/52] start moving towards per waypoint contingency checker --- .../make_realistic/problems/simulator.py | 82 ++++++++++++------- 1 file changed, 51 insertions(+), 31 deletions(-) diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index 268591b2d..0e095ab53 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -32,7 +32,7 @@ ) if TYPE_CHECKING: - from virtualship.models.expedition import Expedition, Schedule + from virtualship.models.expedition import Expedition, Schedule, Waypoint LOG_MESSAGING = { "pre_departure": "Hang on! There could be a pre-departure problem in-port...", @@ -175,13 +175,12 @@ def _log_problem( print("\nPROBLEM ENCOUNTERED: " + problem.message + "\n") + breakpoint() + if problem_waypoint_i is None: # pre-departure problem - print( - "\nRESULT: " - + LOG_MESSAGING["pre_departure_delay"].format( - delay_duration=problem.delay_duration.total_seconds() / 3600.0, - expedition_yaml=EXPEDITION, - ) + result_msg = "\nRESULT: " + LOG_MESSAGING["pre_departure_delay"].format( + delay_duration=problem.delay_duration.total_seconds() / 3600.0, + expedition_yaml=EXPEDITION, ) else: # problem occurring during expedition @@ -198,25 +197,17 @@ def _log_problem( problem_wp = self.expedition.schedule.waypoints[problem_waypoint_i] next_wp = self.expedition.schedule.waypoints[problem_waypoint_i + 1] - problem_waypoint_time = problem_wp.time - next_waypoint_time = next_wp.time - time_diff = ( - next_waypoint_time - problem_waypoint_time - ).total_seconds() / 3600.0 # [hours] - sail_time = ( - _calc_sail_time( - problem_wp.location, - next_wp.location, - ship_speed_knots=self.expedition.ship_config.ship_speed_knots, - projection=PROJECTION, - )[0].total_seconds() - / 3600.0 - ) # [hours] + missed_waypoints = self._waypoints_missed( + problem_waypoint_i, problem, projection=PROJECTION + ) - if ( - time_diff - >= (problem.delay_duration.total_seconds() / 3600.0) + sail_time - ): + if len(missed_waypoints) > 0: + print( + 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) + + else: print(LOG_MESSAGING["problem_avoided"]) # update problem json to resolved = True @@ -230,12 +221,6 @@ def _log_problem( time.sleep(7.0) return - else: - print( - 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=problem_waypoint_i + 1 @@ -256,6 +241,41 @@ def _log_problem( # pause simulation sys.exit(0) + def _waypoints_missed( + self, + problem_waypoint_i: Waypoint, + problem: InstrumentProblem | GeneralProblem, + projection=PROJECTION, + ) -> list[Waypoint]: + """Return a list of waypoints (after wp1) that can no longer be reached in time given the delay associated with the problem.""" + waypoints = self.expedition.schedule.waypoints + missed_waypoints = [] + cumulative_delay = problem.delay_duration.total_seconds() / 3600.0 # hours + + for i in range(problem_waypoint_i, len(waypoints) - 1): + curr_wp = waypoints[i] + next_wp = waypoints[i + 1] + scheduled_time_diff = ( + next_wp.time - curr_wp.time + ).total_seconds() / 3600.0 # hours + sail_time = ( + _calc_sail_time( + curr_wp.location, + next_wp.location, + ship_speed_knots=self.expedition.ship_config.ship_speed_knots, + projection=projection, + )[0].total_seconds() + / 3600.0 + ) + + if scheduled_time_diff < sail_time + cumulative_delay: + missed_waypoints.append(next_wp) + cumulative_delay = max( + 0, (sail_time + cumulative_delay) - scheduled_time_diff + ) # delay propagates forward + + return missed_waypoints + def _make_checkpoint(self, failed_waypoint_i: int | None = None) -> Checkpoint: """Make checkpoint, also handling pre-departure.""" fpi = None if failed_waypoint_i is None else failed_waypoint_i From 00a85cd0cc785a872b871fcd65f44f52e54753a2 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 27 Jan 2026 13:16:11 +0100 Subject: [PATCH 25/52] progress towards individual waypoint contingency checking --- .../make_realistic/problems/simulator.py | 128 +++++++++++------- src/virtualship/models/checkpoint.py | 91 +++++++------ 2 files changed, 130 insertions(+), 89 deletions(-) diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index 0e095ab53..c296191ed 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -22,7 +22,6 @@ ) from virtualship.models.checkpoint import Checkpoint from virtualship.utils import ( - CHECKPOINT, EXPEDITION, PROBLEMS_ENCOUNTERED_DIR, PROJECTION, @@ -37,8 +36,7 @@ LOG_MESSAGING = { "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", - "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", + "schedule_problems": "This problem will cause a delay of {delay_duration} hours. The following waypoints will \033[4mnot\033[0m be reached in time given the delay: {waypoints_missed}. The following will still be reached in time due to sufficient contingency time being planned already: {waypoints_reachable}. Please account for the delays in your schedule (`virtualship plan` or directly in {expedition_yaml}), then continue the expedition by executing the `virtualship run` command again.\n", "problem_avoided": "Phew! You had enough contingency time scheduled to avoid delays from this problem. The expedition can carry on shortly...\n", } @@ -143,8 +141,6 @@ def execute( ) if hash_path.exists(): continue # problem * waypoint combination has already occurred; don't repeat - else: - self._hash_to_json(problem, problem_hash, problem_waypoint_i, hash_path) if issubclass(problem, GeneralProblem) and problem.pre_departure: alert_msg = LOG_MESSAGING["pre_departure"] @@ -156,7 +152,12 @@ def execute( # log problem occurrence, save to checkpoint, and pause simulation self._log_problem( - problem, problem_waypoint_i, alert_msg, hash_path, log_delay + problem, + problem_waypoint_i, + alert_msg, + problem_hash, + hash_path, + log_delay, ) def _log_problem( @@ -164,6 +165,7 @@ def _log_problem( problem: GeneralProblem | InstrumentProblem, problem_waypoint_i: int | None, alert_msg: str, + problem_hash: str, hash_path: Path, log_delay: float, ): @@ -175,51 +177,56 @@ def _log_problem( print("\nPROBLEM ENCOUNTERED: " + problem.message + "\n") - breakpoint() + # 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) - if problem_waypoint_i is None: # pre-departure problem - result_msg = "\nRESULT: " + LOG_MESSAGING["pre_departure_delay"].format( - delay_duration=problem.delay_duration.total_seconds() / 3600.0, - expedition_yaml=EXPEDITION, - ) - - else: # problem occurring during expedition - result_msg = "\nRESULT: " + LOG_MESSAGING["simulation_paused"].format( - waypoint_i=int(problem_waypoint_i) + 1, - expedition_yaml=EXPEDITION, - checkpoint_path=self.expedition_dir.joinpath(CHECKPOINT), - ) + missed_waypoint_idxs, reachable_waypoint_idxs = ( + self._waypoints_missed_and_reached(problem_waypoint_i, problem) + ) - # 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) + result_msg = "\nRESULT: " + LOG_MESSAGING["schedule_problems"].format( + delay_duration=problem.delay_duration.total_seconds() / 3600.0, + waypoints_missed=[wp + 1 for wp in missed_waypoint_idxs], + waypoints_reachable=[wp + 1 for wp in reachable_waypoint_idxs] + if len(reachable_waypoint_idxs) > 0 + else ["None"], + expedition_yaml=EXPEDITION, + ) - problem_wp = self.expedition.schedule.waypoints[problem_waypoint_i] - next_wp = self.expedition.schedule.waypoints[problem_waypoint_i + 1] + self._hash_to_json( + problem, + problem_hash, + problem_waypoint_i, + missed_waypoint_idxs, + reachable_waypoint_idxs, + hash_path, + ) - missed_waypoints = self._waypoints_missed( - problem_waypoint_i, problem, projection=PROJECTION + if len(missed_waypoint_idxs) > 0: + affected = ( + "in-port" + if problem_waypoint_i is None + else f"at waypoint {problem_waypoint_i + 1}" ) + print( + f"\nNot enough contingency time scheduled to mitigate delay of {problem.delay_duration.total_seconds() / 3600.0} hours occuring {affected} (future waypoint(s) would be reached too late).\n" + ) + print(result_msg) - if len(missed_waypoints) > 0: - print( - 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) - - else: - print(LOG_MESSAGING["problem_avoided"]) + else: + print(LOG_MESSAGING["problem_avoided"]) - # update problem json to resolved = True - with open(hash_path, encoding="utf-8") as f: - problem_json = json.load(f) - problem_json["resolved"] = True - with open(hash_path, "w", encoding="utf-8") as f_out: - json.dump(problem_json, f_out, indent=4) + # update problem json to resolved = True + with open(hash_path, encoding="utf-8") as f: + problem_json = json.load(f) + problem_json["resolved"] = True + with open(hash_path, "w", encoding="utf-8") as f_out: + json.dump(problem_json, f_out, indent=4) - with yaspin(): # time to read message before simulation continues - time.sleep(7.0) - return + with yaspin(): # time to read message before simulation continues + time.sleep(7.0) + return # save checkpoint checkpoint = self._make_checkpoint( @@ -241,20 +248,31 @@ def _log_problem( # pause simulation sys.exit(0) - def _waypoints_missed( + def _waypoints_missed_and_reached( self, - problem_waypoint_i: Waypoint, + problem_waypoint_i: int | None, problem: InstrumentProblem | GeneralProblem, projection=PROJECTION, ) -> list[Waypoint]: """Return a list of waypoints (after wp1) that can no longer be reached in time given the delay associated with the problem.""" waypoints = self.expedition.schedule.waypoints - missed_waypoints = [] cumulative_delay = problem.delay_duration.total_seconds() / 3600.0 # hours + missed_waypoint_idxs = [] + + if problem_waypoint_i is None: + missed_waypoint_idxs.append( + 0 + ) # first waypoint will always be missed if pre-departure problem - for i in range(problem_waypoint_i, len(waypoints) - 1): - curr_wp = waypoints[i] - next_wp = waypoints[i + 1] + waypoint_range = ( + range(problem_waypoint_i, len(waypoints) - 1) + if problem_waypoint_i is not None + else range(0, len(waypoints) - 1) + ) # only need to assess timings for waypoints after the problem waypoint (all waypoints if pre-departure) + + for wp_i in waypoint_range: + curr_wp = waypoints[wp_i] + next_wp = waypoints[wp_i + 1] scheduled_time_diff = ( next_wp.time - curr_wp.time ).total_seconds() / 3600.0 # hours @@ -269,12 +287,16 @@ def _waypoints_missed( ) if scheduled_time_diff < sail_time + cumulative_delay: - missed_waypoints.append(next_wp) + missed_waypoint_idxs.append(wp_i + 1) cumulative_delay = max( 0, (sail_time + cumulative_delay) - scheduled_time_diff ) # delay propagates forward - return missed_waypoints + reachable_waypoint_idxs = [ + i for i in range(len(waypoints)) if i not in missed_waypoint_idxs + ] + + return missed_waypoint_idxs, reachable_waypoint_idxs def _make_checkpoint(self, failed_waypoint_i: int | None = None) -> Checkpoint: """Make checkpoint, also handling pre-departure.""" @@ -292,6 +314,8 @@ def _hash_to_json( problem: InstrumentProblem | GeneralProblem, problem_hash: str, failed_waypoint_i: int | None, + missed_waypoint_idxs: list, + reachable_waypoint_idxs: list, hash_path: Path, ) -> dict: """Convert problem details + hash to json.""" @@ -301,6 +325,8 @@ def _hash_to_json( "message": problem.message, "failed_waypoint_i": failed_waypoint_i, "delay_duration_hours": problem.delay_duration.total_seconds() / 3600.0, + "missed_waypoint_idxs": missed_waypoint_idxs, + "reachable_waypoint_idxs": reachable_waypoint_idxs, "timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), "resolved": False, } diff --git a/src/virtualship/models/checkpoint.py b/src/virtualship/models/checkpoint.py index b9f8a396b..b8385cc0c 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -84,46 +84,61 @@ def verify(self, schedule: Schedule, expedition_dir: Path) -> None: for file in hash_fpaths: with open(file, encoding="utf-8") 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 - waypoint_range = ( - range(len(self.past_schedule.waypoints)) - if self.failed_waypoint_i is None - else range( - int(self.failed_waypoint_i), len(schedule.waypoints) - ) + 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 + + time_deltas = [ + schedule.waypoints[i].time + - self.past_schedule.waypoints[i].time + for i in problem["missed_waypoint_idxs"] + ] # difference in time between the two schedules at the waypoints which were previously unreachable + + wp_resolution_status = [td >= delay_duration for td in time_deltas] + + if all(wp_resolution_status): + print( + "\n\n🎉 Previous problem has been resolved in the schedule.\n" ) - 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\n🎉 Previous 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", encoding="utf-8") as f_out: - json.dump(problem, f_out, indent=4) + # save back to json file changing the resolved status to True + problem["resolved"] = True + with open(file, "w", encoding="utf-8") as f_out: + json.dump(problem, f_out, indent=4) + + break # only handle the first problem found; others will be handled in subsequent runs but are not yet known to the user - else: - affected_waypoints = ( - "all waypoints" - if self.failed_waypoint_i is None - else f"waypoint {int(self.failed_waypoint_i) + 1} onwards" + else: + problem_wp = ( + "in-port" + if self.failed_waypoint_i is None + else f"at waypoint {self.failed_waypoint_i + 1}" + ) + still_unresolved = [ + wp + 1 + for wp, resolved in zip( # noqa: B905 + problem["missed_waypoint_idxs"], wp_resolution_status ) - raise CheckpointError( - 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 at waypoint {self.failed_waypoint_i} (affecting scheduling for {affected_waypoints}).\n" - "Hint: you should ensure that the delay time has been added to ALL waypoints after the waypoint where the problem occurred." + if not resolved + ] + shortcoming_times = [ + ( + delay_duration.total_seconds() + - time_deltas[i].total_seconds() ) - - # only handle the first unresolved problem found; others will be handled in subsequent runs but are not yet known to the user - break + / 3600.0 + for i, resolved in enumerate(wp_resolution_status) + if not resolved + ] + + raise CheckpointError( + 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 {problem_wp}.\n" + f"Hint... the following waypoints can still not be reached in time: {still_unresolved} and would be missed by {shortcoming_times} hour(s)" + + (", respectively." if len(shortcoming_times) > 1 else ".") + ) From 1244697cd68d302367afa83580659b27b16eb477 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 27 Jan 2026 20:14:57 +0100 Subject: [PATCH 26/52] revert to simpler next waypoint contingency checks --- src/virtualship/cli/_run.py | 2 +- .../expedition/simulate_schedule.py | 3 +- .../make_realistic/problems/simulator.py | 125 ++++++++---------- src/virtualship/models/checkpoint.py | 84 +++++++----- 4 files changed, 103 insertions(+), 111 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index 70dd40f6e..65e778779 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -85,7 +85,7 @@ def _run( checkpoint = Checkpoint(past_schedule=Schedule(waypoints=[])) # verify that schedule and checkpoint match, and that problems have been resolved - checkpoint.verify(expedition.schedule, expedition_dir) + checkpoint.verify(expedition, expedition_dir) print("\n---- WAYPOINT VERIFICATION ----") diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index 4bfab1f9c..4e3e2a800 100644 --- a/src/virtualship/expedition/simulate_schedule.py +++ b/src/virtualship/expedition/simulate_schedule.py @@ -125,7 +125,8 @@ def simulate(self) -> ScheduleOk | ScheduleProblem: print( f"\nWaypoint {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" - "\nHint: the `virtualship plan` tool will not account for measurement times when verifying the schedule, only the time it takes to sail between waypoints.\n" + "\nHint #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 an unexpected problem during the expedition (e.g. a broken instrument, or a pre-departure problem), ensure any re-scheduling required has not had knock-on impact on waypoints later in the expedition as well." ) 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 c296191ed..f642e5571 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -31,12 +31,12 @@ ) if TYPE_CHECKING: - from virtualship.models.expedition import Expedition, Schedule, Waypoint + from virtualship.models.expedition import Expedition, Schedule LOG_MESSAGING = { "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}...!", - "schedule_problems": "This problem will cause a delay of {delay_duration} hours. The following waypoints will \033[4mnot\033[0m be reached in time given the delay: {waypoints_missed}. The following will still be reached in time due to sufficient contingency time being planned already: {waypoints_reachable}. Please account for the delays in your schedule (`virtualship plan` or directly in {expedition_yaml}), then continue the expedition by executing the `virtualship run` command again.\n", + "during_expedition": "Oh no, a problem has occurred during the expedition, at waypoint {waypoint}...!", + "schedule_problems": "This problem will cause a delay of {delay_duration} hours {affected}. The next waypoint (i.e. waypoint {waypoint_next}) therefore cannot be reached in time. Please account for this in your schedule (`virtualship plan` or directly in {expedition_yaml}), then continue the expedition by executing the `virtualship run` command again.\n", "problem_avoided": "Phew! You had enough contingency time scheduled to avoid delays from this problem. The expedition can carry on shortly...\n", } @@ -147,7 +147,7 @@ def execute( else: alert_msg = LOG_MESSAGING["during_expedition"].format( - waypoint_i=int(problem_waypoint_i) + 1 + waypoint=int(problem_waypoint_i) + 1 ) # log problem occurrence, save to checkpoint, and pause simulation @@ -177,20 +177,16 @@ def _log_problem( print("\nPROBLEM ENCOUNTERED: " + problem.message + "\n") - # 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) - - missed_waypoint_idxs, reachable_waypoint_idxs = ( - self._waypoints_missed_and_reached(problem_waypoint_i, problem) - ) - result_msg = "\nRESULT: " + LOG_MESSAGING["schedule_problems"].format( delay_duration=problem.delay_duration.total_seconds() / 3600.0, - waypoints_missed=[wp + 1 for wp in missed_waypoint_idxs], - waypoints_reachable=[wp + 1 for wp in reachable_waypoint_idxs] - if len(reachable_waypoint_idxs) > 0 - else ["None"], + affected=( + "in-port" + if problem_waypoint_i is None + else f"at waypoint {problem_waypoint_i + 1}" + ), + waypoint_next=int(problem_waypoint_i) + 2 + if problem_waypoint_i is not None + else 1, expedition_yaml=EXPEDITION, ) @@ -198,23 +194,16 @@ def _log_problem( problem, problem_hash, problem_waypoint_i, - missed_waypoint_idxs, - reachable_waypoint_idxs, hash_path, ) - if len(missed_waypoint_idxs) > 0: - affected = ( - "in-port" - if problem_waypoint_i is None - else f"at waypoint {problem_waypoint_i + 1}" - ) - print( - f"\nNot enough contingency time scheduled to mitigate delay of {problem.delay_duration.total_seconds() / 3600.0} hours occuring {affected} (future waypoint(s) would be reached too late).\n" - ) - print(result_msg) + # 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) - else: + has_contingency = self._has_contingency(problem, problem_waypoint_i) + + if has_contingency: print(LOG_MESSAGING["problem_avoided"]) # update problem json to resolved = True @@ -228,12 +217,24 @@ def _log_problem( time.sleep(7.0) return + else: + affected = ( + "in-port" + if problem_waypoint_i is None + else f"at waypoint {problem_waypoint_i + 1}" + ) + print( + f"\nNot enough contingency time scheduled to mitigate delay of {problem.delay_duration.total_seconds() / 3600.0} hours occuring {affected} (future waypoint(s) would be reached too late).\n" + ) + print(result_msg) + # save checkpoint - checkpoint = self._make_checkpoint( + checkpoint = Checkpoint( + past_schedule=self.expedition.schedule, failed_waypoint_i=problem_waypoint_i + 1 if problem_waypoint_i is not None - else None - ) # failed waypoint index then becomes the one after the one where the problem occurred; as this is when scheduling issues would be run into + else 0, + ) # failed waypoint index then becomes the one after the one where the problem occurred; as this is when scheduling issues would be run into; for pre-departure problems this is the first waypoint _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) @@ -248,60 +249,42 @@ def _log_problem( # pause simulation sys.exit(0) - def _waypoints_missed_and_reached( + def _has_contingency( self, - problem_waypoint_i: int | None, problem: InstrumentProblem | GeneralProblem, - projection=PROJECTION, - ) -> list[Waypoint]: - """Return a list of waypoints (after wp1) that can no longer be reached in time given the delay associated with the problem.""" - waypoints = self.expedition.schedule.waypoints - cumulative_delay = problem.delay_duration.total_seconds() / 3600.0 # hours - missed_waypoint_idxs = [] - + problem_waypoint_i: int | None, + ) -> bool: + """Determine if enough contingency time has been scheduled to avoid delay affecting the waypoint immediately after the problem.""" if problem_waypoint_i is None: - missed_waypoint_idxs.append( - 0 - ) # first waypoint will always be missed if pre-departure problem + return False # pre-departure problems always cause delay to first waypoint - waypoint_range = ( - range(problem_waypoint_i, len(waypoints) - 1) - if problem_waypoint_i is not None - else range(0, len(waypoints) - 1) - ) # only need to assess timings for waypoints after the problem waypoint (all waypoints if pre-departure) + else: + #! TODO: this still needs to incoporate the instrument deployment times as well!! + + delay_duration = problem.delay_duration.total_seconds() / 3600.0 # hours + curr_wp = self.expedition.schedule.waypoints[problem_waypoint_i] + next_wp = self.expedition.schedule.waypoints[problem_waypoint_i + 1] - for wp_i in waypoint_range: - curr_wp = waypoints[wp_i] - next_wp = waypoints[wp_i + 1] scheduled_time_diff = ( next_wp.time - curr_wp.time ).total_seconds() / 3600.0 # hours + sail_time = ( _calc_sail_time( curr_wp.location, next_wp.location, ship_speed_knots=self.expedition.ship_config.ship_speed_knots, - projection=projection, + projection=PROJECTION, )[0].total_seconds() / 3600.0 ) - - if scheduled_time_diff < sail_time + cumulative_delay: - missed_waypoint_idxs.append(wp_i + 1) - cumulative_delay = max( - 0, (sail_time + cumulative_delay) - scheduled_time_diff - ) # delay propagates forward - - reachable_waypoint_idxs = [ - i for i in range(len(waypoints)) if i not in missed_waypoint_idxs - ] - - return missed_waypoint_idxs, reachable_waypoint_idxs + return scheduled_time_diff > sail_time + delay_duration def _make_checkpoint(self, failed_waypoint_i: int | None = None) -> Checkpoint: """Make checkpoint, also handling pre-departure.""" - fpi = None if failed_waypoint_i is None else failed_waypoint_i - return Checkpoint(past_schedule=self.expedition.schedule, failed_waypoint_i=fpi) + return Checkpoint( + past_schedule=self.expedition.schedule, failed_waypoint_i=failed_waypoint_i + ) def _make_hash(self, s: str, length: int) -> str: """Make unique hash for problem occurrence.""" @@ -313,9 +296,7 @@ def _hash_to_json( self, problem: InstrumentProblem | GeneralProblem, problem_hash: str, - failed_waypoint_i: int | None, - missed_waypoint_idxs: list, - reachable_waypoint_idxs: list, + problem_waypoint_i: int | None, hash_path: Path, ) -> dict: """Convert problem details + hash to json.""" @@ -323,10 +304,8 @@ def _hash_to_json( hash_data = { "problem_hash": problem_hash, "message": problem.message, - "failed_waypoint_i": failed_waypoint_i, + "problem_waypoint_i": problem_waypoint_i, "delay_duration_hours": problem.delay_duration.total_seconds() / 3600.0, - "missed_waypoint_idxs": missed_waypoint_idxs, - "reachable_waypoint_idxs": reachable_waypoint_idxs, "timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), "resolved": False, } diff --git a/src/virtualship/models/checkpoint.py b/src/virtualship/models/checkpoint.py index b8385cc0c..59ec30bfc 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -11,8 +11,13 @@ 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 +from virtualship.models.expedition import Expedition, Schedule +from virtualship.utils import ( + EXPEDITION, + PROBLEMS_ENCOUNTERED_DIR, + PROJECTION, + _calc_sail_time, +) class _YamlDumper(yaml.SafeDumper): @@ -55,18 +60,20 @@ def from_yaml(cls, file_path: str | Path) -> Checkpoint: data = yaml.safe_load(file) return Checkpoint(**data) - def verify(self, schedule: Schedule, expedition_dir: Path) -> None: + def verify(self, expedition: Expedition, expedition_dir: Path) -> None: """ 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. """ + new_schedule = expedition.schedule + # 1) check that past waypoints have not been changed, unless is a pre-departure problem 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 - not schedule.waypoints[: int(self.failed_waypoint_i)] + not new_schedule.waypoints[: int(self.failed_waypoint_i)] == self.past_schedule.waypoints[: int(self.failed_waypoint_i)] ): raise CheckpointError( @@ -87,21 +94,44 @@ def verify(self, schedule: Schedule, expedition_dir: Path) -> None: if problem["resolved"]: continue elif not problem["resolved"]: - # check if delay has been accounted for in the schedule + # check if delay has been accounted for in the new schedule (at waypoint immediately after problem waypoint) + + # TODO: should be that the new schedule time to reach the waypoint after the problem_waypoint should be sail_time + delay_duration < new_schedule_time_between_affected_waypoints + # TODO: but if it's a pre-departure problem then need to check that the whole departure time has been added on to the 1st waypoint delay_duration = timedelta( hours=float(problem["delay_duration_hours"]) - ) # delay associated with the problem + ) - time_deltas = [ - schedule.waypoints[i].time - - self.past_schedule.waypoints[i].time - for i in problem["missed_waypoint_idxs"] - ] # difference in time between the two schedules at the waypoints which were previously unreachable + # pre-departure problem: check that whole delay duration has been added to first waypoint time (by testing against past schedule) + if self.failed_waypoint_i == 0: + time_diff = ( + new_schedule.waypoints[0].time + - self.past_schedule.waypoints[0].time + ) + resolved = time_diff >= delay_duration - wp_resolution_status = [td >= delay_duration for td in time_deltas] + # problem at a later waypoint: check new scheduled time exceeds sail time + delay duration (rather whole delay duration add-on, as there may be _some_ contingency time already scheduled) + else: + time_delta = ( + new_schedule.waypoints[self.failed_waypoint_i].time + - new_schedule.waypoints[self.failed_waypoint_i - 1].time + ) + breakpoint() + min_time_required = ( + _calc_sail_time( + new_schedule.waypoints[ + self.failed_waypoint_i - 1 + ].location, + new_schedule.waypoints[self.failed_waypoint_i].location, + ship_speed_knots=expedition.ship_config.ship_speed_knots, + projection=PROJECTION, + )[0] + + delay_duration + ) + resolved = time_delta >= min_time_required - if all(wp_resolution_status): + if resolved: print( "\n\n🎉 Previous problem has been resolved in the schedule.\n" ) @@ -111,34 +141,16 @@ def verify(self, schedule: Schedule, expedition_dir: Path) -> None: with open(file, "w", encoding="utf-8") as f_out: json.dump(problem, f_out, indent=4) - break # only handle the first problem found; others will be handled in subsequent runs but are not yet known to the user + # only handle the first unresolved problem found; others will be handled in subsequent runs but are not yet known to the user + break else: problem_wp = ( "in-port" - if self.failed_waypoint_i is None - else f"at waypoint {self.failed_waypoint_i + 1}" + if problem["problem_waypoint_i"] == 0 + else f"at waypoint {problem['problem_waypoint_i'] + 1}" ) - still_unresolved = [ - wp + 1 - for wp, resolved in zip( # noqa: B905 - problem["missed_waypoint_idxs"], wp_resolution_status - ) - if not resolved - ] - shortcoming_times = [ - ( - delay_duration.total_seconds() - - time_deltas[i].total_seconds() - ) - / 3600.0 - for i, resolved in enumerate(wp_resolution_status) - if not resolved - ] - raise CheckpointError( 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 {problem_wp}.\n" - f"Hint... the following waypoints can still not be reached in time: {still_unresolved} and would be missed by {shortcoming_times} hour(s)" - + (", respectively." if len(shortcoming_times) > 1 else ".") + f"The problem was associated with a delay duration of {problem['delay_duration_hours']} hours {problem_wp} (meaning {problem['problem_waypoint_i'] + 2} could not be reached in time).\n" ) From f2693a27435895842e98af4fc0456b46504dc369 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Wed, 28 Jan 2026 13:01:33 +0100 Subject: [PATCH 27/52] waypoint congtingency checking and clearer output logging --- src/virtualship/expedition/simulate_schedule.py | 3 +-- src/virtualship/instruments/base.py | 7 +++++-- src/virtualship/instruments/ctd.py | 2 +- .../make_realistic/problems/simulator.py | 7 ++----- src/virtualship/models/checkpoint.py | 14 +++++++++----- src/virtualship/models/expedition.py | 5 +++-- 6 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index 4e3e2a800..6c27ab79a 100644 --- a/src/virtualship/expedition/simulate_schedule.py +++ b/src/virtualship/expedition/simulate_schedule.py @@ -125,8 +125,7 @@ def simulate(self) -> ScheduleOk | ScheduleProblem: print( f"\nWaypoint {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" - "\nHint #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 an unexpected problem during the expedition (e.g. a broken instrument, or a pre-departure problem), ensure any re-scheduling required has not had knock-on impact on waypoints later in the expedition as well." + "\nHint: previous schedule verification (e.g. in the `virtualship plan` tool) will not account for measurement times, only the time it takes to sail between waypoints.\n" ) return ScheduleProblem(self._time, wp_i) else: diff --git a/src/virtualship/instruments/base.py b/src/virtualship/instruments/base.py index 984e4abf5..d249d3977 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, @@ -67,7 +67,10 @@ def __init__( ) self.wp_times = wp_times - self.min_time, self.max_time = wp_times[0], wp_times[-1] + self.min_time, self.max_time = ( + wp_times[0], + wp_times[-1] + timedelta(days=1), + ) # avoid edge issues self.min_lat, self.max_lat = min(wp_lats), max(wp_lats) self.min_lon, self.max_lon = min(wp_lons), max(wp_lons) diff --git a/src/virtualship/instruments/ctd.py b/src/virtualship/instruments/ctd.py index eb780d3ea..1b6269bc8 100644 --- a/src/virtualship/instruments/ctd.py +++ b/src/virtualship/instruments/ctd.py @@ -3,8 +3,8 @@ from typing import TYPE_CHECKING, ClassVar import numpy as np -from parcels import JITParticle, ParticleSet, Variable +from parcels import JITParticle, ParticleSet, Variable from virtualship.instruments.base import Instrument from virtualship.instruments.types import InstrumentType diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index f642e5571..1ac3599d7 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -36,7 +36,7 @@ LOG_MESSAGING = { "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}...!", - "schedule_problems": "This problem will cause a delay of {delay_duration} hours {affected}. The next waypoint (i.e. waypoint {waypoint_next}) therefore cannot be reached in time. Please account for this in your schedule (`virtualship plan` or directly in {expedition_yaml}), then continue the expedition by executing the `virtualship run` command again.\n", + "schedule_problems": "This problem will cause a delay of {delay_duration} hours {problem_wp}. The next waypoint therefore cannot be reached in time. Please account for this in your schedule (`virtualship plan` or directly in {expedition_yaml}), then continue the expedition by executing the `virtualship run` command again.\n", "problem_avoided": "Phew! You had enough contingency time scheduled to avoid delays from this problem. The expedition can carry on shortly...\n", } @@ -179,14 +179,11 @@ def _log_problem( result_msg = "\nRESULT: " + LOG_MESSAGING["schedule_problems"].format( delay_duration=problem.delay_duration.total_seconds() / 3600.0, - affected=( + problem_wp=( "in-port" if problem_waypoint_i is None else f"at waypoint {problem_waypoint_i + 1}" ), - waypoint_next=int(problem_waypoint_i) + 2 - if problem_waypoint_i is not None - else 1, expedition_yaml=EXPEDITION, ) diff --git a/src/virtualship/models/checkpoint.py b/src/virtualship/models/checkpoint.py index 59ec30bfc..68fdb7f90 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -104,20 +104,19 @@ def verify(self, expedition: Expedition, expedition_dir: Path) -> None: ) # pre-departure problem: check that whole delay duration has been added to first waypoint time (by testing against past schedule) - if self.failed_waypoint_i == 0: + if problem["problem_waypoint_i"] is None: time_diff = ( new_schedule.waypoints[0].time - self.past_schedule.waypoints[0].time ) resolved = time_diff >= delay_duration - # problem at a later waypoint: check new scheduled time exceeds sail time + delay duration (rather whole delay duration add-on, as there may be _some_ contingency time already scheduled) + else: time_delta = ( new_schedule.waypoints[self.failed_waypoint_i].time - new_schedule.waypoints[self.failed_waypoint_i - 1].time ) - breakpoint() min_time_required = ( _calc_sail_time( new_schedule.waypoints[ @@ -147,10 +146,15 @@ def verify(self, expedition: Expedition, expedition_dir: Path) -> None: else: problem_wp = ( "in-port" - if problem["problem_waypoint_i"] == 0 + if problem["problem_waypoint_i"] is None else f"at waypoint {problem['problem_waypoint_i'] + 1}" ) + affected_wp = ( + "1" + if problem["problem_waypoint_i"] is None + else f"{problem['problem_waypoint_i'] + 2}" + ) raise CheckpointError( 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 {problem_wp} (meaning {problem['problem_waypoint_i'] + 2} could not be reached in time).\n" + f"The problem was associated with a delay duration of {problem['delay_duration_hours']} hours {problem_wp} (meaning waypoint {affected_wp} could not be reached in time).\n" ) diff --git a/src/virtualship/models/expedition.py b/src/virtualship/models/expedition.py index 5359f27ca..9ae2365d7 100644 --- a/src/virtualship/models/expedition.py +++ b/src/virtualship/models/expedition.py @@ -179,8 +179,9 @@ def verify( time = arrival_time elif arrival_time > wp_next.time: raise ScheduleError( - f"Waypoint planning is not valid: would arrive too late at waypoint number {wp_i + 2}. " - f"location: {wp_next.location} time: {wp_next.time} instrument: {wp_next.instrument}" + f"Waypoint planning is not valid: would arrive too late at waypoint {wp_i + 2}. " + f"Location: {wp_next.location} Time: {wp_next.time}. " + f"Currently projected to arrive at: {arrival_time}." ) else: time = wp_next.time From dcbc2018de1aa8192268098f8911a8c85a81a84b Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Wed, 28 Jan 2026 16:03:36 +0100 Subject: [PATCH 28/52] account for instrument deployment timings --- .../make_realistic/problems/simulator.py | 37 ++++++------ src/virtualship/models/checkpoint.py | 58 ++++++++++++------- src/virtualship/models/expedition.py | 14 +++++ src/virtualship/utils.py | 28 +++++++++ 4 files changed, 98 insertions(+), 39 deletions(-) diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index 1ac3599d7..6e7ddbd76 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -27,6 +27,7 @@ PROJECTION, SCHEDULE_ORIGINAL, _calc_sail_time, + _calc_wp_stationkeeping_time, _save_checkpoint, ) @@ -79,8 +80,8 @@ def execute( """ # 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; total determined by the length of the expedition), but only one pre-departure problem allowed + # 1 = 1-2 (defo at least 1) problems in expedition (either pre-departure or during expedition, general or instrument) [and set this to DEFAULT prob level] + # 2 = 3-4+ problems can occur (general and instrument; total determined by the length of the expedition), but only one pre-departure problem allowed # 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 #! TODO: may want to ensure duplicate problem types are removed; even if they could theoretically occur at different waypoints, so as not to inundate users... @@ -256,26 +257,26 @@ def _has_contingency( return False # pre-departure problems always cause delay to first waypoint else: - #! TODO: this still needs to incoporate the instrument deployment times as well!! - - delay_duration = problem.delay_duration.total_seconds() / 3600.0 # hours curr_wp = self.expedition.schedule.waypoints[problem_waypoint_i] next_wp = self.expedition.schedule.waypoints[problem_waypoint_i + 1] - scheduled_time_diff = ( - next_wp.time - curr_wp.time - ).total_seconds() / 3600.0 # hours - - sail_time = ( - _calc_sail_time( - curr_wp.location, - next_wp.location, - ship_speed_knots=self.expedition.ship_config.ship_speed_knots, - projection=PROJECTION, - )[0].total_seconds() - / 3600.0 + wp_stationkeeping_time = _calc_wp_stationkeeping_time( + curr_wp.instrument, self.expedition + ) + + scheduled_time_diff = next_wp.time - curr_wp.time + + sail_time = _calc_sail_time( + curr_wp.location, + next_wp.location, + ship_speed_knots=self.expedition.ship_config.ship_speed_knots, + projection=PROJECTION, + )[0] + + return ( + scheduled_time_diff + > sail_time + wp_stationkeeping_time + problem.delay_duration ) - return scheduled_time_diff > sail_time + delay_duration def _make_checkpoint(self, failed_waypoint_i: int | None = None) -> Checkpoint: """Make checkpoint, also handling pre-departure.""" diff --git a/src/virtualship/models/checkpoint.py b/src/virtualship/models/checkpoint.py index 68fdb7f90..2b55aef1b 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -17,6 +17,7 @@ PROBLEMS_ENCOUNTERED_DIR, PROJECTION, _calc_sail_time, + _calc_wp_stationkeeping_time, ) @@ -95,10 +96,6 @@ def verify(self, expedition: Expedition, expedition_dir: Path) -> None: continue elif not problem["resolved"]: # check if delay has been accounted for in the new schedule (at waypoint immediately after problem waypoint) - - # TODO: should be that the new schedule time to reach the waypoint after the problem_waypoint should be sail_time + delay_duration < new_schedule_time_between_affected_waypoints - # TODO: but if it's a pre-departure problem then need to check that the whole departure time has been added on to the 1st waypoint - delay_duration = timedelta( hours=float(problem["delay_duration_hours"]) ) @@ -110,25 +107,33 @@ def verify(self, expedition: Expedition, expedition_dir: Path) -> None: - self.past_schedule.waypoints[0].time ) resolved = time_diff >= delay_duration - # problem at a later waypoint: check new scheduled time exceeds sail time + delay duration (rather whole delay duration add-on, as there may be _some_ contingency time already scheduled) + # problem at a later waypoint: check new scheduled time exceeds sail time + delay duration + instrument deployment time (rather whole delay duration add-on, as there may be _some_ contingency time already scheduled) else: - time_delta = ( - new_schedule.waypoints[self.failed_waypoint_i].time - - new_schedule.waypoints[self.failed_waypoint_i - 1].time - ) + problem_waypoint = new_schedule.waypoints[ + problem["problem_waypoint_i"] + ] + failed_waypoint = new_schedule.waypoints[self.failed_waypoint_i] + + scheduled_time = failed_waypoint.time - problem_waypoint.time + + stationkeeping_time = _calc_wp_stationkeeping_time( + problem_waypoint.instrument, + expedition, + ) # total time required to deploy instruments at problem waypoint + + sail_time = _calc_sail_time( + problem_waypoint.location, + failed_waypoint.location, + ship_speed_knots=expedition.ship_config.ship_speed_knots, + projection=PROJECTION, + )[0] + min_time_required = ( - _calc_sail_time( - new_schedule.waypoints[ - self.failed_waypoint_i - 1 - ].location, - new_schedule.waypoints[self.failed_waypoint_i].location, - ship_speed_knots=expedition.ship_config.ship_speed_knots, - projection=PROJECTION, - )[0] - + delay_duration + sail_time + delay_duration + stationkeeping_time ) - resolved = time_delta >= min_time_required + + resolved = scheduled_time >= min_time_required if resolved: print( @@ -154,7 +159,18 @@ def verify(self, expedition: Expedition, expedition_dir: Path) -> None: if problem["problem_waypoint_i"] is None else f"{problem['problem_waypoint_i'] + 2}" ) + current_time = ( + problem_waypoint.time + + sail_time + + delay_duration + + stationkeeping_time + ) + raise CheckpointError( - 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 {problem_wp} (meaning waypoint {affected_wp} could not be reached in time).\n" + 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\n" + f"The problem was associated with a delay duration of {problem['delay_duration_hours']} hours {problem_wp} (meaning waypoint {affected_wp} could not be reached in time). " + f"Currently, the ship would reach waypoint {affected_wp} at {current_time}, but the scheduled time is {failed_waypoint.time}.\n\n" + + f"Hint: don't forget to factor in the time required to deploy the instruments {problem_wp} when rescheduling waypoint {affected_wp}." + if problem["problem_waypoint_i"] is not None + else None ) diff --git a/src/virtualship/models/expedition.py b/src/virtualship/models/expedition.py index 9ae2365d7..23544c7d3 100644 --- a/src/virtualship/models/expedition.py +++ b/src/virtualship/models/expedition.py @@ -207,6 +207,8 @@ def serialize_instrument(self, instrument): class ArgoFloatConfig(pydantic.BaseModel): """Configuration for argos floats.""" + instrument_type: InstrumentType = InstrumentType.ARGO_FLOAT + min_depth_meter: float = pydantic.Field(le=0.0) max_depth_meter: float = pydantic.Field(le=0.0) drift_depth_meter: float = pydantic.Field(le=0.0) @@ -247,6 +249,8 @@ def _validate_stationkeeping_time(cls, value: int | float | timedelta) -> timede class ADCPConfig(pydantic.BaseModel): """Configuration for ADCP instrument.""" + instrument_type: InstrumentType = InstrumentType.ADCP + max_depth_meter: float = pydantic.Field(le=0.0) num_bins: int = pydantic.Field(gt=0.0) period: timedelta = pydantic.Field( @@ -269,6 +273,8 @@ def _validate_period(cls, value: int | float | timedelta) -> timedelta: class CTDConfig(pydantic.BaseModel): """Configuration for CTD instrument.""" + instrument_type: InstrumentType = InstrumentType.CTD + stationkeeping_time: timedelta = pydantic.Field( serialization_alias="stationkeeping_time_minutes", validation_alias="stationkeeping_time_minutes", @@ -291,6 +297,8 @@ def _validate_stationkeeping_time(cls, value: int | float | timedelta) -> timede class CTD_BGCConfig(pydantic.BaseModel): """Configuration for CTD_BGC instrument.""" + instrument_type: InstrumentType = InstrumentType.CTD_BGC + stationkeeping_time: timedelta = pydantic.Field( serialization_alias="stationkeeping_time_minutes", validation_alias="stationkeeping_time_minutes", @@ -313,6 +321,8 @@ def _validate_stationkeeping_time(cls, value: int | float | timedelta) -> timede class ShipUnderwaterSTConfig(pydantic.BaseModel): """Configuration for underwater ST.""" + instrument_type: InstrumentType = InstrumentType.UNDERWATER_ST + period: timedelta = pydantic.Field( serialization_alias="period_minutes", validation_alias="period_minutes", @@ -333,6 +343,8 @@ def _validate_period(cls, value: int | float | timedelta) -> timedelta: class DrifterConfig(pydantic.BaseModel): """Configuration for drifters.""" + instrument_type: InstrumentType = InstrumentType.DRIFTER + depth_meter: float = pydantic.Field(le=0.0) lifetime: timedelta = pydantic.Field( serialization_alias="lifetime_days", @@ -367,6 +379,8 @@ def _validate_stationkeeping_time(cls, value: int | float | timedelta) -> timede class XBTConfig(pydantic.BaseModel): """Configuration for xbt instrument.""" + instrument_type: InstrumentType = InstrumentType.XBT + min_depth_meter: float = pydantic.Field(le=0.0) max_depth_meter: float = pydantic.Field(le=0.0) fall_speed_meter_per_second: float = pydantic.Field(gt=0.0) diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index e0340b90a..8d4f5e437 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -17,6 +17,7 @@ from parcels import FieldSet from virtualship.errors import CopernicusCatalogueError +from virtualship.instruments.types import InstrumentType if TYPE_CHECKING: from virtualship.expedition.simulate_schedule import ( @@ -608,3 +609,30 @@ def _calc_sail_time( geodinv, ship_speed_meter_per_second, ) + + +def _calc_wp_stationkeeping_time( + wp_instrument_types: list, expedition: Expedition +) -> timedelta: + """For a given waypoint, calculate how much time is required to carry out all instrument deployments.""" + # TODO: this can be removed if/when CTD and CTD_BGC are merged to a single instrument + both_ctd_and_bgc = ( + InstrumentType.CTD in wp_instrument_types + and InstrumentType.CTD_BGC in wp_instrument_types + ) + + # extract configs for instruments present in waypoint + wp_instrument_configs = [ + iconfig + for _, iconfig in expedition.instruments_config.__dict__.items() + if iconfig is not None and iconfig.instrument_type in wp_instrument_types + ] + + cumulative_stationkeeping_time = timedelta() + for iconfig in wp_instrument_configs: + if both_ctd_and_bgc and iconfig.instrument_type == InstrumentType.CTD_BGC: + continue # # only need to add time cost once if both CTD and CTD_BGC are being taken; in reality they would be done on the same instrument + if hasattr(iconfig, "stationkeeping_time"): + cumulative_stationkeeping_time += iconfig.stationkeeping_time + + return cumulative_stationkeeping_time From 148de475612a2717b0c15bdbacc02ef4c93230c4 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Wed, 28 Jan 2026 16:24:53 +0100 Subject: [PATCH 29/52] tidy up TODOs and delete checkpoint file at end of successful expedition --- src/virtualship/cli/_run.py | 7 +++---- src/virtualship/make_realistic/problems/simulator.py | 8 -------- src/virtualship/models/checkpoint.py | 1 - 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index 65e778779..bc6284570 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -140,8 +140,6 @@ def _run( if prob_level > 0: # only helpful if problems are being simulated print(f"\033[4mUp next\033[0m: {itype.name} measurements...\n") - #! TODO: need logic for skipping simulation of instruments which have already been simulated successfully in a previous run of the expedition - #! TODO: and new logic for not overwriting existing zarr files if they already exist from a previous successful simulation of that instrument if problems: problem_simulator.execute( problems, @@ -177,14 +175,15 @@ 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( f"\nA record of problems encountered during the expedition is saved in: {expedition_dir.joinpath(PROBLEMS_ENCOUNTERED_DIR)}" ) + # delete checkpoint file (inteferes with ability to re-run expedition) + os.remove(expedition_dir.joinpath(CHECKPOINT)) + print("\n------------- END -------------\n") # end timing diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index 6e7ddbd76..1d43bad1d 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -59,7 +59,6 @@ def select_problems( instruments_in_expedition: set[InstrumentType], ) -> list[GeneralProblem | InstrumentProblem] | None: """Propagate both general and instrument problems.""" - # TODO: whether a problem can reoccur or not needs to be handled here too! if prob_level > 0: return [ CTDCableJammed, @@ -86,12 +85,7 @@ def execute( # 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 #! TODO: may want to ensure duplicate problem types are removed; even if they could theoretically occur at different waypoints, so as not to inundate users... - #! TODO: what happens if students decide to re-run the expedition multiple times with slightly changed set-ups to try to e.g. get more measurements? Maybe it should be that problems are ignored if the exact expedition.yaml has been run before, and if there's any changes to the expedition.yaml - # TODO: for this reason, `problems_encountered` dir should be housed in `results` dir along with a cache of the expedition.yaml used for that run... - # TODO: and the results dir given a unique name which can be used to check against when re-running the expedition? - # allow only one pre-departure problem to occur (only GeneralProblems can be pre-departure problems) - pre_departure_problems = [ p for p in problems if issubclass(p, GeneralProblem) and p.pre_departure ] @@ -123,7 +117,6 @@ def execute( "waypoint_i": [w for _, w in paired], } - # TODO: make the log output stand out more visually for problem, problem_waypoint_i in zip( problems_sorted["problem_class"], problems_sorted["waypoint_i"], strict=True ): @@ -134,7 +127,6 @@ def execute( ): continue - # TODO: double check the hashing still works as expected when problem_waypoint_i is None (i.e. pre-departure problem) problem_hash = self._make_hash(problem.message + str(problem_waypoint_i), 8) hash_path = Path( self.expedition_dir diff --git a/src/virtualship/models/checkpoint.py b/src/virtualship/models/checkpoint.py index 2b55aef1b..47eb60d99 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -73,7 +73,6 @@ def verify(self, expedition: Expedition, expedition_dir: Path) -> None: 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 not new_schedule.waypoints[: int(self.failed_waypoint_i)] == self.past_schedule.waypoints[: int(self.failed_waypoint_i)] ): From 7f73687644c3cda59552ac4563dd67b6854f1e38 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Wed, 28 Jan 2026 17:09:05 +0100 Subject: [PATCH 30/52] working towards problem selection logic --- src/virtualship/cli/_run.py | 3 +- .../make_realistic/problems/scenarios.py | 220 +++++++++--------- .../make_realistic/problems/simulator.py | 22 +- src/virtualship/utils.py | 20 +- 4 files changed, 141 insertions(+), 124 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index bc6284570..bfd5506e8 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -133,8 +133,9 @@ def _run( # problems # TODO: prob_level needs to be parsed from CLI args + #! TODO: the argument should ensure that only "0", "1", or "2" can be used as arguments problem_simulator = ProblemSimulator(expedition, prob_level, expedition_dir) - problems = problem_simulator.select_problems(prob_level, instruments_in_expedition) + problems = problem_simulator.select_problems(instruments_in_expedition) for itype in instruments_in_expedition: if prob_level > 0: # only helpful if problems are being simulated diff --git a/src/virtualship/make_realistic/problems/scenarios.py b/src/virtualship/make_realistic/problems/scenarios.py index 7cc6a0c36..ed0fd50c0 100644 --- a/src/virtualship/make_realistic/problems/scenarios.py +++ b/src/virtualship/make_realistic/problems/scenarios.py @@ -6,9 +6,10 @@ 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 + pass # ===================================================== @@ -16,9 +17,6 @@ # ===================================================== -# TODO: maybe make some of the problems longer duration; to make it more rare that enough contingency time has been planned...? - - # TODO: pydantic model to ensure correct types? @dataclass class GeneralProblem(abc.ABC): @@ -29,15 +27,14 @@ 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: timedelta 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.""" - ... + # @abc.abstractmethod + # def is_valid() -> bool: + # """Check if the problem can occur based on e.g. waypoint location and/or datetime etc.""" + # ... @dataclass @@ -46,15 +43,14 @@ class InstrumentProblem(abc.ABC): 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: timedelta 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.""" - ... + # @abc.abstractmethod + # def is_valid() -> bool: + # """Check if the problem can occur based on e.g. waypoint location and/or datetime etc.""" + # ... # ===================================================== @@ -63,7 +59,7 @@ def is_valid() -> bool: @dataclass -# @register_general_problem +@register_general_problem class FoodDeliveryDelayed(GeneralProblem): """Problem: Scheduled food delivery is delayed, causing a postponement of departure.""" @@ -72,37 +68,38 @@ class FoodDeliveryDelayed(GeneralProblem): "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 +# @dataclass # @register_general_problem -class VenomousCentipedeOnboard(GeneralProblem): - """Problem: Venomous centipede discovered onboard in tropical waters.""" +# 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 +# # TODO: this needs logic added to the is_valid() method to check if waypoint is in tropical waters - 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. " - "The medical response and search efforts cause an operational delay of about 2 hours." - ) - can_reoccur = False - delay_duration = timedelta(hours=2.0) - base_probability = 0.05 - pre_departure = False +# 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. " +# "The medical response and search efforts cause an operational delay of about 2 hours." +# ) +# +# delay_duration = timedelta(hours=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 +# 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 +@dataclass +@register_general_problem class CaptainSafetyDrill(GeneralProblem): """Problem: Sudden initiation of a mandatory safety drill.""" @@ -111,13 +108,14 @@ 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 = timedelta(hours=2.0) base_probability = 0.1 pre_departure = False @dataclass +@register_general_problem class FuelDeliveryIssue: message = ( "The fuel tanker expected to deliver fuel has not arrived. Port authorities are unable to provide " @@ -125,37 +123,40 @@ class FuelDeliveryIssue: "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 + delay_duration = timedelta(hours=5.0) # dynamic delays based on repeated choices + base_probability = 0.1 + pre_departure = True -@dataclass -class EngineOverheating: - 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." - ) - can_reoccur: bool = False - delay_duration: None = None # speed reduction affects ETA instead of fixed delay - ship_speed_knots: float = 8.5 +# @dataclass +# @register_general_problem +# class EngineOverheating: +# 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." +# ) +# delay_duration: None = None # speed reduction affects ETA instead of fixed delay +# ship_speed_knots: float = 8.5 -# @register_general_problem +@dataclass +@register_general_problem class MarineMammalInDeploymentArea(GeneralProblem): """Problem: Marine mammals observed in deployment area, causing delay.""" 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." + "must pause until the animals move away from the vicinity. This results in a delay of about 2 hours." ) - can_reoccur: bool = True - delay_duration: float = 0.5 - base_probability: float = 0.1 + delay_duration = timedelta(hours=2) + base_probability = 0.1 + pre_departure = False -# @register_general_problem +@dataclass +@register_general_problem class BallastPumpFailure(GeneralProblem): """Problem: Ballast pump failure during ballasting operations.""" @@ -163,53 +164,56 @@ class BallastPumpFailure(GeneralProblem): "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." + "functionality, but the interruption causes a delay of approximately 4 hours." ) - can_reoccur: bool = True - delay_duration: float = 1.0 - base_probability: float = 0.1 + delay_duration = timedelta(hours=4.0) + base_probability = 0.1 + pre_departure = False -# @register_general_problem +@dataclass +@register_general_problem class ThrusterConverterFault(GeneralProblem): """Problem: Bow thruster's power converter fault during station-keeping.""" 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." + "Engineers troubleshoot the converter and perform a reset, resulting in a delay of around 4 hours." ) - can_reoccur: bool = False - delay_duration: float = 1.0 - base_probability: float = 0.1 + delay_duration = timedelta(hours=4.0) + base_probability = 0.1 + pre_departure = False -# @register_general_problem +@dataclass +@register_general_problem class AFrameHydraulicLeak(GeneralProblem): """Problem: Hydraulic fluid leak from A-frame actuator.""" 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." + "Engineering replaces a faulty hose and repressurizes the system. This repair causes a delay of about 6 hours." ) - can_reoccur: bool = True - delay_duration: float = 2.0 - base_probability: float = 0.1 + delay_duration = timedelta(hours=6.0) + base_probability = 0.1 + pre_departure = False -# @register_general_problem +@dataclass +@register_general_problem class CoolingWaterIntakeBlocked(GeneralProblem): """Problem: Main engine's cooling water intake blocked.""" 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." + "and flushes the intake. This results in a delay of approximately 4 hours." ) - can_reoccur: bool = True - delay_duration: float = 1.0 - base_probability: float = 0.1 + delay_duration = timedelta(hours=4.0) + base_probability = 0.1 + pre_departure = False # ===================================================== @@ -217,7 +221,8 @@ class CoolingWaterIntakeBlocked(GeneralProblem): # ===================================================== -# @register_instrument_problem(InstrumentType.CTD) +@dataclass +@register_instrument_problem class CTDCableJammed(InstrumentProblem): """Problem: CTD cable jammed in winch drum, requiring replacement.""" @@ -225,15 +230,15 @@ class CTDCableJammed(InstrumentProblem): "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." + "of approximately 5 hours." ) - can_reoccur = True - delay_duration = timedelta(hours=3.0) + delay_duration = timedelta(hours=5.0) base_probability = 0.1 instrument_type = InstrumentType.CTD -# @register_instrument_problem(InstrumentType.ADCP) +@dataclass +@register_instrument_problem class ADCPMalfunction(InstrumentProblem): """Problem: ADCP returns invalid data, requiring inspection.""" @@ -241,15 +246,15 @@ class ADCPMalfunction(InstrumentProblem): "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." + "of around 2 hours." ) - can_reoccur = True - delay_duration = timedelta(hours=1.0) + delay_duration = timedelta(hours=2.0) base_probability = 0.1 instrument_type = InstrumentType.ADCP -# @register_instrument_problem(InstrumentType.CTD) +@dataclass +@register_instrument_problem class CTDTemperatureSensorFailure(InstrumentProblem): """Problem: CTD temperature sensor failure, requiring replacement.""" @@ -257,15 +262,15 @@ class CTDTemperatureSensorFailure(InstrumentProblem): "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." + "This procedure leads to an estimated delay of around 3 hours." ) - can_reoccur: bool = True - delay_duration: float = 2.0 - base_probability: float = 0.1 + delay_duration = timedelta(hours=3.0) + base_probability = 0.1 instrument_type = InstrumentType.CTD -# @register_instrument_problem(InstrumentType.CTD) +@dataclass +@register_instrument_problem class CTDSalinitySensorFailureWithCalibration(InstrumentProblem): """Problem: CTD salinity sensor failure, requiring replacement and calibration.""" @@ -274,13 +279,13 @@ class CTDSalinitySensorFailureWithCalibration(InstrumentProblem): "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 - base_probability: float = 0.1 + delay_duration = timedelta(hours=4.0) + base_probability = 0.1 instrument_type = InstrumentType.CTD -# @register_instrument_problem(InstrumentType.CTD) +@dataclass +@register_instrument_problem class WinchHydraulicPressureDrop(InstrumentProblem): """Problem: CTD winch hydraulic pressure drop, requiring repair.""" @@ -288,15 +293,15 @@ class WinchHydraulicPressureDrop(InstrumentProblem): "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." + "This results in an estimated delay of 2.5 hours." ) - can_reoccur: bool = True - delay_duration: float = 1.5 - base_probability: float = 0.1 + delay_duration = timedelta(hours=2.5) + base_probability = 0.1 instrument_type = InstrumentType.CTD -# @register_instrument_problem(InstrumentType.CTD) +@dataclass +@register_instrument_problem class RosetteTriggerFailure(InstrumentProblem): """Problem: CTD rosette trigger failure, requiring inspection.""" @@ -304,15 +309,15 @@ class RosetteTriggerFailure(InstrumentProblem): "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." + "delay of approximately 3.5 hours." ) - can_reoccur: bool = True - delay_duration: float = 2.5 - base_probability: float = 0.1 + delay_duration = timedelta(hours=3.5) + base_probability = 0.1 instrument_type = InstrumentType.CTD -# @register_instrument_problem(InstrumentType.DRIFTER) +@dataclass +@register_instrument_problem class DrifterSatelliteConnectionDelay(InstrumentProblem): """Problem: Drifter fails to establish satellite connection before deployment.""" @@ -322,13 +327,13 @@ class DrifterSatelliteConnectionDelay(InstrumentProblem): "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 - base_probability: float = 0.1 + delay_duration = timedelta(hours=2.0) + base_probability = 0.1 instrument_type = InstrumentType.DRIFTER -# @register_instrument_problem(InstrumentType.ARGO_FLOAT) +@dataclass +@register_instrument_problem class ArgoSatelliteConnectionDelay(InstrumentProblem): """Problem: Argo float fails to establish satellite connection before deployment.""" @@ -338,7 +343,6 @@ class ArgoSatelliteConnectionDelay(InstrumentProblem): "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 - base_probability: float = 0.1 + delay_duration = timedelta(hours=2.0) + base_probability = 0.1 instrument_type = InstrumentType.ARGO_FLOAT diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index 1d43bad1d..fedfa7b08 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -23,6 +23,8 @@ from virtualship.models.checkpoint import Checkpoint from virtualship.utils import ( EXPEDITION, + GENERAL_PROBLEM_REG, + INSTRUMENT_PROBLEM_REG, PROBLEMS_ENCOUNTERED_DIR, PROJECTION, SCHEDULE_ORIGINAL, @@ -55,17 +57,33 @@ def __init__( def select_problems( self, - prob_level, instruments_in_expedition: set[InstrumentType], ) -> list[GeneralProblem | InstrumentProblem] | None: """Propagate both general and instrument problems.""" - if prob_level > 0: + breakpoint() + + valid_instrument_problems = [ + problem + for problem in INSTRUMENT_PROBLEM_REG + if problem.instrument_type in instruments_in_expedition + ] + + # TODO: func here for calculating how many problems to select + # TODO: use different constraint levels (as in the drifter_offset func) for the different prob_levels + # TODO: then weighted by expedition len (time), number of waypoints, number of different instruments + + GENERAL_PROBLEM_REG + + if self.prob_level > 0: return [ CTDCableJammed, FoodDeliveryDelayed, CaptainSafetyDrill, ] # TODO: temporary placeholder!! + else: + return None + def execute( self, problems: dict[str, list[GeneralProblem | InstrumentProblem]], diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 8d4f5e437..af0184a69 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -282,24 +282,18 @@ def add_dummy_UV(fieldset: FieldSet): # problems inventory registry and registration utilities -INSTRUMENT_PROBLEM_MAP = [] +INSTRUMENT_PROBLEM_REG = [] GENERAL_PROBLEM_REG = [] -def register_instrument_problem(instrument_type): - def decorator(cls): - INSTRUMENT_PROBLEM_MAP[instrument_type] = cls - return cls - - return decorator +def register_instrument_problem(cls): + INSTRUMENT_PROBLEM_REG.append(cls) + return cls -def register_general_problem(): - def decorator(cls): - GENERAL_PROBLEM_REG.append(cls) - return cls - - return decorator +def register_general_problem(cls): + GENERAL_PROBLEM_REG.append(cls) + return cls # Copernicus Marine product IDs From 08ad6e621346c016a14ac19126acc11729b4a98a Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:05:13 +0100 Subject: [PATCH 31/52] define problem selection logic and build system for caching selected problems for re-use with the same expedition --- src/virtualship/cli/_run.py | 33 ++- .../make_realistic/problems/scenarios.py | 2 +- .../make_realistic/problems/simulator.py | 246 ++++++++++++------ src/virtualship/models/checkpoint.py | 1 + src/virtualship/models/expedition.py | 9 + src/virtualship/utils.py | 8 + 6 files changed, 217 insertions(+), 82 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index bfd5506e8..5b4fa0df6 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -126,20 +126,41 @@ def _run( print("\n--- MEASUREMENT SIMULATIONS ---") - # simulate measurements - print("\nSimulating measurements. This may take a while...\n") - + # identify instruments in expedition instruments_in_expedition = expedition.get_instruments() + # unique hash for this expedition (based on waypoint locations and instrument types); used for identifying previously encountered problems; therefore new set of problems if waypoint locations or instrument types change + expedition_hash = expedition.get_expedition_hash() + # problems + selected_problems_fname = ( + "selected_problems_" + expedition_hash + ".json" + ) # for caching selected problems for this expedition + + problem_simulator = ProblemSimulator(expedition, expedition_dir) + # TODO: prob_level needs to be parsed from CLI args #! TODO: the argument should ensure that only "0", "1", or "2" can be used as arguments - problem_simulator = ProblemSimulator(expedition, prob_level, expedition_dir) - problems = problem_simulator.select_problems(instruments_in_expedition) + + # re-load previously encountered, valid (same expedition as previously) problems if they exist, else select new problems and cache them + if os.path.exists( + expedition_dir / PROBLEMS_ENCOUNTERED_DIR / selected_problems_fname + ): + problems = problem_simulator.load_selected_problems(selected_problems_fname) + else: + problems = problem_simulator.select_problems( + instruments_in_expedition, prob_level + ) + problem_simulator.cache_selected_problems(problems, selected_problems_fname) + + # simulate measurements + print("\nSimulating measurements. This may take a while...\n") for itype in instruments_in_expedition: if prob_level > 0: # only helpful if problems are being simulated - print(f"\033[4mUp next\033[0m: {itype.name} measurements...\n") + print( + f"\033[4mUp next\033[0m: {itype.name} measurements...\n" + ) # TODO: will want to clear once simulation line is running... if problems: problem_simulator.execute( diff --git a/src/virtualship/make_realistic/problems/scenarios.py b/src/virtualship/make_realistic/problems/scenarios.py index ed0fd50c0..ed9b377fc 100644 --- a/src/virtualship/make_realistic/problems/scenarios.py +++ b/src/virtualship/make_realistic/problems/scenarios.py @@ -116,7 +116,7 @@ class CaptainSafetyDrill(GeneralProblem): @dataclass @register_general_problem -class FuelDeliveryIssue: +class FuelDeliveryIssue(GeneralProblem): 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 " diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index fedfa7b08..746f27eb0 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -1,6 +1,5 @@ from __future__ import annotations -import hashlib import json import os import random @@ -9,14 +8,10 @@ from pathlib import Path 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 ( - CaptainSafetyDrill, - CTDCableJammed, - FoodDeliveryDelayed, GeneralProblem, InstrumentProblem, ) @@ -30,6 +25,7 @@ SCHEDULE_ORIGINAL, _calc_sail_time, _calc_wp_stationkeeping_time, + _make_hash, _save_checkpoint, ) @@ -44,50 +40,135 @@ } +# default problem weights for problems simulator (i.e. add +1 problem for every n days/waypoints/instruments in expedition) +PROBLEM_WEIGHTS = { + "every_ndays": 7, + "every_nwaypoints": 6, + "every_ninstruments": 3, +} + + class ProblemSimulator: """Handle problem simulation during expedition.""" - def __init__( - self, expedition: Expedition, prob_level: int, expedition_dir: str | Path - ): + def __init__(self, expedition: Expedition, expedition_dir: str | Path): """Initialise ProblemSimulator with a schedule and probability level.""" self.expedition = expedition - self.prob_level = prob_level self.expedition_dir = Path(expedition_dir) def select_problems( self, instruments_in_expedition: set[InstrumentType], - ) -> list[GeneralProblem | InstrumentProblem] | None: - """Propagate both general and instrument problems.""" - breakpoint() + prob_level: int, + ) -> dict[str, list[GeneralProblem | InstrumentProblem] | None]: + """ + Select problems (general and instrument-specific). Number of problems is determined by probability level, expedition length, instrument count etc. + Map each selected problem to a random waypoint (or None if pre-departure). Finally, cache the suite of problems to a directory (expedition-specific via hash) for reference. + """ valid_instrument_problems = [ problem for problem in INSTRUMENT_PROBLEM_REG if problem.instrument_type in instruments_in_expedition ] - # TODO: func here for calculating how many problems to select - # TODO: use different constraint levels (as in the drifter_offset func) for the different prob_levels - # TODO: then weighted by expedition len (time), number of waypoints, number of different instruments + num_waypoints = len(self.expedition.schedule.waypoints) + num_instruments = len(instruments_in_expedition) + expedition_duration_days = ( + self.expedition.schedule.waypoints[-1].time + - self.expedition.schedule.waypoints[0].time + ).days + + if prob_level == 0: + num_problems = 0 + elif prob_level == 1: + num_problems = random.randint(1, 2) + elif prob_level == 2: + base = 1 + extra = ( # i.e. +1 problem for every n days/waypoints/instruments (tunable above) + (expedition_duration_days // PROBLEM_WEIGHTS["every_ndays"]) + + (num_waypoints // PROBLEM_WEIGHTS["every_nwaypoints"]) + + (num_instruments // PROBLEM_WEIGHTS["every_ninstruments"]) + ) + num_problems = base + extra + num_problems = min( + num_problems, len(GENERAL_PROBLEM_REG) + len(valid_instrument_problems) + ) - GENERAL_PROBLEM_REG + selected_problems = [] + if num_problems > 0: + random.shuffle(GENERAL_PROBLEM_REG) + random.shuffle(valid_instrument_problems) - if self.prob_level > 0: - return [ - CTDCableJammed, - FoodDeliveryDelayed, - CaptainSafetyDrill, - ] # TODO: temporary placeholder!! + # bias towards more instrument problems when there are more instruments + instrument_bias = min(0.7, num_instruments / (num_instruments + 2)) + n_instrument = round(num_problems * instrument_bias) + n_general = min(len(GENERAL_PROBLEM_REG), num_problems - n_instrument) + n_instrument = ( + num_problems - n_general + ) # recalc in case n_general was capped to len(GENERAL_PROBLEM_REG) - else: - return None + selected_problems.extend(GENERAL_PROBLEM_REG[:n_general]) + selected_problems.extend(valid_instrument_problems[:n_instrument]) + + # allow only one pre-departure problem to occur; replace any extras with non-pre-departure problems + pre_departure_problems = [ + p + for p in selected_problems + if issubclass(p, GeneralProblem) and p.pre_departure + ] + if len(pre_departure_problems) > 1: + to_keep = random.choice(pre_departure_problems) + num_to_replace = len(pre_departure_problems) - 1 + # remove all but one pre_departure problem + selected_problems = [ + problem + for problem in selected_problems + if not ( + issubclass(problem, GeneralProblem) + and problem.pre_departure + and problem is not to_keep + ) + ] + # available non-pre_departure problems not already selected + available_general = [ + p + for p in GENERAL_PROBLEM_REG + if not p.pre_departure and p not in selected_problems + ] + available_instrument = [ + p for p in valid_instrument_problems if p not in selected_problems + ] + available_replacements = available_general + available_instrument + random.shuffle(available_replacements) + selected_problems.extend(available_replacements[:num_to_replace]) + + # map each problem to a [random] waypoint (or None if pre-departure) + waypoint_idxs = [] + for problem in selected_problems: + if getattr(problem, "pre_departure", False): + waypoint_idxs.append(None) + else: + waypoint_idxs.append( + random.randint(0, len(self.expedition.schedule.waypoints) - 1) + ) # last waypoint excluded (would not impact any future scheduling) + + # pair problems with their waypoint indices and sort by waypoint index (pre-departure first) + paired = sorted( + zip(selected_problems, waypoint_idxs, strict=True), + key=lambda x: (x[1] is not None, x[1] if x[1] is not None else -1), + ) + problems_sorted = { + "problem_class": [p for p, _ in paired], + "waypoint_i": [w for _, w in paired], + } + + return problems_sorted if selected_problems else None def execute( self, - problems: dict[str, list[GeneralProblem | InstrumentProblem]], - instrument_type_validation: InstrumentType | None = None, + problems: dict[str, list[GeneralProblem | InstrumentProblem] | None], + instrument_type_validation: InstrumentType | None, log_delay: float = 7.0, ): """ @@ -95,48 +176,11 @@ def execute( N.B. a problem_waypoint_i is different to a failed_waypoint_i defined in the Checkpoint class; 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: re: prob levels: - # 0 = no problems - # 1 = 1-2 (defo at least 1) problems in expedition (either pre-departure or during expedition, general or instrument) [and set this to DEFAULT prob level] - # 2 = 3-4+ problems can occur (general and instrument; total determined by the length of the expedition), but only one pre-departure problem allowed - # 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 #! TODO: may want to ensure duplicate problem types are removed; even if they could theoretically occur at different waypoints, so as not to inundate users... - # allow only one pre-departure problem to occur (only GeneralProblems can be pre-departure problems) - pre_departure_problems = [ - p for p in problems if issubclass(p, GeneralProblem) and p.pre_departure - ] - if len(pre_departure_problems) > 1: # keep only one pre-departure problem - to_keep = random.choice(pre_departure_problems) # pick one at random - problems = [ - p - for p in problems - if not getattr(p, "pre_departure", False) or p is to_keep - ] - - # map each problem to a [random] waypoint (or None if pre-departure) - waypoint_idxs = [] - for p in problems: - if getattr(p, "pre_departure", False): - waypoint_idxs.append(None) - else: - waypoint_idxs.append( - np.random.randint(0, len(self.expedition.schedule.waypoints) - 1) - ) # last waypoint excluded (would not impact any future scheduling) - - # air problems with their waypoint indices and sort by waypoint index (pre-departure first) - paired = sorted( - zip(problems, waypoint_idxs, strict=True), - key=lambda x: (x[1] is not None, x[1] if x[1] is not None else -1), - ) - problems_sorted = { - "problem_class": [p for p, _ in paired], - "waypoint_i": [w for _, w in paired], - } - for problem, problem_waypoint_i in zip( - problems_sorted["problem_class"], problems_sorted["waypoint_i"], strict=True + problems["problem_class"], problems["waypoint_i"], strict=True ): # skip if instrument problem but `p.instrument_type` does not match `instrument_type_validation` (i.e. the current instrument being simulated in the expedition, e.g. from _run.py) if ( @@ -145,10 +189,9 @@ def execute( ): continue - problem_hash = self._make_hash(problem.message + str(problem_waypoint_i), 8) - hash_path = Path( - self.expedition_dir - / f"{PROBLEMS_ENCOUNTERED_DIR}/problem_{problem_hash}.json" + problem_hash = _make_hash(problem.message + str(problem_waypoint_i), 8) + hash_path = self.expedition_dir.joinpath( + PROBLEMS_ENCOUNTERED_DIR, f"problem_{problem_hash}.json" ) if hash_path.exists(): continue # problem * waypoint combination has already occurred; don't repeat @@ -171,6 +214,66 @@ def execute( log_delay, ) + def cache_selected_problems( + self, + problems: dict[str, list[GeneralProblem | InstrumentProblem] | None], + selected_problems_fname: str, + ) -> None: + """Cache suite of problems to json, for reference.""" + # make dir to contain problem jsons (unique to expedition) + os.makedirs(self.expedition_dir / PROBLEMS_ENCOUNTERED_DIR, exist_ok=True) + + # cache dict of selected_problems to json + with open( + self.expedition_dir / PROBLEMS_ENCOUNTERED_DIR / selected_problems_fname, + "w", + encoding="utf-8", + ) as f: + json.dump( + { + "problem_class": [p.__name__ for p in problems["problem_class"]], + "waypoint_i": problems["waypoint_i"], + }, + f, + indent=4, + ) + + def load_selected_problems( + self, selected_problems_fname: str + ) -> dict[str, list[GeneralProblem | InstrumentProblem] | None]: + """Load previously selected problem classes from json.""" + with open( + self.expedition_dir / PROBLEMS_ENCOUNTERED_DIR / selected_problems_fname, + encoding="utf-8", + ) as f: + problems_json = json.load(f) + + # extract selected problem classes from their names (using the lookups preserves order they were saved in) + selected_problems = {"problem_class": [], "waypoint_i": []} + general_problems_lookup = {cls.__name__: cls for cls in GENERAL_PROBLEM_REG} + instrument_problems_lookup = { + cls.__name__: cls for cls in INSTRUMENT_PROBLEM_REG + } + + for cls_name, wp_idx in zip( + problems_json["problem_class"], problems_json["waypoint_i"], strict=True + ): + if cls_name in general_problems_lookup: + selected_problems["problem_class"].append( + general_problems_lookup[cls_name] + ) + elif cls_name in instrument_problems_lookup: + selected_problems["problem_class"].append( + instrument_problems_lookup[cls_name] + ) + else: + raise ValueError( + f"Problem class '{cls_name}' not found in known problem registries." + ) + selected_problems["waypoint_i"].append(wp_idx) + + return selected_problems + def _log_problem( self, problem: GeneralProblem | InstrumentProblem, @@ -294,12 +397,6 @@ def _make_checkpoint(self, failed_waypoint_i: int | None = None) -> Checkpoint: past_schedule=self.expedition.schedule, failed_waypoint_i=failed_waypoint_i ) - 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 - return hashlib.shake_128(s.encode("utf-8")).hexdigest(half_length) - def _hash_to_json( self, problem: InstrumentProblem | GeneralProblem, @@ -308,7 +405,6 @@ def _hash_to_json( hash_path: Path, ) -> dict: """Convert problem details + hash to json.""" - 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 47eb60d99..da5757fee 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -87,6 +87,7 @@ def verify(self, expedition: Expedition, expedition_dir: Path) -> None: "problem_*.json" ) ] + if len(hash_fpaths) > 0: for file in hash_fpaths: with open(file, encoding="utf-8") as f: diff --git a/src/virtualship/models/expedition.py b/src/virtualship/models/expedition.py index 23544c7d3..3454380ac 100644 --- a/src/virtualship/models/expedition.py +++ b/src/virtualship/models/expedition.py @@ -15,6 +15,7 @@ _calc_sail_time, _get_bathy_data, _get_waypoint_latlons, + _make_hash, _validate_numeric_to_timedelta, ) @@ -66,6 +67,14 @@ def get_instruments(self) -> set[InstrumentType]: "Underway instrument config attribute(s) are missing from YAML. Must be Config object or None." ) from e + def get_expedition_hash(self) -> str: + """Generate a unique hash for the expedition based waypoints locations and instrument types. Therefore, any changes to location, number of waypoints or instrument types will change the hash.""" + waypoint_data = "".join( + f"{wp.location.lat},{wp.location.lon};{wp.instrument}" + for wp in self.schedule.waypoints + ) + return _make_hash(waypoint_data, length=16) + class ShipConfig(pydantic.BaseModel): """Configuration of the ship.""" diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index af0184a69..8bb5a7973 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -1,6 +1,7 @@ from __future__ import annotations import glob +import hashlib import os import re import warnings @@ -630,3 +631,10 @@ def _calc_wp_stationkeeping_time( cumulative_stationkeeping_time += iconfig.stationkeeping_time return cumulative_stationkeeping_time + + +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) From 3b1da68456cd41ec2127b4db654ce8347cbff9b3 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:34:46 +0100 Subject: [PATCH 32/52] parse prob_level argument through `run` CLI command --- src/virtualship/cli/_run.py | 15 +++++---------- src/virtualship/cli/commands.py | 15 +++++++++++++-- src/virtualship/expedition/simulate_schedule.py | 2 +- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index 5b4fa0df6..33ad71ad9 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -35,9 +35,8 @@ logging.getLogger("copernicusmarine").setLevel("ERROR") -# 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 + expedition_dir: str | Path, prob_level: int, from_data: Path | None = None ) -> None: """ Perform an expedition, providing terminal feedback and file output. @@ -133,15 +132,10 @@ def _run( expedition_hash = expedition.get_expedition_hash() # problems - selected_problems_fname = ( - "selected_problems_" + expedition_hash + ".json" - ) # for caching selected problems for this expedition + selected_problems_fname = "selected_problems_" + expedition_hash + ".json" problem_simulator = ProblemSimulator(expedition, expedition_dir) - # TODO: prob_level needs to be parsed from CLI args - #! TODO: the argument should ensure that only "0", "1", or "2" can be used as arguments - # re-load previously encountered, valid (same expedition as previously) problems if they exist, else select new problems and cache them if os.path.exists( expedition_dir / PROBLEMS_ENCOUNTERED_DIR / selected_problems_fname @@ -151,13 +145,14 @@ def _run( problems = problem_simulator.select_problems( instruments_in_expedition, prob_level ) - problem_simulator.cache_selected_problems(problems, selected_problems_fname) + if problems: + problem_simulator.cache_selected_problems(problems, selected_problems_fname) # simulate measurements print("\nSimulating measurements. This may take a while...\n") for itype in instruments_in_expedition: - if prob_level > 0: # only helpful if problems are being simulated + if problems: # only helpful if problems are being simulated print( f"\033[4mUp next\033[0m: {itype.name} measurements...\n" ) # TODO: will want to clear once simulation line is running... diff --git a/src/virtualship/cli/commands.py b/src/virtualship/cli/commands.py index f349dc6cf..be088ed5c 100644 --- a/src/virtualship/cli/commands.py +++ b/src/virtualship/cli/commands.py @@ -82,6 +82,17 @@ def plan(path): "path", type=click.Path(exists=True, file_okay=False, dir_okay=True, readable=True), ) +@click.option( + "--prob-level", + type=click.IntRange(0, 2), + default=1, + help="Set the problem level for the expedition simulation [default = 1].\n\n" + "Level 0 = No problems encountered during the expedition.\n\n" + "Level 1 = 1-2 problems encountered.\n\n" + "Level 2 = 1 or more problems encountered, depending on expedition length and complexity, where longer and more complex expeditions will encounter more problems.\n\n" + "N.B.: If an expedition has already been run with problems encountered, changing the prob_level on a subsequent re-run will have no effect (previously encountered problems will be re-used). To select new problems (or to skip problems altogether), delete the 'problems_encountered' directory in the expedition directory before re-running with a new prob_level.\n\n" + "Changing waypoint locations and/or instrument types will also result in new problems being selected on the next run.", +) @click.option( "--from-data", type=str, @@ -92,6 +103,6 @@ def plan(path): "Assumes that variable names at least contain the standard Copernicus Marine variable name as a substring. " "Will also take the first file found containing the variable name substring. CAUTION if multiple files contain the same variable name substring.", ) -def run(path, from_data): +def run(path, prob_level, from_data): """Execute the expedition simulations.""" - _run(Path(path), from_data) + _run(Path(path), prob_level, from_data) diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index 6c27ab79a..64d63392c 100644 --- a/src/virtualship/expedition/simulate_schedule.py +++ b/src/virtualship/expedition/simulate_schedule.py @@ -125,7 +125,7 @@ def simulate(self) -> ScheduleOk | ScheduleProblem: print( f"\nWaypoint {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" - "\nHint: previous schedule verification (e.g. in the `virtualship plan` tool) will not account for measurement times, only the time it takes to sail between waypoints.\n" + "\nHint: previous schedule verification checks (e.g. in the `virtualship plan` tool or after dealing with unexpected problems during the expedition) will not account for measurement times, only the time it takes to sail between waypoints.\n" ) return ScheduleProblem(self._time, wp_i) else: From 2153a43a6981a4015cfd006a128e07016e05fecc Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 29 Jan 2026 15:52:49 +0100 Subject: [PATCH 33/52] update class structure --- .../make_realistic/problems/scenarios.py | 230 ++++++++---------- 1 file changed, 98 insertions(+), 132 deletions(-) diff --git a/src/virtualship/make_realistic/problems/scenarios.py b/src/virtualship/make_realistic/problems/scenarios.py index ed9b377fc..f528238f8 100644 --- a/src/virtualship/make_realistic/problems/scenarios.py +++ b/src/virtualship/make_realistic/problems/scenarios.py @@ -1,56 +1,36 @@ from __future__ import annotations -import abc from dataclasses import dataclass from datetime import timedelta -from typing import TYPE_CHECKING from virtualship.instruments.types import InstrumentType from virtualship.utils import register_general_problem, register_instrument_problem -if TYPE_CHECKING: - pass - - # ===================================================== # SECTION: Base Classes # ===================================================== -# TODO: pydantic model to ensure correct types? @dataclass -class GeneralProblem(abc.ABC): - """ - Base class for general problems. - - Problems occur at each waypoint. - """ +class GeneralProblem: + """Base class for general problems. Can occur pre-depature or during expedition.""" message: str - 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: timedelta 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.""" - # ... + # TODO: could add a (abstract) method to check if problem is valid for given waypoint, e.g. location (tropical waters etc.) @dataclass -class InstrumentProblem(abc.ABC): - """Base class for instrument-specific problems.""" +class InstrumentProblem: + """Base class for instrument-specific problems. Cannot occur before expedition departure.""" - instrument_dataclass: type message: str - 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: timedelta - pre_departure: bool # True if problem can occur before expedition departure, False if during expedition + instrument_type: InstrumentType - # @abc.abstractmethod - # def is_valid() -> bool: - # """Check if the problem can occur based on e.g. waypoint location and/or datetime etc.""" - # ... + # TODO: could add a (abstract) method to check if problem is valid for given waypoint, e.g. location () # ===================================================== @@ -63,39 +43,14 @@ class InstrumentProblem(abc.ABC): class FoodDeliveryDelayed(GeneralProblem): """Problem: Scheduled food delivery is delayed, causing a postponement of departure.""" - message = ( + 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 " + "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." ) - 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.""" - -# # TODO: this needs logic added to the is_valid() method to check if waypoint is in tropical waters - -# 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. " -# "The medical response and search efforts cause an operational delay of about 2 hours." -# ) -# -# delay_duration = timedelta(hours=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 + delay_duration: timedelta = timedelta(hours=5.0) + pre_departure: bool = True @dataclass @@ -103,41 +58,30 @@ class FoodDeliveryDelayed(GeneralProblem): class CaptainSafetyDrill(GeneralProblem): """Problem: Sudden initiation of a mandatory safety drill.""" - message = ( - "A miscommunication with the ship’s captain results in the sudden initiation of a mandatory safety drill. " + 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." ) - - delay_duration = timedelta(hours=2.0) - base_probability = 0.1 - pre_departure = False + delay_duration: timedelta = timedelta(hours=2.0) + pre_departure: bool = False @dataclass @register_general_problem class FuelDeliveryIssue(GeneralProblem): - message = ( + """Problem: Fuel delivery tanker delayed, causing a postponement of departure.""" + + 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." ) - delay_duration = timedelta(hours=5.0) # dynamic delays based on repeated choices - base_probability = 0.1 - pre_departure = True - - -# @dataclass -# @register_general_problem -# class EngineOverheating: -# 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." -# ) -# delay_duration: None = None # speed reduction affects ETA instead of fixed delay -# ship_speed_knots: float = 8.5 + delay_duration: timedelta = timedelta( + hours=5.0 + ) # dynamic delays based on repeated choices + pre_departure: bool = True @dataclass @@ -145,14 +89,13 @@ class FuelDeliveryIssue(GeneralProblem): class MarineMammalInDeploymentArea(GeneralProblem): """Problem: Marine mammals observed in deployment area, causing delay.""" - message = ( + 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 2 hours." ) - delay_duration = timedelta(hours=2) - base_probability = 0.1 - pre_departure = False + delay_duration: timedelta = timedelta(hours=2) + pre_departure: bool = False @dataclass @@ -160,15 +103,14 @@ class MarineMammalInDeploymentArea(GeneralProblem): class BallastPumpFailure(GeneralProblem): """Problem: Ballast pump failure during ballasting operations.""" - message = ( - "One of the ship’s ballast pumps suddenly stops responding during routine 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. " "Engineering isolates the faulty pump and performs a rapid inspection. Temporary repairs allow limited " "functionality, but the interruption causes a delay of approximately 4 hours." ) - delay_duration = timedelta(hours=4.0) - base_probability = 0.1 - pre_departure = False + delay_duration: timedelta = timedelta(hours=4.0) + pre_departure: bool = False @dataclass @@ -176,14 +118,13 @@ class BallastPumpFailure(GeneralProblem): class ThrusterConverterFault(GeneralProblem): """Problem: Bow thruster's power converter fault during station-keeping.""" - message = ( + 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 4 hours." ) - delay_duration = timedelta(hours=4.0) - base_probability = 0.1 - pre_departure = False + delay_duration: timedelta = timedelta(hours=4.0) + pre_departure: bool = False @dataclass @@ -191,14 +132,13 @@ class ThrusterConverterFault(GeneralProblem): class AFrameHydraulicLeak(GeneralProblem): """Problem: Hydraulic fluid leak from A-frame actuator.""" - message = ( + 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 6 hours." ) - delay_duration = timedelta(hours=6.0) - base_probability = 0.1 - pre_departure = False + delay_duration: timedelta = timedelta(hours=6.0) + pre_departure: bool = False @dataclass @@ -206,14 +146,48 @@ class AFrameHydraulicLeak(GeneralProblem): class CoolingWaterIntakeBlocked(GeneralProblem): """Problem: Main engine's cooling water intake blocked.""" - message = ( + 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 4 hours." ) - delay_duration = timedelta(hours=4.0) - base_probability = 0.1 - pre_departure = False + delay_duration: timedelta = timedelta(hours=4.0) + pre_departure: bool = False + + +# TODO: draft problem below, but needs a method to adjust ETA based on reduced speed (future PR) +# @dataclass +# @register_general_problem +# 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." +# ) +# delay_duration: None = None # speed reduction affects ETA instead of fixed delay +# ship_speed_knots: float = 8.5 + + +# TODO: draft problem below, but needs a method to check if waypoint is in tropical waters (future PR) +# @dataclass +# @register_general_problem +# class VenomousCentipedeOnboard(GeneralProblem): +# """Problem: Venomous centipede discovered onboard 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." +# ) +# +# delay_duration: timedelta = timedelta(hours=2.0) +# pre_departure: bool = 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 # ===================================================== @@ -226,15 +200,14 @@ class CoolingWaterIntakeBlocked(GeneralProblem): class CTDCableJammed(InstrumentProblem): """Problem: CTD cable jammed in winch drum, requiring replacement.""" - message = ( + 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 5 hours." ) - delay_duration = timedelta(hours=5.0) - base_probability = 0.1 - instrument_type = InstrumentType.CTD + delay_duration: timedelta = timedelta(hours=5.0) + instrument_type: InstrumentType = InstrumentType.CTD @dataclass @@ -242,15 +215,14 @@ class CTDCableJammed(InstrumentProblem): class ADCPMalfunction(InstrumentProblem): """Problem: ADCP returns invalid data, requiring inspection.""" - message = ( + 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 2 hours." ) - delay_duration = timedelta(hours=2.0) - base_probability = 0.1 - instrument_type = InstrumentType.ADCP + delay_duration: timedelta = timedelta(hours=2.0) + instrument_type: InstrumentType = InstrumentType.ADCP @dataclass @@ -258,15 +230,14 @@ class ADCPMalfunction(InstrumentProblem): class CTDTemperatureSensorFailure(InstrumentProblem): """Problem: CTD temperature sensor failure, requiring replacement.""" - message = ( + 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 3 hours." ) - delay_duration = timedelta(hours=3.0) - base_probability = 0.1 - instrument_type = InstrumentType.CTD + delay_duration: timedelta = timedelta(hours=3.0) + instrument_type: InstrumentType = InstrumentType.CTD @dataclass @@ -274,14 +245,13 @@ class CTDTemperatureSensorFailure(InstrumentProblem): class CTDSalinitySensorFailureWithCalibration(InstrumentProblem): """Problem: CTD salinity sensor failure, requiring replacement and calibration.""" - message = ( - "The CTD’s primary salinity sensor fails and must be replaced with a backup. After installation, " + 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." ) - delay_duration = timedelta(hours=4.0) - base_probability = 0.1 - instrument_type = InstrumentType.CTD + delay_duration: timedelta = timedelta(hours=4.0) + instrument_type: InstrumentType = InstrumentType.CTD @dataclass @@ -289,15 +259,14 @@ class CTDSalinitySensorFailureWithCalibration(InstrumentProblem): class WinchHydraulicPressureDrop(InstrumentProblem): """Problem: CTD winch hydraulic pressure drop, requiring repair.""" - message = ( + 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 2.5 hours." ) - delay_duration = timedelta(hours=2.5) - base_probability = 0.1 - instrument_type = InstrumentType.CTD + delay_duration: timedelta = timedelta(hours=2.5) + instrument_type: InstrumentType = InstrumentType.CTD @dataclass @@ -305,15 +274,14 @@ class WinchHydraulicPressureDrop(InstrumentProblem): class RosetteTriggerFailure(InstrumentProblem): """Problem: CTD rosette trigger failure, requiring inspection.""" - message = ( + 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 3.5 hours." ) - delay_duration = timedelta(hours=3.5) - base_probability = 0.1 - instrument_type = InstrumentType.CTD + delay_duration: timedelta = timedelta(hours=3.5) + instrument_type: InstrumentType = InstrumentType.CTD @dataclass @@ -321,15 +289,14 @@ class RosetteTriggerFailure(InstrumentProblem): class DrifterSatelliteConnectionDelay(InstrumentProblem): """Problem: Drifter fails to establish satellite connection before deployment.""" - message = ( + 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." ) - delay_duration = timedelta(hours=2.0) - base_probability = 0.1 - instrument_type = InstrumentType.DRIFTER + delay_duration: timedelta = timedelta(hours=2.0) + instrument_type: InstrumentType = InstrumentType.DRIFTER @dataclass @@ -337,12 +304,11 @@ class DrifterSatelliteConnectionDelay(InstrumentProblem): class ArgoSatelliteConnectionDelay(InstrumentProblem): """Problem: Argo float fails to establish satellite connection before deployment.""" - message = ( + 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." ) - delay_duration = timedelta(hours=2.0) - base_probability = 0.1 - instrument_type = InstrumentType.ARGO_FLOAT + delay_duration: timedelta = timedelta(hours=2.0) + instrument_type: InstrumentType = InstrumentType.ARGO_FLOAT From 3c4808e41714d9f560e1fd9d12eba045cc291658 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 29 Jan 2026 16:07:15 +0100 Subject: [PATCH 34/52] add info to repo README and tidy up --- README.md | 2 +- src/virtualship/cli/_run.py | 19 ++++++++++--------- .../make_realistic/problems/simulator.py | 3 --- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index b30e566ed..a5833339a 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ VirtualShip is a command line simulator allowing students to plan and conduct a - Surface drifters - Argo float deployments - +Along the way, students will encounter realistic problems that may occur during an oceanographic expedition, requiring them to make decisions to adapt their plans accordingly. For example, delays due to equipment failures, pre-depature logistical issues or safety drills. ## Installation diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index 33ad71ad9..c50b977e6 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -152,22 +152,22 @@ def _run( print("\nSimulating measurements. This may take a while...\n") for itype in instruments_in_expedition: - if problems: # only helpful if problems are being simulated + # get instrument class + instrument_class = get_instrument_class(itype) + if instrument_class is None: + raise RuntimeError(f"No instrument class found for type {itype}.") + + # execute problem simulations for this instrument type + if problems: print( f"\033[4mUp next\033[0m: {itype.name} measurements...\n" - ) # TODO: will want to clear once simulation line is running... + ) # TODO: this line is helpful for user to see so it makes sense when a relevant instrument-related problem occurs; but ideally would be overwritten when the actual measurement simulation spinner starts (try and address this in future PR which improves log output) - if problems: problem_simulator.execute( problems, instrument_type_validation=itype, ) - # get instrument class - instrument_class = get_instrument_class(itype) - if instrument_class is None: - raise RuntimeError(f"No instrument class found for type {itype}.") - # get measurements to simulate attr = MeasurementsToSimulate.get_attr_for_instrumenttype(itype) measurements = getattr(schedule_results.measurements_to_simulate, attr) @@ -199,7 +199,8 @@ def _run( ) # delete checkpoint file (inteferes with ability to re-run expedition) - os.remove(expedition_dir.joinpath(CHECKPOINT)) + if os.path.exists(expedition_dir.joinpath(CHECKPOINT)): + os.remove(expedition_dir.joinpath(CHECKPOINT)) print("\n------------- END -------------\n") diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index 746f27eb0..02bae7ec3 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -176,9 +176,6 @@ def execute( N.B. a problem_waypoint_i is different to a failed_waypoint_i defined in the Checkpoint class; 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: 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 - #! TODO: may want to ensure duplicate problem types are removed; even if they could theoretically occur at different waypoints, so as not to inundate users... - for problem, problem_waypoint_i in zip( problems["problem_class"], problems["waypoint_i"], strict=True ): From eff65ceb27541a09dd612c1e00cf38acaee8c126 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 30 Jan 2026 10:20:21 +0100 Subject: [PATCH 35/52] re-organise utils.py, improve instrument config register and downstream stationkeeping time calculations --- src/virtualship/cli/_run.py | 1 + .../expedition/simulate_schedule.py | 2 + .../make_realistic/problems/simulator.py | 5 +- src/virtualship/models/expedition.py | 22 +- src/virtualship/utils.py | 236 +++++++++++------- 5 files changed, 155 insertions(+), 111 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index c50b977e6..a0bb5db8f 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -130,6 +130,7 @@ def _run( # unique hash for this expedition (based on waypoint locations and instrument types); used for identifying previously encountered problems; therefore new set of problems if waypoint locations or instrument types change expedition_hash = expedition.get_expedition_hash() + # TODO: give this a datetime as well, i.e. 8 digit hash + dateime stamp # problems selected_problems_fname = "selected_problems_" + expedition_hash + ".json" diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index 64d63392c..4e7b64bb4 100644 --- a/src/virtualship/expedition/simulate_schedule.py +++ b/src/virtualship/expedition/simulate_schedule.py @@ -116,6 +116,8 @@ def __init__(self, projection: pyproj.Geod, expedition: Expedition) -> None: self._next_ship_underwater_st_time = self._time def simulate(self) -> ScheduleOk | ScheduleProblem: + # TODO: instrument config mapping (as introduced in #269) should be helpful for refactoring here... + for wp_i, waypoint in enumerate(self._expedition.schedule.waypoints): # sail towards waypoint self._progress_time_traveling_towards(waypoint.location) diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index 02bae7ec3..fe84767f0 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -149,9 +149,10 @@ def select_problems( if getattr(problem, "pre_departure", False): waypoint_idxs.append(None) else: + # TODO: if incorporate departure and arrival port/waypoints in future, bear in mind index selection here may need to change waypoint_idxs.append( - random.randint(0, len(self.expedition.schedule.waypoints) - 1) - ) # last waypoint excluded (would not impact any future scheduling) + random.randint(0, len(self.expedition.schedule.waypoints) - 2) + ) # -1 to get index and -1 exclude last waypoint (would not impact any future scheduling as arrival in port is not part of schedule) # pair problems with their waypoint indices and sort by waypoint index (pre-departure first) paired = sorted( diff --git a/src/virtualship/models/expedition.py b/src/virtualship/models/expedition.py index 3454380ac..a36e26418 100644 --- a/src/virtualship/models/expedition.py +++ b/src/virtualship/models/expedition.py @@ -17,6 +17,7 @@ _get_waypoint_latlons, _make_hash, _validate_numeric_to_timedelta, + register_instrument_config, ) from .location import Location @@ -213,11 +214,10 @@ def serialize_instrument(self, instrument): return instrument.value if instrument else None +@register_instrument_config(InstrumentType.ARGO_FLOAT) class ArgoFloatConfig(pydantic.BaseModel): """Configuration for argos floats.""" - instrument_type: InstrumentType = InstrumentType.ARGO_FLOAT - min_depth_meter: float = pydantic.Field(le=0.0) max_depth_meter: float = pydantic.Field(le=0.0) drift_depth_meter: float = pydantic.Field(le=0.0) @@ -255,11 +255,10 @@ def _validate_stationkeeping_time(cls, value: int | float | timedelta) -> timede model_config = pydantic.ConfigDict(populate_by_name=True) +@register_instrument_config(InstrumentType.ADCP) class ADCPConfig(pydantic.BaseModel): """Configuration for ADCP instrument.""" - instrument_type: InstrumentType = InstrumentType.ADCP - max_depth_meter: float = pydantic.Field(le=0.0) num_bins: int = pydantic.Field(gt=0.0) period: timedelta = pydantic.Field( @@ -279,11 +278,10 @@ def _validate_period(cls, value: int | float | timedelta) -> timedelta: return _validate_numeric_to_timedelta(value, "minutes") +@register_instrument_config(InstrumentType.CTD) class CTDConfig(pydantic.BaseModel): """Configuration for CTD instrument.""" - instrument_type: InstrumentType = InstrumentType.CTD - stationkeeping_time: timedelta = pydantic.Field( serialization_alias="stationkeeping_time_minutes", validation_alias="stationkeeping_time_minutes", @@ -303,11 +301,10 @@ def _validate_stationkeeping_time(cls, value: int | float | timedelta) -> timede return _validate_numeric_to_timedelta(value, "minutes") +@register_instrument_config(InstrumentType.CTD_BGC) class CTD_BGCConfig(pydantic.BaseModel): """Configuration for CTD_BGC instrument.""" - instrument_type: InstrumentType = InstrumentType.CTD_BGC - stationkeeping_time: timedelta = pydantic.Field( serialization_alias="stationkeeping_time_minutes", validation_alias="stationkeeping_time_minutes", @@ -327,11 +324,10 @@ def _validate_stationkeeping_time(cls, value: int | float | timedelta) -> timede return _validate_numeric_to_timedelta(value, "minutes") +@register_instrument_config(InstrumentType.UNDERWATER_ST) class ShipUnderwaterSTConfig(pydantic.BaseModel): """Configuration for underwater ST.""" - instrument_type: InstrumentType = InstrumentType.UNDERWATER_ST - period: timedelta = pydantic.Field( serialization_alias="period_minutes", validation_alias="period_minutes", @@ -349,11 +345,10 @@ def _validate_period(cls, value: int | float | timedelta) -> timedelta: return _validate_numeric_to_timedelta(value, "minutes") +@register_instrument_config(InstrumentType.DRIFTER) class DrifterConfig(pydantic.BaseModel): """Configuration for drifters.""" - instrument_type: InstrumentType = InstrumentType.DRIFTER - depth_meter: float = pydantic.Field(le=0.0) lifetime: timedelta = pydantic.Field( serialization_alias="lifetime_days", @@ -385,11 +380,10 @@ def _validate_stationkeeping_time(cls, value: int | float | timedelta) -> timede return _validate_numeric_to_timedelta(value, "minutes") +@register_instrument_config(InstrumentType.XBT) class XBTConfig(pydantic.BaseModel): """Configuration for xbt instrument.""" - instrument_type: InstrumentType = InstrumentType.XBT - min_depth_meter: float = pydantic.Field(le=0.0) max_depth_meter: float = pydantic.Field(le=0.0) fall_speed_meter_per_second: float = pydantic.Field(gt=0.0) diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 8bb5a7973..319fc433a 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -32,6 +32,10 @@ from pydantic import BaseModel from yaspin import Spinner +# ===================================================== +# SECTION: simulation constants +# ===================================================== + EXPEDITION = "expedition.yaml" CHECKPOINT = "checkpoint.yaml" SCHEDULE_ORIGINAL = "schedule_original.yaml" @@ -41,6 +45,105 @@ PROJECTION = pyproj.Geod(ellps="WGS84") +# ===================================================== +# SECTION: Copernicus Marine Service constants +# ===================================================== + +# Copernicus Marine product IDs + +PRODUCT_IDS = { + "phys": { + "reanalysis": "cmems_mod_glo_phy_my_0.083deg_P1D-m", + "reanalysis_interim": "cmems_mod_glo_phy_myint_0.083deg_P1D-m", + "analysis": "cmems_mod_glo_phy_anfc_0.083deg_P1D-m", + }, + "bgc": { + "reanalysis": "cmems_mod_glo_bgc_my_0.25deg_P1D-m", + "reanalysis_interim": "cmems_mod_glo_bgc_myint_0.25deg_P1D-m", + "analysis": None, # will be set per variable + }, +} + +BGC_ANALYSIS_IDS = { + "o2": "cmems_mod_glo_bgc-bio_anfc_0.25deg_P1D-m", + "chl": "cmems_mod_glo_bgc-pft_anfc_0.25deg_P1D-m", + "no3": "cmems_mod_glo_bgc-nut_anfc_0.25deg_P1D-m", + "po4": "cmems_mod_glo_bgc-nut_anfc_0.25deg_P1D-m", + "ph": "cmems_mod_glo_bgc-car_anfc_0.25deg_P1D-m", + "phyc": "cmems_mod_glo_bgc-pft_anfc_0.25deg_P1D-m", + "nppv": "cmems_mod_glo_bgc-bio_anfc_0.25deg_P1D-m", +} + +MONTHLY_BGC_REANALYSIS_IDS = { + "ph": "cmems_mod_glo_bgc_my_0.25deg_P1M-m", + "phyc": "cmems_mod_glo_bgc_my_0.25deg_P1M-m", +} +MONTHLY_BGC_REANALYSIS_INTERIM_IDS = { + "ph": "cmems_mod_glo_bgc_myint_0.25deg_P1M-m", + "phyc": "cmems_mod_glo_bgc_myint_0.25deg_P1M-m", +} + +# variables used in VirtualShip which are physical or biogeochemical variables, respectively +COPERNICUSMARINE_PHYS_VARIABLES = ["uo", "vo", "so", "thetao"] +COPERNICUSMARINE_BGC_VARIABLES = ["o2", "chl", "no3", "po4", "ph", "phyc", "nppv"] + +BATHYMETRY_ID = "cmems_mod_glo_phy_my_0.083deg_static" + + +# ===================================================== +# SECTION: dynamic registries and mapping +# ===================================================== + +# helpful for dynamic access in different parts of the codebase + +# main instrument (simulation) class registry and registration utilities +INSTRUMENT_CLASS_MAP = {} + + +def register_instrument(instrument_type): + def decorator(cls): + INSTRUMENT_CLASS_MAP[instrument_type] = cls + return cls + + return decorator + + +def get_instrument_class(instrument_type): + return INSTRUMENT_CLASS_MAP.get(instrument_type) + + +# problems inventory registry and registration utilities +INSTRUMENT_PROBLEM_REG = [] +GENERAL_PROBLEM_REG = [] + + +def register_instrument_problem(cls): + INSTRUMENT_PROBLEM_REG.append(cls) + return cls + + +def register_general_problem(cls): + GENERAL_PROBLEM_REG.append(cls) + return cls + + +# map for instrument type to instrument config (pydantic basemodel) names +INSTRUMENT_CONFIG_MAP = {} + + +def register_instrument_config(instrument_type): + def decorator(cls): + INSTRUMENT_CONFIG_MAP[instrument_type] = cls.__name__ + return cls + + return decorator + + +# ===================================================== +# SECTION: helper functions +# ===================================================== + + def load_static_file(name: str) -> str: """Load static file from the ``virtualship.static`` module by file name.""" return files("virtualship.static").joinpath(name).read_text(encoding="utf-8") @@ -225,40 +328,6 @@ def _get_expedition(expedition_dir: Path) -> Expedition: ) from e -# custom ship spinner -ship_spinner = Spinner( - interval=240, - frames=[ - " 🚢 ", - " 🚢 ", - " 🚢 ", - " 🚢 ", - " 🚢", - " 🚢 ", - " 🚢 ", - " 🚢 ", - " 🚢 ", - "🚢 ", - ], -) - - -# InstrumentType -> Instrument registry and registration utilities. -INSTRUMENT_CLASS_MAP = {} - - -def register_instrument(instrument_type): - def decorator(cls): - INSTRUMENT_CLASS_MAP[instrument_type] = cls - return cls - - return decorator - - -def get_instrument_class(instrument_type): - return INSTRUMENT_CLASS_MAP.get(instrument_type) - - def add_dummy_UV(fieldset: FieldSet): """Add a dummy U and V field to a FieldSet to satisfy parcels FieldSet completeness checks.""" if "U" not in fieldset.__dict__.keys(): @@ -282,62 +351,6 @@ def add_dummy_UV(fieldset: FieldSet): ) from None -# problems inventory registry and registration utilities -INSTRUMENT_PROBLEM_REG = [] -GENERAL_PROBLEM_REG = [] - - -def register_instrument_problem(cls): - INSTRUMENT_PROBLEM_REG.append(cls) - return cls - - -def register_general_problem(cls): - GENERAL_PROBLEM_REG.append(cls) - return cls - - -# Copernicus Marine product IDs - -PRODUCT_IDS = { - "phys": { - "reanalysis": "cmems_mod_glo_phy_my_0.083deg_P1D-m", - "reanalysis_interim": "cmems_mod_glo_phy_myint_0.083deg_P1D-m", - "analysis": "cmems_mod_glo_phy_anfc_0.083deg_P1D-m", - }, - "bgc": { - "reanalysis": "cmems_mod_glo_bgc_my_0.25deg_P1D-m", - "reanalysis_interim": "cmems_mod_glo_bgc_myint_0.25deg_P1D-m", - "analysis": None, # will be set per variable - }, -} - -BGC_ANALYSIS_IDS = { - "o2": "cmems_mod_glo_bgc-bio_anfc_0.25deg_P1D-m", - "chl": "cmems_mod_glo_bgc-pft_anfc_0.25deg_P1D-m", - "no3": "cmems_mod_glo_bgc-nut_anfc_0.25deg_P1D-m", - "po4": "cmems_mod_glo_bgc-nut_anfc_0.25deg_P1D-m", - "ph": "cmems_mod_glo_bgc-car_anfc_0.25deg_P1D-m", - "phyc": "cmems_mod_glo_bgc-pft_anfc_0.25deg_P1D-m", - "nppv": "cmems_mod_glo_bgc-bio_anfc_0.25deg_P1D-m", -} - -MONTHLY_BGC_REANALYSIS_IDS = { - "ph": "cmems_mod_glo_bgc_my_0.25deg_P1M-m", - "phyc": "cmems_mod_glo_bgc_my_0.25deg_P1M-m", -} -MONTHLY_BGC_REANALYSIS_INTERIM_IDS = { - "ph": "cmems_mod_glo_bgc_myint_0.25deg_P1M-m", - "phyc": "cmems_mod_glo_bgc_myint_0.25deg_P1M-m", -} - -# variables used in VirtualShip which are physical or biogeochemical variables, respectively -COPERNICUSMARINE_PHYS_VARIABLES = ["uo", "vo", "so", "thetao"] -COPERNICUSMARINE_BGC_VARIABLES = ["o2", "chl", "no3", "po4", "ph", "phyc", "nppv"] - -BATHYMETRY_ID = "cmems_mod_glo_phy_my_0.083deg_static" - - def _select_product_id( physical: bool, schedule_start, @@ -607,7 +620,9 @@ def _calc_sail_time( def _calc_wp_stationkeeping_time( - wp_instrument_types: list, expedition: Expedition + wp_instrument_types: list, + expedition: Expedition, + instrument_config_map: dict = INSTRUMENT_CONFIG_MAP, ) -> timedelta: """For a given waypoint, calculate how much time is required to carry out all instrument deployments.""" # TODO: this can be removed if/when CTD and CTD_BGC are merged to a single instrument @@ -616,17 +631,25 @@ def _calc_wp_stationkeeping_time( and InstrumentType.CTD_BGC in wp_instrument_types ) - # extract configs for instruments present in waypoint - wp_instrument_configs = [ + # extract configs for all instruments present in expedition + valid_instrument_configs = [ iconfig for _, iconfig in expedition.instruments_config.__dict__.items() - if iconfig is not None and iconfig.instrument_type in wp_instrument_types + if iconfig ] + # extract configs for instruments present in given waypoint + wp_instrument_configs = [] + for iconfig in valid_instrument_configs: + for itype in wp_instrument_types: + if instrument_config_map[itype] == iconfig.__class__.__name__: + wp_instrument_configs.append(iconfig) + + # get wp total stationkeeping time cumulative_stationkeeping_time = timedelta() for iconfig in wp_instrument_configs: if both_ctd_and_bgc and iconfig.instrument_type == InstrumentType.CTD_BGC: - continue # # only need to add time cost once if both CTD and CTD_BGC are being taken; in reality they would be done on the same instrument + continue # only need to add time cost once if both CTD and CTD_BGC are being taken; in reality they would be done on the same instrument if hasattr(iconfig, "stationkeeping_time"): cumulative_stationkeeping_time += iconfig.stationkeeping_time @@ -638,3 +661,26 @@ def _make_hash(s: str, length: int) -> str: assert length % 2 == 0, "Length must be even." half_length = length // 2 return hashlib.shake_128(s.encode("utf-8")).hexdigest(half_length) + + +# ===================================================== +# SECTION: misc. +# ===================================================== + + +# custom ship spinner +ship_spinner = Spinner( + interval=240, + frames=[ + " 🚢 ", + " 🚢 ", + " 🚢 ", + " 🚢 ", + " 🚢", + " 🚢 ", + " 🚢 ", + " 🚢 ", + " 🚢 ", + "🚢 ", + ], +) From f3bad07bf6766114da0a37d268fec3e53b43482d Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:41:52 +0100 Subject: [PATCH 36/52] improve problems dir structures and giving unique identifiers, plus improve checkpoint error messaging --- src/virtualship/cli/_run.py | 55 ++++++------ .../make_realistic/problems/simulator.py | 85 +++++++++---------- src/virtualship/models/checkpoint.py | 53 ++++++------ src/virtualship/models/expedition.py | 4 +- src/virtualship/utils.py | 6 +- 5 files changed, 102 insertions(+), 101 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index a0bb5db8f..dc78876c2 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -1,6 +1,5 @@ """do_expedition function.""" -import glob import logging import os import shutil @@ -21,6 +20,7 @@ EXPEDITION, PROBLEMS_ENCOUNTERED_DIR, PROJECTION, + SELECTED_PROBLEMS, _get_expedition, _save_checkpoint, expedition_cost, @@ -74,6 +74,12 @@ def _run( expedition_dir = Path(expedition_dir) expedition = _get_expedition(expedition_dir) + expedition_identifier = expedition.get_unique_identifier() + + # dedicated problems directory for this expedition + problems_dir = expedition_dir / PROBLEMS_ENCOUNTERED_DIR.format( + expedition_identifier=expedition_identifier + ) # verify instruments_config file is consistent with schedule expedition.instruments_config.verify(expedition) @@ -84,7 +90,7 @@ def _run( checkpoint = Checkpoint(past_schedule=Schedule(waypoints=[])) # verify that schedule and checkpoint match, and that problems have been resolved - checkpoint.verify(expedition, expedition_dir) + checkpoint.verify(expedition, problems_dir) print("\n---- WAYPOINT VERIFICATION ----") @@ -128,28 +134,23 @@ def _run( # identify instruments in expedition instruments_in_expedition = expedition.get_instruments() - # unique hash for this expedition (based on waypoint locations and instrument types); used for identifying previously encountered problems; therefore new set of problems if waypoint locations or instrument types change - expedition_hash = expedition.get_expedition_hash() - # TODO: give this a datetime as well, i.e. 8 digit hash + dateime stamp - - # problems - selected_problems_fname = "selected_problems_" + expedition_hash + ".json" - + # initialise problem simulator problem_simulator = ProblemSimulator(expedition, expedition_dir) - # re-load previously encountered, valid (same expedition as previously) problems if they exist, else select new problems and cache them - if os.path.exists( - expedition_dir / PROBLEMS_ENCOUNTERED_DIR / selected_problems_fname - ): - problems = problem_simulator.load_selected_problems(selected_problems_fname) + # re-load previously encountered (same expedition as previously) problems if they exist, else select new problems and cache them + if os.path.exists(problems_dir / SELECTED_PROBLEMS): + problems = problem_simulator.load_selected_problems( + problems_dir / SELECTED_PROBLEMS + ) else: problems = problem_simulator.select_problems( instruments_in_expedition, prob_level ) - if problems: - problem_simulator.cache_selected_problems(problems, selected_problems_fname) + problem_simulator.cache_selected_problems( + problems, problems_dir / SELECTED_PROBLEMS + ) if problems else None - # simulate measurements + # simulate instrument measurements print("\nSimulating measurements. This may take a while...\n") for itype in instruments_in_expedition: @@ -160,13 +161,18 @@ def _run( # execute problem simulations for this instrument type if problems: + # TODO: this print statement is helpful for user to see so it makes sense when a relevant instrument-related problem occurs; but ideally would be overwritten when the actual measurement simulation spinner starts (try and address this in future PR which improves log output) print( - f"\033[4mUp next\033[0m: {itype.name} measurements...\n" - ) # TODO: this line is helpful for user to see so it makes sense when a relevant instrument-related problem occurs; but ideally would be overwritten when the actual measurement simulation spinner starts (try and address this in future PR which improves log output) + "" + if hasattr(problems["problem_class"][0], "pre_departure") + and problems["problem_class"][0].pre_departure + else f"\033[4mUp next\033[0m: {itype.name} measurements...\n" + ) problem_simulator.execute( problems, instrument_type_validation=itype, + problems_dir=problems_dir, ) # get measurements to simulate @@ -196,7 +202,7 @@ def _run( 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)}" + f"\nA record of problems encountered during the expedition is saved in: {problems_dir}" ) # delete checkpoint file (inteferes with ability to re-run expedition) @@ -219,15 +225,6 @@ 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_DIR) - 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/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index fe84767f0..29eb120c4 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -20,7 +20,6 @@ EXPEDITION, GENERAL_PROBLEM_REG, INSTRUMENT_PROBLEM_REG, - PROBLEMS_ENCOUNTERED_DIR, PROJECTION, SCHEDULE_ORIGINAL, _calc_sail_time, @@ -166,10 +165,36 @@ def select_problems( return problems_sorted if selected_problems else None + def cache_selected_problems( + self, + problems: dict[str, list[GeneralProblem | InstrumentProblem] | None], + selected_problems_fpath: str, + ) -> None: + """Cache suite of problems to json, for reference.""" + # make dir to contain problem jsons (unique to expedition) + os.makedirs(Path(selected_problems_fpath).parent, exist_ok=True) + + # cache dict of selected_problems to json + with open( + selected_problems_fpath, + "w", + encoding="utf-8", + ) as f: + json.dump( + { + "problem_class": [p.__name__ for p in problems["problem_class"]], + "waypoint_i": problems["waypoint_i"], + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), + }, + f, + indent=4, + ) + def execute( self, problems: dict[str, list[GeneralProblem | InstrumentProblem] | None], instrument_type_validation: InstrumentType | None, + problems_dir: Path, log_delay: float = 7.0, ): """ @@ -188,10 +213,8 @@ def execute( continue problem_hash = _make_hash(problem.message + str(problem_waypoint_i), 8) - hash_path = self.expedition_dir.joinpath( - PROBLEMS_ENCOUNTERED_DIR, f"problem_{problem_hash}.json" - ) - if hash_path.exists(): + hash_fpath = problems_dir.joinpath(f"problem_{problem_hash}.json") + if hash_fpath.exists(): continue # problem * waypoint combination has already occurred; don't repeat if issubclass(problem, GeneralProblem) and problem.pre_departure: @@ -208,40 +231,23 @@ def execute( problem_waypoint_i, alert_msg, problem_hash, - hash_path, + hash_fpath, log_delay, ) - def cache_selected_problems( - self, - problems: dict[str, list[GeneralProblem | InstrumentProblem] | None], - selected_problems_fname: str, - ) -> None: - """Cache suite of problems to json, for reference.""" - # make dir to contain problem jsons (unique to expedition) - os.makedirs(self.expedition_dir / PROBLEMS_ENCOUNTERED_DIR, exist_ok=True) - - # cache dict of selected_problems to json - with open( - self.expedition_dir / PROBLEMS_ENCOUNTERED_DIR / selected_problems_fname, - "w", - encoding="utf-8", - ) as f: - json.dump( - { - "problem_class": [p.__name__ for p in problems["problem_class"]], - "waypoint_i": problems["waypoint_i"], - }, - f, - indent=4, - ) + # cache original schedule for reference and/or restoring later if needed (checkpoint.yaml [written in _log_problem] can be overwritten if multiple problems occur so is not a persistent record of original schedule) + schedule_original_fpath = problems_dir / SCHEDULE_ORIGINAL + if not os.path.exists(schedule_original_fpath): + self._cache_original_schedule( + self.expedition.schedule, schedule_original_fpath + ) def load_selected_problems( - self, selected_problems_fname: str + self, selected_problems_fpath: str ) -> dict[str, list[GeneralProblem | InstrumentProblem] | None]: """Load previously selected problem classes from json.""" with open( - self.expedition_dir / PROBLEMS_ENCOUNTERED_DIR / selected_problems_fname, + selected_problems_fpath, encoding="utf-8", ) as f: problems_json = json.load(f) @@ -278,7 +284,7 @@ def _log_problem( problem_waypoint_i: int | None, alert_msg: str, problem_hash: str, - hash_path: Path, + hash_fpath: Path, log_delay: float, ): """Log problem occurrence with spinner and delay, save to checkpoint, write hash.""" @@ -303,7 +309,7 @@ def _log_problem( problem, problem_hash, problem_waypoint_i, - hash_path, + hash_fpath, ) # check if enough contingency time has been scheduled to avoid delay affecting future waypoints @@ -316,10 +322,10 @@ def _log_problem( print(LOG_MESSAGING["problem_avoided"]) # update problem json to resolved = True - with open(hash_path, encoding="utf-8") as f: + with open(hash_fpath, encoding="utf-8") as f: problem_json = json.load(f) problem_json["resolved"] = True - with open(hash_path, "w", encoding="utf-8") as f_out: + with open(hash_fpath, "w", encoding="utf-8") as f_out: json.dump(problem_json, f_out, indent=4) with yaspin(): # time to read message before simulation continues @@ -346,15 +352,6 @@ def _log_problem( ) # failed waypoint index then becomes the one after the one where the problem occurred; as this is when scheduling issues would be run into; for pre-departure problems this is the first waypoint _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) - schedule_original_path = ( - self.expedition_dir / PROBLEMS_ENCOUNTERED_DIR / SCHEDULE_ORIGINAL - ) - if os.path.exists(schedule_original_path) is False: - self._cache_original_schedule( - self.expedition.schedule, schedule_original_path - ) - # pause simulation sys.exit(0) diff --git a/src/virtualship/models/checkpoint.py b/src/virtualship/models/checkpoint.py index da5757fee..700c714f5 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -14,7 +14,6 @@ from virtualship.models.expedition import Expedition, Schedule from virtualship.utils import ( EXPEDITION, - PROBLEMS_ENCOUNTERED_DIR, PROJECTION, _calc_sail_time, _calc_wp_stationkeeping_time, @@ -61,7 +60,7 @@ def from_yaml(cls, file_path: str | Path) -> Checkpoint: data = yaml.safe_load(file) return Checkpoint(**data) - def verify(self, expedition: Expedition, expedition_dir: Path) -> None: + def verify(self, expedition: Expedition, problems_dir: Path) -> None: """ Verify that the given schedule matches the checkpoint's past schedule , and/or that any problem has been resolved. @@ -82,10 +81,7 @@ def verify(self, expedition: Expedition, expedition_dir: Path) -> None: # 2) check that problems have been resolved in the new schedule hash_fpaths = [ - str(path.resolve()) - for path in Path(expedition_dir, PROBLEMS_ENCOUNTERED_DIR).glob( - "problem_*.json" - ) + str(path.resolve()) for path in problems_dir.glob("problem_*.json") ] if len(hash_fpaths) > 0: @@ -95,24 +91,26 @@ def verify(self, expedition: Expedition, expedition_dir: Path) -> None: if problem["resolved"]: continue elif not problem["resolved"]: - # check if delay has been accounted for in the new schedule (at waypoint immediately after problem waypoint) + # check if delay has been accounted for in the new schedule (at waypoint immediately after problem waypoint; or first waypoint if pre-departure problem) delay_duration = timedelta( hours=float(problem["delay_duration_hours"]) ) + problem_waypoint = ( + new_schedule.waypoints[0] + if problem["problem_waypoint_i"] is None + else new_schedule.waypoints[problem["problem_waypoint_i"]] + ) + # pre-departure problem: check that whole delay duration has been added to first waypoint time (by testing against past schedule) if problem["problem_waypoint_i"] is None: time_diff = ( - new_schedule.waypoints[0].time - - self.past_schedule.waypoints[0].time + problem_waypoint.time - self.past_schedule.waypoints[0].time ) resolved = time_diff >= delay_duration # problem at a later waypoint: check new scheduled time exceeds sail time + delay duration + instrument deployment time (rather whole delay duration add-on, as there may be _some_ contingency time already scheduled) else: - problem_waypoint = new_schedule.waypoints[ - problem["problem_waypoint_i"] - ] failed_waypoint = new_schedule.waypoints[self.failed_waypoint_i] scheduled_time = failed_waypoint.time - problem_waypoint.time @@ -149,28 +147,35 @@ def verify(self, expedition: Expedition, expedition_dir: Path) -> None: break else: - problem_wp = ( + problem_wp_str = ( "in-port" if problem["problem_waypoint_i"] is None else f"at waypoint {problem['problem_waypoint_i'] + 1}" ) - affected_wp = ( + affected_wp_str = ( "1" if problem["problem_waypoint_i"] is None else f"{problem['problem_waypoint_i'] + 2}" ) - current_time = ( - problem_waypoint.time - + sail_time - + delay_duration - + stationkeeping_time + time_elapsed = ( + (sail_time + delay_duration + stationkeeping_time) + if problem["problem_waypoint_i"] is not None + else delay_duration ) + failed_waypoint_time = ( + failed_waypoint.time + if problem["problem_waypoint_i"] is not None + else new_schedule.waypoints[0].time + ) + current_time = problem_waypoint.time + time_elapsed raise CheckpointError( 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\n" - f"The problem was associated with a delay duration of {problem['delay_duration_hours']} hours {problem_wp} (meaning waypoint {affected_wp} could not be reached in time). " - f"Currently, the ship would reach waypoint {affected_wp} at {current_time}, but the scheduled time is {failed_waypoint.time}.\n\n" - + f"Hint: don't forget to factor in the time required to deploy the instruments {problem_wp} when rescheduling waypoint {affected_wp}." - if problem["problem_waypoint_i"] is not None - else None + f"The problem was associated with a delay duration of {problem['delay_duration_hours']} hours {problem_wp_str} (meaning waypoint {affected_wp_str} could not be reached in time). " + f"Currently, the ship would reach waypoint {affected_wp_str} at {current_time}, but the scheduled time is {failed_waypoint_time}." + + ( + f"\n\nHint: don't forget to factor in the time required to deploy the instruments {problem_wp_str} when rescheduling waypoint {affected_wp_str}." + if problem["problem_waypoint_i"] is not None + else "" + ) ) diff --git a/src/virtualship/models/expedition.py b/src/virtualship/models/expedition.py index a36e26418..f9c75ee79 100644 --- a/src/virtualship/models/expedition.py +++ b/src/virtualship/models/expedition.py @@ -68,13 +68,13 @@ def get_instruments(self) -> set[InstrumentType]: "Underway instrument config attribute(s) are missing from YAML. Must be Config object or None." ) from e - def get_expedition_hash(self) -> str: + def get_unique_identifier(self) -> str: """Generate a unique hash for the expedition based waypoints locations and instrument types. Therefore, any changes to location, number of waypoints or instrument types will change the hash.""" waypoint_data = "".join( f"{wp.location.lat},{wp.location.lon};{wp.instrument}" for wp in self.schedule.waypoints ) - return _make_hash(waypoint_data, length=16) + return _make_hash(waypoint_data, length=12) class ShipConfig(pydantic.BaseModel): diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 319fc433a..198410ad0 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -39,7 +39,9 @@ EXPEDITION = "expedition.yaml" CHECKPOINT = "checkpoint.yaml" SCHEDULE_ORIGINAL = "schedule_original.yaml" -PROBLEMS_ENCOUNTERED_DIR = "problems_encountered" + +PROBLEMS_ENCOUNTERED_DIR = "problems_encountered_" + "{expedition_identifier}" +SELECTED_PROBLEMS = "selected_problems.json" # projection used to sail between waypoints PROJECTION = pyproj.Geod(ellps="WGS84") @@ -91,7 +93,7 @@ # ===================================================== -# SECTION: dynamic registries and mapping +# SECTION: decorators / dynamic registries and mapping # ===================================================== # helpful for dynamic access in different parts of the codebase From 650cb21fffdc25dace4d75dbf3c288d4e84600ce Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 30 Jan 2026 14:20:10 +0100 Subject: [PATCH 37/52] updating tests and better unexpected error handling --- src/virtualship/cli/_run.py | 21 +++++++++++++------ src/virtualship/errors.py | 4 ++-- .../expedition/simulate_schedule.py | 1 - src/virtualship/models/checkpoint.py | 1 + src/virtualship/utils.py | 9 ++++++-- tests/cli/test_run.py | 4 +++- 6 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index dc78876c2..8ccbc0404 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -8,6 +8,7 @@ import copernicusmarine +from virtualship.errors import ProblemsError from virtualship.expedition.simulate_schedule import ( MeasurementsToSimulate, ScheduleProblem, @@ -168,12 +169,20 @@ def _run( and problems["problem_class"][0].pre_departure else f"\033[4mUp next\033[0m: {itype.name} measurements...\n" ) - - problem_simulator.execute( - problems, - instrument_type_validation=itype, - problems_dir=problems_dir, - ) + try: + problem_simulator.execute( + problems, + instrument_type_validation=itype, + problems_dir=problems_dir, + ) + + except Exception as e: + os.removedirs(problems_dir) # clean up if fails + os.remove(expedition_dir.joinpath(CHECKPOINT)) + raise ProblemsError( + f"An error occurred while simulating problems: {e}. Please report this issue, with a description and the traceback, " + "to the VirtualShip issue tracker at: https://github.com/OceanParcels/virtualship/issues" + ) from e # get measurements to simulate attr = MeasurementsToSimulate.get_attr_for_instrumenttype(itype) diff --git a/src/virtualship/errors.py b/src/virtualship/errors.py index 60a4b0ef2..d6d0daf47 100644 --- a/src/virtualship/errors.py +++ b/src/virtualship/errors.py @@ -52,7 +52,7 @@ class CopernicusCatalogueError(Exception): pass -class ProblemEncountered(Exception): - """Error raised when a problem is encountered during simulation.""" +class ProblemsError(Exception): + """Error raised when simulation of problem(s) in expedition fails.""" pass diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index 4e7b64bb4..94fccbc2a 100644 --- a/src/virtualship/expedition/simulate_schedule.py +++ b/src/virtualship/expedition/simulate_schedule.py @@ -150,7 +150,6 @@ def _progress_time_traveling_towards(self, location: Location) -> None: self._projection, ) end_time = self._time + time_to_reach - # note all ADCP measurements if self._expedition.instruments_config.adcp_config is not None: location = self._location diff --git a/src/virtualship/models/checkpoint.py b/src/virtualship/models/checkpoint.py index 700c714f5..2c5117069 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -111,6 +111,7 @@ def verify(self, expedition: Expedition, problems_dir: Path) -> None: # problem at a later waypoint: check new scheduled time exceeds sail time + delay duration + instrument deployment time (rather whole delay duration add-on, as there may be _some_ contingency time already scheduled) else: + breakpoint() failed_waypoint = new_schedule.waypoints[self.failed_waypoint_i] scheduled_time = failed_waypoint.time - problem_waypoint.time diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 198410ad0..630cb07e0 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -616,7 +616,7 @@ def _calc_sail_time( distance_to_next_waypoint = geodinv[2] return ( timedelta(seconds=distance_to_next_waypoint / ship_speed_meter_per_second), - geodinv, + geodinv[0], ship_speed_meter_per_second, ) @@ -650,7 +650,12 @@ def _calc_wp_stationkeeping_time( # get wp total stationkeeping time cumulative_stationkeeping_time = timedelta() for iconfig in wp_instrument_configs: - if both_ctd_and_bgc and iconfig.instrument_type == InstrumentType.CTD_BGC: + breakpoint() + if ( + both_ctd_and_bgc + and iconfig.__class__.__name__ + == INSTRUMENT_CONFIG_MAP[InstrumentType.CTD_BGC] + ): continue # only need to add time cost once if both CTD and CTD_BGC are being taken; in reality they would be done on the same instrument if hasattr(iconfig, "stationkeeping_time"): cumulative_stationkeeping_time += iconfig.stationkeeping_time diff --git a/tests/cli/test_run.py b/tests/cli/test_run.py index 190442347..d546cae8c 100644 --- a/tests/cli/test_run.py +++ b/tests/cli/test_run.py @@ -53,7 +53,9 @@ def test_run(tmp_path, monkeypatch): fake_data_dir = tmp_path / "fake_data" fake_data_dir.mkdir() - _run(expedition_dir, from_data=fake_data_dir) + _run( + expedition_dir, prob_level=0, from_data=fake_data_dir + ) # problems turned off here results_dir = expedition_dir / "results" From b21463d3dee25189b453893df84d596d639999cb Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 30 Jan 2026 14:57:40 +0100 Subject: [PATCH 38/52] better handling of old problems/checkpoint files if simulation fails for unexpected errors --- src/virtualship/cli/_run.py | 78 +++++++++++++++------------- src/virtualship/models/checkpoint.py | 1 - src/virtualship/utils.py | 1 - 3 files changed, 41 insertions(+), 39 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index 8ccbc0404..20de173fb 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -8,7 +8,6 @@ import copernicusmarine -from virtualship.errors import ProblemsError from virtualship.expedition.simulate_schedule import ( MeasurementsToSimulate, ScheduleProblem, @@ -155,50 +154,55 @@ def _run( print("\nSimulating measurements. This may take a while...\n") for itype in instruments_in_expedition: - # get instrument class - instrument_class = get_instrument_class(itype) - if instrument_class is None: - raise RuntimeError(f"No instrument class found for type {itype}.") - - # execute problem simulations for this instrument type - if problems: - # TODO: this print statement is helpful for user to see so it makes sense when a relevant instrument-related problem occurs; but ideally would be overwritten when the actual measurement simulation spinner starts (try and address this in future PR which improves log output) - print( - "" - if hasattr(problems["problem_class"][0], "pre_departure") - and problems["problem_class"][0].pre_departure - else f"\033[4mUp next\033[0m: {itype.name} measurements...\n" - ) - try: + try: + # get instrument class + instrument_class = get_instrument_class(itype) + if instrument_class is None: + raise RuntimeError(f"No instrument class found for type {itype}.") + + # execute problem simulations for this instrument type + if problems: + # TODO: this print statement is helpful for user to see so it makes sense when a relevant instrument-related problem occurs; but ideally would be overwritten when the actual measurement simulation spinner starts (try and address this in future PR which improves log output) + print( + "" + if hasattr(problems["problem_class"][0], "pre_departure") + and problems["problem_class"][0].pre_departure + else f"\033[4mUp next\033[0m: {itype.name} measurements...\n" + ) problem_simulator.execute( problems, instrument_type_validation=itype, problems_dir=problems_dir, ) - except Exception as e: - os.removedirs(problems_dir) # clean up if fails + # get measurements to simulate + attr = MeasurementsToSimulate.get_attr_for_instrumenttype(itype) + measurements = getattr(schedule_results.measurements_to_simulate, attr) + + # initialise instrument + instrument = instrument_class( + expedition=expedition, + from_data=Path(from_data) if from_data is not None else None, + ) + + # execute simulation + instrument.execute( + measurements=measurements, + out_path=expedition_dir.joinpath( + "results", f"{itype.name.lower()}.zarr" + ), + ) + except Exception as e: + # clean up if unexpected error occurs + if os.path.exists(problems_dir): + shutil.rmtree(problems_dir) + if expedition_dir.joinpath(CHECKPOINT).exists(): os.remove(expedition_dir.joinpath(CHECKPOINT)) - raise ProblemsError( - f"An error occurred while simulating problems: {e}. Please report this issue, with a description and the traceback, " - "to the VirtualShip issue tracker at: https://github.com/OceanParcels/virtualship/issues" - ) from e - - # get measurements to simulate - attr = MeasurementsToSimulate.get_attr_for_instrumenttype(itype) - measurements = getattr(schedule_results.measurements_to_simulate, attr) - - # initialise instrument - instrument = instrument_class( - expedition=expedition, - from_data=Path(from_data) if from_data is not None else None, - ) - # execute simulation - instrument.execute( - measurements=measurements, - out_path=expedition_dir.joinpath("results", f"{itype.name.lower()}.zarr"), - ) + raise RuntimeError( + f"An unexpected error occurred while simulating measurements: {e}. Please report this issue, with a description and the traceback, " + "to the VirtualShip issue tracker at: https://github.com/OceanParcels/virtualship/issues" + ) from e print("\nAll measurement simulations are complete.") diff --git a/src/virtualship/models/checkpoint.py b/src/virtualship/models/checkpoint.py index 2c5117069..700c714f5 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -111,7 +111,6 @@ def verify(self, expedition: Expedition, problems_dir: Path) -> None: # problem at a later waypoint: check new scheduled time exceeds sail time + delay duration + instrument deployment time (rather whole delay duration add-on, as there may be _some_ contingency time already scheduled) else: - breakpoint() failed_waypoint = new_schedule.waypoints[self.failed_waypoint_i] scheduled_time = failed_waypoint.time - problem_waypoint.time diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 630cb07e0..5a517bbce 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -650,7 +650,6 @@ def _calc_wp_stationkeeping_time( # get wp total stationkeeping time cumulative_stationkeeping_time = timedelta() for iconfig in wp_instrument_configs: - breakpoint() if ( both_ctd_and_bgc and iconfig.__class__.__name__ From c258a51664f5e2738c70dbc790a2ae6dac8d1467 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:44:04 +0100 Subject: [PATCH 39/52] update test with new log output --- tests/expedition/test_expedition.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/expedition/test_expedition.py b/tests/expedition/test_expedition.py index 90027e8ec..d3a5cff3e 100644 --- a/tests/expedition/test_expedition.py +++ b/tests/expedition/test_expedition.py @@ -6,8 +6,8 @@ import pyproj import pytest import xarray as xr -from parcels import FieldSet +from parcels import FieldSet from virtualship.errors import InstrumentsConfigError, ScheduleError from virtualship.models import ( Expedition, @@ -199,7 +199,7 @@ def test_verify_on_land(): ] ), ScheduleError, - "Waypoint planning is not valid: would arrive too late at waypoint number 2...", + "Waypoint planning is not valid: would arrive too late at waypoint 2\\.", id="NotEnoughTime", ), ], From 19b2c2a53813b0ebe04e581326c78c4fa0a8f80e Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:37:00 +0100 Subject: [PATCH 40/52] tidy up --- src/virtualship/errors.py | 6 ------ src/virtualship/make_realistic/problems/simulator.py | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/virtualship/errors.py b/src/virtualship/errors.py index d6d0daf47..ac1aa8a1b 100644 --- a/src/virtualship/errors.py +++ b/src/virtualship/errors.py @@ -50,9 +50,3 @@ class CopernicusCatalogueError(Exception): """Error raised when a relevant product is not found in the Copernicus Catalogue.""" pass - - -class ProblemsError(Exception): - """Error raised when simulation of problem(s) in expedition fails.""" - - pass diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index 29eb120c4..f19758383 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -61,7 +61,7 @@ def select_problems( prob_level: int, ) -> dict[str, list[GeneralProblem | InstrumentProblem] | None]: """ - Select problems (general and instrument-specific). Number of problems is determined by probability level, expedition length, instrument count etc. + Select problems (general and instrument-specific). When prob_level = 2, number of problems is determined by expedition length, instrument count etc. Map each selected problem to a random waypoint (or None if pre-departure). Finally, cache the suite of problems to a directory (expedition-specific via hash) for reference. """ From dae03ea40381f229e4ec154709f42c2bfff6a342 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 30 Jan 2026 23:56:19 +0100 Subject: [PATCH 41/52] handle single waypoint expeditions and tidy up --- src/virtualship/cli/_run.py | 14 +++++----- .../make_realistic/problems/scenarios.py | 16 +++++++++--- .../make_realistic/problems/simulator.py | 26 +++++++++++++++---- 3 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index 20de173fb..62dea5434 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -163,12 +163,14 @@ def _run( # execute problem simulations for this instrument type if problems: # TODO: this print statement is helpful for user to see so it makes sense when a relevant instrument-related problem occurs; but ideally would be overwritten when the actual measurement simulation spinner starts (try and address this in future PR which improves log output) - print( - "" - if hasattr(problems["problem_class"][0], "pre_departure") + if ( + hasattr(problems["problem_class"][0], "pre_departure") and problems["problem_class"][0].pre_departure - else f"\033[4mUp next\033[0m: {itype.name} measurements...\n" - ) + ): + pass + else: + print(f"\033[4mUp next\033[0m: {itype.name} measurements...\n") + problem_simulator.execute( problems, instrument_type_validation=itype, @@ -218,7 +220,7 @@ def _run( f"\nA record of problems encountered during the expedition is saved in: {problems_dir}" ) - # delete checkpoint file (inteferes with ability to re-run expedition) + # delete checkpoint file (in case it interferes with any future re-runs) if os.path.exists(expedition_dir.joinpath(CHECKPOINT)): os.remove(expedition_dir.joinpath(CHECKPOINT)) diff --git a/src/virtualship/make_realistic/problems/scenarios.py b/src/virtualship/make_realistic/problems/scenarios.py index f528238f8..48e8a5086 100644 --- a/src/virtualship/make_realistic/problems/scenarios.py +++ b/src/virtualship/make_realistic/problems/scenarios.py @@ -73,11 +73,19 @@ class FuelDeliveryIssue(GeneralProblem): """Problem: Fuel delivery tanker delayed, causing a postponement of departure.""" 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." + "The fuel tanker expected to deliver fuel has not arrived. Until the tanker reaches the pier, " + "we cannot leave. Once it arrives, securing the fuel lines in the ship's tanks and fueling operations " + "will also take additional time. These combined delays postpone departure by approximately 5 hours." ) + delay_duration: timedelta = timedelta(hours=5.0) + pre_departure: bool = True + + # 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 wait for the tanker or get a " + # "harbor pilot to guide the vessel to an available bunker dock instead. Regardless of the chosen option, " + # "the resulting delays postpone departure by approximately 5 hours." + # ) delay_duration: timedelta = timedelta( hours=5.0 ) # dynamic delays based on repeated choices diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index f19758383..987226431 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -59,10 +59,12 @@ def select_problems( self, instruments_in_expedition: set[InstrumentType], prob_level: int, - ) -> dict[str, list[GeneralProblem | InstrumentProblem] | None]: + ) -> dict[str, list[GeneralProblem | InstrumentProblem] | None] | None: """ Select problems (general and instrument-specific). When prob_level = 2, number of problems is determined by expedition length, instrument count etc. + If only one waypoint, return just a pre-departure problem. + Map each selected problem to a random waypoint (or None if pre-departure). Finally, cache the suite of problems to a directory (expedition-specific via hash) for reference. """ valid_instrument_problems = [ @@ -71,6 +73,12 @@ def select_problems( if problem.instrument_type in instruments_in_expedition ] + pre_departure_problems = [ + p + for p in GENERAL_PROBLEM_REG + if issubclass(p, GeneralProblem) and p.pre_departure + ] + num_waypoints = len(self.expedition.schedule.waypoints) num_instruments = len(instruments_in_expedition) expedition_duration_days = ( @@ -78,6 +86,13 @@ def select_problems( - self.expedition.schedule.waypoints[0].time ).days + # if only one waypoint, return just a pre-departure problem + if num_waypoints < 2: + return { + "problem_class": [random.choice(pre_departure_problems)], + "waypoint_i": [None], + } + if prob_level == 0: num_problems = 0 elif prob_level == 1: @@ -95,6 +110,7 @@ def select_problems( ) selected_problems = [] + problems_sorted = None if num_problems > 0: random.shuffle(GENERAL_PROBLEM_REG) random.shuffle(valid_instrument_problems) @@ -111,14 +127,14 @@ def select_problems( selected_problems.extend(valid_instrument_problems[:n_instrument]) # allow only one pre-departure problem to occur; replace any extras with non-pre-departure problems - pre_departure_problems = [ + selected_pre_departure = [ p for p in selected_problems if issubclass(p, GeneralProblem) and p.pre_departure ] - if len(pre_departure_problems) > 1: - to_keep = random.choice(pre_departure_problems) - num_to_replace = len(pre_departure_problems) - 1 + if len(selected_pre_departure) > 1: + to_keep = random.choice(selected_pre_departure) + num_to_replace = len(selected_pre_departure) - 1 # remove all but one pre_departure problem selected_problems = [ problem From 9b2972486a8e713c8ce2d218e0beac4b917a87a6 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 30 Jan 2026 23:58:48 +0100 Subject: [PATCH 42/52] remove duplication --- src/virtualship/make_realistic/problems/scenarios.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/virtualship/make_realistic/problems/scenarios.py b/src/virtualship/make_realistic/problems/scenarios.py index 48e8a5086..369825c65 100644 --- a/src/virtualship/make_realistic/problems/scenarios.py +++ b/src/virtualship/make_realistic/problems/scenarios.py @@ -80,17 +80,6 @@ class FuelDeliveryIssue(GeneralProblem): delay_duration: timedelta = timedelta(hours=5.0) pre_departure: bool = True - # 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 wait for the tanker or get a " - # "harbor pilot to guide the vessel to an available bunker dock instead. Regardless of the chosen option, " - # "the resulting delays postpone departure by approximately 5 hours." - # ) - delay_duration: timedelta = timedelta( - hours=5.0 - ) # dynamic delays based on repeated choices - pre_departure: bool = True - @dataclass @register_general_problem From b40ba73f1548c7e64d8a4f8c2a6dbb0861f25a30 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 2 Feb 2026 09:34:39 +0100 Subject: [PATCH 43/52] re-structure dir --- tests/make_realistic/adcp.zarr/.zattrs | 8 - tests/make_realistic/adcp.zarr/.zgroup | 3 - tests/make_realistic/adcp.zarr/.zmetadata | 264 ------------ tests/make_realistic/adcp.zarr/U/.zarray | 23 - tests/make_realistic/adcp.zarr/U/.zattrs | 9 - tests/make_realistic/adcp.zarr/U/0.0 | Bin 161 -> 0 bytes tests/make_realistic/adcp.zarr/U/0.1 | Bin 160 -> 0 bytes tests/make_realistic/adcp.zarr/V/.zarray | 23 - tests/make_realistic/adcp.zarr/V/.zattrs | 9 - tests/make_realistic/adcp.zarr/V/0.0 | Bin 160 -> 0 bytes tests/make_realistic/adcp.zarr/V/0.1 | Bin 160 -> 0 bytes tests/make_realistic/adcp.zarr/lat/.zarray | 23 - tests/make_realistic/adcp.zarr/lat/.zattrs | 10 - tests/make_realistic/adcp.zarr/lat/0.0 | Bin 45 -> 0 bytes tests/make_realistic/adcp.zarr/lat/0.1 | Bin 40 -> 0 bytes tests/make_realistic/adcp.zarr/lon/.zarray | 23 - tests/make_realistic/adcp.zarr/lon/.zattrs | 10 - tests/make_realistic/adcp.zarr/lon/0.0 | Bin 40 -> 0 bytes tests/make_realistic/adcp.zarr/lon/0.1 | Bin 45 -> 0 bytes tests/make_realistic/adcp.zarr/obs/.zarray | 21 - tests/make_realistic/adcp.zarr/obs/.zattrs | 5 - tests/make_realistic/adcp.zarr/obs/0 | Bin 20 -> 0 bytes tests/make_realistic/adcp.zarr/obs/1 | Bin 20 -> 0 bytes tests/make_realistic/adcp.zarr/time/.zarray | 23 - tests/make_realistic/adcp.zarr/time/.zattrs | 11 - tests/make_realistic/adcp.zarr/time/0.0 | Bin 36 -> 0 bytes tests/make_realistic/adcp.zarr/time/0.1 | Bin 45 -> 0 bytes .../adcp.zarr/trajectory/.zarray | 20 - .../adcp.zarr/trajectory/.zattrs | 5 - tests/make_realistic/adcp.zarr/trajectory/0 | Bin 80 -> 0 bytes tests/make_realistic/adcp.zarr/z/.zarray | 23 - tests/make_realistic/adcp.zarr/z/.zattrs | 10 - tests/make_realistic/adcp.zarr/z/0.0 | Bin 163 -> 0 bytes tests/make_realistic/adcp.zarr/z/0.1 | Bin 163 -> 0 bytes tests/make_realistic/ctd.zarr/.zattrs | 8 - tests/make_realistic/ctd.zarr/.zgroup | 3 - tests/make_realistic/ctd.zarr/.zmetadata | 392 ------------------ tests/make_realistic/ctd.zarr/lat/.zarray | 23 - tests/make_realistic/ctd.zarr/lat/.zattrs | 10 - tests/make_realistic/ctd.zarr/lat/0.0 | Bin 24 -> 0 bytes tests/make_realistic/ctd.zarr/lat/0.1 | Bin 24 -> 0 bytes tests/make_realistic/ctd.zarr/lat/0.2 | Bin 24 -> 0 bytes tests/make_realistic/ctd.zarr/lon/.zarray | 23 - tests/make_realistic/ctd.zarr/lon/.zattrs | 10 - tests/make_realistic/ctd.zarr/lon/0.0 | Bin 24 -> 0 bytes tests/make_realistic/ctd.zarr/lon/0.1 | Bin 24 -> 0 bytes tests/make_realistic/ctd.zarr/lon/0.2 | Bin 24 -> 0 bytes .../make_realistic/ctd.zarr/max_depth/.zarray | 23 - .../make_realistic/ctd.zarr/max_depth/.zattrs | 9 - tests/make_realistic/ctd.zarr/max_depth/0.0 | Bin 24 -> 0 bytes tests/make_realistic/ctd.zarr/max_depth/0.1 | Bin 24 -> 0 bytes tests/make_realistic/ctd.zarr/max_depth/0.2 | Bin 24 -> 0 bytes .../make_realistic/ctd.zarr/min_depth/.zarray | 23 - .../make_realistic/ctd.zarr/min_depth/.zattrs | 9 - tests/make_realistic/ctd.zarr/min_depth/0.0 | Bin 24 -> 0 bytes tests/make_realistic/ctd.zarr/min_depth/0.1 | Bin 24 -> 0 bytes tests/make_realistic/ctd.zarr/min_depth/0.2 | Bin 24 -> 0 bytes tests/make_realistic/ctd.zarr/obs/.zarray | 21 - tests/make_realistic/ctd.zarr/obs/.zattrs | 5 - tests/make_realistic/ctd.zarr/obs/0 | Bin 20 -> 0 bytes tests/make_realistic/ctd.zarr/obs/1 | Bin 20 -> 0 bytes tests/make_realistic/ctd.zarr/obs/2 | Bin 20 -> 0 bytes tests/make_realistic/ctd.zarr/raising/.zarray | 23 - tests/make_realistic/ctd.zarr/raising/.zattrs | 9 - tests/make_realistic/ctd.zarr/raising/0.0 | Bin 18 -> 0 bytes tests/make_realistic/ctd.zarr/raising/0.1 | Bin 18 -> 0 bytes tests/make_realistic/ctd.zarr/raising/0.2 | Bin 18 -> 0 bytes .../make_realistic/ctd.zarr/salinity/.zarray | 23 - .../make_realistic/ctd.zarr/salinity/.zattrs | 9 - tests/make_realistic/ctd.zarr/salinity/0.0 | Bin 24 -> 0 bytes tests/make_realistic/ctd.zarr/salinity/0.1 | Bin 24 -> 0 bytes tests/make_realistic/ctd.zarr/salinity/0.2 | Bin 24 -> 0 bytes .../ctd.zarr/temperature/.zarray | 23 - .../ctd.zarr/temperature/.zattrs | 9 - tests/make_realistic/ctd.zarr/temperature/0.0 | Bin 24 -> 0 bytes tests/make_realistic/ctd.zarr/temperature/0.1 | Bin 24 -> 0 bytes tests/make_realistic/ctd.zarr/temperature/0.2 | Bin 24 -> 0 bytes tests/make_realistic/ctd.zarr/time/.zarray | 23 - tests/make_realistic/ctd.zarr/time/.zattrs | 11 - tests/make_realistic/ctd.zarr/time/0.0 | Bin 32 -> 0 bytes tests/make_realistic/ctd.zarr/time/0.1 | Bin 32 -> 0 bytes tests/make_realistic/ctd.zarr/time/0.2 | Bin 32 -> 0 bytes .../ctd.zarr/trajectory/.zarray | 20 - .../ctd.zarr/trajectory/.zattrs | 5 - tests/make_realistic/ctd.zarr/trajectory/0 | Bin 32 -> 0 bytes .../ctd.zarr/winch_speed/.zarray | 23 - .../ctd.zarr/winch_speed/.zattrs | 9 - tests/make_realistic/ctd.zarr/winch_speed/0.0 | Bin 24 -> 0 bytes tests/make_realistic/ctd.zarr/winch_speed/0.1 | Bin 24 -> 0 bytes tests/make_realistic/ctd.zarr/winch_speed/0.2 | Bin 24 -> 0 bytes tests/make_realistic/ctd.zarr/z/.zarray | 23 - tests/make_realistic/ctd.zarr/z/.zattrs | 10 - tests/make_realistic/ctd.zarr/z/0.0 | Bin 24 -> 0 bytes tests/make_realistic/ctd.zarr/z/0.1 | Bin 24 -> 0 bytes tests/make_realistic/ctd.zarr/z/0.2 | Bin 24 -> 0 bytes .../test_adcp_make_realistic.py | 0 .../test_ctd_make_realistic.py | 0 .../make_realistic/problems/test_scenarios.py | 0 tests/test_utils.py | 7 +- 99 files changed, 6 insertions(+), 1303 deletions(-) delete mode 100644 tests/make_realistic/adcp.zarr/.zattrs delete mode 100644 tests/make_realistic/adcp.zarr/.zgroup delete mode 100644 tests/make_realistic/adcp.zarr/.zmetadata delete mode 100644 tests/make_realistic/adcp.zarr/U/.zarray delete mode 100644 tests/make_realistic/adcp.zarr/U/.zattrs delete mode 100644 tests/make_realistic/adcp.zarr/U/0.0 delete mode 100644 tests/make_realistic/adcp.zarr/U/0.1 delete mode 100644 tests/make_realistic/adcp.zarr/V/.zarray delete mode 100644 tests/make_realistic/adcp.zarr/V/.zattrs delete mode 100644 tests/make_realistic/adcp.zarr/V/0.0 delete mode 100644 tests/make_realistic/adcp.zarr/V/0.1 delete mode 100644 tests/make_realistic/adcp.zarr/lat/.zarray delete mode 100644 tests/make_realistic/adcp.zarr/lat/.zattrs delete mode 100644 tests/make_realistic/adcp.zarr/lat/0.0 delete mode 100644 tests/make_realistic/adcp.zarr/lat/0.1 delete mode 100644 tests/make_realistic/adcp.zarr/lon/.zarray delete mode 100644 tests/make_realistic/adcp.zarr/lon/.zattrs delete mode 100644 tests/make_realistic/adcp.zarr/lon/0.0 delete mode 100644 tests/make_realistic/adcp.zarr/lon/0.1 delete mode 100644 tests/make_realistic/adcp.zarr/obs/.zarray delete mode 100644 tests/make_realistic/adcp.zarr/obs/.zattrs delete mode 100644 tests/make_realistic/adcp.zarr/obs/0 delete mode 100644 tests/make_realistic/adcp.zarr/obs/1 delete mode 100644 tests/make_realistic/adcp.zarr/time/.zarray delete mode 100644 tests/make_realistic/adcp.zarr/time/.zattrs delete mode 100644 tests/make_realistic/adcp.zarr/time/0.0 delete mode 100644 tests/make_realistic/adcp.zarr/time/0.1 delete mode 100644 tests/make_realistic/adcp.zarr/trajectory/.zarray delete mode 100644 tests/make_realistic/adcp.zarr/trajectory/.zattrs delete mode 100644 tests/make_realistic/adcp.zarr/trajectory/0 delete mode 100644 tests/make_realistic/adcp.zarr/z/.zarray delete mode 100644 tests/make_realistic/adcp.zarr/z/.zattrs delete mode 100644 tests/make_realistic/adcp.zarr/z/0.0 delete mode 100644 tests/make_realistic/adcp.zarr/z/0.1 delete mode 100644 tests/make_realistic/ctd.zarr/.zattrs delete mode 100644 tests/make_realistic/ctd.zarr/.zgroup delete mode 100644 tests/make_realistic/ctd.zarr/.zmetadata delete mode 100644 tests/make_realistic/ctd.zarr/lat/.zarray delete mode 100644 tests/make_realistic/ctd.zarr/lat/.zattrs delete mode 100644 tests/make_realistic/ctd.zarr/lat/0.0 delete mode 100644 tests/make_realistic/ctd.zarr/lat/0.1 delete mode 100644 tests/make_realistic/ctd.zarr/lat/0.2 delete mode 100644 tests/make_realistic/ctd.zarr/lon/.zarray delete mode 100644 tests/make_realistic/ctd.zarr/lon/.zattrs delete mode 100644 tests/make_realistic/ctd.zarr/lon/0.0 delete mode 100644 tests/make_realistic/ctd.zarr/lon/0.1 delete mode 100644 tests/make_realistic/ctd.zarr/lon/0.2 delete mode 100644 tests/make_realistic/ctd.zarr/max_depth/.zarray delete mode 100644 tests/make_realistic/ctd.zarr/max_depth/.zattrs delete mode 100644 tests/make_realistic/ctd.zarr/max_depth/0.0 delete mode 100644 tests/make_realistic/ctd.zarr/max_depth/0.1 delete mode 100644 tests/make_realistic/ctd.zarr/max_depth/0.2 delete mode 100644 tests/make_realistic/ctd.zarr/min_depth/.zarray delete mode 100644 tests/make_realistic/ctd.zarr/min_depth/.zattrs delete mode 100644 tests/make_realistic/ctd.zarr/min_depth/0.0 delete mode 100644 tests/make_realistic/ctd.zarr/min_depth/0.1 delete mode 100644 tests/make_realistic/ctd.zarr/min_depth/0.2 delete mode 100644 tests/make_realistic/ctd.zarr/obs/.zarray delete mode 100644 tests/make_realistic/ctd.zarr/obs/.zattrs delete mode 100644 tests/make_realistic/ctd.zarr/obs/0 delete mode 100644 tests/make_realistic/ctd.zarr/obs/1 delete mode 100644 tests/make_realistic/ctd.zarr/obs/2 delete mode 100644 tests/make_realistic/ctd.zarr/raising/.zarray delete mode 100644 tests/make_realistic/ctd.zarr/raising/.zattrs delete mode 100644 tests/make_realistic/ctd.zarr/raising/0.0 delete mode 100644 tests/make_realistic/ctd.zarr/raising/0.1 delete mode 100644 tests/make_realistic/ctd.zarr/raising/0.2 delete mode 100644 tests/make_realistic/ctd.zarr/salinity/.zarray delete mode 100644 tests/make_realistic/ctd.zarr/salinity/.zattrs delete mode 100644 tests/make_realistic/ctd.zarr/salinity/0.0 delete mode 100644 tests/make_realistic/ctd.zarr/salinity/0.1 delete mode 100644 tests/make_realistic/ctd.zarr/salinity/0.2 delete mode 100644 tests/make_realistic/ctd.zarr/temperature/.zarray delete mode 100644 tests/make_realistic/ctd.zarr/temperature/.zattrs delete mode 100644 tests/make_realistic/ctd.zarr/temperature/0.0 delete mode 100644 tests/make_realistic/ctd.zarr/temperature/0.1 delete mode 100644 tests/make_realistic/ctd.zarr/temperature/0.2 delete mode 100644 tests/make_realistic/ctd.zarr/time/.zarray delete mode 100644 tests/make_realistic/ctd.zarr/time/.zattrs delete mode 100644 tests/make_realistic/ctd.zarr/time/0.0 delete mode 100644 tests/make_realistic/ctd.zarr/time/0.1 delete mode 100644 tests/make_realistic/ctd.zarr/time/0.2 delete mode 100644 tests/make_realistic/ctd.zarr/trajectory/.zarray delete mode 100644 tests/make_realistic/ctd.zarr/trajectory/.zattrs delete mode 100644 tests/make_realistic/ctd.zarr/trajectory/0 delete mode 100644 tests/make_realistic/ctd.zarr/winch_speed/.zarray delete mode 100644 tests/make_realistic/ctd.zarr/winch_speed/.zattrs delete mode 100644 tests/make_realistic/ctd.zarr/winch_speed/0.0 delete mode 100644 tests/make_realistic/ctd.zarr/winch_speed/0.1 delete mode 100644 tests/make_realistic/ctd.zarr/winch_speed/0.2 delete mode 100644 tests/make_realistic/ctd.zarr/z/.zarray delete mode 100644 tests/make_realistic/ctd.zarr/z/.zattrs delete mode 100644 tests/make_realistic/ctd.zarr/z/0.0 delete mode 100644 tests/make_realistic/ctd.zarr/z/0.1 delete mode 100644 tests/make_realistic/ctd.zarr/z/0.2 rename tests/make_realistic/{ => instrument_noise}/test_adcp_make_realistic.py (100%) rename tests/make_realistic/{ => instrument_noise}/test_ctd_make_realistic.py (100%) create mode 100644 tests/make_realistic/problems/test_scenarios.py diff --git a/tests/make_realistic/adcp.zarr/.zattrs b/tests/make_realistic/adcp.zarr/.zattrs deleted file mode 100644 index 90d4d0b43..000000000 --- a/tests/make_realistic/adcp.zarr/.zattrs +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Conventions": "CF-1.6/CF-1.7", - "feature_type": "trajectory", - "ncei_template_version": "NCEI_NetCDF_Trajectory_Template_v2.0", - "parcels_kernels": "NewParticle_sample_velocity", - "parcels_mesh": "spherical", - "parcels_version": "0.0.1-16-g947e7d0" -} \ No newline at end of file diff --git a/tests/make_realistic/adcp.zarr/.zgroup b/tests/make_realistic/adcp.zarr/.zgroup deleted file mode 100644 index 3b7daf227..000000000 --- a/tests/make_realistic/adcp.zarr/.zgroup +++ /dev/null @@ -1,3 +0,0 @@ -{ - "zarr_format": 2 -} \ No newline at end of file diff --git a/tests/make_realistic/adcp.zarr/.zmetadata b/tests/make_realistic/adcp.zarr/.zmetadata deleted file mode 100644 index d1bf51f5c..000000000 --- a/tests/make_realistic/adcp.zarr/.zmetadata +++ /dev/null @@ -1,264 +0,0 @@ -{ - "metadata": { - ".zattrs": { - "Conventions": "CF-1.6/CF-1.7", - "feature_type": "trajectory", - "ncei_template_version": "NCEI_NetCDF_Trajectory_Template_v2.0", - "parcels_kernels": "NewParticle_sample_velocity", - "parcels_mesh": "spherical", - "parcels_version": "0.0.1-16-g947e7d0" - }, - ".zgroup": { - "zarr_format": 2 - }, - "U/.zarray": { - "chunks": [ - 40, - 1 - ], - "compressor": { - "blocksize": 0, - "clevel": 5, - "cname": "lz4", - "id": "blosc", - "shuffle": 1 - }, - "dimension_separator": ".", - "dtype": "i^pFVzg|L*PEH?Lp4eEH(}v!_p!2?GJ5MX8C H3IHwMojJO1$Hvu5=1rf_)m&RvkeL({>hIxX zZK9{HC?&$n&cHBD_ek`oPKE7$Piur%Io`-;n`?49>E{IXeW7ofr8aupFXLTeeJPW{ z!QS4^&eqn}#>U#(+RDn(($d1h+}zB}%+%Dx#KhRx$jHdh(7?dJ5d>HnxC6ip02=W% ArT_o{ diff --git a/tests/make_realistic/adcp.zarr/V/.zarray b/tests/make_realistic/adcp.zarr/V/.zarray deleted file mode 100644 index 54146227b..000000000 --- a/tests/make_realistic/adcp.zarr/V/.zarray +++ /dev/null @@ -1,23 +0,0 @@ -{ - "chunks": [ - 40, - 1 - ], - "compressor": { - "blocksize": 0, - "clevel": 5, - "cname": "lz4", - "id": "blosc", - "shuffle": 1 - }, - "dimension_separator": ".", - "dtype": "9N=|MlknrK3Am&zsO( zmKo#kWTGy`%fO%;-MRg#uw(w*(?8Win>XI)wa%P&QyUOu2{Zo>5?Uj7cE?{zySnU8Mp($ F3;<$SNL>H` diff --git a/tests/make_realistic/adcp.zarr/V/0.1 b/tests/make_realistic/adcp.zarr/V/0.1 deleted file mode 100644 index 35217ac1c0d232d243d832d927d63560244a9646..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 160 zcmZQ#G-O%8z`y{*B0zQr5dY6(`1$$Gv->wMojJO1$Hvu5=1rf_)m&RvkeL({>hIxX zZK9{HC?&$n&cHBD_ek`oPKE7$Piur%Io`-;n`?49>E{IXeW7ofr8aupFXLTeeJPVc zL0(=?PF7Y{Mn+m%T1rY%Qc^-fTwF{{OjJ}vL_}CvNJvOfP(VPy5d>HnxC6ip0B&M1 ArT_o{ diff --git a/tests/make_realistic/adcp.zarr/lat/.zarray b/tests/make_realistic/adcp.zarr/lat/.zarray deleted file mode 100644 index 54146227b..000000000 --- a/tests/make_realistic/adcp.zarr/lat/.zarray +++ /dev/null @@ -1,23 +0,0 @@ -{ - "chunks": [ - 40, - 1 - ], - "compressor": { - "blocksize": 0, - "clevel": 5, - "cname": "lz4", - "id": "blosc", - "shuffle": 1 - }, - "dimension_separator": ".", - "dtype": "@KA0su-z1V{h? diff --git a/tests/make_realistic/adcp.zarr/lon/.zarray b/tests/make_realistic/adcp.zarr/lon/.zarray deleted file mode 100644 index 54146227b..000000000 --- a/tests/make_realistic/adcp.zarr/lon/.zarray +++ /dev/null @@ -1,23 +0,0 @@ -{ - "chunks": [ - 40, - 1 - ], - "compressor": { - "blocksize": 0, - "clevel": 5, - "cname": "lz4", - "id": "blosc", - "shuffle": 1 - }, - "dimension_separator": ".", - "dtype": "zUKMpP?Ih zGrOFAZp?Jr=;Xw}FyHQOjr8uQpEJy_m5FW%{xr$(!hPB3SvSN&CtnhnD>Y4cf|Rh7 z6hl>UZdyWAu&zUKMpP?Ih zGrOFAZp?Jr=;Xw}FyHQOjr8uQpEJy_m5FW%{xr$(!hPB3SvSN&CtnhnD>Y4cf|Rh7 z6hl>UZdyWAu&(^b diff --git a/tests/make_realistic/ctd.zarr/max_depth/0.1 b/tests/make_realistic/ctd.zarr/max_depth/0.1 deleted file mode 100644 index 0e79b19fa37e44e66d82a0decc36afb18f7f734a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24 YcmZQ#G-lyoU|;}Y2_R-*P&f#}01Vp#B>(^b diff --git a/tests/make_realistic/ctd.zarr/max_depth/0.2 b/tests/make_realistic/ctd.zarr/max_depth/0.2 deleted file mode 100644 index 0e79b19fa37e44e66d82a0decc36afb18f7f734a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24 YcmZQ#G-lyoU|;}Y2_R-*P&f#}01Vp#B>(^b diff --git a/tests/make_realistic/ctd.zarr/min_depth/.zarray b/tests/make_realistic/ctd.zarr/min_depth/.zarray deleted file mode 100644 index 14bf5ecc8..000000000 --- a/tests/make_realistic/ctd.zarr/min_depth/.zarray +++ /dev/null @@ -1,23 +0,0 @@ -{ - "chunks": [ - 2, - 1 - ], - "compressor": { - "blocksize": 0, - "clevel": 5, - "cname": "lz4", - "id": "blosc", - "shuffle": 1 - }, - "dimension_separator": ".", - "dtype": "(^b diff --git a/tests/make_realistic/ctd.zarr/z/0.2 b/tests/make_realistic/ctd.zarr/z/0.2 deleted file mode 100644 index ce7fe5ce35b50130d97d1efb14227af965eb9953..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24 UcmZQ#G-lyoU|;}Y2_S|600&h7V*mgE diff --git a/tests/make_realistic/test_adcp_make_realistic.py b/tests/make_realistic/instrument_noise/test_adcp_make_realistic.py similarity index 100% rename from tests/make_realistic/test_adcp_make_realistic.py rename to tests/make_realistic/instrument_noise/test_adcp_make_realistic.py diff --git a/tests/make_realistic/test_ctd_make_realistic.py b/tests/make_realistic/instrument_noise/test_ctd_make_realistic.py similarity index 100% rename from tests/make_realistic/test_ctd_make_realistic.py rename to tests/make_realistic/instrument_noise/test_ctd_make_realistic.py diff --git a/tests/make_realistic/problems/test_scenarios.py b/tests/make_realistic/problems/test_scenarios.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_utils.py b/tests/test_utils.py index deca66d5c..647243dd8 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,9 +4,9 @@ import numpy as np import pytest import xarray as xr -from parcels import FieldSet import virtualship.utils +from parcels import FieldSet from virtualship.models.expedition import Expedition from virtualship.utils import ( _find_nc_file_with_variable, @@ -236,3 +236,8 @@ def test_data_dir_and_filename_compliance(): assert 'elif all("P1M" in s for s in all_files):' in utils_code, ( "Expected check for 'P1M' in all_files not found in _find_files_in_timerange. This indicates a drift between docs and implementation." ) + + +# TODO: test for calc_sail_time + +# TODO: test for calc_stationkeeping_time From dc80617fbf5e071341bb3d3ad928ae4c4c0ba4b4 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 2 Feb 2026 09:42:18 +0100 Subject: [PATCH 44/52] scenario class tests --- .../make_realistic/problems/test_scenarios.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/make_realistic/problems/test_scenarios.py b/tests/make_realistic/problems/test_scenarios.py index e69de29bb..f9f3402bb 100644 --- a/tests/make_realistic/problems/test_scenarios.py +++ b/tests/make_realistic/problems/test_scenarios.py @@ -0,0 +1,57 @@ +from dataclasses import is_dataclass +from datetime import timedelta + +from virtualship.instruments.types import InstrumentType +from virtualship.make_realistic.problems.scenarios import ( + GeneralProblem, + InstrumentProblem, +) +from virtualship.utils import GENERAL_PROBLEM_REG, INSTRUMENT_PROBLEM_REG + + +def _assert_general_problem_class(cls): + assert issubclass(cls, GeneralProblem) + instance = cls() + assert is_dataclass(instance) + + # required attributes and types + assert hasattr(instance, "message") + assert isinstance(instance.message, str) + assert instance.message.strip(), "message should not be empty" + + assert hasattr(instance, "delay_duration") + assert isinstance(instance.delay_duration, timedelta) + + assert hasattr(instance, "pre_departure") + assert isinstance(instance.pre_departure, bool) + + +def _assert_instrument_problem_class(cls): + assert issubclass(cls, InstrumentProblem) + instance = cls() + assert is_dataclass(instance) + + # required attributes and types + assert hasattr(instance, "message") + assert isinstance(instance.message, str) + assert instance.message.strip(), "message should not be empty" + + assert hasattr(instance, "delay_duration") + assert isinstance(instance.delay_duration, timedelta) + + assert hasattr(instance, "instrument_type") + assert isinstance(instance.instrument_type, InstrumentType) + + +def test_general_problems(): + assert GENERAL_PROBLEM_REG, "GENERAL_PROBLEM_REG should not be empty" + + for cls in GENERAL_PROBLEM_REG: + _assert_general_problem_class(cls) + + +def test_instrument_problems(): + assert INSTRUMENT_PROBLEM_REG, "INSTRUMENT_PROBLEM_REG should not be empty" + + for cls in INSTRUMENT_PROBLEM_REG: + _assert_instrument_problem_class(cls) From 7cf9e63720f90ea9f852a192e499f5387404c6a2 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 2 Feb 2026 10:49:03 +0100 Subject: [PATCH 45/52] tests for problems simulator class --- .../make_realistic/problems/test_simulator.py | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 tests/make_realistic/problems/test_simulator.py diff --git a/tests/make_realistic/problems/test_simulator.py b/tests/make_realistic/problems/test_simulator.py new file mode 100644 index 000000000..128c3def2 --- /dev/null +++ b/tests/make_realistic/problems/test_simulator.py @@ -0,0 +1,167 @@ +import json +from datetime import datetime, timedelta + +from virtualship.make_realistic.problems.scenarios import GeneralProblem +from virtualship.make_realistic.problems.simulator import ProblemSimulator +from virtualship.models.expedition import ( + Expedition, + InstrumentsConfig, + Schedule, + ShipConfig, + Waypoint, +) +from virtualship.models.location import Location +from virtualship.utils import GENERAL_PROBLEM_REG + + +def _make_simple_expedition( + num_waypoints: int = 2, distance_scale: float = 1.0 +) -> Expedition: + sample_datetime = datetime(2024, 1, 1, 0, 0, 0) + waypoints = [] + for i in range(num_waypoints): + wp = Waypoint( + location=Location( + latitude=0.0 + i * distance_scale, longitude=0.0 + i * distance_scale + ), + time=sample_datetime + timedelta(days=i), + instrument=[], # ensure is list, not None + ) + waypoints.append(wp) + + schedule = Schedule(waypoints=waypoints) + instruments = InstrumentsConfig() + ship = ShipConfig(ship_speed_knots=10.0) + return Expedition( + schedule=schedule, instruments_config=instruments, ship_config=ship + ) + + +def test_select_problems_single_waypoint_returns_pre_departure(tmp_path): + expedition = _make_simple_expedition(num_waypoints=1) + simulator = ProblemSimulator(expedition, str(tmp_path)) + problems = simulator.select_problems(set(), prob_level=2) + + assert isinstance(problems, dict) + assert len(problems["problem_class"]) == 1 + assert problems["waypoint_i"] == [None] + + problem_cls = problems["problem_class"][0] + assert issubclass(problem_cls, GeneralProblem) + assert getattr(problem_cls, "pre_departure", False) is True + + +def test_select_problems_prob_level_zero(): + expedition = _make_simple_expedition(num_waypoints=2) + simulator = ProblemSimulator(expedition, ".") + + problems = simulator.select_problems(set(), prob_level=0) + assert problems is None + + +def test_cache_and_load_selected_problems_roundtrip(tmp_path): + expedition = _make_simple_expedition(num_waypoints=2) + simulator = ProblemSimulator(expedition, str(tmp_path)) + + # pick two general problems (registry should contain entries) + cls1 = GENERAL_PROBLEM_REG[0] + cls2 = GENERAL_PROBLEM_REG[1] if len(GENERAL_PROBLEM_REG) > 1 else cls1 + + problems = {"problem_class": [cls1, cls2], "waypoint_i": [None, 0]} + + sel_fpath = tmp_path / "subdir" / "selected_problems.json" + simulator.cache_selected_problems(problems, str(sel_fpath)) + + assert sel_fpath.exists() + with open(sel_fpath, encoding="utf-8") as f: + data = json.load(f) + assert "problem_class" in data and "waypoint_i" in data + + # now load via simulator, verify class names map back to original selected problem classes + loaded = simulator.load_selected_problems(str(sel_fpath)) + assert loaded["waypoint_i"] == problems["waypoint_i"] + assert [c.__name__ for c in problems["problem_class"]] == [ + c.__name__ for c in loaded["problem_class"] + ] + + +def test_hash_to_json(tmp_path): + expedition = _make_simple_expedition(num_waypoints=2) + simulator = ProblemSimulator(expedition, str(tmp_path)) + + cls = GENERAL_PROBLEM_REG[0] + + hash_path = tmp_path / "problem_hash.json" + simulator._hash_to_json( + cls, "deadbeef", None, hash_path + ) # "deadbeef" as sub for hex in test + + assert hash_path.exists() + with open(hash_path, encoding="utf-8") as f: + obj = json.load(f) + assert obj["problem_hash"] == "deadbeef" + assert "message" in obj and "delay_duration_hours" in obj + assert obj["resolved"] is False + + +def test_has_contingency_pre_departure(tmp_path): + expedition = _make_simple_expedition(num_waypoints=2) + simulator = ProblemSimulator(expedition, str(tmp_path)) + cls = GENERAL_PROBLEM_REG[0] + + # _has_contingency should return False for pre-departure (None) + assert simulator._has_contingency(cls, None) is False + + +def test_select_problems_prob_levels(tmp_path): + expedition = _make_simple_expedition(num_waypoints=3) + simulator = ProblemSimulator(expedition, str(tmp_path)) + + for level in range(3): # prob levels 0, 1, 2 + problems = simulator.select_problems(set(), prob_level=level) + if level == 0: + assert problems is None + else: + assert isinstance(problems, dict) + assert len(problems["problem_class"]) > 0 + assert len(problems["waypoint_i"]) == len(problems["problem_class"]) + if level == 1: + assert len(problems["problem_class"]) <= 2 + + +def test_prob_level_two_more_problems(tmp_path): + prob_level = 2 + + short_expedition = _make_simple_expedition(num_waypoints=2) + simulator_short = ProblemSimulator(short_expedition, str(tmp_path)) + long_expedition = _make_simple_expedition(num_waypoints=12) + simulator_long = ProblemSimulator(long_expedition, str(tmp_path)) + + problems_short = simulator_short.select_problems(set(), prob_level=prob_level) + problems_long = simulator_long.select_problems(set(), prob_level=prob_level) + + assert len(problems_long["problem_class"]) >= len( + problems_short["problem_class"] + ), "Longer expedition should have more problems than shorter one at prob_level=2" + + +def test_has_contingency_during_expedition(tmp_path): + # expedition with long distance between waypoints + long_wp_expedition = _make_simple_expedition(num_waypoints=2, distance_scale=3.0) + long_simulator = ProblemSimulator(long_wp_expedition, str(tmp_path)) + # short distance + short_wp_expedition = _make_simple_expedition(num_waypoints=2, distance_scale=0.01) + short_simulator = ProblemSimulator(short_wp_expedition, str(tmp_path)) + + # a during-expedition general problem + problem_cls = next( + c for c in GENERAL_PROBLEM_REG if not getattr(c, "pre_departure", False) + ) + + assert problem_cls is not None, ( + "Need at least one non-pre-departure problem class in the general problem registry" + ) + + # short distance expedition should have contingency, long distance should not (given time between waypoints and ship speed is constant) + assert short_simulator._has_contingency(problem_cls, problem_waypoint_i=0) is True + assert long_simulator._has_contingency(problem_cls, problem_waypoint_i=0) is False From ab0489ea95d61bbfbe6e4ede83e7d02d210ee6ff Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:31:21 +0100 Subject: [PATCH 46/52] add test for calc_wp_stationkeeping_time --- src/virtualship/utils.py | 5 +-- tests/test_utils.py | 78 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 5a517bbce..b56c730bb 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -18,7 +18,6 @@ from parcels import FieldSet from virtualship.errors import CopernicusCatalogueError -from virtualship.instruments.types import InstrumentType if TYPE_CHECKING: from virtualship.expedition.simulate_schedule import ( @@ -626,7 +625,9 @@ def _calc_wp_stationkeeping_time( expedition: Expedition, instrument_config_map: dict = INSTRUMENT_CONFIG_MAP, ) -> timedelta: - """For a given waypoint, calculate how much time is required to carry out all instrument deployments.""" + """For a given waypoint (and the instruments present at this waypoint), calculate how much time is required to carry out all instrument deployments.""" + from virtualship.instruments.types import InstrumentType # avoid circular imports + # TODO: this can be removed if/when CTD and CTD_BGC are merged to a single instrument both_ctd_and_bgc = ( InstrumentType.CTD in wp_instrument_types diff --git a/tests/test_utils.py b/tests/test_utils.py index 647243dd8..9efef8789 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -7,8 +7,10 @@ import virtualship.utils from parcels import FieldSet +from virtualship.instruments.types import InstrumentType from virtualship.models.expedition import Expedition from virtualship.utils import ( + _calc_wp_stationkeeping_time, _find_nc_file_with_variable, _get_bathy_data, _select_product_id, @@ -241,3 +243,79 @@ def test_data_dir_and_filename_compliance(): # TODO: test for calc_sail_time # TODO: test for calc_stationkeeping_time + + +def test_calc_wp_stationkeeping_time(expedition, monkeypatch): + """Test _calc_wp_stationkeeping_time for correct stationkeeping time calculation.""" + + class DummyInstrumentsConfig: + def __init__(self, ctd, ctd_bgc, argo, xbt): + self.ctd = ctd + self.ctd_bgc = ctd_bgc + self.argo = argo + self.xbt = xbt + + class CTDConfig: + stationkeeping_time = datetime.timedelta(minutes=50) + + class CTD_BGCConfig: + stationkeeping_time = datetime.timedelta(minutes=50) + + class ArgoFloatConfig: + stationkeeping_time = datetime.timedelta(minutes=20) + + class XBTConfig: # has no stationkeeping time + deceleration_coefficient = 0.1 + + monkeypatch.setattr( + "virtualship.utils.INSTRUMENT_CONFIG_MAP", + { + InstrumentType.CTD: "CTDConfig", + InstrumentType.CTD_BGC: "CTD_BGCConfig", + InstrumentType.ARGO_FLOAT: "ArgoFloatConfig", + InstrumentType.XBT: "XBTConfig", + }, + ) + + # Create a dummy expedition with instruments_config containing the dummy configs + instruments_config = DummyInstrumentsConfig( + ctd=CTDConfig(), + ctd_bgc=CTD_BGCConfig(), + argo=ArgoFloatConfig(), + xbt=XBTConfig(), + ) + expedition.instruments_config = ( + instruments_config # overwrite instruments_config with test dummy + ) + + # instruments at a given waypoint + wp_instrument_types_all = [ + InstrumentType.CTD, + InstrumentType.CTD_BGC, + InstrumentType.ARGO_FLOAT, + InstrumentType.XBT, + ] + + breakpoint() + + # all dummy instruments + stationkeeping_time_all = _calc_wp_stationkeeping_time( + wp_instrument_types_all, expedition + ) + assert ( + stationkeeping_time_all + == CTDConfig.stationkeeping_time + + ( + CTD_BGCConfig.stationkeeping_time * 0.0 + ) # CTD(_BGC) counted once when both present + + ArgoFloatConfig.stationkeeping_time + ) + + # xbt only (no stationkeeping time) + wp_instrument_types_xbt = [InstrumentType.XBT] + stationkeeping_time_xbt = _calc_wp_stationkeeping_time( + wp_instrument_types_xbt, expedition + ) + assert stationkeeping_time_xbt == datetime.timedelta(0), ( + "XBT should have zero stationkeeping time" + ) From c00051584bfb2676a8e0ab837bb96ae40a11aa0b Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:53:38 +0100 Subject: [PATCH 47/52] add test for _calc_sail_time --- tests/test_utils.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index 9efef8789..6e0b953c6 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -9,7 +9,10 @@ from parcels import FieldSet from virtualship.instruments.types import InstrumentType from virtualship.models.expedition import Expedition +from virtualship.models.location import Location from virtualship.utils import ( + PROJECTION, + _calc_sail_time, _calc_wp_stationkeeping_time, _find_nc_file_with_variable, _get_bathy_data, @@ -242,7 +245,25 @@ def test_data_dir_and_filename_compliance(): # TODO: test for calc_sail_time -# TODO: test for calc_stationkeeping_time + +def test_calc_sail_time(projection=PROJECTION): + LATITUDE = 0.0 # constant at equator + + location1 = Location(latitude=LATITUDE, longitude=0.0) + location2 = Location(latitude=LATITUDE, longitude=1.0) + ship_speed_knots = 10.0 + + sail_time, _, ship_speed_ms = _calc_sail_time( + location1, location2, ship_speed_knots, projection + ) + + # should be approximately 21638 seconds (6 hours, 0 minutes, 38 seconds) + assert abs(sail_time.total_seconds() - 21638) < 10 # small tolerance + + calculated_distance_m = ship_speed_ms * sail_time.total_seconds() + assert ( + abs(calculated_distance_m - 111319) < 100 + ) # # 1 degree longitude at equator ≈ 111319 meters; allow small tolerance def test_calc_wp_stationkeeping_time(expedition, monkeypatch): @@ -296,8 +317,6 @@ class XBTConfig: # has no stationkeeping time InstrumentType.XBT, ] - breakpoint() - # all dummy instruments stationkeeping_time_all = _calc_wp_stationkeeping_time( wp_instrument_types_all, expedition From e46acd06c82f4fff3bfe6f264c347521c687812e Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:43:13 +0100 Subject: [PATCH 48/52] tests for checkpoint class --- tests/test_checkpoint.py | 145 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 tests/test_checkpoint.py diff --git a/tests/test_checkpoint.py b/tests/test_checkpoint.py new file mode 100644 index 000000000..a325d15bc --- /dev/null +++ b/tests/test_checkpoint.py @@ -0,0 +1,145 @@ +import json +from datetime import datetime, timedelta +from pathlib import Path +from unittest.mock import patch + +import pytest + +from virtualship.models.checkpoint import Checkpoint +from virtualship.models.expedition import Expedition, Schedule, Waypoint +from virtualship.models.location import Location +from virtualship.utils import get_example_expedition + + +@pytest.fixture +def expedition(tmp_file): + with open(tmp_file, "w") as file: + file.write(get_example_expedition()) + return Expedition.from_yaml(tmp_file) + + +def make_dummy_checkpoint(failed_waypoint_i=None): + wp1 = Waypoint( + location=Location(latitude=0.0, longitude=0.0), + time=datetime(2024, 2, 1, 10, 0, 0), + instrument=[], + ) + wp2 = Waypoint( + location=Location(latitude=1.0, longitude=1.0), + time=datetime(2024, 2, 1, 12, 0, 0), + instrument=[], + ) + + schedule = Schedule(waypoints=[wp1, wp2]) + return Checkpoint(past_schedule=schedule, failed_waypoint_i=failed_waypoint_i) + + +def test_to_and_from_yaml(tmp_path): + cp = make_dummy_checkpoint() + file_path = tmp_path / "checkpoint.yaml" + cp.to_yaml(file_path) + loaded = Checkpoint.from_yaml(file_path) + + assert isinstance(loaded, Checkpoint) + assert loaded.past_schedule.waypoints[0].time == cp.past_schedule.waypoints[0].time + + +def test_verify_no_failed_waypoint(expedition): + cp = make_dummy_checkpoint(failed_waypoint_i=None) + cp.verify(expedition, Path("/tmp/empty")) # should not raise errors + + +def test_verify_past_waypoints_changed(expedition): + cp = make_dummy_checkpoint(failed_waypoint_i=1) + + # change past waypoints + new_wp1 = Waypoint( + location=Location(latitude=0.0, longitude=0.0), + time=datetime(2024, 2, 1, 11, 0, 0), + instrument=None, + ) + new_wp2 = Waypoint( + location=Location(latitude=1.0, longitude=1.0), + time=datetime(2024, 2, 1, 12, 0, 0), + instrument=None, + ) + new_schedule = Schedule(waypoints=[new_wp1, new_wp2]) + expedition.schedule = new_schedule + + with pytest.raises(Exception) as excinfo: + cp.verify(expedition, Path("/tmp/empty")) + assert "Past waypoints in schedule have been changed" in str(excinfo.value) + + +@pytest.mark.parametrize( + "delay_duration_hours, new_wp2_time, should_resolve", + [ + (1.0, datetime(2024, 2, 1, 15, 0, 0), True), # problem resolved + (5.0, datetime(2024, 2, 1, 12, 0, 0), False), # problem unresolved + ], +) +@patch( + "virtualship.models.checkpoint._calc_wp_stationkeeping_time", + return_value=timedelta(hours=1), +) +@patch( + "virtualship.models.checkpoint._calc_sail_time", + return_value=(timedelta(hours=2), None), +) +def test_verify_problem_resolution( + mock_sail, + mock_stationkeeping, + tmp_path, + expedition, + delay_duration_hours, + new_wp2_time, + should_resolve, +): + wp1 = Waypoint( + location=Location(latitude=0.0, longitude=0.0), + time=datetime(2024, 2, 1, 10, 0, 0), + instrument=[], + ) + wp2 = Waypoint( + location=Location(latitude=1.0, longitude=1.0), + time=datetime(2024, 2, 1, 12, 0, 0), + instrument=[], + ) + past_schedule = Schedule(waypoints=[wp1, wp2]) + cp = Checkpoint(past_schedule=past_schedule, failed_waypoint_i=1) + + # new schedule + new_wp1 = Waypoint( + location=Location(latitude=0.0, longitude=0.0), + time=datetime(2024, 2, 1, 10, 0, 0), + instrument=[], + ) + new_wp2 = Waypoint( + location=Location(latitude=1.0, longitude=1.0), + time=new_wp2_time, + instrument=[], + ) + new_schedule = Schedule(waypoints=[new_wp1, new_wp2]) + expedition.schedule = new_schedule + + # unresolved problem file + problems_dir = tmp_path + problem = { + "resolved": False, + "delay_duration_hours": delay_duration_hours, + "problem_waypoint_i": 0, + } + problem_file = problems_dir / "problem_1.json" + with open(problem_file, "w") as f: + json.dump(problem, f) + + # check if resolution is detected correctly + if should_resolve: + cp.verify(expedition, problems_dir) + with open(problem_file) as f: + updated = json.load(f) + assert updated["resolved"] is True + else: + with pytest.raises(Exception) as excinfo: + cp.verify(expedition, problems_dir) + assert "has not been resolved in the schedule" in str(excinfo.value) From 28ef9b7ae6f48e231567eff1ee0a8338a2e31cb2 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:48:50 +0100 Subject: [PATCH 49/52] move back test zarr files --- tests/make_realistic/adcp.zarr/.zattrs | 8 + tests/make_realistic/adcp.zarr/.zgroup | 3 + tests/make_realistic/adcp.zarr/.zmetadata | 264 ++++++++++++ tests/make_realistic/adcp.zarr/U/.zarray | 23 + tests/make_realistic/adcp.zarr/U/.zattrs | 9 + tests/make_realistic/adcp.zarr/U/0.0 | Bin 0 -> 161 bytes tests/make_realistic/adcp.zarr/U/0.1 | Bin 0 -> 160 bytes tests/make_realistic/adcp.zarr/V/.zarray | 23 + tests/make_realistic/adcp.zarr/V/.zattrs | 9 + tests/make_realistic/adcp.zarr/V/0.0 | Bin 0 -> 160 bytes tests/make_realistic/adcp.zarr/V/0.1 | Bin 0 -> 160 bytes tests/make_realistic/adcp.zarr/lat/.zarray | 23 + tests/make_realistic/adcp.zarr/lat/.zattrs | 10 + tests/make_realistic/adcp.zarr/lat/0.0 | Bin 0 -> 45 bytes tests/make_realistic/adcp.zarr/lat/0.1 | Bin 0 -> 40 bytes tests/make_realistic/adcp.zarr/lon/.zarray | 23 + tests/make_realistic/adcp.zarr/lon/.zattrs | 10 + tests/make_realistic/adcp.zarr/lon/0.0 | Bin 0 -> 40 bytes tests/make_realistic/adcp.zarr/lon/0.1 | Bin 0 -> 45 bytes tests/make_realistic/adcp.zarr/obs/.zarray | 21 + tests/make_realistic/adcp.zarr/obs/.zattrs | 5 + tests/make_realistic/adcp.zarr/obs/0 | Bin 0 -> 20 bytes tests/make_realistic/adcp.zarr/obs/1 | Bin 0 -> 20 bytes tests/make_realistic/adcp.zarr/time/.zarray | 23 + tests/make_realistic/adcp.zarr/time/.zattrs | 11 + tests/make_realistic/adcp.zarr/time/0.0 | Bin 0 -> 36 bytes tests/make_realistic/adcp.zarr/time/0.1 | Bin 0 -> 45 bytes .../adcp.zarr/trajectory/.zarray | 20 + .../adcp.zarr/trajectory/.zattrs | 5 + tests/make_realistic/adcp.zarr/trajectory/0 | Bin 0 -> 80 bytes tests/make_realistic/adcp.zarr/z/.zarray | 23 + tests/make_realistic/adcp.zarr/z/.zattrs | 10 + tests/make_realistic/adcp.zarr/z/0.0 | Bin 0 -> 163 bytes tests/make_realistic/adcp.zarr/z/0.1 | Bin 0 -> 163 bytes tests/make_realistic/ctd.zarr/.zattrs | 8 + tests/make_realistic/ctd.zarr/.zgroup | 3 + tests/make_realistic/ctd.zarr/.zmetadata | 392 ++++++++++++++++++ tests/make_realistic/ctd.zarr/lat/.zarray | 23 + tests/make_realistic/ctd.zarr/lat/.zattrs | 10 + tests/make_realistic/ctd.zarr/lat/0.0 | Bin 0 -> 24 bytes tests/make_realistic/ctd.zarr/lat/0.1 | Bin 0 -> 24 bytes tests/make_realistic/ctd.zarr/lat/0.2 | Bin 0 -> 24 bytes tests/make_realistic/ctd.zarr/lon/.zarray | 23 + tests/make_realistic/ctd.zarr/lon/.zattrs | 10 + tests/make_realistic/ctd.zarr/lon/0.0 | Bin 0 -> 24 bytes tests/make_realistic/ctd.zarr/lon/0.1 | Bin 0 -> 24 bytes tests/make_realistic/ctd.zarr/lon/0.2 | Bin 0 -> 24 bytes .../make_realistic/ctd.zarr/max_depth/.zarray | 23 + .../make_realistic/ctd.zarr/max_depth/.zattrs | 9 + tests/make_realistic/ctd.zarr/max_depth/0.0 | Bin 0 -> 24 bytes tests/make_realistic/ctd.zarr/max_depth/0.1 | Bin 0 -> 24 bytes tests/make_realistic/ctd.zarr/max_depth/0.2 | Bin 0 -> 24 bytes .../make_realistic/ctd.zarr/min_depth/.zarray | 23 + .../make_realistic/ctd.zarr/min_depth/.zattrs | 9 + tests/make_realistic/ctd.zarr/min_depth/0.0 | Bin 0 -> 24 bytes tests/make_realistic/ctd.zarr/min_depth/0.1 | Bin 0 -> 24 bytes tests/make_realistic/ctd.zarr/min_depth/0.2 | Bin 0 -> 24 bytes tests/make_realistic/ctd.zarr/obs/.zarray | 21 + tests/make_realistic/ctd.zarr/obs/.zattrs | 5 + tests/make_realistic/ctd.zarr/obs/0 | Bin 0 -> 20 bytes tests/make_realistic/ctd.zarr/obs/1 | Bin 0 -> 20 bytes tests/make_realistic/ctd.zarr/obs/2 | Bin 0 -> 20 bytes tests/make_realistic/ctd.zarr/raising/.zarray | 23 + tests/make_realistic/ctd.zarr/raising/.zattrs | 9 + tests/make_realistic/ctd.zarr/raising/0.0 | Bin 0 -> 18 bytes tests/make_realistic/ctd.zarr/raising/0.1 | Bin 0 -> 18 bytes tests/make_realistic/ctd.zarr/raising/0.2 | Bin 0 -> 18 bytes .../make_realistic/ctd.zarr/salinity/.zarray | 23 + .../make_realistic/ctd.zarr/salinity/.zattrs | 9 + tests/make_realistic/ctd.zarr/salinity/0.0 | Bin 0 -> 24 bytes tests/make_realistic/ctd.zarr/salinity/0.1 | Bin 0 -> 24 bytes tests/make_realistic/ctd.zarr/salinity/0.2 | Bin 0 -> 24 bytes .../ctd.zarr/temperature/.zarray | 23 + .../ctd.zarr/temperature/.zattrs | 9 + tests/make_realistic/ctd.zarr/temperature/0.0 | Bin 0 -> 24 bytes tests/make_realistic/ctd.zarr/temperature/0.1 | Bin 0 -> 24 bytes tests/make_realistic/ctd.zarr/temperature/0.2 | Bin 0 -> 24 bytes tests/make_realistic/ctd.zarr/time/.zarray | 23 + tests/make_realistic/ctd.zarr/time/.zattrs | 11 + tests/make_realistic/ctd.zarr/time/0.0 | Bin 0 -> 32 bytes tests/make_realistic/ctd.zarr/time/0.1 | Bin 0 -> 32 bytes tests/make_realistic/ctd.zarr/time/0.2 | Bin 0 -> 32 bytes .../ctd.zarr/trajectory/.zarray | 20 + .../ctd.zarr/trajectory/.zattrs | 5 + tests/make_realistic/ctd.zarr/trajectory/0 | Bin 0 -> 32 bytes .../ctd.zarr/winch_speed/.zarray | 23 + .../ctd.zarr/winch_speed/.zattrs | 9 + tests/make_realistic/ctd.zarr/winch_speed/0.0 | Bin 0 -> 24 bytes tests/make_realistic/ctd.zarr/winch_speed/0.1 | Bin 0 -> 24 bytes tests/make_realistic/ctd.zarr/winch_speed/0.2 | Bin 0 -> 24 bytes tests/make_realistic/ctd.zarr/z/.zarray | 23 + tests/make_realistic/ctd.zarr/z/.zattrs | 10 + tests/make_realistic/ctd.zarr/z/0.0 | Bin 0 -> 24 bytes tests/make_realistic/ctd.zarr/z/0.1 | Bin 0 -> 24 bytes tests/make_realistic/ctd.zarr/z/0.2 | Bin 0 -> 24 bytes 95 files changed, 1302 insertions(+) create mode 100644 tests/make_realistic/adcp.zarr/.zattrs create mode 100644 tests/make_realistic/adcp.zarr/.zgroup create mode 100644 tests/make_realistic/adcp.zarr/.zmetadata create mode 100644 tests/make_realistic/adcp.zarr/U/.zarray create mode 100644 tests/make_realistic/adcp.zarr/U/.zattrs create mode 100644 tests/make_realistic/adcp.zarr/U/0.0 create mode 100644 tests/make_realistic/adcp.zarr/U/0.1 create mode 100644 tests/make_realistic/adcp.zarr/V/.zarray create mode 100644 tests/make_realistic/adcp.zarr/V/.zattrs create mode 100644 tests/make_realistic/adcp.zarr/V/0.0 create mode 100644 tests/make_realistic/adcp.zarr/V/0.1 create mode 100644 tests/make_realistic/adcp.zarr/lat/.zarray create mode 100644 tests/make_realistic/adcp.zarr/lat/.zattrs create mode 100644 tests/make_realistic/adcp.zarr/lat/0.0 create mode 100644 tests/make_realistic/adcp.zarr/lat/0.1 create mode 100644 tests/make_realistic/adcp.zarr/lon/.zarray create mode 100644 tests/make_realistic/adcp.zarr/lon/.zattrs create mode 100644 tests/make_realistic/adcp.zarr/lon/0.0 create mode 100644 tests/make_realistic/adcp.zarr/lon/0.1 create mode 100644 tests/make_realistic/adcp.zarr/obs/.zarray create mode 100644 tests/make_realistic/adcp.zarr/obs/.zattrs create mode 100644 tests/make_realistic/adcp.zarr/obs/0 create mode 100644 tests/make_realistic/adcp.zarr/obs/1 create mode 100644 tests/make_realistic/adcp.zarr/time/.zarray create mode 100644 tests/make_realistic/adcp.zarr/time/.zattrs create mode 100644 tests/make_realistic/adcp.zarr/time/0.0 create mode 100644 tests/make_realistic/adcp.zarr/time/0.1 create mode 100644 tests/make_realistic/adcp.zarr/trajectory/.zarray create mode 100644 tests/make_realistic/adcp.zarr/trajectory/.zattrs create mode 100644 tests/make_realistic/adcp.zarr/trajectory/0 create mode 100644 tests/make_realistic/adcp.zarr/z/.zarray create mode 100644 tests/make_realistic/adcp.zarr/z/.zattrs create mode 100644 tests/make_realistic/adcp.zarr/z/0.0 create mode 100644 tests/make_realistic/adcp.zarr/z/0.1 create mode 100644 tests/make_realistic/ctd.zarr/.zattrs create mode 100644 tests/make_realistic/ctd.zarr/.zgroup create mode 100644 tests/make_realistic/ctd.zarr/.zmetadata create mode 100644 tests/make_realistic/ctd.zarr/lat/.zarray create mode 100644 tests/make_realistic/ctd.zarr/lat/.zattrs create mode 100644 tests/make_realistic/ctd.zarr/lat/0.0 create mode 100644 tests/make_realistic/ctd.zarr/lat/0.1 create mode 100644 tests/make_realistic/ctd.zarr/lat/0.2 create mode 100644 tests/make_realistic/ctd.zarr/lon/.zarray create mode 100644 tests/make_realistic/ctd.zarr/lon/.zattrs create mode 100644 tests/make_realistic/ctd.zarr/lon/0.0 create mode 100644 tests/make_realistic/ctd.zarr/lon/0.1 create mode 100644 tests/make_realistic/ctd.zarr/lon/0.2 create mode 100644 tests/make_realistic/ctd.zarr/max_depth/.zarray create mode 100644 tests/make_realistic/ctd.zarr/max_depth/.zattrs create mode 100644 tests/make_realistic/ctd.zarr/max_depth/0.0 create mode 100644 tests/make_realistic/ctd.zarr/max_depth/0.1 create mode 100644 tests/make_realistic/ctd.zarr/max_depth/0.2 create mode 100644 tests/make_realistic/ctd.zarr/min_depth/.zarray create mode 100644 tests/make_realistic/ctd.zarr/min_depth/.zattrs create mode 100644 tests/make_realistic/ctd.zarr/min_depth/0.0 create mode 100644 tests/make_realistic/ctd.zarr/min_depth/0.1 create mode 100644 tests/make_realistic/ctd.zarr/min_depth/0.2 create mode 100644 tests/make_realistic/ctd.zarr/obs/.zarray create mode 100644 tests/make_realistic/ctd.zarr/obs/.zattrs create mode 100644 tests/make_realistic/ctd.zarr/obs/0 create mode 100644 tests/make_realistic/ctd.zarr/obs/1 create mode 100644 tests/make_realistic/ctd.zarr/obs/2 create mode 100644 tests/make_realistic/ctd.zarr/raising/.zarray create mode 100644 tests/make_realistic/ctd.zarr/raising/.zattrs create mode 100644 tests/make_realistic/ctd.zarr/raising/0.0 create mode 100644 tests/make_realistic/ctd.zarr/raising/0.1 create mode 100644 tests/make_realistic/ctd.zarr/raising/0.2 create mode 100644 tests/make_realistic/ctd.zarr/salinity/.zarray create mode 100644 tests/make_realistic/ctd.zarr/salinity/.zattrs create mode 100644 tests/make_realistic/ctd.zarr/salinity/0.0 create mode 100644 tests/make_realistic/ctd.zarr/salinity/0.1 create mode 100644 tests/make_realistic/ctd.zarr/salinity/0.2 create mode 100644 tests/make_realistic/ctd.zarr/temperature/.zarray create mode 100644 tests/make_realistic/ctd.zarr/temperature/.zattrs create mode 100644 tests/make_realistic/ctd.zarr/temperature/0.0 create mode 100644 tests/make_realistic/ctd.zarr/temperature/0.1 create mode 100644 tests/make_realistic/ctd.zarr/temperature/0.2 create mode 100644 tests/make_realistic/ctd.zarr/time/.zarray create mode 100644 tests/make_realistic/ctd.zarr/time/.zattrs create mode 100644 tests/make_realistic/ctd.zarr/time/0.0 create mode 100644 tests/make_realistic/ctd.zarr/time/0.1 create mode 100644 tests/make_realistic/ctd.zarr/time/0.2 create mode 100644 tests/make_realistic/ctd.zarr/trajectory/.zarray create mode 100644 tests/make_realistic/ctd.zarr/trajectory/.zattrs create mode 100644 tests/make_realistic/ctd.zarr/trajectory/0 create mode 100644 tests/make_realistic/ctd.zarr/winch_speed/.zarray create mode 100644 tests/make_realistic/ctd.zarr/winch_speed/.zattrs create mode 100644 tests/make_realistic/ctd.zarr/winch_speed/0.0 create mode 100644 tests/make_realistic/ctd.zarr/winch_speed/0.1 create mode 100644 tests/make_realistic/ctd.zarr/winch_speed/0.2 create mode 100644 tests/make_realistic/ctd.zarr/z/.zarray create mode 100644 tests/make_realistic/ctd.zarr/z/.zattrs create mode 100644 tests/make_realistic/ctd.zarr/z/0.0 create mode 100644 tests/make_realistic/ctd.zarr/z/0.1 create mode 100644 tests/make_realistic/ctd.zarr/z/0.2 diff --git a/tests/make_realistic/adcp.zarr/.zattrs b/tests/make_realistic/adcp.zarr/.zattrs new file mode 100644 index 000000000..90d4d0b43 --- /dev/null +++ b/tests/make_realistic/adcp.zarr/.zattrs @@ -0,0 +1,8 @@ +{ + "Conventions": "CF-1.6/CF-1.7", + "feature_type": "trajectory", + "ncei_template_version": "NCEI_NetCDF_Trajectory_Template_v2.0", + "parcels_kernels": "NewParticle_sample_velocity", + "parcels_mesh": "spherical", + "parcels_version": "0.0.1-16-g947e7d0" +} \ No newline at end of file diff --git a/tests/make_realistic/adcp.zarr/.zgroup b/tests/make_realistic/adcp.zarr/.zgroup new file mode 100644 index 000000000..3b7daf227 --- /dev/null +++ b/tests/make_realistic/adcp.zarr/.zgroup @@ -0,0 +1,3 @@ +{ + "zarr_format": 2 +} \ No newline at end of file diff --git a/tests/make_realistic/adcp.zarr/.zmetadata b/tests/make_realistic/adcp.zarr/.zmetadata new file mode 100644 index 000000000..d1bf51f5c --- /dev/null +++ b/tests/make_realistic/adcp.zarr/.zmetadata @@ -0,0 +1,264 @@ +{ + "metadata": { + ".zattrs": { + "Conventions": "CF-1.6/CF-1.7", + "feature_type": "trajectory", + "ncei_template_version": "NCEI_NetCDF_Trajectory_Template_v2.0", + "parcels_kernels": "NewParticle_sample_velocity", + "parcels_mesh": "spherical", + "parcels_version": "0.0.1-16-g947e7d0" + }, + ".zgroup": { + "zarr_format": 2 + }, + "U/.zarray": { + "chunks": [ + 40, + 1 + ], + "compressor": { + "blocksize": 0, + "clevel": 5, + "cname": "lz4", + "id": "blosc", + "shuffle": 1 + }, + "dimension_separator": ".", + "dtype": "i^pFVzg|L*PEH?Lp4eEH(}v!_p!2?GJ5MX8C H3IHwMojJO1$Hvu5=1rf_)m&RvkeL({>hIxX zZK9{HC?&$n&cHBD_ek`oPKE7$Piur%Io`-;n`?49>E{IXeW7ofr8aupFXLTeeJPW{ z!QS4^&eqn}#>U#(+RDn(($d1h+}zB}%+%Dx#KhRx$jHdh(7?dJ5d>HnxC6ip02=W% ArT_o{ literal 0 HcmV?d00001 diff --git a/tests/make_realistic/adcp.zarr/V/.zarray b/tests/make_realistic/adcp.zarr/V/.zarray new file mode 100644 index 000000000..54146227b --- /dev/null +++ b/tests/make_realistic/adcp.zarr/V/.zarray @@ -0,0 +1,23 @@ +{ + "chunks": [ + 40, + 1 + ], + "compressor": { + "blocksize": 0, + "clevel": 5, + "cname": "lz4", + "id": "blosc", + "shuffle": 1 + }, + "dimension_separator": ".", + "dtype": "9N=|MlknrK3Am&zsO( zmKo#kWTGy`%fO%;-MRg#uw(w*(?8Win>XI)wa%P&QyUOu2{Zo>5?Uj7cE?{zySnU8Mp($ F3;<$SNL>H` literal 0 HcmV?d00001 diff --git a/tests/make_realistic/adcp.zarr/V/0.1 b/tests/make_realistic/adcp.zarr/V/0.1 new file mode 100644 index 0000000000000000000000000000000000000000..35217ac1c0d232d243d832d927d63560244a9646 GIT binary patch literal 160 zcmZQ#G-O%8z`y{*B0zQr5dY6(`1$$Gv->wMojJO1$Hvu5=1rf_)m&RvkeL({>hIxX zZK9{HC?&$n&cHBD_ek`oPKE7$Piur%Io`-;n`?49>E{IXeW7ofr8aupFXLTeeJPVc zL0(=?PF7Y{Mn+m%T1rY%Qc^-fTwF{{OjJ}vL_}CvNJvOfP(VPy5d>HnxC6ip0B&M1 ArT_o{ literal 0 HcmV?d00001 diff --git a/tests/make_realistic/adcp.zarr/lat/.zarray b/tests/make_realistic/adcp.zarr/lat/.zarray new file mode 100644 index 000000000..54146227b --- /dev/null +++ b/tests/make_realistic/adcp.zarr/lat/.zarray @@ -0,0 +1,23 @@ +{ + "chunks": [ + 40, + 1 + ], + "compressor": { + "blocksize": 0, + "clevel": 5, + "cname": "lz4", + "id": "blosc", + "shuffle": 1 + }, + "dimension_separator": ".", + "dtype": "@KA0su-z1V{h? literal 0 HcmV?d00001 diff --git a/tests/make_realistic/adcp.zarr/lon/.zarray b/tests/make_realistic/adcp.zarr/lon/.zarray new file mode 100644 index 000000000..54146227b --- /dev/null +++ b/tests/make_realistic/adcp.zarr/lon/.zarray @@ -0,0 +1,23 @@ +{ + "chunks": [ + 40, + 1 + ], + "compressor": { + "blocksize": 0, + "clevel": 5, + "cname": "lz4", + "id": "blosc", + "shuffle": 1 + }, + "dimension_separator": ".", + "dtype": "zUKMpP?Ih zGrOFAZp?Jr=;Xw}FyHQOjr8uQpEJy_m5FW%{xr$(!hPB3SvSN&CtnhnD>Y4cf|Rh7 z6hl>UZdyWAu&zUKMpP?Ih zGrOFAZp?Jr=;Xw}FyHQOjr8uQpEJy_m5FW%{xr$(!hPB3SvSN&CtnhnD>Y4cf|Rh7 z6hl>UZdyWAu&(^b literal 0 HcmV?d00001 diff --git a/tests/make_realistic/ctd.zarr/max_depth/0.1 b/tests/make_realistic/ctd.zarr/max_depth/0.1 new file mode 100644 index 0000000000000000000000000000000000000000..0e79b19fa37e44e66d82a0decc36afb18f7f734a GIT binary patch literal 24 YcmZQ#G-lyoU|;}Y2_R-*P&f#}01Vp#B>(^b literal 0 HcmV?d00001 diff --git a/tests/make_realistic/ctd.zarr/max_depth/0.2 b/tests/make_realistic/ctd.zarr/max_depth/0.2 new file mode 100644 index 0000000000000000000000000000000000000000..0e79b19fa37e44e66d82a0decc36afb18f7f734a GIT binary patch literal 24 YcmZQ#G-lyoU|;}Y2_R-*P&f#}01Vp#B>(^b literal 0 HcmV?d00001 diff --git a/tests/make_realistic/ctd.zarr/min_depth/.zarray b/tests/make_realistic/ctd.zarr/min_depth/.zarray new file mode 100644 index 000000000..14bf5ecc8 --- /dev/null +++ b/tests/make_realistic/ctd.zarr/min_depth/.zarray @@ -0,0 +1,23 @@ +{ + "chunks": [ + 2, + 1 + ], + "compressor": { + "blocksize": 0, + "clevel": 5, + "cname": "lz4", + "id": "blosc", + "shuffle": 1 + }, + "dimension_separator": ".", + "dtype": "(^b literal 0 HcmV?d00001 diff --git a/tests/make_realistic/ctd.zarr/z/0.2 b/tests/make_realistic/ctd.zarr/z/0.2 new file mode 100644 index 0000000000000000000000000000000000000000..ce7fe5ce35b50130d97d1efb14227af965eb9953 GIT binary patch literal 24 UcmZQ#G-lyoU|;}Y2_S|600&h7V*mgE literal 0 HcmV?d00001 From baaf1b929ba90ded27b9bc2336aa96ab8bd33ec3 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:51:42 +0100 Subject: [PATCH 50/52] reinstate original structure --- .../{instrument_noise => }/test_adcp_make_realistic.py | 0 .../{instrument_noise => }/test_ctd_make_realistic.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename tests/make_realistic/{instrument_noise => }/test_adcp_make_realistic.py (100%) rename tests/make_realistic/{instrument_noise => }/test_ctd_make_realistic.py (100%) diff --git a/tests/make_realistic/instrument_noise/test_adcp_make_realistic.py b/tests/make_realistic/test_adcp_make_realistic.py similarity index 100% rename from tests/make_realistic/instrument_noise/test_adcp_make_realistic.py rename to tests/make_realistic/test_adcp_make_realistic.py diff --git a/tests/make_realistic/instrument_noise/test_ctd_make_realistic.py b/tests/make_realistic/test_ctd_make_realistic.py similarity index 100% rename from tests/make_realistic/instrument_noise/test_ctd_make_realistic.py rename to tests/make_realistic/test_ctd_make_realistic.py From c025781665fd62adab2b43da7391711018110538 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 3 Feb 2026 09:31:52 +0100 Subject: [PATCH 51/52] refactor, move methods to static --- .../make_realistic/problems/simulator.py | 58 ++++++++++--------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index 987226431..f260a0133 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -181,31 +181,6 @@ def select_problems( return problems_sorted if selected_problems else None - def cache_selected_problems( - self, - problems: dict[str, list[GeneralProblem | InstrumentProblem] | None], - selected_problems_fpath: str, - ) -> None: - """Cache suite of problems to json, for reference.""" - # make dir to contain problem jsons (unique to expedition) - os.makedirs(Path(selected_problems_fpath).parent, exist_ok=True) - - # cache dict of selected_problems to json - with open( - selected_problems_fpath, - "w", - encoding="utf-8", - ) as f: - json.dump( - { - "problem_class": [p.__name__ for p in problems["problem_class"]], - "waypoint_i": problems["waypoint_i"], - "timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), - }, - f, - indent=4, - ) - def execute( self, problems: dict[str, list[GeneralProblem | InstrumentProblem] | None], @@ -258,8 +233,34 @@ def execute( self.expedition.schedule, schedule_original_fpath ) + @staticmethod + def cache_selected_problems( + problems: dict[str, list[GeneralProblem | InstrumentProblem] | None], + selected_problems_fpath: str, + ) -> None: + """Cache suite of problems to json, for reference.""" + # make dir to contain problem jsons (unique to expedition) + os.makedirs(Path(selected_problems_fpath).parent, exist_ok=True) + + # cache dict of selected_problems to json + with open( + selected_problems_fpath, + "w", + encoding="utf-8", + ) as f: + json.dump( + { + "problem_class": [p.__name__ for p in problems["problem_class"]], + "waypoint_i": problems["waypoint_i"], + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), + }, + f, + indent=4, + ) + + @staticmethod def load_selected_problems( - self, selected_problems_fpath: str + selected_problems_fpath: str, ) -> dict[str, list[GeneralProblem | InstrumentProblem] | None]: """Load previously selected problem classes from json.""" with open( @@ -408,8 +409,8 @@ def _make_checkpoint(self, failed_waypoint_i: int | None = None) -> Checkpoint: past_schedule=self.expedition.schedule, failed_waypoint_i=failed_waypoint_i ) + @staticmethod def _hash_to_json( - self, problem: InstrumentProblem | GeneralProblem, problem_hash: str, problem_waypoint_i: int | None, @@ -427,7 +428,8 @@ def _hash_to_json( with open(hash_path, "w", encoding="utf-8") as f: json.dump(hash_data, f, indent=4) - def _cache_original_schedule(self, schedule: Schedule, path: Path | str): + @staticmethod + def _cache_original_schedule(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) From 2ee5f156cb72dccff7af2415982bf80d53edb944 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 08:34:07 +0000 Subject: [PATCH 52/52] [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/instruments/ctd.py | 2 +- src/virtualship/utils.py | 2 +- tests/expedition/test_expedition.py | 2 +- tests/test_utils.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/virtualship/instruments/base.py b/src/virtualship/instruments/base.py index d249d3977..2ca1b7836 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/instruments/ctd.py b/src/virtualship/instruments/ctd.py index 1b6269bc8..eb780d3ea 100644 --- a/src/virtualship/instruments/ctd.py +++ b/src/virtualship/instruments/ctd.py @@ -3,8 +3,8 @@ from typing import TYPE_CHECKING, ClassVar import numpy as np - from parcels import JITParticle, ParticleSet, Variable + from virtualship.instruments.base import Instrument from virtualship.instruments.types import InstrumentType diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index b56c730bb..97c3e3116 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -15,8 +15,8 @@ import numpy as np import pyproj import xarray as xr - from parcels import FieldSet + from virtualship.errors import CopernicusCatalogueError if TYPE_CHECKING: diff --git a/tests/expedition/test_expedition.py b/tests/expedition/test_expedition.py index d3a5cff3e..314b9db89 100644 --- a/tests/expedition/test_expedition.py +++ b/tests/expedition/test_expedition.py @@ -6,8 +6,8 @@ import pyproj import pytest import xarray as xr - from parcels import FieldSet + from virtualship.errors import InstrumentsConfigError, ScheduleError from virtualship.models import ( Expedition, diff --git a/tests/test_utils.py b/tests/test_utils.py index 6e0b953c6..b3ad75043 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,9 +4,9 @@ import numpy as np import pytest import xarray as xr +from parcels import FieldSet import virtualship.utils -from parcels import FieldSet from virtualship.instruments.types import InstrumentType from virtualship.models.expedition import Expedition from virtualship.models.location import Location