From ce39e312c4bc00fe07aad5a360adbbfa0224f5bc Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:50:52 +0200 Subject: [PATCH 01/28] large refactor of simulator logic --- .../make_realistic/problems/simulator.py | 676 ++++++++---------- 1 file changed, 315 insertions(+), 361 deletions(-) diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index e08e0827..ebeb1137 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -1,12 +1,11 @@ from __future__ import annotations import json -import os import random import sys import time from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from rich import box from rich.console import Console @@ -42,21 +41,28 @@ 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 {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", + "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.\n", } - -# default problem weights for problems simulator (i.e. add +1 problem for every n days/waypoints/instruments in expedition) +# default problem weights for problems simulator (e.g., +1 problem every N days/waypoints/instruments) PROBLEM_WEIGHTS = { "every_ndays": 7, "every_nwaypoints": 6, "every_ninstruments": 3, } +ProblemType = GeneralProblem | InstrumentProblem +SelectedProblemsDict = dict[str, list[ProblemType | None]] + class ProblemSimulator: - """Handle problem simulation during expedition.""" + """Handle problem simulation during an expedition.""" def __init__(self, expedition: Expedition, expedition_dir: str | Path): """Initialise ProblemSimulator with a schedule and probability level.""" @@ -67,7 +73,7 @@ def select_problems( self, instruments_in_expedition: set[InstrumentType], difficulty_level: str, - ) -> dict[str, list[GeneralProblem | InstrumentProblem] | None] | None: + ) -> SelectedProblemsDict | None: """ Select problems (general and instrument-specific). When difficulty_level = 'hard', number of problems is determined by expedition length, instrument count etc. @@ -77,350 +83,258 @@ def select_problems( """ waypoints = self.expedition.schedule.waypoints - valid_instrument_problems = [ - problem - for problem in INSTRUMENT_PROBLEMS - if problem.instrument_type in instruments_in_expedition - ] + # handle early-exit single waypoint case (pre-departure only) + if len(waypoints) < 2: + pre_departure = [p for p in GENERAL_PROBLEMS if p.pre_departure] + return { + "problem_class": [random.choice(pre_departure)], + "waypoint_i": [None], + } - pre_departure_problems = [ + valid_instruments = [ p - for p in GENERAL_PROBLEMS - if isinstance(p, GeneralProblem) and p.pre_departure + for p in INSTRUMENT_PROBLEMS + if p.instrument_type in instruments_in_expedition ] + num_problems = self._calculate_problem_count( + difficulty_level=difficulty_level, + expedition_days=(waypoints[-1].time - waypoints[0].time).days, + num_waypoints=len(waypoints), + num_instruments=len(instruments_in_expedition), + max_available=len(GENERAL_PROBLEMS) + len(valid_instruments), + ) - num_waypoints = len(waypoints) - num_instruments = len(instruments_in_expedition) - expedition_duration_days = (waypoints[-1].time - waypoints[0].time).days + if num_problems <= 0: + return None - # 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], - } + selected = self._sample_problems( + num_problems, valid_instruments, len(instruments_in_expedition) + ) + selected = self._limit_pre_departure(selected, valid_instruments) + return self._assign_problems_to_waypoints(selected) + + def _calculate_problem_count( + self, + difficulty_level: str, + expedition_days: int, + num_waypoints: int, + num_instruments: int, + max_available: int, + ) -> int: + """Determine problem count based on difficulty setting.""" if difficulty_level == "easy": - num_problems = 0 - elif difficulty_level == "medium": - num_problems = random.randint(1, 2) - - elif difficulty_level == "hard": - base = 1 - extra = ( # i.e. +1 problem for every n days/waypoints/instruments (tunable above) - (expedition_duration_days // PROBLEM_WEIGHTS["every_ndays"]) + return 0 + if difficulty_level == "medium": + return random.randint(1, 2) + if difficulty_level == "hard": + extra = ( + (expedition_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_PROBLEMS) + len(valid_instrument_problems) - ) + return min(1 + extra, max_available) + return 0 - selected_problems = [] - problems_sorted = None - if num_problems > 0: - random.shuffle(GENERAL_PROBLEMS) - random.shuffle(valid_instrument_problems) - - # 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_PROBLEMS), num_problems - n_instrument) - n_instrument = ( - num_problems - n_general - ) # recalc in case n_general was capped to len(GENERAL_PROBLEMS) - - selected_problems.extend(GENERAL_PROBLEMS[: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 - selected_pre_departure = [ - p - for p in selected_problems - if isinstance(p, GeneralProblem) and p.pre_departure - ] - 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 - for problem in selected_problems - if not ( - isinstance(problem, GeneralProblem) - and problem.pre_departure - and problem is not to_keep - ) - ] - # available non-pre_departure problems not already selected - available_general = [ + def _sample_problems( + self, + num_problems: int, + valid_instruments: list[InstrumentProblem], + num_instruments: int, + ) -> list[ProblemType]: + """Sample a balanced ratio of general and instrument problems.""" + general_pool = list(GENERAL_PROBLEMS) + instrument_pool = list(valid_instruments) + random.shuffle(general_pool) + random.shuffle(instrument_pool) + + bias = min(0.7, num_instruments / (num_instruments + 2)) + n_inst = round(num_problems * bias) + n_gen = min(len(general_pool), num_problems - n_inst) + n_inst = ( + num_problems - n_gen + ) # recalc in case n_gen was capped to len(GENERAL_PROBLEMS) + + return general_pool[:n_gen] + instrument_pool[:n_inst] + + def _limit_pre_departure( + self, + selected: list[ProblemType], + valid_instruments: list[InstrumentProblem], + ) -> list[ProblemType]: + """Ensure maximum of one pre-departure problem is selected.""" + pre_deps = [ + p for p in selected if isinstance(p, GeneralProblem) and p.pre_departure + ] + if len(pre_deps) <= 1: + return selected + + keep = random.choice(pre_deps) + replacements_needed = len(pre_deps) - 1 + filtered = [ + p for p in selected if p is keep or not getattr(p, "pre_departure", False) + ] + + avail_gen = [ + p for p in GENERAL_PROBLEMS if not p.pre_departure and p not in filtered + ] + avail_inst = [p for p in valid_instruments if p not in filtered] + replacements = avail_gen + avail_inst + random.shuffle(replacements) + + return filtered + replacements[:replacements_needed] + + def _assign_problems_to_waypoints( + self, selected: list[ProblemType] + ) -> SelectedProblemsDict | None: + """Assign sampled problems to valid, non-port waypoint indices.""" + waypoints = self.expedition.schedule.waypoints + avail_indices = [ + i for i, wp in enumerate(waypoints) if not isinstance(wp, Port) + ] + random.shuffle(avail_indices) + + assigned_problems: list[ProblemType] = [] + assigned_indices: list[int | None] = [] + + for problem in selected: + if getattr(problem, "pre_departure", False): + assigned_problems.append(problem) + assigned_indices.append(None) + continue + + if not avail_indices: + break + + # find matching waypoint or substitute with general problem + target_idx = None + for idx in avail_indices: + wp_instruments = waypoints[idx].instrument or [] + if ( + isinstance(problem, InstrumentProblem) + and problem.instrument_type not in wp_instruments + ): + continue + target_idx = idx + break + + if target_idx is not None: + avail_indices.remove(target_idx) + assigned_problems.append(problem) + assigned_indices.append(target_idx) + else: + # fall back to a general problem if instrument match fails + avail_general = [ p for p in GENERAL_PROBLEMS - 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 + if not p.pre_departure and p not in assigned_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, non-port waypoint] (or None if pre-departure) - # limited to one per waypoint, else complicates scheduling and contingency checking - waypoint_idxs = [] - unassigned_problems = [] - is_port = [isinstance(wp, Port) for wp in waypoints] - available_idxs = [i for i, port in enumerate(is_port) if not port] - - # TODO: if incorporate departure and arrival port/waypoints in future, bear in mind index selection here may need to change - for problem in selected_problems: - if getattr(problem, "pre_departure", False): - waypoint_idxs.append(None) - else: - if available_idxs: - wp_select = random.choice(available_idxs) - wp_instruments = waypoints[wp_select].instrument - wp_instruments = wp_instruments if wp_instruments else [] # noqa; handle when waypoint instruments set to "null" in expedition.yaml - - # check waypoint actually deploys the instrument associated with the problem...if not, replace it with a general (non-instrument related) problem - # rather than a different waypoint, because it's possible no applicable waypoint is still available - needs_replacement = ( - isinstance(problem, InstrumentProblem) - and problem.instrument_type not in wp_instruments - ) - if needs_replacement: - available_general = [ - p - for p in GENERAL_PROBLEMS - if not p.pre_departure and p not in selected_problems - ] - - if not available_general: - unassigned_problems.append(problem) - continue - - replacement = random.choice(available_general) - problem_idx = selected_problems.index(problem) - selected_problems[problem_idx] = replacement - - waypoint_idxs.append(wp_select) - available_idxs.remove(wp_select) # each waypoint only used once - - else: - unassigned_problems.append(problem) # noqa; if run out of available waypoints, remove problem from selection - - # remove any problems that couldn't be assigned a waypoint (i.e. if more problems than available waypoints) - if unassigned_problems: - selected_problems = [ - p for p in selected_problems if p not in unassigned_problems - ] - - # 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 + if avail_general and avail_indices: + substitute = random.choice(avail_general) + assigned_problems.append(substitute) + assigned_indices.append(avail_indices.pop()) + + if not assigned_problems: + return None + + # Sort chronologically (pre-departure/None first, then waypoint index order) + paired = sorted( + zip(assigned_problems, assigned_indices, strict=True), + key=lambda x: -1 if x[1] is None else x[1], + ) + return { + "problem_class": [p for p, _ in paired], + "waypoint_i": [w for _, w in paired], + } def execute( self, - problems: dict[str, list[GeneralProblem | InstrumentProblem] | None], + problems: SelectedProblemsDict, instrument_type_validation: InstrumentType | None, log_dir: Path, log_delay: float = 4.0, - ): - """ - Execute the selected problems, returning messaging and delay times. - - 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: when difficulty_level = 'hard' and have general problems which occur at later waypoints: could artificially delay their propagation until later in the simulation? Otherwise they are front-loaded at the start of the simulation... Instrument problems are fine because they only propagate when instrument is simulated... - - for problem, problem_waypoint_i in zip( + ) -> None: + """Execute simulation problems and apply delay/schedule impacts.""" + for problem, wp_i in zip( 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 ( isinstance(problem, InstrumentProblem) and problem.instrument_type is not instrument_type_validation ): continue - problem_hash = _make_hash(problem.message + str(problem_waypoint_i), 8) - hash_fpath = log_dir.joinpath(f"problem_{problem_hash}.json") + problem_hash = _make_hash(problem.message + str(wp_i), 8) + hash_fpath = log_dir / f"problem_{problem_hash}.json" if hash_fpath.exists(): - continue # problem * waypoint combination has already occurred; don't repeat - - if isinstance(problem, GeneralProblem) and problem.pre_departure: - alert_msg = LOG_MESSAGING["pre_departure"] + continue - else: - alert_msg = LOG_MESSAGING["during_expedition"].format( - waypoint=int(problem_waypoint_i) + 1 - ) + alert_msg = ( + LOG_MESSAGING["pre_departure"] + if isinstance(problem, GeneralProblem) and problem.pre_departure + else LOG_MESSAGING["during_expedition"].format(waypoint=wp_i + 1) + ) - # log problem occurrence, save to checkpoint, and pause simulation self._log_problem( - problem, - problem_waypoint_i, - alert_msg, - problem_hash, - hash_fpath, - log_delay, + problem, wp_i, alert_msg, problem_hash, hash_fpath, log_delay ) - - # cache original expedition 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) self._cache_original_expedition(self.expedition) - @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.short_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 post_expedition_report( - problems: dict[str, list[GeneralProblem | InstrumentProblem] | None], - report_fpath: str | Path, - ) -> None: - """Produce human-readable post-expedition report (.txt), including problems that occured (their full messages), the waypoint and what delay they caused.""" - for problem, problem_waypoint_i in zip( - problems["problem_class"], problems["waypoint_i"], strict=True - ): - affected_wp = ( - "in-port" if problem_waypoint_i is None else f"{problem_waypoint_i + 1}" - ) - delay_hours = problem.delay_duration.total_seconds() / 3600.0 - with open(report_fpath, "a", encoding="utf-8") as f: - f.write("---\n") - f.write(f"Waypoint: {affected_wp}\n") - f.write(f"Problem: {problem.message}\n") - f.write(f"Delay caused: {delay_hours} hours\n\n") - - @staticmethod - def load_selected_problems( - selected_problems_fpath: str, - ) -> dict[str, list[GeneralProblem | InstrumentProblem] | None]: - """Load previously selected problem classes from json.""" - with open( - selected_problems_fpath, - 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.short_name: cls for cls in GENERAL_PROBLEMS} - instrument_problems_lookup = { - cls.short_name: cls for cls in INSTRUMENT_PROBLEMS - } - - 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, + problem: ProblemType, problem_waypoint_i: int | None, alert_msg: str, problem_hash: str, hash_fpath: Path, log_delay: float, - ): - """Log problem occurrence with spinner and delay, save to checkpoint, write hash.""" - time.sleep(3.0) # brief pause before spinner + ) -> None: + """Handle execution sequence, logging, checkpoint saving, and user presentation.""" + # TODO: the affected waypoint messaging is wrong considering the addition of Ports + #! under the hood, waypoint_i is the index of the waypoint in the expedition schedule, but the user-facing message should be based on the index of the waypoint in the list of non-port waypoints + + time.sleep(3.0) with yaspin(text=alert_msg) as spinner: time.sleep(log_delay) spinner.ok("šŸ’„ ") - self._hash_to_json( - problem, - problem_hash, - problem_waypoint_i, - hash_fpath, - ) - + self._hash_to_json(problem, problem_hash, problem_waypoint_i, hash_fpath) has_contingency = self._has_contingency(problem, problem_waypoint_i) + delay_hrs = problem.delay_duration.total_seconds() / 3600.0 if has_contingency: impact_str = LOG_MESSAGING["problem_avoided"] result_str = "The expedition will carry on shortly as planned." - - # update problem json to resolved = True - with open(hash_fpath, encoding="utf-8") as f: - problem_json = json.load(f) - problem_json["resolved"] = True - with open(hash_fpath, "w", encoding="utf-8") as f_out: - json.dump(problem_json, f_out, indent=4) - + # update problem JSON state to resolved + data = self._read_json(hash_fpath) + data["resolved"] = True + self._write_json(hash_fpath, data) else: affected = ( "in-port" if problem_waypoint_i is None else f"at waypoint {problem_waypoint_i + 1}" ) - - impact_str = f"Not 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" + impact_str = ( + f"Not enough contingency time scheduled to mitigate delay of {delay_hrs} " + f"hours occurring {affected} (future waypoint(s) would be reached too late).\n" + ) result_str = LOG_MESSAGING["schedule_problems"].format( - delay_duration=problem.delay_duration.total_seconds() / 3600.0, + delay_duration=delay_hrs, problem_wp=affected, expedition_yaml=EXPEDITION, ) - # save checkpoint + # update and save checkpoints checkpoint = Checkpoint( past_schedule=self.expedition.schedule, failed_waypoint_i=problem_waypoint_i + 1 if problem_waypoint_i is not None 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) + self.expedition.to_yaml(self.expedition_dir / CACHE / EXPEDITION_LATEST) - # save latest version of expedition (overwrites previous) - self.expedition.to_yaml(self.expedition_dir.joinpath(CACHE, EXPEDITION_LATEST)) - - # display tabular output in self._tabular_outputter( problem_str=problem.message, impact_str=impact_str, @@ -428,64 +342,110 @@ def _log_problem( has_contingency=has_contingency, ) - if has_contingency: - return # continue expedition as normal - else: - sys.exit(0) # pause simulation + if not has_contingency: + sys.exit(0) def _has_contingency( - self, - problem: InstrumentProblem | GeneralProblem, - problem_waypoint_i: int | None, + self, problem: ProblemType, problem_waypoint_i: int | None ) -> bool: - """Determine if enough contingency time has been scheduled to avoid delay affecting the waypoint immediately after the problem.""" + """Check whether scheduled contingency covers expected delay duration.""" if problem_waypoint_i is None: - return False # pre-departure problems always cause delay to first waypoint + return False - else: - curr_wp = self.expedition.schedule.waypoints[problem_waypoint_i] - next_wp = self.expedition.schedule.waypoints[problem_waypoint_i + 1] + waypoints = self.expedition.schedule.waypoints + curr_wp, next_wp = ( + waypoints[problem_waypoint_i], + waypoints[problem_waypoint_i + 1], + ) - wp_stationkeeping_time = _calc_wp_stationkeeping_time( - curr_wp.instrument, self.expedition - ) + stationkeeping = _calc_wp_stationkeeping_time( + curr_wp.instrument, self.expedition + ) + sail_time = _calc_sail_time( + curr_wp.location, + next_wp.location, + ship_speed_knots=self.expedition.ship_config.ship_speed_knots, + projection=PROJECTION, + )[0] - scheduled_time_diff = next_wp.time - curr_wp.time + scheduled_time = next_wp.time - curr_wp.time + required_time = sail_time + stationkeeping + problem.delay_duration - 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 > required_time - return ( - scheduled_time_diff - > sail_time + wp_stationkeeping_time + problem.delay_duration - ) + def _cache_original_expedition(self, expedition: Expedition) -> None: + """Cache original schedule configuration to file for recovery.""" + path = self.expedition_dir / CACHE / EXPEDITION_ORIGINAL + if not path.exists(): + expedition.to_yaml(path) + print(f"\nOriginal expedition.yaml cached to {path}.\n") - def _make_checkpoint(self, failed_waypoint_i: int | None = None) -> Checkpoint: - """Make checkpoint, also handling pre-departure.""" - return Checkpoint( - past_schedule=self.expedition.schedule, failed_waypoint_i=failed_waypoint_i - ) + @staticmethod + def cache_selected_problems( + problems: SelectedProblemsDict, selected_problems_fpath: str | Path + ) -> None: + """Cache suite of selected problems to JSON.""" + fpath = Path(selected_problems_fpath) + fpath.parent.mkdir(parents=True, exist_ok=True) + + payload = { + "problem_class": [p.short_name for p in problems["problem_class"]], + "waypoint_i": problems["waypoint_i"], + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), + } + ProblemSimulator._write_json(fpath, payload) + + @staticmethod + def load_selected_problems( + selected_problems_fpath: str | Path, + ) -> SelectedProblemsDict: + """Load selected problems suite from a cached JSON file.""" + data = ProblemSimulator._read_json(Path(selected_problems_fpath)) + + general_lookup = {cls.short_name: cls for cls in GENERAL_PROBLEMS} + instrument_lookup = {cls.short_name: cls for cls in INSTRUMENT_PROBLEMS} - def _cache_original_expedition(self, expedition: Expedition): - """Cache original schedule to file for user's reference.""" - path = self.expedition_dir.joinpath(CACHE, EXPEDITION_ORIGINAL) - if path.exists(): - return # don't overwrite if already cached - expedition.to_yaml(path) - print(f"\nOriginal expedition.yaml cached to {path}.\n") + selected_classes, waypoint_indices = [], [] + for cls_name, wp_idx in zip( + data["problem_class"], data["waypoint_i"], strict=True + ): + if cls_name in general_lookup: + selected_classes.append(general_lookup[cls_name]) + elif cls_name in instrument_lookup: + selected_classes.append(instrument_lookup[cls_name]) + else: + raise ValueError( + f"Problem class '{cls_name}' not found in known registries." + ) + waypoint_indices.append(wp_idx) + + return {"problem_class": selected_classes, "waypoint_i": waypoint_indices} + + @staticmethod + def post_expedition_report( + problems: SelectedProblemsDict, report_fpath: str | Path + ) -> None: + """Append human-readable report summary of all occurring problems.""" + with open(report_fpath, "a", encoding="utf-8") as f: + for problem, wp_i in zip( + problems["problem_class"], problems["waypoint_i"], strict=True + ): + affected = "in-port" if wp_i is None else f"{wp_i + 1}" + delay_hrs = problem.delay_duration.total_seconds() / 3600.0 + f.write( + f"---\nWaypoint: {affected}\n" + f"Problem: {problem.message}\n" + f"Delay caused: {delay_hrs} hours\n\n" + ) @staticmethod def _hash_to_json( - problem: InstrumentProblem | GeneralProblem, + problem: ProblemType, problem_hash: str, problem_waypoint_i: int | None, hash_path: Path, - ) -> dict: - """Convert problem details + hash to json.""" + ) -> None: + """Serialize runtime problem detail to JSON.""" hash_data = { "problem_hash": problem_hash, "message": problem.message, @@ -494,58 +454,52 @@ def _hash_to_json( "timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), "resolved": False, } - with open(hash_path, "w", encoding="utf-8") as f: - json.dump(hash_data, f, indent=4) + ProblemSimulator._write_json(hash_path, hash_data) + + @staticmethod + def _read_json(path: Path) -> dict[str, Any]: + with open(path, encoding="utf-8") as f: + return json.load(f) + + @staticmethod + def _write_json(path: Path, data: dict[str, Any]) -> None: + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=4) @staticmethod - def _tabular_outputter(problem_str, impact_str, result_str, has_contingency: bool): + def _tabular_outputter( + problem_str: str, impact_str: str, result_str: str, has_contingency: bool + ) -> None: """Display the problem, impact, and result in a live-updating table. Sleep times are included to increase readability and engagement for user.""" console = Console() console.print() # line break before table - col_kwargs = dict(ratio=1, no_wrap=False, max_width=None, justify="left") + col_kwargs = dict(ratio=1, no_wrap=False, justify="left") - def make_table(problem, impact, result, col_kwargs, colour_results=False): + def make_table(problem, impact, result, colour_results=False) -> Table: table = Table(box=box.SIMPLE, expand=True) table.add_column("Problem Encountered", **col_kwargs) table.add_column("Impact on schedule", **col_kwargs) - if colour_results: - style = "green1" if has_contingency else "red1" - table.add_column("Result", style=style, **col_kwargs) - else: - table.add_column("Result", **col_kwargs) - + style = ( + ("green1" if has_contingency else "red1") if colour_results else None + ) + table.add_column("Result", style=style, **col_kwargs) table.add_row(problem, impact, result) return table - empty_spinner = Spinner("dots", text="") + empty = Spinner("dots", text="") impact_spinner = Spinner("dots", text="Assessing impact on schedule...") + stages = [ + (empty, empty, empty, False, 3.0), + (problem_str, empty, empty, False, 3.0), + (problem_str, impact_spinner, empty, False, 7.0), + (problem_str, impact_str, empty, False, 4.0), + (problem_str, impact_str, result_str, True, 3.0), + ] + with Live(console=console, refresh_per_second=10) as live: - # stage 0: empty table - table = make_table(empty_spinner, empty_spinner, empty_spinner, col_kwargs) - live.update(table) - time.sleep(3.0) - - # stage 1: show problem - table = make_table(problem_str, empty_spinner, empty_spinner, col_kwargs) - live.update(table) - time.sleep(3.0) - - # stage 2: spinner in "Impact on schedule" column - table = make_table(problem_str, impact_spinner, empty_spinner, col_kwargs) - live.update(table) - time.sleep(7.0) - - # stage 3: table with problem and impact-investigation complete - table = make_table(problem_str, impact_str, empty_spinner, col_kwargs) - live.update(table) - time.sleep(4.0) - - # stage 4: complete table with problem, impact, and result (give final outcome colour based on fail/success) - table = make_table( - problem_str, impact_str, result_str, col_kwargs, colour_results=True - ) - live.update(table) - time.sleep(3.0) + for prob, imp, res, colour, sleep_time in stages: + live.update(make_table(prob, imp, res, colour_results=colour)) + time.sleep(sleep_time) From a10c2d41cfe4c7d51b72c70073eb07696e3e8b88 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:20:32 +0200 Subject: [PATCH 02/28] start fixing public waypoint number comms, tidy up some var names --- src/virtualship/cli/_run.py | 3 +- .../expedition/simulate_schedule.py | 2 +- .../make_realistic/problems/simulator.py | 66 ++++++++++--------- src/virtualship/models/checkpoint.py | 34 +++++----- .../make_realistic/problems/test_simulator.py | 4 +- tests/test_checkpoint.py | 12 ++-- 6 files changed, 62 insertions(+), 59 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index 6969d32f..b4a58ca9 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -97,6 +97,7 @@ def _run( checkpoint = Checkpoint(past_schedule=Schedule(waypoints=[])) # verify that schedule and checkpoint match, and that problems have been resolved + # TODO: problems related verifcation should probably be refactored out of the Checkpoint class (which is Pydantic model) and into the ProblemSimulator class (which is more appropriate for handling problems) checkpoint.verify(expedition, problems_dir) print("\n---- WAYPOINT VERIFICATION ----") @@ -121,7 +122,7 @@ def _run( _save_checkpoint( Checkpoint( past_schedule=expedition.schedule, - failed_waypoint_i=schedule_results.failed_waypoint_i, + failed_wp=schedule_results.failed_wp, ), expedition_dir, ) diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index 6f1fed05..516c1491 100644 --- a/src/virtualship/expedition/simulate_schedule.py +++ b/src/virtualship/expedition/simulate_schedule.py @@ -36,7 +36,7 @@ class ScheduleProblem: """Result of schedule that could not be fully completed.""" time: datetime - failed_waypoint_i: int + failed_wp: int @dataclass diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index ebeb1137..e41ffb96 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -237,7 +237,7 @@ def _assign_problems_to_waypoints( if not assigned_problems: return None - # Sort chronologically (pre-departure/None first, then waypoint index order) + # sort chronologically (pre-departure/None first, then waypoint index order) paired = sorted( zip(assigned_problems, assigned_indices, strict=True), key=lambda x: -1 if x[1] is None else x[1], @@ -269,37 +269,43 @@ def execute( if hash_fpath.exists(): continue - alert_msg = ( - LOG_MESSAGING["pre_departure"] - if isinstance(problem, GeneralProblem) and problem.pre_departure - else LOG_MESSAGING["during_expedition"].format(waypoint=wp_i + 1) - ) - - self._log_problem( - problem, wp_i, alert_msg, problem_hash, hash_fpath, log_delay - ) + self._log_problem(problem, wp_i, problem_hash, hash_fpath, log_delay) self._cache_original_expedition(self.expedition) def _log_problem( self, problem: ProblemType, - problem_waypoint_i: int | None, - alert_msg: str, + problem_wp_i: int | None, problem_hash: str, hash_fpath: Path, log_delay: float, ) -> None: - """Handle execution sequence, logging, checkpoint saving, and user presentation.""" - # TODO: the affected waypoint messaging is wrong considering the addition of Ports - #! under the hood, waypoint_i is the index of the waypoint in the expedition schedule, but the user-facing message should be based on the index of the waypoint in the list of non-port waypoints + """ + Handle execution sequence, logging, checkpoint saving, and user presentation. + + Note, problem_wp_i is the index of the waypoint in the expedition schedule, but the user-facing message should be based on the index of the waypoint in the list of non-port waypoints. + Use problem_wp_i for internal logic, but user-facing messages (below) should use public_wp (non indexed version). + problem_wp_i will often == public_wp (given 0-indexing), but this makes the logic explicit and clear. + """ + waypoints = self.expedition.schedule.waypoints + non_port_wps = [i for i, wp in enumerate(waypoints) if not isinstance(wp, Port)] + public_wp = ( + non_port_wps.index(problem_wp_i) + 1 if problem_wp_i is not None else None + ) + + alert_msg = ( + LOG_MESSAGING["pre_departure"] + if isinstance(problem, GeneralProblem) and problem.pre_departure + else LOG_MESSAGING["during_expedition"].format(waypoint=public_wp) + ) time.sleep(3.0) with yaspin(text=alert_msg) as spinner: time.sleep(log_delay) spinner.ok("šŸ’„ ") - self._hash_to_json(problem, problem_hash, problem_waypoint_i, hash_fpath) - has_contingency = self._has_contingency(problem, problem_waypoint_i) + self._hash_to_json(problem, problem_hash, problem_wp_i, hash_fpath) + has_contingency = self._has_contingency(problem, problem_wp_i) delay_hrs = problem.delay_duration.total_seconds() / 3600.0 if has_contingency: @@ -310,11 +316,8 @@ def _log_problem( data["resolved"] = True self._write_json(hash_fpath, data) else: - affected = ( - "in-port" - if problem_waypoint_i is None - else f"at waypoint {problem_waypoint_i + 1}" - ) + breakpoint() + affected = "in-port" if problem_wp_i is None else f"at waypoint {public_wp}" impact_str = ( f"Not enough contingency time scheduled to mitigate delay of {delay_hrs} " f"hours occurring {affected} (future waypoint(s) would be reached too late).\n" @@ -328,8 +331,9 @@ def _log_problem( # update and save checkpoints checkpoint = Checkpoint( past_schedule=self.expedition.schedule, - failed_waypoint_i=problem_waypoint_i + 1 - if problem_waypoint_i is not None + failed_wp=problem_wp_i + + 1 # TODO: should this use user_facing_wp_i instead of problem_wp_i? + if problem_wp_i is not None else 0, ) _save_checkpoint(checkpoint, self.expedition_dir) @@ -345,17 +349,15 @@ def _log_problem( if not has_contingency: sys.exit(0) - def _has_contingency( - self, problem: ProblemType, problem_waypoint_i: int | None - ) -> bool: + def _has_contingency(self, problem: ProblemType, problem_wp_i: int | None) -> bool: """Check whether scheduled contingency covers expected delay duration.""" - if problem_waypoint_i is None: + if problem_wp_i is None: return False waypoints = self.expedition.schedule.waypoints curr_wp, next_wp = ( - waypoints[problem_waypoint_i], - waypoints[problem_waypoint_i + 1], + waypoints[problem_wp_i], + waypoints[problem_wp_i + 1], ) stationkeeping = _calc_wp_stationkeeping_time( @@ -442,14 +444,14 @@ def post_expedition_report( def _hash_to_json( problem: ProblemType, problem_hash: str, - problem_waypoint_i: int | None, + problem_wp_i: int | None, hash_path: Path, ) -> None: """Serialize runtime problem detail to JSON.""" hash_data = { "problem_hash": problem_hash, "message": problem.message, - "problem_waypoint_i": problem_waypoint_i, + "problem_wp_i": problem_wp_i, "delay_duration_hours": problem.delay_duration.total_seconds() / 3600.0, "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 ce620af1..a304c676 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -37,7 +37,7 @@ class Checkpoint(pydantic.BaseModel): """ past_schedule: Schedule - failed_waypoint_i: int | None = None + failed_wp: int | None = None def to_yaml(self, file_path: str | Path) -> None: """ @@ -69,14 +69,14 @@ def verify(self, expedition: Expedition, problems_dir: Path) -> None: 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: + if self.failed_wp is None: pass elif ( - not new_schedule.waypoints[: int(self.failed_waypoint_i)] - == self.past_schedule.waypoints[: int(self.failed_waypoint_i)] + not new_schedule.waypoints[: int(self.failed_wp)] + == self.past_schedule.waypoints[: int(self.failed_wp)] ): raise CheckpointError( - f"Past waypoints in schedule have been changed! Restore past schedule and only change future waypoints (waypoint {int(self.failed_waypoint_i) + 1} onwards)." + f"Past waypoints in schedule have been changed! Restore past schedule and only change future waypoints (waypoint {int(self.failed_wp) + 1} onwards)." ) # 2) check that problems have been resolved in the new schedule @@ -98,12 +98,12 @@ def verify(self, expedition: Expedition, problems_dir: Path) -> None: problem_waypoint = ( new_schedule.waypoints[0] - if problem["problem_waypoint_i"] is None - else new_schedule.waypoints[problem["problem_waypoint_i"]] + if problem["problem_wp_i"] is None + else new_schedule.waypoints[problem["problem_wp_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: + if problem["problem_wp_i"] is None: time_diff = ( problem_waypoint.time - self.past_schedule.waypoints[0].time ) @@ -111,7 +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: - failed_waypoint = new_schedule.waypoints[self.failed_waypoint_i] + failed_waypoint = new_schedule.waypoints[self.failed_wp] scheduled_time = failed_waypoint.time - problem_waypoint.time @@ -149,27 +149,27 @@ def verify(self, expedition: Expedition, problems_dir: Path) -> None: else: problem_wp_str = ( "in-port" - if problem["problem_waypoint_i"] is None - else f"at waypoint {problem['problem_waypoint_i'] + 1}" + if problem["problem_wp_i"] is None + else f"at waypoint {problem['problem_wp_i'] + 1}" ) affected_wp_str = ( "1" - if problem["problem_waypoint_i"] is None - else f"{problem['problem_waypoint_i'] + 2}" + if problem["problem_wp_i"] is None + else f"{problem['problem_wp_i'] + 2}" ) time_elapsed = ( (sail_time + delay_duration + stationkeeping_time) - if problem["problem_waypoint_i"] is not None + if problem["problem_wp_i"] is not None else delay_duration ) failed_waypoint_time = ( failed_waypoint.time - if problem["problem_waypoint_i"] is not None + if problem["problem_wp_i"] is not None else new_schedule.waypoints[0].time ) current_time = ( problem_waypoint.time + time_elapsed - if problem["problem_waypoint_i"] is not None + if problem["problem_wp_i"] is not None else self.past_schedule.waypoints[0].time + time_elapsed ) @@ -179,7 +179,7 @@ def verify(self, expedition: Expedition, problems_dir: Path) -> None: 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 + if problem["problem_wp_i"] is not None else "" ) ) diff --git a/tests/make_realistic/problems/test_simulator.py b/tests/make_realistic/problems/test_simulator.py index ccd1de18..f1241b37 100644 --- a/tests/make_realistic/problems/test_simulator.py +++ b/tests/make_realistic/problems/test_simulator.py @@ -235,8 +235,8 @@ def test_has_contingency_during_expedition(tmp_path): ) # 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 + assert short_simulator._has_contingency(problem_cls, problem_wp_i=0) is True + assert long_simulator._has_contingency(problem_cls, problem_wp_i=0) is False def test_post_expedition_report(tmp_path): diff --git a/tests/test_checkpoint.py b/tests/test_checkpoint.py index f84693c9..b6cb60f1 100644 --- a/tests/test_checkpoint.py +++ b/tests/test_checkpoint.py @@ -17,7 +17,7 @@ def expedition(tmp_file): return Expedition.from_yaml(tmp_file) -def make_dummy_checkpoint(failed_waypoint_i=None): +def make_dummy_checkpoint(failed_wp=None): wp1 = Waypoint( location=Location(latitude=0.0, longitude=0.0), time=datetime(2024, 2, 1, 10, 0, 0), @@ -30,7 +30,7 @@ def make_dummy_checkpoint(failed_waypoint_i=None): ) schedule = Schedule(waypoints=[wp1, wp2]) - return Checkpoint(past_schedule=schedule, failed_waypoint_i=failed_waypoint_i) + return Checkpoint(past_schedule=schedule, failed_wp=failed_wp) def test_to_and_from_yaml(tmp_path): @@ -44,12 +44,12 @@ def test_to_and_from_yaml(tmp_path): def test_verify_no_failed_waypoint(expedition): - cp = make_dummy_checkpoint(failed_waypoint_i=None) + cp = make_dummy_checkpoint(failed_wp=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) + cp = make_dummy_checkpoint(failed_wp=1) # change past waypoints new_wp1 = Waypoint( @@ -94,7 +94,7 @@ def test_verify_problem_resolution( instrument=[], ) past_schedule = Schedule(waypoints=[wp1, wp2]) - cp = Checkpoint(past_schedule=past_schedule, failed_waypoint_i=1) + cp = Checkpoint(past_schedule=past_schedule, failed_wp=1) # new schedule new_wp1 = wp1 @@ -110,7 +110,7 @@ def test_verify_problem_resolution( problem = { "resolved": False, "delay_duration_hours": delay_duration_hours, - "problem_waypoint_i": 0, + "problem_wp_i": 0, } problem_file = tmp_path / "problem_1.json" with open(problem_file, "w") as f: From d4ccffeccf1fd10acae0cf339e0d52582088423a Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:48:11 +0200 Subject: [PATCH 03/28] refactor getting public facing wp number to utils method --- src/virtualship/instruments/base.py | 4 ++-- src/virtualship/make_realistic/problems/simulator.py | 11 +++-------- src/virtualship/utils.py | 9 ++++++++- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/virtualship/instruments/base.py b/src/virtualship/instruments/base.py index e06ae344..bdeeaa2e 100644 --- a/src/virtualship/instruments/base.py +++ b/src/virtualship/instruments/base.py @@ -26,9 +26,9 @@ _find_files_in_timerange, _find_nc_file_with_variable, _get_bathy_data, + _get_clean_encoding, _get_waypoint_latlons, _select_product_id, - get_clean_encoding, ship_spinner, ) @@ -319,7 +319,7 @@ def _get_local_ds(self, files: list[Path]) -> xr.Dataset: @staticmethod def _via_tmp_ds(ds: xr.Dataset) -> xr.Dataset: """Create and re-load a temporary local dataset.""" - encoding = get_clean_encoding(ds) + encoding = _get_clean_encoding(ds) with tempfile.TemporaryDirectory() as tmpdir: tmp_fpath = Path(tmpdir) / "tmp.nc" diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index e41ffb96..ca98ba88 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -31,6 +31,7 @@ PROJECTION, _calc_sail_time, _calc_wp_stationkeeping_time, + _get_public_wp, _make_hash, _save_checkpoint, ) @@ -288,10 +289,7 @@ def _log_problem( problem_wp_i will often == public_wp (given 0-indexing), but this makes the logic explicit and clear. """ waypoints = self.expedition.schedule.waypoints - non_port_wps = [i for i, wp in enumerate(waypoints) if not isinstance(wp, Port)] - public_wp = ( - non_port_wps.index(problem_wp_i) + 1 if problem_wp_i is not None else None - ) + public_wp = _get_public_wp(problem_wp_i, waypoints) alert_msg = ( LOG_MESSAGING["pre_departure"] @@ -331,10 +329,7 @@ def _log_problem( # update and save checkpoints checkpoint = Checkpoint( past_schedule=self.expedition.schedule, - failed_wp=problem_wp_i - + 1 # TODO: should this use user_facing_wp_i instead of problem_wp_i? - if problem_wp_i is not None - else 0, + failed_wp_i=problem_wp_i if problem_wp_i is not None else 0, ) _save_checkpoint(checkpoint, self.expedition_dir) self.expedition.to_yaml(self.expedition_dir / CACHE / EXPEDITION_LATEST) diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 9441defd..5da00c9e 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -15,6 +15,7 @@ from parcels import FieldSet, Particle, Variable from virtualship.errors import CopernicusCatalogueError +from virtualship.models.expedition import Port if TYPE_CHECKING: from virtualship.expedition.simulate_schedule import ( @@ -525,7 +526,7 @@ def build_particle_class_from_sensors( return Particle.add_variable(nonsensor_variables + sensor_variables) -def get_clean_encoding(ds): +def _get_clean_encoding(ds): """ Clean existing encodings and supply explicit native endianness to prevent netCDF4 UserWarnings. @@ -539,6 +540,12 @@ def get_clean_encoding(ds): return encoding +def _get_public_wp(raw_wp_i: int | None, waypoints: list) -> int | None: + """Get the public waypoint index for a given waypoint (accounting for Port waypoints).""" + non_port_wps = [i for i, wp in enumerate(waypoints) if not isinstance(wp, Port)] + return non_port_wps.index(raw_wp_i) + 1 if raw_wp_i is not None else None + + # ===================================================== # SECTION: misc. # ===================================================== From 77c69a7ea223d41e96f5a900ea115e2e09ad5b17 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:51:17 +0200 Subject: [PATCH 04/28] next steps of adapting public facing wp numbers --- src/virtualship/models/checkpoint.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/virtualship/models/checkpoint.py b/src/virtualship/models/checkpoint.py index a304c676..84b02093 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -17,6 +17,7 @@ PROJECTION, _calc_sail_time, _calc_wp_stationkeeping_time, + _get_public_wp, ) @@ -37,7 +38,7 @@ class Checkpoint(pydantic.BaseModel): """ past_schedule: Schedule - failed_wp: int | None = None + failed_wp_i: int | None = None def to_yaml(self, file_path: str | Path) -> None: """ @@ -68,15 +69,20 @@ def verify(self, expedition: Expedition, problems_dir: Path) -> None: """ new_schedule = expedition.schedule + # get the public waypoint number of the failed waypoint (if any), for use in error messages + public_failed_wp = _get_public_wp( + self.failed_wp_i + 1, self.past_schedule.waypoints + ) + # 1) check that past waypoints have not been changed, unless is a pre-departure problem - if self.failed_wp is None: + if self.failed_wp_i is None: pass elif ( - not new_schedule.waypoints[: int(self.failed_wp)] - == self.past_schedule.waypoints[: int(self.failed_wp)] + not new_schedule.waypoints[: int(self.failed_wp_i + 1)] + == self.past_schedule.waypoints[: int(self.failed_wp_i + 1)] ): raise CheckpointError( - f"Past waypoints in schedule have been changed! Restore past schedule and only change future waypoints (waypoint {int(self.failed_wp) + 1} onwards)." + f"Past waypoints in schedule have been changed! Restore past schedule and only change future waypoints (waypoint {int(public_failed_wp) + 1} onwards)." # +1 because it's the waypoint after the failed waypoint ) # 2) check that problems have been resolved in the new schedule @@ -103,6 +109,8 @@ def verify(self, expedition: Expedition, problems_dir: Path) -> None: ) # pre-departure problem: check that whole delay duration has been added to first waypoint time (by testing against past schedule) + # TODO: just taking the 0th waypoint doesn't work anymore given expedition has Port information now! + #! TODO: could combine into one single check that applies to all waypoints now that Ports have locations, rather than hypothetical? if problem["problem_wp_i"] is None: time_diff = ( problem_waypoint.time - self.past_schedule.waypoints[0].time @@ -111,7 +119,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: - failed_waypoint = new_schedule.waypoints[self.failed_wp] + failed_waypoint = new_schedule.waypoints[self.failed_wp_i + 1] scheduled_time = failed_waypoint.time - problem_waypoint.time From b62438234049fdcd2de9aa50b73aadec55b30ca1 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:14:45 +0200 Subject: [PATCH 05/28] continue refactor work + using waypoint index for in-port problems --- src/virtualship/cli/_run.py | 10 +- .../make_realistic/problems/simulator.py | 58 +++++---- src/virtualship/models/checkpoint.py | 118 +++++++----------- src/virtualship/utils.py | 20 ++- 4 files changed, 96 insertions(+), 110 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index b4a58ca9..5d0614a5 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -14,7 +14,7 @@ simulate_schedule, ) from virtualship.make_realistic.problems.simulator import ProblemSimulator -from virtualship.models import Checkpoint, Schedule +from virtualship.models import Checkpoint from virtualship.models.expedition import Expedition from virtualship.utils import ( CACHE, @@ -93,12 +93,10 @@ def _run( # load last checkpoint checkpoint = _load_checkpoint(expedition_dir) - if checkpoint is None: - checkpoint = Checkpoint(past_schedule=Schedule(waypoints=[])) - # verify that schedule and checkpoint match, and that problems have been resolved - # TODO: problems related verifcation should probably be refactored out of the Checkpoint class (which is Pydantic model) and into the ProblemSimulator class (which is more appropriate for handling problems) - checkpoint.verify(expedition, problems_dir) + # verify that schedule and checkpoint match, and that problems have been resolved (if checkpoint exists) + if checkpoint is not None: + checkpoint.verify(expedition, problems_dir) print("\n---- WAYPOINT VERIFICATION ----") diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index ca98ba88..92b98605 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -4,6 +4,7 @@ import random import sys import time +from datetime import timedelta from pathlib import Path from typing import TYPE_CHECKING, Any @@ -70,6 +71,14 @@ def __init__(self, expedition: Expedition, expedition_dir: str | Path): self.expedition = expedition self.expedition_dir = Path(expedition_dir) + self.waypoints = expedition.schedule.waypoints + + def __post_init__(self): + """Ensure first and last waypoints are Ports. Allows the problem selection to work properly.""" + assert isinstance(self.waypoints[0], Port) & isinstance( + self.waypoints[-1], Port + ), "First and last waypoints must be Port types." + def select_problems( self, instruments_in_expedition: set[InstrumentType], @@ -80,16 +89,14 @@ def select_problems( 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) for reference. + Map each selected problem to a random waypoint (or 0th [i.e. departure port] if pre-departure). Finally, cache the suite of problems to a directory (expedition-specific) for reference. """ - waypoints = self.expedition.schedule.waypoints - # handle early-exit single waypoint case (pre-departure only) - if len(waypoints) < 2: + if len(self.waypoints) < 2: pre_departure = [p for p in GENERAL_PROBLEMS if p.pre_departure] return { "problem_class": [random.choice(pre_departure)], - "waypoint_i": [None], + "waypoint_i": [0], # noqa; pre-departure problem is always associated with the departure port (index 0) } valid_instruments = [ @@ -99,8 +106,8 @@ def select_problems( ] num_problems = self._calculate_problem_count( difficulty_level=difficulty_level, - expedition_days=(waypoints[-1].time - waypoints[0].time).days, - num_waypoints=len(waypoints), + expedition_days=(self.waypoints[-1].time - self.waypoints[0].time).days, + num_waypoints=len(self.waypoints), num_instruments=len(instruments_in_expedition), max_available=len(GENERAL_PROBLEMS) + len(valid_instruments), ) @@ -152,9 +159,7 @@ def _sample_problems( bias = min(0.7, num_instruments / (num_instruments + 2)) n_inst = round(num_problems * bias) n_gen = min(len(general_pool), num_problems - n_inst) - n_inst = ( - num_problems - n_gen - ) # recalc in case n_gen was capped to len(GENERAL_PROBLEMS) + n_inst = num_problems - n_gen # noqa; recalc in case n_gen was capped to len(GENERAL_PROBLEMS) return general_pool[:n_gen] + instrument_pool[:n_inst] @@ -189,19 +194,23 @@ def _assign_problems_to_waypoints( self, selected: list[ProblemType] ) -> SelectedProblemsDict | None: """Assign sampled problems to valid, non-port waypoint indices.""" - waypoints = self.expedition.schedule.waypoints + waypoints = self.waypoints avail_indices = [ i for i, wp in enumerate(waypoints) if not isinstance(wp, Port) ] random.shuffle(avail_indices) + assert 0 not in avail_indices, ( + "Index 0 (departure port) should not be in available waypoint indices for non-pre-departure problems." + ) + assigned_problems: list[ProblemType] = [] assigned_indices: list[int | None] = [] for problem in selected: if getattr(problem, "pre_departure", False): assigned_problems.append(problem) - assigned_indices.append(None) + assigned_indices.append(0) # noqa; pre-departure problem is always associated with the departure port (index 0) continue if not avail_indices: @@ -238,10 +247,10 @@ def _assign_problems_to_waypoints( if not assigned_problems: return None - # sort chronologically (pre-departure/None first, then waypoint index order) + # sort chronologically (waypoint 0 first, then remaining waypoint index order) paired = sorted( zip(assigned_problems, assigned_indices, strict=True), - key=lambda x: -1 if x[1] is None else x[1], + key=lambda x: 0 if x[1] == 0 else x[1], ) return { "problem_class": [p for p, _ in paired], @@ -288,7 +297,7 @@ def _log_problem( Use problem_wp_i for internal logic, but user-facing messages (below) should use public_wp (non indexed version). problem_wp_i will often == public_wp (given 0-indexing), but this makes the logic explicit and clear. """ - waypoints = self.expedition.schedule.waypoints + waypoints = self.waypoints public_wp = _get_public_wp(problem_wp_i, waypoints) alert_msg = ( @@ -314,8 +323,7 @@ def _log_problem( data["resolved"] = True self._write_json(hash_fpath, data) else: - breakpoint() - affected = "in-port" if problem_wp_i is None else f"at waypoint {public_wp}" + affected = "in-port" if public_wp is None else f"at waypoint {public_wp}" impact_str = ( f"Not enough contingency time scheduled to mitigate delay of {delay_hrs} " f"hours occurring {affected} (future waypoint(s) would be reached too late).\n" @@ -329,7 +337,7 @@ def _log_problem( # update and save checkpoints checkpoint = Checkpoint( past_schedule=self.expedition.schedule, - failed_wp_i=problem_wp_i if problem_wp_i is not None else 0, + problem_wp_i=problem_wp_i, ) _save_checkpoint(checkpoint, self.expedition_dir) self.expedition.to_yaml(self.expedition_dir / CACHE / EXPEDITION_LATEST) @@ -346,17 +354,15 @@ def _log_problem( def _has_contingency(self, problem: ProblemType, problem_wp_i: int | None) -> bool: """Check whether scheduled contingency covers expected delay duration.""" - if problem_wp_i is None: - return False - - waypoints = self.expedition.schedule.waypoints curr_wp, next_wp = ( - waypoints[problem_wp_i], - waypoints[problem_wp_i + 1], + self.waypoints[problem_wp_i], + self.waypoints[problem_wp_i + 1], ) - stationkeeping = _calc_wp_stationkeeping_time( - curr_wp.instrument, self.expedition + stationkeeping = ( + _calc_wp_stationkeeping_time(curr_wp.instrument, self.expedition) + if not isinstance(curr_wp, Port) + else timedelta(0) ) sail_time = _calc_sail_time( curr_wp.location, diff --git a/src/virtualship/models/checkpoint.py b/src/virtualship/models/checkpoint.py index 84b02093..a0631922 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -11,7 +11,7 @@ from virtualship.errors import CheckpointError from virtualship.instruments.types import InstrumentType -from virtualship.models.expedition import Expedition, Schedule +from virtualship.models.expedition import Expedition, Port, Schedule from virtualship.utils import ( EXPEDITION, PROJECTION, @@ -38,7 +38,7 @@ class Checkpoint(pydantic.BaseModel): """ past_schedule: Schedule - failed_wp_i: int | None = None + problem_wp_i: int | None = None def to_yaml(self, file_path: str | Path) -> None: """ @@ -69,20 +69,25 @@ def verify(self, expedition: Expedition, problems_dir: Path) -> None: """ new_schedule = expedition.schedule - # get the public waypoint number of the failed waypoint (if any), for use in error messages - public_failed_wp = _get_public_wp( - self.failed_wp_i + 1, self.past_schedule.waypoints + # TODO: problems related verifcation should probably be refactored out of the Checkpoint class (which is Pydantic model) and into the ProblemSimulator class (which is more appropriate for handling problems) + #! Do some re-thinking to move all the problems related logic over into the Problems (simulator). + + # failed waypoint is the waypoint immediately *after* the problem waypoint (i.e. the one that will not be reached in time) + failed_wp_i = self.problem_wp_i + 1 + + # public waypoint number of problem and failed waypoints, for use in error messages + public_problem_wp = _get_public_wp( + self.problem_wp_i, self.past_schedule.waypoints ) + public_failed_wp = _get_public_wp(failed_wp_i, self.past_schedule.waypoints) - # 1) check that past waypoints have not been changed, unless is a pre-departure problem - if self.failed_wp_i is None: - pass - elif ( - not new_schedule.waypoints[: int(self.failed_wp_i + 1)] - == self.past_schedule.waypoints[: int(self.failed_wp_i + 1)] + # 1) check that past waypoints have not been changed (up to but not including failed_wp) + if ( + not new_schedule.waypoints[: int(failed_wp_i)] + == self.past_schedule.waypoints[: int(failed_wp_i)] ): raise CheckpointError( - f"Past waypoints in schedule have been changed! Restore past schedule and only change future waypoints (waypoint {int(public_failed_wp) + 1} onwards)." # +1 because it's the waypoint after the failed waypoint + f"Past waypoints in schedule have been changed! Restore past schedule and only change future waypoints (waypoint {int(public_failed_wp)} onwards)." ) # 2) check that problems have been resolved in the new schedule @@ -94,54 +99,38 @@ def verify(self, expedition: Expedition, problems_dir: Path) -> None: for file in hash_fpaths: with open(file, encoding="utf-8") as f: problem = json.load(f) + + # continue if problem is already resolved, else perform checks to see if delay is accounted for 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; or first waypoint if pre-departure problem) + else: delay_duration = timedelta( hours=float(problem["delay_duration_hours"]) ) - problem_waypoint = ( - new_schedule.waypoints[0] - if problem["problem_wp_i"] is None - else new_schedule.waypoints[problem["problem_wp_i"]] - ) - - # pre-departure problem: check that whole delay duration has been added to first waypoint time (by testing against past schedule) - # TODO: just taking the 0th waypoint doesn't work anymore given expedition has Port information now! - #! TODO: could combine into one single check that applies to all waypoints now that Ports have locations, rather than hypothetical? - if problem["problem_wp_i"] is None: - time_diff = ( - 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: - failed_waypoint = new_schedule.waypoints[self.failed_wp_i + 1] - - scheduled_time = failed_waypoint.time - problem_waypoint.time + problem_waypoint = new_schedule.waypoints[self.problem_wp_i] + failed_waypoint = new_schedule.waypoints[failed_wp_i] + scheduled_time_diff = failed_waypoint.time - problem_waypoint.time - stationkeeping_time = _calc_wp_stationkeeping_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 = ( - sail_time + delay_duration + stationkeeping_time ) + if not isinstance(problem_waypoint, Port) + else timedelta(0) + ) + + sail_time = _calc_sail_time( + problem_waypoint.location, + failed_waypoint.location, + ship_speed_knots=expedition.ship_config.ship_speed_knots, + projection=PROJECTION, + )[0] - resolved = scheduled_time >= min_time_required + min_time_required = sail_time + delay_duration + stationkeeping_time - if resolved: + if scheduled_time_diff >= min_time_required: print( "\n\nšŸŽ‰ Previous problem has been resolved in the schedule.\n" ) @@ -157,37 +146,18 @@ def verify(self, expedition: Expedition, problems_dir: Path) -> None: else: problem_wp_str = ( "in-port" - if problem["problem_wp_i"] is None - else f"at waypoint {problem['problem_wp_i'] + 1}" - ) - affected_wp_str = ( - "1" - if problem["problem_wp_i"] is None - else f"{problem['problem_wp_i'] + 2}" - ) - time_elapsed = ( - (sail_time + delay_duration + stationkeeping_time) - if problem["problem_wp_i"] is not None - else delay_duration - ) - failed_waypoint_time = ( - failed_waypoint.time - if problem["problem_wp_i"] is not None - else new_schedule.waypoints[0].time - ) - current_time = ( - problem_waypoint.time + time_elapsed - if problem["problem_wp_i"] is not None - else self.past_schedule.waypoints[0].time + time_elapsed + if problem["problem_wp_i"] == 0 # i.e. pre-departure + else f"at waypoint {public_problem_wp}" ) + time_elapsed = 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\n" - 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"The problem was associated with a delay duration of {problem['delay_duration_hours']} hours {problem_wp_str} (meaning waypoint {public_failed_wp} could not be reached in time). " + f"Currently, the ship would reach waypoint {public_failed_wp} at {problem_waypoint.time + time_elapsed}, 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_wp_i"] is not None + f"\n\nHint: don't forget to factor in the time required to deploy the instruments {problem_wp_str} when rescheduling waypoint {public_failed_wp}." + if problem["problem_wp_i"] != 0 else "" ) ) diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 5da00c9e..58431b3e 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -15,7 +15,6 @@ from parcels import FieldSet, Particle, Variable from virtualship.errors import CopernicusCatalogueError -from virtualship.models.expedition import Port if TYPE_CHECKING: from virtualship.expedition.simulate_schedule import ( @@ -541,9 +540,22 @@ def _get_clean_encoding(ds): def _get_public_wp(raw_wp_i: int | None, waypoints: list) -> int | None: - """Get the public waypoint index for a given waypoint (accounting for Port waypoints).""" - non_port_wps = [i for i, wp in enumerate(waypoints) if not isinstance(wp, Port)] - return non_port_wps.index(raw_wp_i) + 1 if raw_wp_i is not None else None + """ + Get the public waypoint number for a given raw waypoint index (accounting for Port waypoints). + + Note, the returned number is not an index, rather it corresponds to Waypoint numbers ignoring Ports (which are not waypoints from the user's perspective). + """ + from virtualship.models.expedition import Port # avoid circular import + + port_wps = [i for i, wp in enumerate(waypoints) if isinstance(wp, Port)] + non_port_wps = [i for i in range(len(waypoints)) if i not in port_wps] + + if raw_wp_i in port_wps: + public_wp = None # Port waypoints do not have public waypoint numbers + else: + public_wp = non_port_wps.index(raw_wp_i) + 1 + + return public_wp # ===================================================== From 73e5204ed2e61b76c9c5d7124a53163f2ae42ac1 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:51:23 +0200 Subject: [PATCH 06/28] refactor: separate problem-specific checkpoint verification logic from core checkpoint model, move away from reliance on problem-specific tracking via json tmp files --- src/virtualship/cli/_run.py | 23 ++- .../make_realistic/problems/simulator.py | 80 +++++++- src/virtualship/models/checkpoint.py | 176 +++++------------- 3 files changed, 128 insertions(+), 151 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index 5d0614a5..348111ca 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -42,11 +42,7 @@ def _run( expedition_dir: str | Path, difficulty_level: str, from_data: Path | None = None ) -> None: - """ - Perform an expedition, providing terminal feedback and file output. - - :param expedition_dir: The base directory for the expedition. - """ + """Perform an expedition, providing terminal feedback and file output.""" # start timing start_time = time.time() print("[TIMER] Expedition started...") @@ -91,12 +87,18 @@ def _run( # verify instruments_config file is consistent with schedule expedition.instruments_config.verify(expedition) - # load last checkpoint + # initialise problem simulator + problem_simulator = ProblemSimulator(expedition, expedition_dir) + + # load last checkpoint if present checkpoint = _load_checkpoint(expedition_dir) - # verify that schedule and checkpoint match, and that problems have been resolved (if checkpoint exists) if checkpoint is not None: - checkpoint.verify(expedition, problems_dir) + # 1) core structural check: verify past waypoints have not changed + checkpoint.verify_past_schedule(expedition.schedule) + + # 2) problems-specific check: verify active problem delay is resolved in new schedule + problem_simulator.verify_problem_resolution(checkpoint) print("\n---- WAYPOINT VERIFICATION ----") @@ -120,7 +122,7 @@ def _run( _save_checkpoint( Checkpoint( past_schedule=expedition.schedule, - failed_wp=schedule_results.failed_wp, + failed_wp_i=schedule_results.failed_wp, ), expedition_dir, ) @@ -144,9 +146,6 @@ def _run( # identify instruments in expedition instruments_in_expedition = expedition.get_instruments() - # initialise problem simulator - problem_simulator = ProblemSimulator(expedition, expedition_dir) - # 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.joinpath(SELECTED_PROBLEMS)): problems = problem_simulator.load_selected_problems( diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index 92b98605..1ae2ba33 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -15,6 +15,7 @@ from rich.table import Table from yaspin import yaspin +from virtualship.errors import CheckpointError from virtualship.instruments.types import InstrumentType from virtualship.make_realistic.problems.scenarios import ( GENERAL_PROBLEMS, @@ -22,7 +23,7 @@ GeneralProblem, InstrumentProblem, ) -from virtualship.models.checkpoint import Checkpoint +from virtualship.models.checkpoint import ActiveProblem, Checkpoint from virtualship.models.expedition import Port from virtualship.utils import ( CACHE, @@ -282,6 +283,61 @@ def execute( self._log_problem(problem, wp_i, problem_hash, hash_fpath, log_delay) self._cache_original_expedition(self.expedition) + def verify_problem_resolution(self, checkpoint: Checkpoint) -> None: + """Verify active problem delay is resolved in new schedule.""" + active_problem = checkpoint.active_problem + if active_problem is None or active_problem.resolved: + return + + failed_wp_i = checkpoint.get_effective_failed_wp_i() + new_schedule = self.expedition.schedule + + # problem-specific delay calculation & resolution check + delay_duration = timedelta(hours=active_problem.delay_duration_hours) + problem_waypoint = new_schedule.waypoints[checkpoint.problem_wp_i] + failed_waypoint = new_schedule.waypoints[failed_wp_i] + + scheduled_time_diff = failed_waypoint.time - problem_waypoint.time + stationkeeping_time = ( + _calc_wp_stationkeeping_time(problem_waypoint.instrument, self.expedition) + if not isinstance(problem_waypoint, Port) + else timedelta(0) + ) + sail_time = _calc_sail_time( + problem_waypoint.location, + failed_waypoint.location, + ship_speed_knots=self.expedition.ship_config.ship_speed_knots, + projection=PROJECTION, + )[0] + + min_time_required = sail_time + delay_duration + stationkeeping_time + + if scheduled_time_diff >= min_time_required: + print("\n\nšŸŽ‰ Previous problem has been resolved in the schedule.\n") + active_problem.resolved = True + _save_checkpoint(checkpoint, self.expedition_dir) + else: + public_problem_wp = _get_public_wp( + checkpoint.problem_wp_i, checkpoint.past_schedule.waypoints + ) + public_failed_wp = _get_public_wp( + failed_wp_i, checkpoint.past_schedule.waypoints + ) + problem_wp_str = ( + "in-port" + if checkpoint.problem_wp_i == 0 + else f"at waypoint {public_problem_wp}" + ) + time_elapsed = sail_time + delay_duration + stationkeeping_time + + raise CheckpointError( + f"The problem encountered in previous simulation has not been resolved in the schedule! " + f"Please adjust the schedule to account for delays caused by the problem...\n\n" + f"The problem was associated with a delay duration of {active_problem.delay_duration_hours} hours {problem_wp_str} " + f"(meaning waypoint {public_failed_wp} could not be reached in time). " + f"Currently, the ship would reach waypoint {public_failed_wp} at {problem_waypoint.time + time_elapsed}, but the scheduled time is {failed_waypoint.time}." + ) + def _log_problem( self, problem: ProblemType, @@ -293,9 +349,10 @@ def _log_problem( """ Handle execution sequence, logging, checkpoint saving, and user presentation. - Note, problem_wp_i is the index of the waypoint in the expedition schedule, but the user-facing message should be based on the index of the waypoint in the list of non-port waypoints. - Use problem_wp_i for internal logic, but user-facing messages (below) should use public_wp (non indexed version). - problem_wp_i will often == public_wp (given 0-indexing), but this makes the logic explicit and clear. + Note, problem_wp_i is the index of the waypoint in the expedition schedule, but the user-facing message + should be based on the index of the waypoint in the list of non-port waypoints. + Use problem_wp_i for internal logic, but user-facing messages should use public_wp. + Incidentally, problem_wp_i will often == public_wp (given 0-indexing), but this makes the logic explicit and clear. """ waypoints = self.waypoints public_wp = _get_public_wp(problem_wp_i, waypoints) @@ -311,17 +368,13 @@ def _log_problem( time.sleep(log_delay) spinner.ok("šŸ’„ ") - self._hash_to_json(problem, problem_hash, problem_wp_i, hash_fpath) has_contingency = self._has_contingency(problem, problem_wp_i) delay_hrs = problem.delay_duration.total_seconds() / 3600.0 if has_contingency: impact_str = LOG_MESSAGING["problem_avoided"] result_str = "The expedition will carry on shortly as planned." - # update problem JSON state to resolved - data = self._read_json(hash_fpath) - data["resolved"] = True - self._write_json(hash_fpath, data) + active_problem = None else: affected = "in-port" if public_wp is None else f"at waypoint {public_wp}" impact_str = ( @@ -333,11 +386,18 @@ def _log_problem( problem_wp=affected, expedition_yaml=EXPEDITION, ) + active_problem = ActiveProblem( + message=problem.message, + problem_wp_i=problem_wp_i, + delay_duration_hours=delay_hrs, + resolved=False, + ) - # update and save checkpoints + # update and save checkpoint with active problem information checkpoint = Checkpoint( past_schedule=self.expedition.schedule, problem_wp_i=problem_wp_i, + active_problem=active_problem, ) _save_checkpoint(checkpoint, self.expedition_dir) self.expedition.to_yaml(self.expedition_dir / CACHE / EXPEDITION_LATEST) diff --git a/src/virtualship/models/checkpoint.py b/src/virtualship/models/checkpoint.py index a0631922..8ce43326 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -2,8 +2,6 @@ from __future__ import annotations -import json -from datetime import timedelta from pathlib import Path import pydantic @@ -11,14 +9,8 @@ from virtualship.errors import CheckpointError from virtualship.instruments.types import InstrumentType -from virtualship.models.expedition import Expedition, Port, Schedule -from virtualship.utils import ( - EXPEDITION, - PROJECTION, - _calc_sail_time, - _calc_wp_stationkeeping_time, - _get_public_wp, -) +from virtualship.models.expedition import Schedule +from virtualship.utils import _get_public_wp class _YamlDumper(yaml.SafeDumper): @@ -30,134 +22,60 @@ class _YamlDumper(yaml.SafeDumper): ) -class Checkpoint(pydantic.BaseModel): - """ - A checkpoint of schedule simulation. - - Copy of the schedule until where the simulation proceeded without troubles. - """ - - past_schedule: Schedule - problem_wp_i: int | None = None - - def to_yaml(self, file_path: str | Path) -> None: - """ - Write checkpoint to yaml file. - - :param file_path: Path to the file to write to. - """ - with open(file_path, "w") as file: - yaml.dump(self.model_dump(by_alias=True), file, Dumper=_YamlDumper) - - @classmethod - def from_yaml(cls, file_path: str | Path) -> Checkpoint: - """ - Load checkpoint from yaml file. - - :param file_path: Path to the file to load from. - :returns: The checkpoint. - """ - with open(file_path) as file: - data = yaml.safe_load(file) - return Checkpoint(**data) +class ActiveProblem(pydantic.BaseModel): + """Runtime state of a problem halting simulation.""" - 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. + message: str + problem_wp_i: int | None + delay_duration_hours: float + resolved: bool = False - 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 - # TODO: problems related verifcation should probably be refactored out of the Checkpoint class (which is Pydantic model) and into the ProblemSimulator class (which is more appropriate for handling problems) - #! Do some re-thinking to move all the problems related logic over into the Problems (simulator). +class Checkpoint(pydantic.BaseModel): + """A checkpoint of the schedule simulation storing past schedule state and any active problem that halted execution.""" - # failed waypoint is the waypoint immediately *after* the problem waypoint (i.e. the one that will not be reached in time) - failed_wp_i = self.problem_wp_i + 1 + past_schedule: Schedule + problem_wp_i: int | None = ( + None # index of the waypoint that caused a problem (if any) + ) + failed_wp_i: int | None = ( + None # index of the waypoint that could not be reached in time (either because of problem or incompatible user scheduling) + ) + active_problem: ActiveProblem | None = None + + def get_effective_failed_wp_i(self) -> int | None: + """Return the index of the waypoint that failed or could not be reached.""" + if self.failed_wp_i is not None: + return self.failed_wp_i + if self.problem_wp_i is not None: + return self.problem_wp_i + 1 + return None + + def verify_past_schedule(self, new_schedule: Schedule) -> None: + """Core structural check: ensure past history hasn't been edited.""" + failed_wp_i = self.get_effective_failed_wp_i() + if failed_wp_i is None: + return - # public waypoint number of problem and failed waypoints, for use in error messages - public_problem_wp = _get_public_wp( - self.problem_wp_i, self.past_schedule.waypoints - ) public_failed_wp = _get_public_wp(failed_wp_i, self.past_schedule.waypoints) - # 1) check that past waypoints have not been changed (up to but not including failed_wp) if ( - not new_schedule.waypoints[: int(failed_wp_i)] - == self.past_schedule.waypoints[: int(failed_wp_i)] + new_schedule.waypoints[: int(failed_wp_i)] + != self.past_schedule.waypoints[: int(failed_wp_i)] ): raise CheckpointError( - f"Past waypoints in schedule have been changed! Restore past schedule and only change future waypoints (waypoint {int(public_failed_wp)} onwards)." + f"Past waypoints in schedule have been changed! Restore past schedule " + f"and only change future waypoints (waypoint {int(public_failed_wp)} onwards)." ) - # 2) check that problems have been resolved in the new schedule - hash_fpaths = [ - str(path.resolve()) for path in problems_dir.glob("problem_*.json") - ] - - if len(hash_fpaths) > 0: - for file in hash_fpaths: - with open(file, encoding="utf-8") as f: - problem = json.load(f) - - # continue if problem is already resolved, else perform checks to see if delay is accounted for - if problem["resolved"]: - continue - else: - delay_duration = timedelta( - hours=float(problem["delay_duration_hours"]) - ) - - problem_waypoint = new_schedule.waypoints[self.problem_wp_i] - failed_waypoint = new_schedule.waypoints[failed_wp_i] - scheduled_time_diff = failed_waypoint.time - problem_waypoint.time - - stationkeeping_time = ( - _calc_wp_stationkeeping_time( - problem_waypoint.instrument, - expedition, - ) - if not isinstance(problem_waypoint, Port) - else timedelta(0) - ) - - 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 = sail_time + delay_duration + stationkeeping_time - - if scheduled_time_diff >= min_time_required: - 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) - - # 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_str = ( - "in-port" - if problem["problem_wp_i"] == 0 # i.e. pre-departure - else f"at waypoint {public_problem_wp}" - ) - time_elapsed = 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\n" - f"The problem was associated with a delay duration of {problem['delay_duration_hours']} hours {problem_wp_str} (meaning waypoint {public_failed_wp} could not be reached in time). " - f"Currently, the ship would reach waypoint {public_failed_wp} at {problem_waypoint.time + time_elapsed}, 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 {public_failed_wp}." - if problem["problem_wp_i"] != 0 - else "" - ) - ) + def to_yaml(self, file_path: str | Path) -> None: + """Write checkpoint to YAML file.""" + with open(file_path, "w", encoding="utf-8") as file: + yaml.dump(self.model_dump(by_alias=True), file, Dumper=_YamlDumper) + + @classmethod + def from_yaml(cls, file_path: str | Path) -> Checkpoint: + """Load checkpoint from YAML file.""" + with open(file_path, encoding="utf-8") as file: + data = yaml.safe_load(file) + return Checkpoint(**data) From 24dc97c49ade69b8127a060a79bc18f5d7458665 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:27:31 +0200 Subject: [PATCH 07/28] user messaging fix when failed wp is port of arrival --- src/virtualship/make_realistic/problems/simulator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index 1ae2ba33..32cf1b4c 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -323,6 +323,9 @@ def verify_problem_resolution(self, checkpoint: Checkpoint) -> None: public_failed_wp = _get_public_wp( failed_wp_i, checkpoint.past_schedule.waypoints ) + if public_failed_wp is None: + public_failed_wp = "\b/Port of Arrival" + problem_wp_str = ( "in-port" if checkpoint.problem_wp_i == 0 From b7398458926a7f123525e2499ab6719ed3b45ef4 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:59:34 +0200 Subject: [PATCH 08/28] fix check for unique expedition --- src/virtualship/cli/_run.py | 43 +++++++----- .../make_realistic/problems/simulator.py | 69 ++++++++++++------- 2 files changed, 71 insertions(+), 41 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index 348111ca..b4401822 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -75,9 +75,9 @@ def _run( expedition = _get_expedition(expedition_dir) - # unique id to determine if an expedition has 'changed' since last run (to avoid re-selecting problems when user makes tweaks to schedule to deal with problems encountered) + # unique id to determine if an expedition has 'changed' since last run cache_dir = expedition_dir.joinpath(CACHE) - expedition_id = _unique_id(expedition, cache_dir) + expedition_id = _unique_id(expedition, cache_dir, expedition_dir) # dedicated problems directory for this expedition problems_dir = expedition_dir.joinpath( @@ -128,13 +128,15 @@ def _run( ) return - # delete and create results directory + # warn about existing results on fresh runs (when no active checkpoint exists) results_dir = expedition_dir.joinpath(RESULTS) - _warn_overwrite_results_dir(results_dir) + if checkpoint is None: + _warn_overwrite_results_dir(results_dir) - if os.path.exists(results_dir): + # re-initialize/clean results directory + if os.path.exists(results_dir) and checkpoint is None: shutil.rmtree(results_dir) - os.makedirs(results_dir) + os.makedirs(results_dir, exist_ok=True) print("\n----- EXPEDITION SUMMARY ------") @@ -146,7 +148,7 @@ def _run( # identify instruments in expedition instruments_in_expedition = expedition.get_instruments() - # re-load previously encountered (same expedition as previously) problems if they exist, else select new problems and cache them + # re-load previously encountered problems if they exist, else select new problems and cache them if os.path.exists(problems_dir.joinpath(SELECTED_PROBLEMS)): problems = problem_simulator.load_selected_problems( problems_dir.joinpath(SELECTED_PROBLEMS) @@ -155,20 +157,16 @@ def _run( problems = problem_simulator.select_problems( instruments_in_expedition, difficulty_level ) - problem_simulator.cache_selected_problems( - problems, problems_dir.joinpath(SELECTED_PROBLEMS) - ) if problems else None + if problems: + problem_simulator.cache_selected_problems( + problems, problems_dir.joinpath(SELECTED_PROBLEMS) + ) # simulate instrument measurements print("\nSimulating measurements. This may take a while...\n") for itype in instruments_in_expedition: 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: if ( @@ -185,6 +183,11 @@ def _run( log_dir=problems_dir, ) + # 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) @@ -247,7 +250,7 @@ def _run( print(f"[TIMER] Expedition completed in {elapsed / 60.0:.2f} minutes.") -def _unique_id(expedition: Expedition, cache_dir: Path) -> str: +def _unique_id(expedition: Expedition, cache_dir: Path, expedition_dir: Path) -> str: """ Return a unique id for the expedition (marked by datetime), which can be used to determine whether the expedition has 'changed' since the last run. @@ -258,6 +261,7 @@ def _unique_id(expedition: Expedition, cache_dir: Path) -> str: id_path = cache_dir.joinpath(EXPEDITION_IDENTIFIER) last_expedition_path = cache_dir.joinpath(EXPEDITION_LATEST) + checkpoint_path = expedition_dir.joinpath(CHECKPOINT) new_id = datetime.now().strftime("%Y%m%d%H%M%S") if not id_path.exists(): @@ -266,10 +270,13 @@ def _unique_id(expedition: Expedition, cache_dir: Path) -> str: previous_id = id_path.read_text().strip() + # if an active checkpoint exists, retain the existing expedition id to preserve problem state + if checkpoint_path.exists(): + return previous_id + try: last_expedition = Expedition.from_yaml(last_expedition_path) except FileNotFoundError: - # cache is not useful in this case as it implies the previous run was interrupted and is incomplete; update passively id_path.write_text(new_id) return new_id @@ -277,7 +284,7 @@ def _unique_id(expedition: Expedition, cache_dir: Path) -> str: last_expedition.get_instruments() ) if not added_instruments: - return previous_id # if no additions, keep previous id to allow re-use of previously encountered problems + return previous_id id_path.write_text(new_id) return new_id diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index 32cf1b4c..8234af62 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -28,9 +28,12 @@ from virtualship.utils import ( CACHE, EXPEDITION, + EXPEDITION_IDENTIFIER, EXPEDITION_LATEST, EXPEDITION_ORIGINAL, + PROBLEMS_ENCOUNTERED, PROJECTION, + SELECTED_PROBLEMS, _calc_sail_time, _calc_wp_stationkeeping_time, _get_public_wp, @@ -61,7 +64,7 @@ } ProblemType = GeneralProblem | InstrumentProblem -SelectedProblemsDict = dict[str, list[ProblemType | None]] +SelectedProblemsDict = dict[str, Any] class ProblemSimulator: @@ -71,9 +74,16 @@ def __init__(self, expedition: Expedition, expedition_dir: str | Path): """Initialise ProblemSimulator with a schedule and probability level.""" self.expedition = expedition self.expedition_dir = Path(expedition_dir) - self.waypoints = expedition.schedule.waypoints + @property + def expedition_id(self) -> str: + """Retrieve the current expedition unique identifier from cache.""" + id_path = self.expedition_dir.joinpath(CACHE, EXPEDITION_IDENTIFIER) + if id_path.exists(): + return id_path.read_text().strip() + return "" + def __post_init__(self): """Ensure first and last waypoints are Ports. Allows the problem selection to work properly.""" assert isinstance(self.waypoints[0], Port) & isinstance( @@ -256,6 +266,7 @@ def _assign_problems_to_waypoints( return { "problem_class": [p for p, _ in paired], "waypoint_i": [w for _, w in paired], + "resolved": False, } def execute( @@ -266,9 +277,15 @@ def execute( log_delay: float = 4.0, ) -> None: """Execute simulation problems and apply delay/schedule impacts.""" + if not problems or problems.get("resolved", False): + return + for problem, wp_i in zip( problems["problem_class"], problems["waypoint_i"], strict=True ): + if getattr(problem, "resolved", False): + continue + if ( isinstance(problem, InstrumentProblem) and problem.instrument_type is not instrument_type_validation @@ -316,6 +333,22 @@ def verify_problem_resolution(self, checkpoint: Checkpoint) -> None: print("\n\nšŸŽ‰ Previous problem has been resolved in the schedule.\n") active_problem.resolved = True _save_checkpoint(checkpoint, self.expedition_dir) + + # persist resolved status to selected_problems.json cache + problems_path = self.expedition_dir.joinpath( + CACHE, + PROBLEMS_ENCOUNTERED.format(expedition_id=self.expedition_id), + SELECTED_PROBLEMS, + ) + if problems_path.exists(): + problems = self.load_selected_problems(problems_path) + if isinstance(problems, dict): + problems["resolved"] = True + for p in problems.get("problem_class", []): + if getattr(p, "message", None) == active_problem.message: + p.resolved = True + self.cache_selected_problems(problems, problems_path) + else: public_problem_wp = _get_public_wp( checkpoint.problem_wp_i, checkpoint.past_schedule.waypoints @@ -399,7 +432,6 @@ def _log_problem( # update and save checkpoint with active problem information checkpoint = Checkpoint( past_schedule=self.expedition.schedule, - problem_wp_i=problem_wp_i, active_problem=active_problem, ) _save_checkpoint(checkpoint, self.expedition_dir) @@ -457,6 +489,7 @@ def cache_selected_problems( payload = { "problem_class": [p.short_name for p in problems["problem_class"]], "waypoint_i": problems["waypoint_i"], + "resolved": problems.get("resolved", False), "timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), } ProblemSimulator._write_json(fpath, payload) @@ -485,7 +518,11 @@ def load_selected_problems( ) waypoint_indices.append(wp_idx) - return {"problem_class": selected_classes, "waypoint_i": waypoint_indices} + return { + "problem_class": selected_classes, + "waypoint_i": waypoint_indices, + "resolved": data.get("resolved", False), + } @staticmethod def post_expedition_report( @@ -504,24 +541,6 @@ def post_expedition_report( f"Delay caused: {delay_hrs} hours\n\n" ) - @staticmethod - def _hash_to_json( - problem: ProblemType, - problem_hash: str, - problem_wp_i: int | None, - hash_path: Path, - ) -> None: - """Serialize runtime problem detail to JSON.""" - hash_data = { - "problem_hash": problem_hash, - "message": problem.message, - "problem_wp_i": problem_wp_i, - "delay_duration_hours": problem.delay_duration.total_seconds() / 3600.0, - "timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), - "resolved": False, - } - ProblemSimulator._write_json(hash_path, hash_data) - @staticmethod def _read_json(path: Path) -> dict[str, Any]: with open(path, encoding="utf-8") as f: @@ -536,7 +555,11 @@ def _write_json(path: Path, data: dict[str, Any]) -> None: def _tabular_outputter( problem_str: str, impact_str: str, result_str: str, has_contingency: bool ) -> None: - """Display the problem, impact, and result in a live-updating table. Sleep times are included to increase readability and engagement for user.""" + """ + Display the problem, impact, and result in a live-updating table. + + Sleep times are included to increase readability and engagement for user. + """ console = Console() console.print() # line break before table From 69877ab772c36dc8118103e8c8d2bc857e122e6b Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:59:49 +0200 Subject: [PATCH 09/28] remove duplicate problem_wp_i declaration --- src/virtualship/models/checkpoint.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/virtualship/models/checkpoint.py b/src/virtualship/models/checkpoint.py index 8ce43326..3b939fc1 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -26,7 +26,7 @@ class ActiveProblem(pydantic.BaseModel): """Runtime state of a problem halting simulation.""" message: str - problem_wp_i: int | None + problem_wp_i: int | None # noqa; index of the waypoint that caused a problem (if any) delay_duration_hours: float resolved: bool = False @@ -35,14 +35,14 @@ class Checkpoint(pydantic.BaseModel): """A checkpoint of the schedule simulation storing past schedule state and any active problem that halted execution.""" past_schedule: Schedule - problem_wp_i: int | None = ( - None # index of the waypoint that caused a problem (if any) - ) - failed_wp_i: int | None = ( - None # index of the waypoint that could not be reached in time (either because of problem or incompatible user scheduling) - ) + failed_wp_i: int | None = None # noqa; index of the waypoint that could not be reached in time active_problem: ActiveProblem | None = None + @property + def problem_wp_i(self) -> int | None: + """Delegate to active_problem to avoid duplication.""" + return self.active_problem.problem_wp_i if self.active_problem else None + def get_effective_failed_wp_i(self) -> int | None: """Return the index of the waypoint that failed or could not be reached.""" if self.failed_wp_i is not None: From 99e7df32c2c9d1a2748fc576a0a1d88a47b188cf Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:50:52 +0200 Subject: [PATCH 10/28] large refactor of simulator logic --- .../make_realistic/problems/simulator.py | 676 ++++++++---------- 1 file changed, 315 insertions(+), 361 deletions(-) diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index e08e0827..ebeb1137 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -1,12 +1,11 @@ from __future__ import annotations import json -import os import random import sys import time from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from rich import box from rich.console import Console @@ -42,21 +41,28 @@ 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 {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", + "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.\n", } - -# default problem weights for problems simulator (i.e. add +1 problem for every n days/waypoints/instruments in expedition) +# default problem weights for problems simulator (e.g., +1 problem every N days/waypoints/instruments) PROBLEM_WEIGHTS = { "every_ndays": 7, "every_nwaypoints": 6, "every_ninstruments": 3, } +ProblemType = GeneralProblem | InstrumentProblem +SelectedProblemsDict = dict[str, list[ProblemType | None]] + class ProblemSimulator: - """Handle problem simulation during expedition.""" + """Handle problem simulation during an expedition.""" def __init__(self, expedition: Expedition, expedition_dir: str | Path): """Initialise ProblemSimulator with a schedule and probability level.""" @@ -67,7 +73,7 @@ def select_problems( self, instruments_in_expedition: set[InstrumentType], difficulty_level: str, - ) -> dict[str, list[GeneralProblem | InstrumentProblem] | None] | None: + ) -> SelectedProblemsDict | None: """ Select problems (general and instrument-specific). When difficulty_level = 'hard', number of problems is determined by expedition length, instrument count etc. @@ -77,350 +83,258 @@ def select_problems( """ waypoints = self.expedition.schedule.waypoints - valid_instrument_problems = [ - problem - for problem in INSTRUMENT_PROBLEMS - if problem.instrument_type in instruments_in_expedition - ] + # handle early-exit single waypoint case (pre-departure only) + if len(waypoints) < 2: + pre_departure = [p for p in GENERAL_PROBLEMS if p.pre_departure] + return { + "problem_class": [random.choice(pre_departure)], + "waypoint_i": [None], + } - pre_departure_problems = [ + valid_instruments = [ p - for p in GENERAL_PROBLEMS - if isinstance(p, GeneralProblem) and p.pre_departure + for p in INSTRUMENT_PROBLEMS + if p.instrument_type in instruments_in_expedition ] + num_problems = self._calculate_problem_count( + difficulty_level=difficulty_level, + expedition_days=(waypoints[-1].time - waypoints[0].time).days, + num_waypoints=len(waypoints), + num_instruments=len(instruments_in_expedition), + max_available=len(GENERAL_PROBLEMS) + len(valid_instruments), + ) - num_waypoints = len(waypoints) - num_instruments = len(instruments_in_expedition) - expedition_duration_days = (waypoints[-1].time - waypoints[0].time).days + if num_problems <= 0: + return None - # 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], - } + selected = self._sample_problems( + num_problems, valid_instruments, len(instruments_in_expedition) + ) + selected = self._limit_pre_departure(selected, valid_instruments) + return self._assign_problems_to_waypoints(selected) + + def _calculate_problem_count( + self, + difficulty_level: str, + expedition_days: int, + num_waypoints: int, + num_instruments: int, + max_available: int, + ) -> int: + """Determine problem count based on difficulty setting.""" if difficulty_level == "easy": - num_problems = 0 - elif difficulty_level == "medium": - num_problems = random.randint(1, 2) - - elif difficulty_level == "hard": - base = 1 - extra = ( # i.e. +1 problem for every n days/waypoints/instruments (tunable above) - (expedition_duration_days // PROBLEM_WEIGHTS["every_ndays"]) + return 0 + if difficulty_level == "medium": + return random.randint(1, 2) + if difficulty_level == "hard": + extra = ( + (expedition_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_PROBLEMS) + len(valid_instrument_problems) - ) + return min(1 + extra, max_available) + return 0 - selected_problems = [] - problems_sorted = None - if num_problems > 0: - random.shuffle(GENERAL_PROBLEMS) - random.shuffle(valid_instrument_problems) - - # 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_PROBLEMS), num_problems - n_instrument) - n_instrument = ( - num_problems - n_general - ) # recalc in case n_general was capped to len(GENERAL_PROBLEMS) - - selected_problems.extend(GENERAL_PROBLEMS[: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 - selected_pre_departure = [ - p - for p in selected_problems - if isinstance(p, GeneralProblem) and p.pre_departure - ] - 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 - for problem in selected_problems - if not ( - isinstance(problem, GeneralProblem) - and problem.pre_departure - and problem is not to_keep - ) - ] - # available non-pre_departure problems not already selected - available_general = [ + def _sample_problems( + self, + num_problems: int, + valid_instruments: list[InstrumentProblem], + num_instruments: int, + ) -> list[ProblemType]: + """Sample a balanced ratio of general and instrument problems.""" + general_pool = list(GENERAL_PROBLEMS) + instrument_pool = list(valid_instruments) + random.shuffle(general_pool) + random.shuffle(instrument_pool) + + bias = min(0.7, num_instruments / (num_instruments + 2)) + n_inst = round(num_problems * bias) + n_gen = min(len(general_pool), num_problems - n_inst) + n_inst = ( + num_problems - n_gen + ) # recalc in case n_gen was capped to len(GENERAL_PROBLEMS) + + return general_pool[:n_gen] + instrument_pool[:n_inst] + + def _limit_pre_departure( + self, + selected: list[ProblemType], + valid_instruments: list[InstrumentProblem], + ) -> list[ProblemType]: + """Ensure maximum of one pre-departure problem is selected.""" + pre_deps = [ + p for p in selected if isinstance(p, GeneralProblem) and p.pre_departure + ] + if len(pre_deps) <= 1: + return selected + + keep = random.choice(pre_deps) + replacements_needed = len(pre_deps) - 1 + filtered = [ + p for p in selected if p is keep or not getattr(p, "pre_departure", False) + ] + + avail_gen = [ + p for p in GENERAL_PROBLEMS if not p.pre_departure and p not in filtered + ] + avail_inst = [p for p in valid_instruments if p not in filtered] + replacements = avail_gen + avail_inst + random.shuffle(replacements) + + return filtered + replacements[:replacements_needed] + + def _assign_problems_to_waypoints( + self, selected: list[ProblemType] + ) -> SelectedProblemsDict | None: + """Assign sampled problems to valid, non-port waypoint indices.""" + waypoints = self.expedition.schedule.waypoints + avail_indices = [ + i for i, wp in enumerate(waypoints) if not isinstance(wp, Port) + ] + random.shuffle(avail_indices) + + assigned_problems: list[ProblemType] = [] + assigned_indices: list[int | None] = [] + + for problem in selected: + if getattr(problem, "pre_departure", False): + assigned_problems.append(problem) + assigned_indices.append(None) + continue + + if not avail_indices: + break + + # find matching waypoint or substitute with general problem + target_idx = None + for idx in avail_indices: + wp_instruments = waypoints[idx].instrument or [] + if ( + isinstance(problem, InstrumentProblem) + and problem.instrument_type not in wp_instruments + ): + continue + target_idx = idx + break + + if target_idx is not None: + avail_indices.remove(target_idx) + assigned_problems.append(problem) + assigned_indices.append(target_idx) + else: + # fall back to a general problem if instrument match fails + avail_general = [ p for p in GENERAL_PROBLEMS - 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 + if not p.pre_departure and p not in assigned_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, non-port waypoint] (or None if pre-departure) - # limited to one per waypoint, else complicates scheduling and contingency checking - waypoint_idxs = [] - unassigned_problems = [] - is_port = [isinstance(wp, Port) for wp in waypoints] - available_idxs = [i for i, port in enumerate(is_port) if not port] - - # TODO: if incorporate departure and arrival port/waypoints in future, bear in mind index selection here may need to change - for problem in selected_problems: - if getattr(problem, "pre_departure", False): - waypoint_idxs.append(None) - else: - if available_idxs: - wp_select = random.choice(available_idxs) - wp_instruments = waypoints[wp_select].instrument - wp_instruments = wp_instruments if wp_instruments else [] # noqa; handle when waypoint instruments set to "null" in expedition.yaml - - # check waypoint actually deploys the instrument associated with the problem...if not, replace it with a general (non-instrument related) problem - # rather than a different waypoint, because it's possible no applicable waypoint is still available - needs_replacement = ( - isinstance(problem, InstrumentProblem) - and problem.instrument_type not in wp_instruments - ) - if needs_replacement: - available_general = [ - p - for p in GENERAL_PROBLEMS - if not p.pre_departure and p not in selected_problems - ] - - if not available_general: - unassigned_problems.append(problem) - continue - - replacement = random.choice(available_general) - problem_idx = selected_problems.index(problem) - selected_problems[problem_idx] = replacement - - waypoint_idxs.append(wp_select) - available_idxs.remove(wp_select) # each waypoint only used once - - else: - unassigned_problems.append(problem) # noqa; if run out of available waypoints, remove problem from selection - - # remove any problems that couldn't be assigned a waypoint (i.e. if more problems than available waypoints) - if unassigned_problems: - selected_problems = [ - p for p in selected_problems if p not in unassigned_problems - ] - - # 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 + if avail_general and avail_indices: + substitute = random.choice(avail_general) + assigned_problems.append(substitute) + assigned_indices.append(avail_indices.pop()) + + if not assigned_problems: + return None + + # Sort chronologically (pre-departure/None first, then waypoint index order) + paired = sorted( + zip(assigned_problems, assigned_indices, strict=True), + key=lambda x: -1 if x[1] is None else x[1], + ) + return { + "problem_class": [p for p, _ in paired], + "waypoint_i": [w for _, w in paired], + } def execute( self, - problems: dict[str, list[GeneralProblem | InstrumentProblem] | None], + problems: SelectedProblemsDict, instrument_type_validation: InstrumentType | None, log_dir: Path, log_delay: float = 4.0, - ): - """ - Execute the selected problems, returning messaging and delay times. - - 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: when difficulty_level = 'hard' and have general problems which occur at later waypoints: could artificially delay their propagation until later in the simulation? Otherwise they are front-loaded at the start of the simulation... Instrument problems are fine because they only propagate when instrument is simulated... - - for problem, problem_waypoint_i in zip( + ) -> None: + """Execute simulation problems and apply delay/schedule impacts.""" + for problem, wp_i in zip( 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 ( isinstance(problem, InstrumentProblem) and problem.instrument_type is not instrument_type_validation ): continue - problem_hash = _make_hash(problem.message + str(problem_waypoint_i), 8) - hash_fpath = log_dir.joinpath(f"problem_{problem_hash}.json") + problem_hash = _make_hash(problem.message + str(wp_i), 8) + hash_fpath = log_dir / f"problem_{problem_hash}.json" if hash_fpath.exists(): - continue # problem * waypoint combination has already occurred; don't repeat - - if isinstance(problem, GeneralProblem) and problem.pre_departure: - alert_msg = LOG_MESSAGING["pre_departure"] + continue - else: - alert_msg = LOG_MESSAGING["during_expedition"].format( - waypoint=int(problem_waypoint_i) + 1 - ) + alert_msg = ( + LOG_MESSAGING["pre_departure"] + if isinstance(problem, GeneralProblem) and problem.pre_departure + else LOG_MESSAGING["during_expedition"].format(waypoint=wp_i + 1) + ) - # log problem occurrence, save to checkpoint, and pause simulation self._log_problem( - problem, - problem_waypoint_i, - alert_msg, - problem_hash, - hash_fpath, - log_delay, + problem, wp_i, alert_msg, problem_hash, hash_fpath, log_delay ) - - # cache original expedition 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) self._cache_original_expedition(self.expedition) - @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.short_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 post_expedition_report( - problems: dict[str, list[GeneralProblem | InstrumentProblem] | None], - report_fpath: str | Path, - ) -> None: - """Produce human-readable post-expedition report (.txt), including problems that occured (their full messages), the waypoint and what delay they caused.""" - for problem, problem_waypoint_i in zip( - problems["problem_class"], problems["waypoint_i"], strict=True - ): - affected_wp = ( - "in-port" if problem_waypoint_i is None else f"{problem_waypoint_i + 1}" - ) - delay_hours = problem.delay_duration.total_seconds() / 3600.0 - with open(report_fpath, "a", encoding="utf-8") as f: - f.write("---\n") - f.write(f"Waypoint: {affected_wp}\n") - f.write(f"Problem: {problem.message}\n") - f.write(f"Delay caused: {delay_hours} hours\n\n") - - @staticmethod - def load_selected_problems( - selected_problems_fpath: str, - ) -> dict[str, list[GeneralProblem | InstrumentProblem] | None]: - """Load previously selected problem classes from json.""" - with open( - selected_problems_fpath, - 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.short_name: cls for cls in GENERAL_PROBLEMS} - instrument_problems_lookup = { - cls.short_name: cls for cls in INSTRUMENT_PROBLEMS - } - - 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, + problem: ProblemType, problem_waypoint_i: int | None, alert_msg: str, problem_hash: str, hash_fpath: Path, log_delay: float, - ): - """Log problem occurrence with spinner and delay, save to checkpoint, write hash.""" - time.sleep(3.0) # brief pause before spinner + ) -> None: + """Handle execution sequence, logging, checkpoint saving, and user presentation.""" + # TODO: the affected waypoint messaging is wrong considering the addition of Ports + #! under the hood, waypoint_i is the index of the waypoint in the expedition schedule, but the user-facing message should be based on the index of the waypoint in the list of non-port waypoints + + time.sleep(3.0) with yaspin(text=alert_msg) as spinner: time.sleep(log_delay) spinner.ok("šŸ’„ ") - self._hash_to_json( - problem, - problem_hash, - problem_waypoint_i, - hash_fpath, - ) - + self._hash_to_json(problem, problem_hash, problem_waypoint_i, hash_fpath) has_contingency = self._has_contingency(problem, problem_waypoint_i) + delay_hrs = problem.delay_duration.total_seconds() / 3600.0 if has_contingency: impact_str = LOG_MESSAGING["problem_avoided"] result_str = "The expedition will carry on shortly as planned." - - # update problem json to resolved = True - with open(hash_fpath, encoding="utf-8") as f: - problem_json = json.load(f) - problem_json["resolved"] = True - with open(hash_fpath, "w", encoding="utf-8") as f_out: - json.dump(problem_json, f_out, indent=4) - + # update problem JSON state to resolved + data = self._read_json(hash_fpath) + data["resolved"] = True + self._write_json(hash_fpath, data) else: affected = ( "in-port" if problem_waypoint_i is None else f"at waypoint {problem_waypoint_i + 1}" ) - - impact_str = f"Not 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" + impact_str = ( + f"Not enough contingency time scheduled to mitigate delay of {delay_hrs} " + f"hours occurring {affected} (future waypoint(s) would be reached too late).\n" + ) result_str = LOG_MESSAGING["schedule_problems"].format( - delay_duration=problem.delay_duration.total_seconds() / 3600.0, + delay_duration=delay_hrs, problem_wp=affected, expedition_yaml=EXPEDITION, ) - # save checkpoint + # update and save checkpoints checkpoint = Checkpoint( past_schedule=self.expedition.schedule, failed_waypoint_i=problem_waypoint_i + 1 if problem_waypoint_i is not None 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) + self.expedition.to_yaml(self.expedition_dir / CACHE / EXPEDITION_LATEST) - # save latest version of expedition (overwrites previous) - self.expedition.to_yaml(self.expedition_dir.joinpath(CACHE, EXPEDITION_LATEST)) - - # display tabular output in self._tabular_outputter( problem_str=problem.message, impact_str=impact_str, @@ -428,64 +342,110 @@ def _log_problem( has_contingency=has_contingency, ) - if has_contingency: - return # continue expedition as normal - else: - sys.exit(0) # pause simulation + if not has_contingency: + sys.exit(0) def _has_contingency( - self, - problem: InstrumentProblem | GeneralProblem, - problem_waypoint_i: int | None, + self, problem: ProblemType, problem_waypoint_i: int | None ) -> bool: - """Determine if enough contingency time has been scheduled to avoid delay affecting the waypoint immediately after the problem.""" + """Check whether scheduled contingency covers expected delay duration.""" if problem_waypoint_i is None: - return False # pre-departure problems always cause delay to first waypoint + return False - else: - curr_wp = self.expedition.schedule.waypoints[problem_waypoint_i] - next_wp = self.expedition.schedule.waypoints[problem_waypoint_i + 1] + waypoints = self.expedition.schedule.waypoints + curr_wp, next_wp = ( + waypoints[problem_waypoint_i], + waypoints[problem_waypoint_i + 1], + ) - wp_stationkeeping_time = _calc_wp_stationkeeping_time( - curr_wp.instrument, self.expedition - ) + stationkeeping = _calc_wp_stationkeeping_time( + curr_wp.instrument, self.expedition + ) + sail_time = _calc_sail_time( + curr_wp.location, + next_wp.location, + ship_speed_knots=self.expedition.ship_config.ship_speed_knots, + projection=PROJECTION, + )[0] - scheduled_time_diff = next_wp.time - curr_wp.time + scheduled_time = next_wp.time - curr_wp.time + required_time = sail_time + stationkeeping + problem.delay_duration - 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 > required_time - return ( - scheduled_time_diff - > sail_time + wp_stationkeeping_time + problem.delay_duration - ) + def _cache_original_expedition(self, expedition: Expedition) -> None: + """Cache original schedule configuration to file for recovery.""" + path = self.expedition_dir / CACHE / EXPEDITION_ORIGINAL + if not path.exists(): + expedition.to_yaml(path) + print(f"\nOriginal expedition.yaml cached to {path}.\n") - def _make_checkpoint(self, failed_waypoint_i: int | None = None) -> Checkpoint: - """Make checkpoint, also handling pre-departure.""" - return Checkpoint( - past_schedule=self.expedition.schedule, failed_waypoint_i=failed_waypoint_i - ) + @staticmethod + def cache_selected_problems( + problems: SelectedProblemsDict, selected_problems_fpath: str | Path + ) -> None: + """Cache suite of selected problems to JSON.""" + fpath = Path(selected_problems_fpath) + fpath.parent.mkdir(parents=True, exist_ok=True) + + payload = { + "problem_class": [p.short_name for p in problems["problem_class"]], + "waypoint_i": problems["waypoint_i"], + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), + } + ProblemSimulator._write_json(fpath, payload) + + @staticmethod + def load_selected_problems( + selected_problems_fpath: str | Path, + ) -> SelectedProblemsDict: + """Load selected problems suite from a cached JSON file.""" + data = ProblemSimulator._read_json(Path(selected_problems_fpath)) + + general_lookup = {cls.short_name: cls for cls in GENERAL_PROBLEMS} + instrument_lookup = {cls.short_name: cls for cls in INSTRUMENT_PROBLEMS} - def _cache_original_expedition(self, expedition: Expedition): - """Cache original schedule to file for user's reference.""" - path = self.expedition_dir.joinpath(CACHE, EXPEDITION_ORIGINAL) - if path.exists(): - return # don't overwrite if already cached - expedition.to_yaml(path) - print(f"\nOriginal expedition.yaml cached to {path}.\n") + selected_classes, waypoint_indices = [], [] + for cls_name, wp_idx in zip( + data["problem_class"], data["waypoint_i"], strict=True + ): + if cls_name in general_lookup: + selected_classes.append(general_lookup[cls_name]) + elif cls_name in instrument_lookup: + selected_classes.append(instrument_lookup[cls_name]) + else: + raise ValueError( + f"Problem class '{cls_name}' not found in known registries." + ) + waypoint_indices.append(wp_idx) + + return {"problem_class": selected_classes, "waypoint_i": waypoint_indices} + + @staticmethod + def post_expedition_report( + problems: SelectedProblemsDict, report_fpath: str | Path + ) -> None: + """Append human-readable report summary of all occurring problems.""" + with open(report_fpath, "a", encoding="utf-8") as f: + for problem, wp_i in zip( + problems["problem_class"], problems["waypoint_i"], strict=True + ): + affected = "in-port" if wp_i is None else f"{wp_i + 1}" + delay_hrs = problem.delay_duration.total_seconds() / 3600.0 + f.write( + f"---\nWaypoint: {affected}\n" + f"Problem: {problem.message}\n" + f"Delay caused: {delay_hrs} hours\n\n" + ) @staticmethod def _hash_to_json( - problem: InstrumentProblem | GeneralProblem, + problem: ProblemType, problem_hash: str, problem_waypoint_i: int | None, hash_path: Path, - ) -> dict: - """Convert problem details + hash to json.""" + ) -> None: + """Serialize runtime problem detail to JSON.""" hash_data = { "problem_hash": problem_hash, "message": problem.message, @@ -494,58 +454,52 @@ def _hash_to_json( "timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), "resolved": False, } - with open(hash_path, "w", encoding="utf-8") as f: - json.dump(hash_data, f, indent=4) + ProblemSimulator._write_json(hash_path, hash_data) + + @staticmethod + def _read_json(path: Path) -> dict[str, Any]: + with open(path, encoding="utf-8") as f: + return json.load(f) + + @staticmethod + def _write_json(path: Path, data: dict[str, Any]) -> None: + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=4) @staticmethod - def _tabular_outputter(problem_str, impact_str, result_str, has_contingency: bool): + def _tabular_outputter( + problem_str: str, impact_str: str, result_str: str, has_contingency: bool + ) -> None: """Display the problem, impact, and result in a live-updating table. Sleep times are included to increase readability and engagement for user.""" console = Console() console.print() # line break before table - col_kwargs = dict(ratio=1, no_wrap=False, max_width=None, justify="left") + col_kwargs = dict(ratio=1, no_wrap=False, justify="left") - def make_table(problem, impact, result, col_kwargs, colour_results=False): + def make_table(problem, impact, result, colour_results=False) -> Table: table = Table(box=box.SIMPLE, expand=True) table.add_column("Problem Encountered", **col_kwargs) table.add_column("Impact on schedule", **col_kwargs) - if colour_results: - style = "green1" if has_contingency else "red1" - table.add_column("Result", style=style, **col_kwargs) - else: - table.add_column("Result", **col_kwargs) - + style = ( + ("green1" if has_contingency else "red1") if colour_results else None + ) + table.add_column("Result", style=style, **col_kwargs) table.add_row(problem, impact, result) return table - empty_spinner = Spinner("dots", text="") + empty = Spinner("dots", text="") impact_spinner = Spinner("dots", text="Assessing impact on schedule...") + stages = [ + (empty, empty, empty, False, 3.0), + (problem_str, empty, empty, False, 3.0), + (problem_str, impact_spinner, empty, False, 7.0), + (problem_str, impact_str, empty, False, 4.0), + (problem_str, impact_str, result_str, True, 3.0), + ] + with Live(console=console, refresh_per_second=10) as live: - # stage 0: empty table - table = make_table(empty_spinner, empty_spinner, empty_spinner, col_kwargs) - live.update(table) - time.sleep(3.0) - - # stage 1: show problem - table = make_table(problem_str, empty_spinner, empty_spinner, col_kwargs) - live.update(table) - time.sleep(3.0) - - # stage 2: spinner in "Impact on schedule" column - table = make_table(problem_str, impact_spinner, empty_spinner, col_kwargs) - live.update(table) - time.sleep(7.0) - - # stage 3: table with problem and impact-investigation complete - table = make_table(problem_str, impact_str, empty_spinner, col_kwargs) - live.update(table) - time.sleep(4.0) - - # stage 4: complete table with problem, impact, and result (give final outcome colour based on fail/success) - table = make_table( - problem_str, impact_str, result_str, col_kwargs, colour_results=True - ) - live.update(table) - time.sleep(3.0) + for prob, imp, res, colour, sleep_time in stages: + live.update(make_table(prob, imp, res, colour_results=colour)) + time.sleep(sleep_time) From 0e0eee520827d2cb29fbf6dd4645d5b1cd6dc777 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:20:32 +0200 Subject: [PATCH 11/28] start fixing public waypoint number comms, tidy up some var names --- src/virtualship/cli/_run.py | 3 +- .../expedition/simulate_schedule.py | 2 +- .../make_realistic/problems/simulator.py | 66 ++++++++++--------- src/virtualship/models/checkpoint.py | 34 +++++----- .../make_realistic/problems/test_simulator.py | 4 +- tests/test_checkpoint.py | 12 ++-- 6 files changed, 62 insertions(+), 59 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index 6969d32f..b4a58ca9 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -97,6 +97,7 @@ def _run( checkpoint = Checkpoint(past_schedule=Schedule(waypoints=[])) # verify that schedule and checkpoint match, and that problems have been resolved + # TODO: problems related verifcation should probably be refactored out of the Checkpoint class (which is Pydantic model) and into the ProblemSimulator class (which is more appropriate for handling problems) checkpoint.verify(expedition, problems_dir) print("\n---- WAYPOINT VERIFICATION ----") @@ -121,7 +122,7 @@ def _run( _save_checkpoint( Checkpoint( past_schedule=expedition.schedule, - failed_waypoint_i=schedule_results.failed_waypoint_i, + failed_wp=schedule_results.failed_wp, ), expedition_dir, ) diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index 6f1fed05..516c1491 100644 --- a/src/virtualship/expedition/simulate_schedule.py +++ b/src/virtualship/expedition/simulate_schedule.py @@ -36,7 +36,7 @@ class ScheduleProblem: """Result of schedule that could not be fully completed.""" time: datetime - failed_waypoint_i: int + failed_wp: int @dataclass diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index ebeb1137..e41ffb96 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -237,7 +237,7 @@ def _assign_problems_to_waypoints( if not assigned_problems: return None - # Sort chronologically (pre-departure/None first, then waypoint index order) + # sort chronologically (pre-departure/None first, then waypoint index order) paired = sorted( zip(assigned_problems, assigned_indices, strict=True), key=lambda x: -1 if x[1] is None else x[1], @@ -269,37 +269,43 @@ def execute( if hash_fpath.exists(): continue - alert_msg = ( - LOG_MESSAGING["pre_departure"] - if isinstance(problem, GeneralProblem) and problem.pre_departure - else LOG_MESSAGING["during_expedition"].format(waypoint=wp_i + 1) - ) - - self._log_problem( - problem, wp_i, alert_msg, problem_hash, hash_fpath, log_delay - ) + self._log_problem(problem, wp_i, problem_hash, hash_fpath, log_delay) self._cache_original_expedition(self.expedition) def _log_problem( self, problem: ProblemType, - problem_waypoint_i: int | None, - alert_msg: str, + problem_wp_i: int | None, problem_hash: str, hash_fpath: Path, log_delay: float, ) -> None: - """Handle execution sequence, logging, checkpoint saving, and user presentation.""" - # TODO: the affected waypoint messaging is wrong considering the addition of Ports - #! under the hood, waypoint_i is the index of the waypoint in the expedition schedule, but the user-facing message should be based on the index of the waypoint in the list of non-port waypoints + """ + Handle execution sequence, logging, checkpoint saving, and user presentation. + + Note, problem_wp_i is the index of the waypoint in the expedition schedule, but the user-facing message should be based on the index of the waypoint in the list of non-port waypoints. + Use problem_wp_i for internal logic, but user-facing messages (below) should use public_wp (non indexed version). + problem_wp_i will often == public_wp (given 0-indexing), but this makes the logic explicit and clear. + """ + waypoints = self.expedition.schedule.waypoints + non_port_wps = [i for i, wp in enumerate(waypoints) if not isinstance(wp, Port)] + public_wp = ( + non_port_wps.index(problem_wp_i) + 1 if problem_wp_i is not None else None + ) + + alert_msg = ( + LOG_MESSAGING["pre_departure"] + if isinstance(problem, GeneralProblem) and problem.pre_departure + else LOG_MESSAGING["during_expedition"].format(waypoint=public_wp) + ) time.sleep(3.0) with yaspin(text=alert_msg) as spinner: time.sleep(log_delay) spinner.ok("šŸ’„ ") - self._hash_to_json(problem, problem_hash, problem_waypoint_i, hash_fpath) - has_contingency = self._has_contingency(problem, problem_waypoint_i) + self._hash_to_json(problem, problem_hash, problem_wp_i, hash_fpath) + has_contingency = self._has_contingency(problem, problem_wp_i) delay_hrs = problem.delay_duration.total_seconds() / 3600.0 if has_contingency: @@ -310,11 +316,8 @@ def _log_problem( data["resolved"] = True self._write_json(hash_fpath, data) else: - affected = ( - "in-port" - if problem_waypoint_i is None - else f"at waypoint {problem_waypoint_i + 1}" - ) + breakpoint() + affected = "in-port" if problem_wp_i is None else f"at waypoint {public_wp}" impact_str = ( f"Not enough contingency time scheduled to mitigate delay of {delay_hrs} " f"hours occurring {affected} (future waypoint(s) would be reached too late).\n" @@ -328,8 +331,9 @@ def _log_problem( # update and save checkpoints checkpoint = Checkpoint( past_schedule=self.expedition.schedule, - failed_waypoint_i=problem_waypoint_i + 1 - if problem_waypoint_i is not None + failed_wp=problem_wp_i + + 1 # TODO: should this use user_facing_wp_i instead of problem_wp_i? + if problem_wp_i is not None else 0, ) _save_checkpoint(checkpoint, self.expedition_dir) @@ -345,17 +349,15 @@ def _log_problem( if not has_contingency: sys.exit(0) - def _has_contingency( - self, problem: ProblemType, problem_waypoint_i: int | None - ) -> bool: + def _has_contingency(self, problem: ProblemType, problem_wp_i: int | None) -> bool: """Check whether scheduled contingency covers expected delay duration.""" - if problem_waypoint_i is None: + if problem_wp_i is None: return False waypoints = self.expedition.schedule.waypoints curr_wp, next_wp = ( - waypoints[problem_waypoint_i], - waypoints[problem_waypoint_i + 1], + waypoints[problem_wp_i], + waypoints[problem_wp_i + 1], ) stationkeeping = _calc_wp_stationkeeping_time( @@ -442,14 +444,14 @@ def post_expedition_report( def _hash_to_json( problem: ProblemType, problem_hash: str, - problem_waypoint_i: int | None, + problem_wp_i: int | None, hash_path: Path, ) -> None: """Serialize runtime problem detail to JSON.""" hash_data = { "problem_hash": problem_hash, "message": problem.message, - "problem_waypoint_i": problem_waypoint_i, + "problem_wp_i": problem_wp_i, "delay_duration_hours": problem.delay_duration.total_seconds() / 3600.0, "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 ce620af1..a304c676 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -37,7 +37,7 @@ class Checkpoint(pydantic.BaseModel): """ past_schedule: Schedule - failed_waypoint_i: int | None = None + failed_wp: int | None = None def to_yaml(self, file_path: str | Path) -> None: """ @@ -69,14 +69,14 @@ def verify(self, expedition: Expedition, problems_dir: Path) -> None: 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: + if self.failed_wp is None: pass elif ( - not new_schedule.waypoints[: int(self.failed_waypoint_i)] - == self.past_schedule.waypoints[: int(self.failed_waypoint_i)] + not new_schedule.waypoints[: int(self.failed_wp)] + == self.past_schedule.waypoints[: int(self.failed_wp)] ): raise CheckpointError( - f"Past waypoints in schedule have been changed! Restore past schedule and only change future waypoints (waypoint {int(self.failed_waypoint_i) + 1} onwards)." + f"Past waypoints in schedule have been changed! Restore past schedule and only change future waypoints (waypoint {int(self.failed_wp) + 1} onwards)." ) # 2) check that problems have been resolved in the new schedule @@ -98,12 +98,12 @@ def verify(self, expedition: Expedition, problems_dir: Path) -> None: problem_waypoint = ( new_schedule.waypoints[0] - if problem["problem_waypoint_i"] is None - else new_schedule.waypoints[problem["problem_waypoint_i"]] + if problem["problem_wp_i"] is None + else new_schedule.waypoints[problem["problem_wp_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: + if problem["problem_wp_i"] is None: time_diff = ( problem_waypoint.time - self.past_schedule.waypoints[0].time ) @@ -111,7 +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: - failed_waypoint = new_schedule.waypoints[self.failed_waypoint_i] + failed_waypoint = new_schedule.waypoints[self.failed_wp] scheduled_time = failed_waypoint.time - problem_waypoint.time @@ -149,27 +149,27 @@ def verify(self, expedition: Expedition, problems_dir: Path) -> None: else: problem_wp_str = ( "in-port" - if problem["problem_waypoint_i"] is None - else f"at waypoint {problem['problem_waypoint_i'] + 1}" + if problem["problem_wp_i"] is None + else f"at waypoint {problem['problem_wp_i'] + 1}" ) affected_wp_str = ( "1" - if problem["problem_waypoint_i"] is None - else f"{problem['problem_waypoint_i'] + 2}" + if problem["problem_wp_i"] is None + else f"{problem['problem_wp_i'] + 2}" ) time_elapsed = ( (sail_time + delay_duration + stationkeeping_time) - if problem["problem_waypoint_i"] is not None + if problem["problem_wp_i"] is not None else delay_duration ) failed_waypoint_time = ( failed_waypoint.time - if problem["problem_waypoint_i"] is not None + if problem["problem_wp_i"] is not None else new_schedule.waypoints[0].time ) current_time = ( problem_waypoint.time + time_elapsed - if problem["problem_waypoint_i"] is not None + if problem["problem_wp_i"] is not None else self.past_schedule.waypoints[0].time + time_elapsed ) @@ -179,7 +179,7 @@ def verify(self, expedition: Expedition, problems_dir: Path) -> None: 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 + if problem["problem_wp_i"] is not None else "" ) ) diff --git a/tests/make_realistic/problems/test_simulator.py b/tests/make_realistic/problems/test_simulator.py index ccd1de18..f1241b37 100644 --- a/tests/make_realistic/problems/test_simulator.py +++ b/tests/make_realistic/problems/test_simulator.py @@ -235,8 +235,8 @@ def test_has_contingency_during_expedition(tmp_path): ) # 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 + assert short_simulator._has_contingency(problem_cls, problem_wp_i=0) is True + assert long_simulator._has_contingency(problem_cls, problem_wp_i=0) is False def test_post_expedition_report(tmp_path): diff --git a/tests/test_checkpoint.py b/tests/test_checkpoint.py index f84693c9..b6cb60f1 100644 --- a/tests/test_checkpoint.py +++ b/tests/test_checkpoint.py @@ -17,7 +17,7 @@ def expedition(tmp_file): return Expedition.from_yaml(tmp_file) -def make_dummy_checkpoint(failed_waypoint_i=None): +def make_dummy_checkpoint(failed_wp=None): wp1 = Waypoint( location=Location(latitude=0.0, longitude=0.0), time=datetime(2024, 2, 1, 10, 0, 0), @@ -30,7 +30,7 @@ def make_dummy_checkpoint(failed_waypoint_i=None): ) schedule = Schedule(waypoints=[wp1, wp2]) - return Checkpoint(past_schedule=schedule, failed_waypoint_i=failed_waypoint_i) + return Checkpoint(past_schedule=schedule, failed_wp=failed_wp) def test_to_and_from_yaml(tmp_path): @@ -44,12 +44,12 @@ def test_to_and_from_yaml(tmp_path): def test_verify_no_failed_waypoint(expedition): - cp = make_dummy_checkpoint(failed_waypoint_i=None) + cp = make_dummy_checkpoint(failed_wp=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) + cp = make_dummy_checkpoint(failed_wp=1) # change past waypoints new_wp1 = Waypoint( @@ -94,7 +94,7 @@ def test_verify_problem_resolution( instrument=[], ) past_schedule = Schedule(waypoints=[wp1, wp2]) - cp = Checkpoint(past_schedule=past_schedule, failed_waypoint_i=1) + cp = Checkpoint(past_schedule=past_schedule, failed_wp=1) # new schedule new_wp1 = wp1 @@ -110,7 +110,7 @@ def test_verify_problem_resolution( problem = { "resolved": False, "delay_duration_hours": delay_duration_hours, - "problem_waypoint_i": 0, + "problem_wp_i": 0, } problem_file = tmp_path / "problem_1.json" with open(problem_file, "w") as f: From 240cc9d8f2d58afbcd6980f48cc0e733502ff442 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:48:11 +0200 Subject: [PATCH 12/28] refactor getting public facing wp number to utils method --- src/virtualship/instruments/base.py | 4 ++-- src/virtualship/make_realistic/problems/simulator.py | 11 +++-------- src/virtualship/utils.py | 9 ++++++++- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/virtualship/instruments/base.py b/src/virtualship/instruments/base.py index 894979dd..ebf8dcac 100644 --- a/src/virtualship/instruments/base.py +++ b/src/virtualship/instruments/base.py @@ -26,9 +26,9 @@ _find_files_in_timerange, _find_nc_file_with_variable, _get_bathy_data, + _get_clean_encoding, _get_waypoint_latlons, _select_product_id, - get_clean_encoding, ship_spinner, ) @@ -328,7 +328,7 @@ def _get_local_ds(self, files: list[Path]) -> xr.Dataset: @staticmethod def _via_tmp_ds(ds: xr.Dataset) -> xr.Dataset: """Create and re-load a temporary local dataset.""" - encoding = get_clean_encoding(ds) + encoding = _get_clean_encoding(ds) with tempfile.TemporaryDirectory() as tmpdir: tmp_fpath = Path(tmpdir) / "tmp.nc" diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index e41ffb96..ca98ba88 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -31,6 +31,7 @@ PROJECTION, _calc_sail_time, _calc_wp_stationkeeping_time, + _get_public_wp, _make_hash, _save_checkpoint, ) @@ -288,10 +289,7 @@ def _log_problem( problem_wp_i will often == public_wp (given 0-indexing), but this makes the logic explicit and clear. """ waypoints = self.expedition.schedule.waypoints - non_port_wps = [i for i, wp in enumerate(waypoints) if not isinstance(wp, Port)] - public_wp = ( - non_port_wps.index(problem_wp_i) + 1 if problem_wp_i is not None else None - ) + public_wp = _get_public_wp(problem_wp_i, waypoints) alert_msg = ( LOG_MESSAGING["pre_departure"] @@ -331,10 +329,7 @@ def _log_problem( # update and save checkpoints checkpoint = Checkpoint( past_schedule=self.expedition.schedule, - failed_wp=problem_wp_i - + 1 # TODO: should this use user_facing_wp_i instead of problem_wp_i? - if problem_wp_i is not None - else 0, + failed_wp_i=problem_wp_i if problem_wp_i is not None else 0, ) _save_checkpoint(checkpoint, self.expedition_dir) self.expedition.to_yaml(self.expedition_dir / CACHE / EXPEDITION_LATEST) diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 9441defd..5da00c9e 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -15,6 +15,7 @@ from parcels import FieldSet, Particle, Variable from virtualship.errors import CopernicusCatalogueError +from virtualship.models.expedition import Port if TYPE_CHECKING: from virtualship.expedition.simulate_schedule import ( @@ -525,7 +526,7 @@ def build_particle_class_from_sensors( return Particle.add_variable(nonsensor_variables + sensor_variables) -def get_clean_encoding(ds): +def _get_clean_encoding(ds): """ Clean existing encodings and supply explicit native endianness to prevent netCDF4 UserWarnings. @@ -539,6 +540,12 @@ def get_clean_encoding(ds): return encoding +def _get_public_wp(raw_wp_i: int | None, waypoints: list) -> int | None: + """Get the public waypoint index for a given waypoint (accounting for Port waypoints).""" + non_port_wps = [i for i, wp in enumerate(waypoints) if not isinstance(wp, Port)] + return non_port_wps.index(raw_wp_i) + 1 if raw_wp_i is not None else None + + # ===================================================== # SECTION: misc. # ===================================================== From 9e39baeb181e1451d6b01d9d3d5657875bcd9f7b Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:51:17 +0200 Subject: [PATCH 13/28] next steps of adapting public facing wp numbers --- src/virtualship/models/checkpoint.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/virtualship/models/checkpoint.py b/src/virtualship/models/checkpoint.py index a304c676..84b02093 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -17,6 +17,7 @@ PROJECTION, _calc_sail_time, _calc_wp_stationkeeping_time, + _get_public_wp, ) @@ -37,7 +38,7 @@ class Checkpoint(pydantic.BaseModel): """ past_schedule: Schedule - failed_wp: int | None = None + failed_wp_i: int | None = None def to_yaml(self, file_path: str | Path) -> None: """ @@ -68,15 +69,20 @@ def verify(self, expedition: Expedition, problems_dir: Path) -> None: """ new_schedule = expedition.schedule + # get the public waypoint number of the failed waypoint (if any), for use in error messages + public_failed_wp = _get_public_wp( + self.failed_wp_i + 1, self.past_schedule.waypoints + ) + # 1) check that past waypoints have not been changed, unless is a pre-departure problem - if self.failed_wp is None: + if self.failed_wp_i is None: pass elif ( - not new_schedule.waypoints[: int(self.failed_wp)] - == self.past_schedule.waypoints[: int(self.failed_wp)] + not new_schedule.waypoints[: int(self.failed_wp_i + 1)] + == self.past_schedule.waypoints[: int(self.failed_wp_i + 1)] ): raise CheckpointError( - f"Past waypoints in schedule have been changed! Restore past schedule and only change future waypoints (waypoint {int(self.failed_wp) + 1} onwards)." + f"Past waypoints in schedule have been changed! Restore past schedule and only change future waypoints (waypoint {int(public_failed_wp) + 1} onwards)." # +1 because it's the waypoint after the failed waypoint ) # 2) check that problems have been resolved in the new schedule @@ -103,6 +109,8 @@ def verify(self, expedition: Expedition, problems_dir: Path) -> None: ) # pre-departure problem: check that whole delay duration has been added to first waypoint time (by testing against past schedule) + # TODO: just taking the 0th waypoint doesn't work anymore given expedition has Port information now! + #! TODO: could combine into one single check that applies to all waypoints now that Ports have locations, rather than hypothetical? if problem["problem_wp_i"] is None: time_diff = ( problem_waypoint.time - self.past_schedule.waypoints[0].time @@ -111,7 +119,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: - failed_waypoint = new_schedule.waypoints[self.failed_wp] + failed_waypoint = new_schedule.waypoints[self.failed_wp_i + 1] scheduled_time = failed_waypoint.time - problem_waypoint.time From 8c0eda0bc8b15d529a732eb6b75ebd12347846e2 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:14:45 +0200 Subject: [PATCH 14/28] continue refactor work + using waypoint index for in-port problems --- src/virtualship/cli/_run.py | 10 +- .../make_realistic/problems/simulator.py | 58 +++++---- src/virtualship/models/checkpoint.py | 118 +++++++----------- src/virtualship/utils.py | 20 ++- 4 files changed, 96 insertions(+), 110 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index b4a58ca9..5d0614a5 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -14,7 +14,7 @@ simulate_schedule, ) from virtualship.make_realistic.problems.simulator import ProblemSimulator -from virtualship.models import Checkpoint, Schedule +from virtualship.models import Checkpoint from virtualship.models.expedition import Expedition from virtualship.utils import ( CACHE, @@ -93,12 +93,10 @@ def _run( # load last checkpoint checkpoint = _load_checkpoint(expedition_dir) - if checkpoint is None: - checkpoint = Checkpoint(past_schedule=Schedule(waypoints=[])) - # verify that schedule and checkpoint match, and that problems have been resolved - # TODO: problems related verifcation should probably be refactored out of the Checkpoint class (which is Pydantic model) and into the ProblemSimulator class (which is more appropriate for handling problems) - checkpoint.verify(expedition, problems_dir) + # verify that schedule and checkpoint match, and that problems have been resolved (if checkpoint exists) + if checkpoint is not None: + checkpoint.verify(expedition, problems_dir) print("\n---- WAYPOINT VERIFICATION ----") diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index ca98ba88..92b98605 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -4,6 +4,7 @@ import random import sys import time +from datetime import timedelta from pathlib import Path from typing import TYPE_CHECKING, Any @@ -70,6 +71,14 @@ def __init__(self, expedition: Expedition, expedition_dir: str | Path): self.expedition = expedition self.expedition_dir = Path(expedition_dir) + self.waypoints = expedition.schedule.waypoints + + def __post_init__(self): + """Ensure first and last waypoints are Ports. Allows the problem selection to work properly.""" + assert isinstance(self.waypoints[0], Port) & isinstance( + self.waypoints[-1], Port + ), "First and last waypoints must be Port types." + def select_problems( self, instruments_in_expedition: set[InstrumentType], @@ -80,16 +89,14 @@ def select_problems( 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) for reference. + Map each selected problem to a random waypoint (or 0th [i.e. departure port] if pre-departure). Finally, cache the suite of problems to a directory (expedition-specific) for reference. """ - waypoints = self.expedition.schedule.waypoints - # handle early-exit single waypoint case (pre-departure only) - if len(waypoints) < 2: + if len(self.waypoints) < 2: pre_departure = [p for p in GENERAL_PROBLEMS if p.pre_departure] return { "problem_class": [random.choice(pre_departure)], - "waypoint_i": [None], + "waypoint_i": [0], # noqa; pre-departure problem is always associated with the departure port (index 0) } valid_instruments = [ @@ -99,8 +106,8 @@ def select_problems( ] num_problems = self._calculate_problem_count( difficulty_level=difficulty_level, - expedition_days=(waypoints[-1].time - waypoints[0].time).days, - num_waypoints=len(waypoints), + expedition_days=(self.waypoints[-1].time - self.waypoints[0].time).days, + num_waypoints=len(self.waypoints), num_instruments=len(instruments_in_expedition), max_available=len(GENERAL_PROBLEMS) + len(valid_instruments), ) @@ -152,9 +159,7 @@ def _sample_problems( bias = min(0.7, num_instruments / (num_instruments + 2)) n_inst = round(num_problems * bias) n_gen = min(len(general_pool), num_problems - n_inst) - n_inst = ( - num_problems - n_gen - ) # recalc in case n_gen was capped to len(GENERAL_PROBLEMS) + n_inst = num_problems - n_gen # noqa; recalc in case n_gen was capped to len(GENERAL_PROBLEMS) return general_pool[:n_gen] + instrument_pool[:n_inst] @@ -189,19 +194,23 @@ def _assign_problems_to_waypoints( self, selected: list[ProblemType] ) -> SelectedProblemsDict | None: """Assign sampled problems to valid, non-port waypoint indices.""" - waypoints = self.expedition.schedule.waypoints + waypoints = self.waypoints avail_indices = [ i for i, wp in enumerate(waypoints) if not isinstance(wp, Port) ] random.shuffle(avail_indices) + assert 0 not in avail_indices, ( + "Index 0 (departure port) should not be in available waypoint indices for non-pre-departure problems." + ) + assigned_problems: list[ProblemType] = [] assigned_indices: list[int | None] = [] for problem in selected: if getattr(problem, "pre_departure", False): assigned_problems.append(problem) - assigned_indices.append(None) + assigned_indices.append(0) # noqa; pre-departure problem is always associated with the departure port (index 0) continue if not avail_indices: @@ -238,10 +247,10 @@ def _assign_problems_to_waypoints( if not assigned_problems: return None - # sort chronologically (pre-departure/None first, then waypoint index order) + # sort chronologically (waypoint 0 first, then remaining waypoint index order) paired = sorted( zip(assigned_problems, assigned_indices, strict=True), - key=lambda x: -1 if x[1] is None else x[1], + key=lambda x: 0 if x[1] == 0 else x[1], ) return { "problem_class": [p for p, _ in paired], @@ -288,7 +297,7 @@ def _log_problem( Use problem_wp_i for internal logic, but user-facing messages (below) should use public_wp (non indexed version). problem_wp_i will often == public_wp (given 0-indexing), but this makes the logic explicit and clear. """ - waypoints = self.expedition.schedule.waypoints + waypoints = self.waypoints public_wp = _get_public_wp(problem_wp_i, waypoints) alert_msg = ( @@ -314,8 +323,7 @@ def _log_problem( data["resolved"] = True self._write_json(hash_fpath, data) else: - breakpoint() - affected = "in-port" if problem_wp_i is None else f"at waypoint {public_wp}" + affected = "in-port" if public_wp is None else f"at waypoint {public_wp}" impact_str = ( f"Not enough contingency time scheduled to mitigate delay of {delay_hrs} " f"hours occurring {affected} (future waypoint(s) would be reached too late).\n" @@ -329,7 +337,7 @@ def _log_problem( # update and save checkpoints checkpoint = Checkpoint( past_schedule=self.expedition.schedule, - failed_wp_i=problem_wp_i if problem_wp_i is not None else 0, + problem_wp_i=problem_wp_i, ) _save_checkpoint(checkpoint, self.expedition_dir) self.expedition.to_yaml(self.expedition_dir / CACHE / EXPEDITION_LATEST) @@ -346,17 +354,15 @@ def _log_problem( def _has_contingency(self, problem: ProblemType, problem_wp_i: int | None) -> bool: """Check whether scheduled contingency covers expected delay duration.""" - if problem_wp_i is None: - return False - - waypoints = self.expedition.schedule.waypoints curr_wp, next_wp = ( - waypoints[problem_wp_i], - waypoints[problem_wp_i + 1], + self.waypoints[problem_wp_i], + self.waypoints[problem_wp_i + 1], ) - stationkeeping = _calc_wp_stationkeeping_time( - curr_wp.instrument, self.expedition + stationkeeping = ( + _calc_wp_stationkeeping_time(curr_wp.instrument, self.expedition) + if not isinstance(curr_wp, Port) + else timedelta(0) ) sail_time = _calc_sail_time( curr_wp.location, diff --git a/src/virtualship/models/checkpoint.py b/src/virtualship/models/checkpoint.py index 84b02093..a0631922 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -11,7 +11,7 @@ from virtualship.errors import CheckpointError from virtualship.instruments.types import InstrumentType -from virtualship.models.expedition import Expedition, Schedule +from virtualship.models.expedition import Expedition, Port, Schedule from virtualship.utils import ( EXPEDITION, PROJECTION, @@ -38,7 +38,7 @@ class Checkpoint(pydantic.BaseModel): """ past_schedule: Schedule - failed_wp_i: int | None = None + problem_wp_i: int | None = None def to_yaml(self, file_path: str | Path) -> None: """ @@ -69,20 +69,25 @@ def verify(self, expedition: Expedition, problems_dir: Path) -> None: """ new_schedule = expedition.schedule - # get the public waypoint number of the failed waypoint (if any), for use in error messages - public_failed_wp = _get_public_wp( - self.failed_wp_i + 1, self.past_schedule.waypoints + # TODO: problems related verifcation should probably be refactored out of the Checkpoint class (which is Pydantic model) and into the ProblemSimulator class (which is more appropriate for handling problems) + #! Do some re-thinking to move all the problems related logic over into the Problems (simulator). + + # failed waypoint is the waypoint immediately *after* the problem waypoint (i.e. the one that will not be reached in time) + failed_wp_i = self.problem_wp_i + 1 + + # public waypoint number of problem and failed waypoints, for use in error messages + public_problem_wp = _get_public_wp( + self.problem_wp_i, self.past_schedule.waypoints ) + public_failed_wp = _get_public_wp(failed_wp_i, self.past_schedule.waypoints) - # 1) check that past waypoints have not been changed, unless is a pre-departure problem - if self.failed_wp_i is None: - pass - elif ( - not new_schedule.waypoints[: int(self.failed_wp_i + 1)] - == self.past_schedule.waypoints[: int(self.failed_wp_i + 1)] + # 1) check that past waypoints have not been changed (up to but not including failed_wp) + if ( + not new_schedule.waypoints[: int(failed_wp_i)] + == self.past_schedule.waypoints[: int(failed_wp_i)] ): raise CheckpointError( - f"Past waypoints in schedule have been changed! Restore past schedule and only change future waypoints (waypoint {int(public_failed_wp) + 1} onwards)." # +1 because it's the waypoint after the failed waypoint + f"Past waypoints in schedule have been changed! Restore past schedule and only change future waypoints (waypoint {int(public_failed_wp)} onwards)." ) # 2) check that problems have been resolved in the new schedule @@ -94,54 +99,38 @@ def verify(self, expedition: Expedition, problems_dir: Path) -> None: for file in hash_fpaths: with open(file, encoding="utf-8") as f: problem = json.load(f) + + # continue if problem is already resolved, else perform checks to see if delay is accounted for 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; or first waypoint if pre-departure problem) + else: delay_duration = timedelta( hours=float(problem["delay_duration_hours"]) ) - problem_waypoint = ( - new_schedule.waypoints[0] - if problem["problem_wp_i"] is None - else new_schedule.waypoints[problem["problem_wp_i"]] - ) - - # pre-departure problem: check that whole delay duration has been added to first waypoint time (by testing against past schedule) - # TODO: just taking the 0th waypoint doesn't work anymore given expedition has Port information now! - #! TODO: could combine into one single check that applies to all waypoints now that Ports have locations, rather than hypothetical? - if problem["problem_wp_i"] is None: - time_diff = ( - 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: - failed_waypoint = new_schedule.waypoints[self.failed_wp_i + 1] - - scheduled_time = failed_waypoint.time - problem_waypoint.time + problem_waypoint = new_schedule.waypoints[self.problem_wp_i] + failed_waypoint = new_schedule.waypoints[failed_wp_i] + scheduled_time_diff = failed_waypoint.time - problem_waypoint.time - stationkeeping_time = _calc_wp_stationkeeping_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 = ( - sail_time + delay_duration + stationkeeping_time ) + if not isinstance(problem_waypoint, Port) + else timedelta(0) + ) + + sail_time = _calc_sail_time( + problem_waypoint.location, + failed_waypoint.location, + ship_speed_knots=expedition.ship_config.ship_speed_knots, + projection=PROJECTION, + )[0] - resolved = scheduled_time >= min_time_required + min_time_required = sail_time + delay_duration + stationkeeping_time - if resolved: + if scheduled_time_diff >= min_time_required: print( "\n\nšŸŽ‰ Previous problem has been resolved in the schedule.\n" ) @@ -157,37 +146,18 @@ def verify(self, expedition: Expedition, problems_dir: Path) -> None: else: problem_wp_str = ( "in-port" - if problem["problem_wp_i"] is None - else f"at waypoint {problem['problem_wp_i'] + 1}" - ) - affected_wp_str = ( - "1" - if problem["problem_wp_i"] is None - else f"{problem['problem_wp_i'] + 2}" - ) - time_elapsed = ( - (sail_time + delay_duration + stationkeeping_time) - if problem["problem_wp_i"] is not None - else delay_duration - ) - failed_waypoint_time = ( - failed_waypoint.time - if problem["problem_wp_i"] is not None - else new_schedule.waypoints[0].time - ) - current_time = ( - problem_waypoint.time + time_elapsed - if problem["problem_wp_i"] is not None - else self.past_schedule.waypoints[0].time + time_elapsed + if problem["problem_wp_i"] == 0 # i.e. pre-departure + else f"at waypoint {public_problem_wp}" ) + time_elapsed = 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\n" - 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"The problem was associated with a delay duration of {problem['delay_duration_hours']} hours {problem_wp_str} (meaning waypoint {public_failed_wp} could not be reached in time). " + f"Currently, the ship would reach waypoint {public_failed_wp} at {problem_waypoint.time + time_elapsed}, 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_wp_i"] is not None + f"\n\nHint: don't forget to factor in the time required to deploy the instruments {problem_wp_str} when rescheduling waypoint {public_failed_wp}." + if problem["problem_wp_i"] != 0 else "" ) ) diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 5da00c9e..58431b3e 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -15,7 +15,6 @@ from parcels import FieldSet, Particle, Variable from virtualship.errors import CopernicusCatalogueError -from virtualship.models.expedition import Port if TYPE_CHECKING: from virtualship.expedition.simulate_schedule import ( @@ -541,9 +540,22 @@ def _get_clean_encoding(ds): def _get_public_wp(raw_wp_i: int | None, waypoints: list) -> int | None: - """Get the public waypoint index for a given waypoint (accounting for Port waypoints).""" - non_port_wps = [i for i, wp in enumerate(waypoints) if not isinstance(wp, Port)] - return non_port_wps.index(raw_wp_i) + 1 if raw_wp_i is not None else None + """ + Get the public waypoint number for a given raw waypoint index (accounting for Port waypoints). + + Note, the returned number is not an index, rather it corresponds to Waypoint numbers ignoring Ports (which are not waypoints from the user's perspective). + """ + from virtualship.models.expedition import Port # avoid circular import + + port_wps = [i for i, wp in enumerate(waypoints) if isinstance(wp, Port)] + non_port_wps = [i for i in range(len(waypoints)) if i not in port_wps] + + if raw_wp_i in port_wps: + public_wp = None # Port waypoints do not have public waypoint numbers + else: + public_wp = non_port_wps.index(raw_wp_i) + 1 + + return public_wp # ===================================================== From d6bf08286bf9a1ddc294c3c16ba1c1bc72163b71 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:51:23 +0200 Subject: [PATCH 15/28] refactor: separate problem-specific checkpoint verification logic from core checkpoint model, move away from reliance on problem-specific tracking via json tmp files --- src/virtualship/cli/_run.py | 23 ++- .../make_realistic/problems/simulator.py | 80 +++++++- src/virtualship/models/checkpoint.py | 176 +++++------------- 3 files changed, 128 insertions(+), 151 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index 5d0614a5..348111ca 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -42,11 +42,7 @@ def _run( expedition_dir: str | Path, difficulty_level: str, from_data: Path | None = None ) -> None: - """ - Perform an expedition, providing terminal feedback and file output. - - :param expedition_dir: The base directory for the expedition. - """ + """Perform an expedition, providing terminal feedback and file output.""" # start timing start_time = time.time() print("[TIMER] Expedition started...") @@ -91,12 +87,18 @@ def _run( # verify instruments_config file is consistent with schedule expedition.instruments_config.verify(expedition) - # load last checkpoint + # initialise problem simulator + problem_simulator = ProblemSimulator(expedition, expedition_dir) + + # load last checkpoint if present checkpoint = _load_checkpoint(expedition_dir) - # verify that schedule and checkpoint match, and that problems have been resolved (if checkpoint exists) if checkpoint is not None: - checkpoint.verify(expedition, problems_dir) + # 1) core structural check: verify past waypoints have not changed + checkpoint.verify_past_schedule(expedition.schedule) + + # 2) problems-specific check: verify active problem delay is resolved in new schedule + problem_simulator.verify_problem_resolution(checkpoint) print("\n---- WAYPOINT VERIFICATION ----") @@ -120,7 +122,7 @@ def _run( _save_checkpoint( Checkpoint( past_schedule=expedition.schedule, - failed_wp=schedule_results.failed_wp, + failed_wp_i=schedule_results.failed_wp, ), expedition_dir, ) @@ -144,9 +146,6 @@ def _run( # identify instruments in expedition instruments_in_expedition = expedition.get_instruments() - # initialise problem simulator - problem_simulator = ProblemSimulator(expedition, expedition_dir) - # 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.joinpath(SELECTED_PROBLEMS)): problems = problem_simulator.load_selected_problems( diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index 92b98605..1ae2ba33 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -15,6 +15,7 @@ from rich.table import Table from yaspin import yaspin +from virtualship.errors import CheckpointError from virtualship.instruments.types import InstrumentType from virtualship.make_realistic.problems.scenarios import ( GENERAL_PROBLEMS, @@ -22,7 +23,7 @@ GeneralProblem, InstrumentProblem, ) -from virtualship.models.checkpoint import Checkpoint +from virtualship.models.checkpoint import ActiveProblem, Checkpoint from virtualship.models.expedition import Port from virtualship.utils import ( CACHE, @@ -282,6 +283,61 @@ def execute( self._log_problem(problem, wp_i, problem_hash, hash_fpath, log_delay) self._cache_original_expedition(self.expedition) + def verify_problem_resolution(self, checkpoint: Checkpoint) -> None: + """Verify active problem delay is resolved in new schedule.""" + active_problem = checkpoint.active_problem + if active_problem is None or active_problem.resolved: + return + + failed_wp_i = checkpoint.get_effective_failed_wp_i() + new_schedule = self.expedition.schedule + + # problem-specific delay calculation & resolution check + delay_duration = timedelta(hours=active_problem.delay_duration_hours) + problem_waypoint = new_schedule.waypoints[checkpoint.problem_wp_i] + failed_waypoint = new_schedule.waypoints[failed_wp_i] + + scheduled_time_diff = failed_waypoint.time - problem_waypoint.time + stationkeeping_time = ( + _calc_wp_stationkeeping_time(problem_waypoint.instrument, self.expedition) + if not isinstance(problem_waypoint, Port) + else timedelta(0) + ) + sail_time = _calc_sail_time( + problem_waypoint.location, + failed_waypoint.location, + ship_speed_knots=self.expedition.ship_config.ship_speed_knots, + projection=PROJECTION, + )[0] + + min_time_required = sail_time + delay_duration + stationkeeping_time + + if scheduled_time_diff >= min_time_required: + print("\n\nšŸŽ‰ Previous problem has been resolved in the schedule.\n") + active_problem.resolved = True + _save_checkpoint(checkpoint, self.expedition_dir) + else: + public_problem_wp = _get_public_wp( + checkpoint.problem_wp_i, checkpoint.past_schedule.waypoints + ) + public_failed_wp = _get_public_wp( + failed_wp_i, checkpoint.past_schedule.waypoints + ) + problem_wp_str = ( + "in-port" + if checkpoint.problem_wp_i == 0 + else f"at waypoint {public_problem_wp}" + ) + time_elapsed = sail_time + delay_duration + stationkeeping_time + + raise CheckpointError( + f"The problem encountered in previous simulation has not been resolved in the schedule! " + f"Please adjust the schedule to account for delays caused by the problem...\n\n" + f"The problem was associated with a delay duration of {active_problem.delay_duration_hours} hours {problem_wp_str} " + f"(meaning waypoint {public_failed_wp} could not be reached in time). " + f"Currently, the ship would reach waypoint {public_failed_wp} at {problem_waypoint.time + time_elapsed}, but the scheduled time is {failed_waypoint.time}." + ) + def _log_problem( self, problem: ProblemType, @@ -293,9 +349,10 @@ def _log_problem( """ Handle execution sequence, logging, checkpoint saving, and user presentation. - Note, problem_wp_i is the index of the waypoint in the expedition schedule, but the user-facing message should be based on the index of the waypoint in the list of non-port waypoints. - Use problem_wp_i for internal logic, but user-facing messages (below) should use public_wp (non indexed version). - problem_wp_i will often == public_wp (given 0-indexing), but this makes the logic explicit and clear. + Note, problem_wp_i is the index of the waypoint in the expedition schedule, but the user-facing message + should be based on the index of the waypoint in the list of non-port waypoints. + Use problem_wp_i for internal logic, but user-facing messages should use public_wp. + Incidentally, problem_wp_i will often == public_wp (given 0-indexing), but this makes the logic explicit and clear. """ waypoints = self.waypoints public_wp = _get_public_wp(problem_wp_i, waypoints) @@ -311,17 +368,13 @@ def _log_problem( time.sleep(log_delay) spinner.ok("šŸ’„ ") - self._hash_to_json(problem, problem_hash, problem_wp_i, hash_fpath) has_contingency = self._has_contingency(problem, problem_wp_i) delay_hrs = problem.delay_duration.total_seconds() / 3600.0 if has_contingency: impact_str = LOG_MESSAGING["problem_avoided"] result_str = "The expedition will carry on shortly as planned." - # update problem JSON state to resolved - data = self._read_json(hash_fpath) - data["resolved"] = True - self._write_json(hash_fpath, data) + active_problem = None else: affected = "in-port" if public_wp is None else f"at waypoint {public_wp}" impact_str = ( @@ -333,11 +386,18 @@ def _log_problem( problem_wp=affected, expedition_yaml=EXPEDITION, ) + active_problem = ActiveProblem( + message=problem.message, + problem_wp_i=problem_wp_i, + delay_duration_hours=delay_hrs, + resolved=False, + ) - # update and save checkpoints + # update and save checkpoint with active problem information checkpoint = Checkpoint( past_schedule=self.expedition.schedule, problem_wp_i=problem_wp_i, + active_problem=active_problem, ) _save_checkpoint(checkpoint, self.expedition_dir) self.expedition.to_yaml(self.expedition_dir / CACHE / EXPEDITION_LATEST) diff --git a/src/virtualship/models/checkpoint.py b/src/virtualship/models/checkpoint.py index a0631922..8ce43326 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -2,8 +2,6 @@ from __future__ import annotations -import json -from datetime import timedelta from pathlib import Path import pydantic @@ -11,14 +9,8 @@ from virtualship.errors import CheckpointError from virtualship.instruments.types import InstrumentType -from virtualship.models.expedition import Expedition, Port, Schedule -from virtualship.utils import ( - EXPEDITION, - PROJECTION, - _calc_sail_time, - _calc_wp_stationkeeping_time, - _get_public_wp, -) +from virtualship.models.expedition import Schedule +from virtualship.utils import _get_public_wp class _YamlDumper(yaml.SafeDumper): @@ -30,134 +22,60 @@ class _YamlDumper(yaml.SafeDumper): ) -class Checkpoint(pydantic.BaseModel): - """ - A checkpoint of schedule simulation. - - Copy of the schedule until where the simulation proceeded without troubles. - """ - - past_schedule: Schedule - problem_wp_i: int | None = None - - def to_yaml(self, file_path: str | Path) -> None: - """ - Write checkpoint to yaml file. - - :param file_path: Path to the file to write to. - """ - with open(file_path, "w") as file: - yaml.dump(self.model_dump(by_alias=True), file, Dumper=_YamlDumper) - - @classmethod - def from_yaml(cls, file_path: str | Path) -> Checkpoint: - """ - Load checkpoint from yaml file. - - :param file_path: Path to the file to load from. - :returns: The checkpoint. - """ - with open(file_path) as file: - data = yaml.safe_load(file) - return Checkpoint(**data) +class ActiveProblem(pydantic.BaseModel): + """Runtime state of a problem halting simulation.""" - 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. + message: str + problem_wp_i: int | None + delay_duration_hours: float + resolved: bool = False - 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 - # TODO: problems related verifcation should probably be refactored out of the Checkpoint class (which is Pydantic model) and into the ProblemSimulator class (which is more appropriate for handling problems) - #! Do some re-thinking to move all the problems related logic over into the Problems (simulator). +class Checkpoint(pydantic.BaseModel): + """A checkpoint of the schedule simulation storing past schedule state and any active problem that halted execution.""" - # failed waypoint is the waypoint immediately *after* the problem waypoint (i.e. the one that will not be reached in time) - failed_wp_i = self.problem_wp_i + 1 + past_schedule: Schedule + problem_wp_i: int | None = ( + None # index of the waypoint that caused a problem (if any) + ) + failed_wp_i: int | None = ( + None # index of the waypoint that could not be reached in time (either because of problem or incompatible user scheduling) + ) + active_problem: ActiveProblem | None = None + + def get_effective_failed_wp_i(self) -> int | None: + """Return the index of the waypoint that failed or could not be reached.""" + if self.failed_wp_i is not None: + return self.failed_wp_i + if self.problem_wp_i is not None: + return self.problem_wp_i + 1 + return None + + def verify_past_schedule(self, new_schedule: Schedule) -> None: + """Core structural check: ensure past history hasn't been edited.""" + failed_wp_i = self.get_effective_failed_wp_i() + if failed_wp_i is None: + return - # public waypoint number of problem and failed waypoints, for use in error messages - public_problem_wp = _get_public_wp( - self.problem_wp_i, self.past_schedule.waypoints - ) public_failed_wp = _get_public_wp(failed_wp_i, self.past_schedule.waypoints) - # 1) check that past waypoints have not been changed (up to but not including failed_wp) if ( - not new_schedule.waypoints[: int(failed_wp_i)] - == self.past_schedule.waypoints[: int(failed_wp_i)] + new_schedule.waypoints[: int(failed_wp_i)] + != self.past_schedule.waypoints[: int(failed_wp_i)] ): raise CheckpointError( - f"Past waypoints in schedule have been changed! Restore past schedule and only change future waypoints (waypoint {int(public_failed_wp)} onwards)." + f"Past waypoints in schedule have been changed! Restore past schedule " + f"and only change future waypoints (waypoint {int(public_failed_wp)} onwards)." ) - # 2) check that problems have been resolved in the new schedule - hash_fpaths = [ - str(path.resolve()) for path in problems_dir.glob("problem_*.json") - ] - - if len(hash_fpaths) > 0: - for file in hash_fpaths: - with open(file, encoding="utf-8") as f: - problem = json.load(f) - - # continue if problem is already resolved, else perform checks to see if delay is accounted for - if problem["resolved"]: - continue - else: - delay_duration = timedelta( - hours=float(problem["delay_duration_hours"]) - ) - - problem_waypoint = new_schedule.waypoints[self.problem_wp_i] - failed_waypoint = new_schedule.waypoints[failed_wp_i] - scheduled_time_diff = failed_waypoint.time - problem_waypoint.time - - stationkeeping_time = ( - _calc_wp_stationkeeping_time( - problem_waypoint.instrument, - expedition, - ) - if not isinstance(problem_waypoint, Port) - else timedelta(0) - ) - - 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 = sail_time + delay_duration + stationkeeping_time - - if scheduled_time_diff >= min_time_required: - 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) - - # 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_str = ( - "in-port" - if problem["problem_wp_i"] == 0 # i.e. pre-departure - else f"at waypoint {public_problem_wp}" - ) - time_elapsed = 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\n" - f"The problem was associated with a delay duration of {problem['delay_duration_hours']} hours {problem_wp_str} (meaning waypoint {public_failed_wp} could not be reached in time). " - f"Currently, the ship would reach waypoint {public_failed_wp} at {problem_waypoint.time + time_elapsed}, 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 {public_failed_wp}." - if problem["problem_wp_i"] != 0 - else "" - ) - ) + def to_yaml(self, file_path: str | Path) -> None: + """Write checkpoint to YAML file.""" + with open(file_path, "w", encoding="utf-8") as file: + yaml.dump(self.model_dump(by_alias=True), file, Dumper=_YamlDumper) + + @classmethod + def from_yaml(cls, file_path: str | Path) -> Checkpoint: + """Load checkpoint from YAML file.""" + with open(file_path, encoding="utf-8") as file: + data = yaml.safe_load(file) + return Checkpoint(**data) From 05b8ca82a37b7b5b13ca04fee1f9056020b1a2b1 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:27:31 +0200 Subject: [PATCH 16/28] user messaging fix when failed wp is port of arrival --- src/virtualship/make_realistic/problems/simulator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index 1ae2ba33..32cf1b4c 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -323,6 +323,9 @@ def verify_problem_resolution(self, checkpoint: Checkpoint) -> None: public_failed_wp = _get_public_wp( failed_wp_i, checkpoint.past_schedule.waypoints ) + if public_failed_wp is None: + public_failed_wp = "\b/Port of Arrival" + problem_wp_str = ( "in-port" if checkpoint.problem_wp_i == 0 From 71d86c74a42754a83c7ab9e40efbdff71c9d20d5 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:59:34 +0200 Subject: [PATCH 17/28] fix check for unique expedition --- src/virtualship/cli/_run.py | 43 +++++++----- .../make_realistic/problems/simulator.py | 69 ++++++++++++------- 2 files changed, 71 insertions(+), 41 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index 348111ca..b4401822 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -75,9 +75,9 @@ def _run( expedition = _get_expedition(expedition_dir) - # unique id to determine if an expedition has 'changed' since last run (to avoid re-selecting problems when user makes tweaks to schedule to deal with problems encountered) + # unique id to determine if an expedition has 'changed' since last run cache_dir = expedition_dir.joinpath(CACHE) - expedition_id = _unique_id(expedition, cache_dir) + expedition_id = _unique_id(expedition, cache_dir, expedition_dir) # dedicated problems directory for this expedition problems_dir = expedition_dir.joinpath( @@ -128,13 +128,15 @@ def _run( ) return - # delete and create results directory + # warn about existing results on fresh runs (when no active checkpoint exists) results_dir = expedition_dir.joinpath(RESULTS) - _warn_overwrite_results_dir(results_dir) + if checkpoint is None: + _warn_overwrite_results_dir(results_dir) - if os.path.exists(results_dir): + # re-initialize/clean results directory + if os.path.exists(results_dir) and checkpoint is None: shutil.rmtree(results_dir) - os.makedirs(results_dir) + os.makedirs(results_dir, exist_ok=True) print("\n----- EXPEDITION SUMMARY ------") @@ -146,7 +148,7 @@ def _run( # identify instruments in expedition instruments_in_expedition = expedition.get_instruments() - # re-load previously encountered (same expedition as previously) problems if they exist, else select new problems and cache them + # re-load previously encountered problems if they exist, else select new problems and cache them if os.path.exists(problems_dir.joinpath(SELECTED_PROBLEMS)): problems = problem_simulator.load_selected_problems( problems_dir.joinpath(SELECTED_PROBLEMS) @@ -155,20 +157,16 @@ def _run( problems = problem_simulator.select_problems( instruments_in_expedition, difficulty_level ) - problem_simulator.cache_selected_problems( - problems, problems_dir.joinpath(SELECTED_PROBLEMS) - ) if problems else None + if problems: + problem_simulator.cache_selected_problems( + problems, problems_dir.joinpath(SELECTED_PROBLEMS) + ) # simulate instrument measurements print("\nSimulating measurements. This may take a while...\n") for itype in instruments_in_expedition: 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: if ( @@ -185,6 +183,11 @@ def _run( log_dir=problems_dir, ) + # 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) @@ -247,7 +250,7 @@ def _run( print(f"[TIMER] Expedition completed in {elapsed / 60.0:.2f} minutes.") -def _unique_id(expedition: Expedition, cache_dir: Path) -> str: +def _unique_id(expedition: Expedition, cache_dir: Path, expedition_dir: Path) -> str: """ Return a unique id for the expedition (marked by datetime), which can be used to determine whether the expedition has 'changed' since the last run. @@ -258,6 +261,7 @@ def _unique_id(expedition: Expedition, cache_dir: Path) -> str: id_path = cache_dir.joinpath(EXPEDITION_IDENTIFIER) last_expedition_path = cache_dir.joinpath(EXPEDITION_LATEST) + checkpoint_path = expedition_dir.joinpath(CHECKPOINT) new_id = datetime.now().strftime("%Y%m%d%H%M%S") if not id_path.exists(): @@ -266,10 +270,13 @@ def _unique_id(expedition: Expedition, cache_dir: Path) -> str: previous_id = id_path.read_text().strip() + # if an active checkpoint exists, retain the existing expedition id to preserve problem state + if checkpoint_path.exists(): + return previous_id + try: last_expedition = Expedition.from_yaml(last_expedition_path) except FileNotFoundError: - # cache is not useful in this case as it implies the previous run was interrupted and is incomplete; update passively id_path.write_text(new_id) return new_id @@ -277,7 +284,7 @@ def _unique_id(expedition: Expedition, cache_dir: Path) -> str: last_expedition.get_instruments() ) if not added_instruments: - return previous_id # if no additions, keep previous id to allow re-use of previously encountered problems + return previous_id id_path.write_text(new_id) return new_id diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index 32cf1b4c..8234af62 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -28,9 +28,12 @@ from virtualship.utils import ( CACHE, EXPEDITION, + EXPEDITION_IDENTIFIER, EXPEDITION_LATEST, EXPEDITION_ORIGINAL, + PROBLEMS_ENCOUNTERED, PROJECTION, + SELECTED_PROBLEMS, _calc_sail_time, _calc_wp_stationkeeping_time, _get_public_wp, @@ -61,7 +64,7 @@ } ProblemType = GeneralProblem | InstrumentProblem -SelectedProblemsDict = dict[str, list[ProblemType | None]] +SelectedProblemsDict = dict[str, Any] class ProblemSimulator: @@ -71,9 +74,16 @@ def __init__(self, expedition: Expedition, expedition_dir: str | Path): """Initialise ProblemSimulator with a schedule and probability level.""" self.expedition = expedition self.expedition_dir = Path(expedition_dir) - self.waypoints = expedition.schedule.waypoints + @property + def expedition_id(self) -> str: + """Retrieve the current expedition unique identifier from cache.""" + id_path = self.expedition_dir.joinpath(CACHE, EXPEDITION_IDENTIFIER) + if id_path.exists(): + return id_path.read_text().strip() + return "" + def __post_init__(self): """Ensure first and last waypoints are Ports. Allows the problem selection to work properly.""" assert isinstance(self.waypoints[0], Port) & isinstance( @@ -256,6 +266,7 @@ def _assign_problems_to_waypoints( return { "problem_class": [p for p, _ in paired], "waypoint_i": [w for _, w in paired], + "resolved": False, } def execute( @@ -266,9 +277,15 @@ def execute( log_delay: float = 4.0, ) -> None: """Execute simulation problems and apply delay/schedule impacts.""" + if not problems or problems.get("resolved", False): + return + for problem, wp_i in zip( problems["problem_class"], problems["waypoint_i"], strict=True ): + if getattr(problem, "resolved", False): + continue + if ( isinstance(problem, InstrumentProblem) and problem.instrument_type is not instrument_type_validation @@ -316,6 +333,22 @@ def verify_problem_resolution(self, checkpoint: Checkpoint) -> None: print("\n\nšŸŽ‰ Previous problem has been resolved in the schedule.\n") active_problem.resolved = True _save_checkpoint(checkpoint, self.expedition_dir) + + # persist resolved status to selected_problems.json cache + problems_path = self.expedition_dir.joinpath( + CACHE, + PROBLEMS_ENCOUNTERED.format(expedition_id=self.expedition_id), + SELECTED_PROBLEMS, + ) + if problems_path.exists(): + problems = self.load_selected_problems(problems_path) + if isinstance(problems, dict): + problems["resolved"] = True + for p in problems.get("problem_class", []): + if getattr(p, "message", None) == active_problem.message: + p.resolved = True + self.cache_selected_problems(problems, problems_path) + else: public_problem_wp = _get_public_wp( checkpoint.problem_wp_i, checkpoint.past_schedule.waypoints @@ -399,7 +432,6 @@ def _log_problem( # update and save checkpoint with active problem information checkpoint = Checkpoint( past_schedule=self.expedition.schedule, - problem_wp_i=problem_wp_i, active_problem=active_problem, ) _save_checkpoint(checkpoint, self.expedition_dir) @@ -457,6 +489,7 @@ def cache_selected_problems( payload = { "problem_class": [p.short_name for p in problems["problem_class"]], "waypoint_i": problems["waypoint_i"], + "resolved": problems.get("resolved", False), "timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), } ProblemSimulator._write_json(fpath, payload) @@ -485,7 +518,11 @@ def load_selected_problems( ) waypoint_indices.append(wp_idx) - return {"problem_class": selected_classes, "waypoint_i": waypoint_indices} + return { + "problem_class": selected_classes, + "waypoint_i": waypoint_indices, + "resolved": data.get("resolved", False), + } @staticmethod def post_expedition_report( @@ -504,24 +541,6 @@ def post_expedition_report( f"Delay caused: {delay_hrs} hours\n\n" ) - @staticmethod - def _hash_to_json( - problem: ProblemType, - problem_hash: str, - problem_wp_i: int | None, - hash_path: Path, - ) -> None: - """Serialize runtime problem detail to JSON.""" - hash_data = { - "problem_hash": problem_hash, - "message": problem.message, - "problem_wp_i": problem_wp_i, - "delay_duration_hours": problem.delay_duration.total_seconds() / 3600.0, - "timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), - "resolved": False, - } - ProblemSimulator._write_json(hash_path, hash_data) - @staticmethod def _read_json(path: Path) -> dict[str, Any]: with open(path, encoding="utf-8") as f: @@ -536,7 +555,11 @@ def _write_json(path: Path, data: dict[str, Any]) -> None: def _tabular_outputter( problem_str: str, impact_str: str, result_str: str, has_contingency: bool ) -> None: - """Display the problem, impact, and result in a live-updating table. Sleep times are included to increase readability and engagement for user.""" + """ + Display the problem, impact, and result in a live-updating table. + + Sleep times are included to increase readability and engagement for user. + """ console = Console() console.print() # line break before table From e4fe04ce5a22540283c19db11adf5391d6d4b11f Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:59:49 +0200 Subject: [PATCH 18/28] remove duplicate problem_wp_i declaration --- src/virtualship/models/checkpoint.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/virtualship/models/checkpoint.py b/src/virtualship/models/checkpoint.py index 8ce43326..3b939fc1 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -26,7 +26,7 @@ class ActiveProblem(pydantic.BaseModel): """Runtime state of a problem halting simulation.""" message: str - problem_wp_i: int | None + problem_wp_i: int | None # noqa; index of the waypoint that caused a problem (if any) delay_duration_hours: float resolved: bool = False @@ -35,14 +35,14 @@ class Checkpoint(pydantic.BaseModel): """A checkpoint of the schedule simulation storing past schedule state and any active problem that halted execution.""" past_schedule: Schedule - problem_wp_i: int | None = ( - None # index of the waypoint that caused a problem (if any) - ) - failed_wp_i: int | None = ( - None # index of the waypoint that could not be reached in time (either because of problem or incompatible user scheduling) - ) + failed_wp_i: int | None = None # noqa; index of the waypoint that could not be reached in time active_problem: ActiveProblem | None = None + @property + def problem_wp_i(self) -> int | None: + """Delegate to active_problem to avoid duplication.""" + return self.active_problem.problem_wp_i if self.active_problem else None + def get_effective_failed_wp_i(self) -> int | None: """Return the index of the waypoint that failed or could not be reached.""" if self.failed_wp_i is not None: From 1e1f6775bcdd8e57257c60ca14336a2825c36aba Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:41:56 +0200 Subject: [PATCH 19/28] heavy refactor of _run.py; move more problems simulation logic into simulator class --- src/virtualship/cli/_run.py | 376 ++++++++---------- .../make_realistic/problems/simulator.py | 129 ++++-- 2 files changed, 268 insertions(+), 237 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index b4401822..3d1b489e 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -10,9 +10,11 @@ from virtualship.expedition.simulate_schedule import ( MeasurementsToSimulate, + ScheduleOk, ScheduleProblem, simulate_schedule, ) +from virtualship.instruments.types import InstrumentType from virtualship.make_realistic.problems.simulator import ProblemSimulator from virtualship.models import Checkpoint from virtualship.models.expedition import Expedition @@ -24,244 +26,130 @@ EXPEDITION_LATEST, PROBLEMS_ENCOUNTERED, PROJECTION, - REPORT, RESULTS, - SELECTED_PROBLEMS, _get_expedition, _save_checkpoint, expedition_cost, get_instrument_class, ) -# suppress INFO messages from copernicusmarine and parcels loggers; prevent log flooding -parcels_logger = logging.getLogger("parcels._logger") -parcels_logger.setLevel(logging.WARNING) -logging.getLogger("copernicusmarine").setLevel("ERROR") +# Suppress INFO messages from copernicusmarine and parcels loggers; prevent log flooding +logging.getLogger("parcels._logger").setLevel(logging.WARNING) +logging.getLogger("copernicusmarine").setLevel(logging.ERROR) def _run( - expedition_dir: str | Path, difficulty_level: str, from_data: Path | None = None + expedition_dir: str | Path, + difficulty_level: str, + from_data: str | Path | None = None, ) -> None: """Perform an expedition, providing terminal feedback and file output.""" - # start timing start_time = time.time() - print("[TIMER] Expedition started...") - - print("\n╔═════════════════════════════════════════════════╗") - print("ā•‘ VIRTUALSHIP EXPEDITION STATUS ā•‘") - print("ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•") + expedition_dir = Path(expedition_dir) + data_path = Path(from_data) if from_data else None - if from_data is None: - # 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" - ) + print("[TIMER] Expedition started...") + _print_banner() - if ( - os.path.isfile(COPERNICUS_CREDS_FILE) - and os.path.getsize(COPERNICUS_CREDS_FILE) > 0 - ): - pass - else: - print( - "\nPlease enter your log in details for the Copernicus Marine Service (only necessary the first time you run VirtualShip). \n\nIf you have not registered yet, please do so at https://marine.copernicus.eu/.\n\n" - "If you did not expect to see this message, and intended to use pre-downloaded data instead of streaming via Copernicus Marine, please use the '--from-data' option to specify the path to the data.\n" - ) - copernicusmarine.login() - - if isinstance(expedition_dir, str): - expedition_dir = Path(expedition_dir) + if not data_path: + _ensure_copernicus_auth() expedition = _get_expedition(expedition_dir) + cache_dir = expedition_dir / CACHE + results_dir = expedition_dir / RESULTS - # unique id to determine if an expedition has 'changed' since last run - cache_dir = expedition_dir.joinpath(CACHE) expedition_id = _unique_id(expedition, cache_dir, expedition_dir) + problems_dir = cache_dir / PROBLEMS_ENCOUNTERED.format(expedition_id=expedition_id) - # dedicated problems directory for this expedition - problems_dir = expedition_dir.joinpath( - CACHE, PROBLEMS_ENCOUNTERED.format(expedition_id=expedition_id) - ) - - # verify instruments_config file is consistent with schedule expedition.instruments_config.verify(expedition) + problem_simulator = ProblemSimulator(expedition, expedition_dir, difficulty_level) - # initialise problem simulator - problem_simulator = ProblemSimulator(expedition, expedition_dir) - - # load last checkpoint if present checkpoint = _load_checkpoint(expedition_dir) - if checkpoint is not None: - # 1) core structural check: verify past waypoints have not changed checkpoint.verify_past_schedule(expedition.schedule) - - # 2) problems-specific check: verify active problem delay is resolved in new schedule problem_simulator.verify_problem_resolution(checkpoint) print("\n---- WAYPOINT VERIFICATION ----") - expedition.schedule.verify( expedition.ship_config.ship_speed_knots, expedition.instruments_config, - from_data=Path(from_data) if from_data else None, + from_data=data_path, ) - # simulate the schedule schedule_results = simulate_schedule( 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"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( - past_schedule=expedition.schedule, - failed_wp_i=schedule_results.failed_wp, - ), - expedition_dir, - ) + _handle_schedule_failure(schedule_results, expedition, expedition_dir) return - # warn about existing results on fresh runs (when no active checkpoint exists) - results_dir = expedition_dir.joinpath(RESULTS) - if checkpoint is None: - _warn_overwrite_results_dir(results_dir) - - # re-initialize/clean results directory - if os.path.exists(results_dir) and checkpoint is None: - shutil.rmtree(results_dir) - os.makedirs(results_dir, exist_ok=True) + _prepare_results_directory(results_dir, is_new_run=(checkpoint is None)) print("\n----- EXPEDITION SUMMARY ------") - - # expedition cost in US$ _write_expedition_cost(expedition, schedule_results, expedition_dir) print("\n--- MEASUREMENT SIMULATIONS ---") - - # identify instruments in expedition instruments_in_expedition = expedition.get_instruments() - # re-load previously encountered problems if they exist, else select new problems and cache them - if os.path.exists(problems_dir.joinpath(SELECTED_PROBLEMS)): - problems = problem_simulator.load_selected_problems( - problems_dir.joinpath(SELECTED_PROBLEMS) - ) - else: - problems = problem_simulator.select_problems( - instruments_in_expedition, difficulty_level + print("\nSimulating measurements. This may take a while...\n") + try: + _simulate_measurements( + expedition=expedition, + schedule_results=schedule_results, + instruments=instruments_in_expedition, + problem_simulator=problem_simulator, + data_path=data_path, ) - if problems: - problem_simulator.cache_selected_problems( - problems, problems_dir.joinpath(SELECTED_PROBLEMS) - ) + except Exception as e: + _cleanup_on_failure(problems_dir, expedition_dir) + raise RuntimeError( + f"An unexpected error occurred while simulating measurements: {e}. " + "Please report this issue to the VirtualShip issue tracker at: " + "https://github.com/OceanParcels/virtualship/issues" + ) from e - # simulate instrument measurements - print("\nSimulating measurements. This may take a while...\n") + print("\nAll measurement simulations are complete.") - for itype in instruments_in_expedition: - try: - # execute problem simulations for this instrument type - if problems: - if ( - hasattr(problems["problem_class"][0], "pre_departure") - and problems["problem_class"][0].pre_departure - ): - pass - else: - print(f"\033[4mUp next\033[0m: {itype.name} measurements...\n") - - problem_simulator.execute( - problems, - instrument_type_validation=itype, - log_dir=problems_dir, - ) - - # 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) - - # 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()}.parquet" - ), - ) - 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 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 + problem_simulator.create_post_expedition_report() + _conclude_expedition(expedition_dir, difficulty_level) - print("\nAll measurement simulations are complete.") + elapsed = time.time() - start_time + print(f"[TIMER] Expedition completed in {elapsed / 60.0:.2f} minutes.") - print("\n----- EXPEDITION RESULTS ------") - print("\nYour expedition has concluded successfully!") - print( - f"Your measurements can be found in the '{expedition_dir}/results' directory." - ) - if problems: - ProblemSimulator.post_expedition_report( - problems, expedition_dir.joinpath(RESULTS, REPORT) - ) - print("\n----- RECORD OF PROBLEMS ENCOUNTERED ------") - print( - f"\nA post-expedition report of problems encountered during the expedition is saved in: {expedition_dir.joinpath(RESULTS, REPORT)}" - ) +# ===================================================== +# SECTION: helpers +# ===================================================== - # 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)) - # delete cache dir if when --difficulty-level is 'easy' (no useful information to cache in this case, and can interfere with re-runs) - if difficulty_level == "easy" and os.path.exists(cache_dir): - shutil.rmtree(cache_dir) +def _print_banner() -> None: + print("\n╔═════════════════════════════════════════════════╗") + print("ā•‘ VIRTUALSHIP EXPEDITION STATUS ā•‘") + print("ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•") - print("\n------------- END -------------\n") - # end timing - end_time = time.time() - elapsed = end_time - start_time - print(f"[TIMER] Expedition completed in {elapsed / 60.0:.2f} minutes.") +def _ensure_copernicus_auth() -> None: + creds_file = Path( + os.path.expandvars("$HOME/.copernicusmarine/.copernicusmarine-credentials") + ) + if not (creds_file.is_file() and creds_file.stat().st_size > 0): + print( + "\nPlease enter your log in details for the Copernicus Marine Service " + "(only necessary the first time you run VirtualShip).\n\n" + "If you have not registered yet, please do so at https://marine.copernicus.eu/.\n\n" + "If you did not expect to see this message, and intended to use pre-downloaded " + "data instead of streaming via Copernicus Marine, please use the '--from-data' option.\n" + ) + copernicusmarine.login() def _unique_id(expedition: Expedition, cache_dir: Path, expedition_dir: Path) -> str: - """ - Return a unique id for the expedition (marked by datetime), which can be used to determine whether the expedition has 'changed' since the last run. - - Returns the previous id if no instruments have been added since the last run, allowing re-use of previously encountered problems. - Otherwise generates and persists a new id. - """ cache_dir.mkdir(exist_ok=True) - - id_path = cache_dir.joinpath(EXPEDITION_IDENTIFIER) - last_expedition_path = cache_dir.joinpath(EXPEDITION_LATEST) - checkpoint_path = expedition_dir.joinpath(CHECKPOINT) + id_path = cache_dir / EXPEDITION_IDENTIFIER + last_expedition_path = cache_dir / EXPEDITION_LATEST + checkpoint_path = expedition_dir / CHECKPOINT new_id = datetime.now().strftime("%Y%m%d%H%M%S") if not id_path.exists(): @@ -270,56 +158,134 @@ def _unique_id(expedition: Expedition, cache_dir: Path, expedition_dir: Path) -> previous_id = id_path.read_text().strip() - # if an active checkpoint exists, retain the existing expedition id to preserve problem state if checkpoint_path.exists(): return previous_id - try: - last_expedition = Expedition.from_yaml(last_expedition_path) - except FileNotFoundError: + if not last_expedition_path.exists(): id_path.write_text(new_id) return new_id + last_expedition = Expedition.from_yaml(last_expedition_path) added_instruments = set(expedition.get_instruments()) - set( last_expedition.get_instruments() ) - if not added_instruments: - return previous_id - id_path.write_text(new_id) - return new_id + if added_instruments: + id_path.write_text(new_id) + return new_id + return previous_id -def _warn_overwrite_results_dir(results_dir: Path) -> None: - if os.path.exists(results_dir): - print( - f"\nWARNING: The '{results_dir}' directory already exists and will be overwritten. If you want to keep the previous results, please move or rename the '{results_dir}' directory before re-running the expedition.\n" + +def _handle_schedule_failure( + schedule_results: ScheduleProblem, expedition: Expedition, expedition_dir: Path +) -> None: + print( + f"Please update your schedule (`virtualship plan` or directly in {EXPEDITION}) " + "and continue the expedition by executing the `virtualship run` command again.\n" + f"Checkpoint has been saved to {expedition_dir / CHECKPOINT}." + ) + _save_checkpoint( + Checkpoint( + past_schedule=expedition.schedule, + failed_wp_i=schedule_results.failed_wp, + ), + expedition_dir, + ) + + +def _prepare_results_directory(results_dir: Path, is_new_run: bool) -> None: + if is_new_run and results_dir.exists(): + _warn_overwrite_results_dir(results_dir) + shutil.rmtree(results_dir) + results_dir.mkdir(parents=True, exist_ok=True) + + +def _simulate_measurements( + expedition: Expedition, + schedule_results: ScheduleOk | ScheduleProblem, + instruments: set[InstrumentType], + problem_simulator: ProblemSimulator, + data_path: Path | None, +) -> None: + for itype in instruments: + problem_simulator.execute_for_instrument(itype) + + instrument_class = get_instrument_class(itype) + measurements = getattr( + schedule_results.measurements_to_simulate, + MeasurementsToSimulate.get_attr_for_instrumenttype(itype), ) - decision = input( - "Do you want to continue the expedition run and thereby overwrite the existing results? (y/n): " + + instrument = instrument_class(expedition=expedition, from_data=data_path) + instrument.execute( + measurements=measurements, + out_path=expedition.expedition_dir + / RESULTS + / f"{itype.name.lower()}.parquet", ) - if decision.lower() != "y": - print("Expedition run cancelled by user.") - sys.exit(0) - if decision.lower() == "y": - print("Continuing with expedition run and overwriting existing results...") + + +def _cleanup_on_failure(problems_dir: Path, expedition_dir: Path) -> None: + if problems_dir.exists(): + shutil.rmtree(problems_dir) + checkpoint_file = expedition_dir / CHECKPOINT + if checkpoint_file.exists(): + checkpoint_file.unlink() + + +def _conclude_expedition( + expedition_dir: Path, + difficulty_level: str, +) -> None: + print("\n----- EXPEDITION RESULTS ------") + print("\nYour expedition has concluded successfully!") + print( + f"Your measurements can be found in the '{expedition_dir / RESULTS}' directory." + ) + + checkpoint_path = expedition_dir / CHECKPOINT + if checkpoint_path.exists(): + checkpoint_path.unlink() + + cache_dir = expedition_dir / CACHE + if difficulty_level == "easy" and cache_dir.exists(): + shutil.rmtree(cache_dir) + + print("\n------------- END -------------\n") + + +def _warn_overwrite_results_dir(results_dir: Path) -> None: + print( + f"\nWARNING: The '{results_dir}' directory already exists and will be overwritten. " + "If you want to keep previous results, move or rename the directory before re-running.\n" + ) + decision = input("Overwrite existing results? (y/n): ") + if decision.lower() != "y": + print("Expedition run cancelled by user.") + sys.exit(0) + print("Continuing with expedition run and overwriting existing results...") def _load_checkpoint(expedition_dir: Path) -> Checkpoint | None: - file_path = expedition_dir.joinpath(CHECKPOINT) try: - return Checkpoint.from_yaml(file_path) + return Checkpoint.from_yaml(expedition_dir / CHECKPOINT) except FileNotFoundError: return None -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, ( - "First waypoint has no time. This should not be possible as it should have been verified before." - ) - time_past = schedule_results.time - expedition.schedule.waypoints[0].time +def _write_expedition_cost( + expedition: Expedition, + schedule_results: ScheduleOk | ScheduleProblem, + expedition_dir: Path, +) -> None: + first_wp = expedition.schedule.waypoints[0] + assert first_wp.time is not None, "First waypoint has no time." + + time_past = schedule_results.time - first_wp.time cost = expedition_cost(schedule_results, time_past) - with open(expedition_dir.joinpath(RESULTS, "cost.txt"), "w") as file: - file.writelines(f"cost: {cost} US$") + + cost_file = expedition_dir / RESULTS / "cost.txt" + cost_file.write_text(f"cost: {cost} US$") + print(f"\nExpedition duration: {time_past}\nExpedition cost: US$ {cost:,.0f}.") diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index 8234af62..0ec23444 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -1,10 +1,11 @@ from __future__ import annotations +import datetime import json import random import sys import time -from datetime import timedelta +from datetime import datetime as dt from pathlib import Path from typing import TYPE_CHECKING, Any @@ -27,12 +28,15 @@ from virtualship.models.expedition import Port from virtualship.utils import ( CACHE, + CHECKPOINT, EXPEDITION, EXPEDITION_IDENTIFIER, EXPEDITION_LATEST, EXPEDITION_ORIGINAL, PROBLEMS_ENCOUNTERED, PROJECTION, + REPORT, + RESULTS, SELECTED_PROBLEMS, _calc_sail_time, _calc_wp_stationkeeping_time, @@ -56,7 +60,6 @@ "problem_avoided": "Phew! You had enough contingency time scheduled to avoid delays from this problem.\n", } -# default problem weights for problems simulator (e.g., +1 problem every N days/waypoints/instruments) PROBLEM_WEIGHTS = { "every_ndays": 7, "every_nwaypoints": 6, @@ -70,25 +73,49 @@ class ProblemSimulator: """Handle problem simulation during an expedition.""" - def __init__(self, expedition: Expedition, expedition_dir: str | Path): - """Initialise ProblemSimulator with a schedule and probability level.""" + def __init__( + self, expedition: Expedition, expedition_dir: str | Path, difficulty_level: str + ): + """Initialise ProblemSimulator with a schedule, dir and difficulty level.""" self.expedition = expedition self.expedition_dir = Path(expedition_dir) - self.waypoints = expedition.schedule.waypoints + self.expedition_id = self._unique_id() + self.problems_dir = ( + self.expedition_dir + / CACHE + / PROBLEMS_ENCOUNTERED.format(expedition_id=self.expedition_id) + ) + self.problems = self._load_or_select_problems(difficulty_level) @property - def expedition_id(self) -> str: - """Retrieve the current expedition unique identifier from cache.""" - id_path = self.expedition_dir.joinpath(CACHE, EXPEDITION_IDENTIFIER) - if id_path.exists(): - return id_path.read_text().strip() - return "" - - def __post_init__(self): - """Ensure first and last waypoints are Ports. Allows the problem selection to work properly.""" - assert isinstance(self.waypoints[0], Port) & isinstance( - self.waypoints[-1], Port - ), "First and last waypoints must be Port types." + def waypoints(self) -> list: + """Convenience accessor for expedition schedule waypoints.""" + return self.expedition.schedule.waypoints + + def execute_for_instrument(self, instrument_type: InstrumentType) -> None: + """Execute problems for a specific instrument type.""" + if not self.problems: + return + + self.execute( + self.problems, + instrument_type_validation=instrument_type, + log_dir=self.problems_dir, + ) + + def _load_or_select_problems( + self, difficulty_level: str + ) -> SelectedProblemsDict | None: + """Load problems from JSON cache if available, otherwise select and cache new ones.""" + selected_problems_path = self.problems_dir / SELECTED_PROBLEMS + if selected_problems_path.exists(): + return self.load_selected_problems(selected_problems_path) + + instruments = self.expedition.get_instruments() + problems = self.select_problems(instruments, difficulty_level) + if problems: + self.cache_selected_problems(problems, selected_problems_path) + return problems def select_problems( self, @@ -211,10 +238,6 @@ def _assign_problems_to_waypoints( ] random.shuffle(avail_indices) - assert 0 not in avail_indices, ( - "Index 0 (departure port) should not be in available waypoint indices for non-pre-departure problems." - ) - assigned_problems: list[ProblemType] = [] assigned_indices: list[int | None] = [] @@ -309,8 +332,7 @@ def verify_problem_resolution(self, checkpoint: Checkpoint) -> None: failed_wp_i = checkpoint.get_effective_failed_wp_i() new_schedule = self.expedition.schedule - # problem-specific delay calculation & resolution check - delay_duration = timedelta(hours=active_problem.delay_duration_hours) + delay_duration = datetime.timedelta(hours=active_problem.delay_duration_hours) problem_waypoint = new_schedule.waypoints[checkpoint.problem_wp_i] failed_waypoint = new_schedule.waypoints[failed_wp_i] @@ -318,7 +340,7 @@ def verify_problem_resolution(self, checkpoint: Checkpoint) -> None: stationkeeping_time = ( _calc_wp_stationkeeping_time(problem_waypoint.instrument, self.expedition) if not isinstance(problem_waypoint, Port) - else timedelta(0) + else datetime.timedelta(0) ) sail_time = _calc_sail_time( problem_waypoint.location, @@ -334,12 +356,7 @@ def verify_problem_resolution(self, checkpoint: Checkpoint) -> None: active_problem.resolved = True _save_checkpoint(checkpoint, self.expedition_dir) - # persist resolved status to selected_problems.json cache - problems_path = self.expedition_dir.joinpath( - CACHE, - PROBLEMS_ENCOUNTERED.format(expedition_id=self.expedition_id), - SELECTED_PROBLEMS, - ) + problems_path = self.problems_dir / SELECTED_PROBLEMS if problems_path.exists(): problems = self.load_selected_problems(problems_path) if isinstance(problems, dict): @@ -429,7 +446,6 @@ def _log_problem( resolved=False, ) - # update and save checkpoint with active problem information checkpoint = Checkpoint( past_schedule=self.expedition.schedule, active_problem=active_problem, @@ -457,7 +473,7 @@ def _has_contingency(self, problem: ProblemType, problem_wp_i: int | None) -> bo stationkeeping = ( _calc_wp_stationkeeping_time(curr_wp.instrument, self.expedition) if not isinstance(curr_wp, Port) - else timedelta(0) + else datetime.timedelta(0) ) sail_time = _calc_sail_time( curr_wp.location, @@ -478,6 +494,55 @@ def _cache_original_expedition(self, expedition: Expedition) -> None: expedition.to_yaml(path) print(f"\nOriginal expedition.yaml cached to {path}.\n") + def _unique_id(self) -> str: + """Resolve or generate the unique identifier for this expedition run.""" + cache_dir = self.expedition_dir / CACHE + cache_dir.mkdir(exist_ok=True) + + id_path = cache_dir / EXPEDITION_IDENTIFIER + last_expedition_path = cache_dir / EXPEDITION_LATEST + checkpoint_path = self.expedition_dir / CHECKPOINT + new_id = dt.now().strftime("%Y%m%d%H%M%S") + + if not id_path.exists(): + id_path.write_text(new_id) + return new_id + + previous_id = id_path.read_text().strip() + + if checkpoint_path.exists(): + return previous_id + + if not last_expedition_path.exists(): + id_path.write_text(new_id) + return new_id + + from virtualship.models.expedition import Expedition as ExpeditionModel + + last_expedition = ExpeditionModel.from_yaml(last_expedition_path) + added_instruments = set(self.expedition.get_instruments()) - set( + last_expedition.get_instruments() + ) + + if added_instruments: + id_path.write_text(new_id) + return new_id + + return previous_id + + def create_post_expedition_report(self) -> None: + """Generate post-expedition report if any problems were selected.""" + if not self.problems: + return + + report_path = self.expedition_dir / RESULTS / REPORT + self.post_expedition_report(self.problems, report_path) + + print("\n----- RECORD OF PROBLEMS ENCOUNTERED ------") + print( + f"\nA post-expedition report of problems encountered is saved in: {report_path}" + ) + @staticmethod def cache_selected_problems( problems: SelectedProblemsDict, selected_problems_fpath: str | Path From 62a03b6bb47c2ffe93c0bd1a1f8200c9e6b89917 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:12:57 +0200 Subject: [PATCH 20/28] move to using ScheduledProblem object and its `.resolved` field as the single source of truth for resolved status --- .../make_realistic/problems/simulator.py | 433 ++++++++++-------- 1 file changed, 242 insertions(+), 191 deletions(-) diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index 0ec23444..abe626cc 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -5,6 +5,8 @@ import random import sys import time +from collections.abc import Iterator +from dataclasses import dataclass, field from datetime import datetime as dt from pathlib import Path from typing import TYPE_CHECKING, Any @@ -67,7 +69,46 @@ } ProblemType = GeneralProblem | InstrumentProblem -SelectedProblemsDict = dict[str, Any] + + +@dataclass +class ScheduledProblem: + """Represents a single problem paired with its assigned waypoint index.""" + + problem: ProblemType + waypoint_index: int | None + resolved: bool = False + + +@dataclass +class SelectedProblems: + """Container holding scheduled problems for an expedition run.""" + + items: list[ScheduledProblem] = field(default_factory=list) + + @property + def is_fully_resolved(self) -> bool: + """True if all scheduled problems are marked as resolved.""" + return bool(self.items) and all(item.resolved for item in self.items) + + @property + def has_unresolved(self) -> bool: + """True if there are remaining unresolved problems.""" + return any(not item.resolved for item in self.items) + + def mark_resolved(self, problem_message: str) -> None: + """Mark a specific problem as resolved.""" + for item in self.items: + if item.problem.message == problem_message: # check is right problem + item.resolved = True + + def __iter__(self) -> Iterator[ScheduledProblem]: + """Iterate over scheduled problems.""" + return iter(self.items) + + def __len__(self) -> int: + """Return the number of scheduled problems.""" + return len(self.items) class ProblemSimulator: @@ -103,25 +144,43 @@ def execute_for_instrument(self, instrument_type: InstrumentType) -> None: log_dir=self.problems_dir, ) - def _load_or_select_problems( - self, difficulty_level: str - ) -> SelectedProblemsDict | None: - """Load problems from JSON cache if available, otherwise select and cache new ones.""" - selected_problems_path = self.problems_dir / SELECTED_PROBLEMS - if selected_problems_path.exists(): - return self.load_selected_problems(selected_problems_path) + def execute( + self, + problems: SelectedProblems, + instrument_type_validation: InstrumentType | None, + log_dir: Path, + log_delay: float = 4.0, + ) -> None: + """Execute simulation problems and apply delay/schedule impacts.""" + if not problems or not problems.has_unresolved: + return - instruments = self.expedition.get_instruments() - problems = self.select_problems(instruments, difficulty_level) - if problems: - self.cache_selected_problems(problems, selected_problems_path) - return problems + for item in problems: + if item.resolved: + continue + + problem = item.problem + wp_i = item.waypoint_index + + if ( + isinstance(problem, InstrumentProblem) + and problem.instrument_type is not instrument_type_validation + ): + continue + + problem_hash = _make_hash(problem.message + str(wp_i), 8) + hash_fpath = log_dir / f"problem_{problem_hash}.json" + if hash_fpath.exists(): + continue + + self._log_problem(problem, wp_i, problem_hash, hash_fpath, log_delay) + self._cache_original_expedition(self.expedition) def select_problems( self, instruments_in_expedition: set[InstrumentType], difficulty_level: str, - ) -> SelectedProblemsDict | None: + ) -> SelectedProblems | None: """ Select problems (general and instrument-specific). When difficulty_level = 'hard', number of problems is determined by expedition length, instrument count etc. @@ -132,10 +191,14 @@ def select_problems( # handle early-exit single waypoint case (pre-departure only) if len(self.waypoints) < 2: pre_departure = [p for p in GENERAL_PROBLEMS if p.pre_departure] - return { - "problem_class": [random.choice(pre_departure)], - "waypoint_i": [0], # noqa; pre-departure problem is always associated with the departure port (index 0) - } + return SelectedProblems( + items=[ + ScheduledProblem( + problem=random.choice(pre_departure), + waypoint_index=0, # noqa; pre-departure problem is always associated with the departure port (index 0) + ) + ] + ) valid_instruments = [ p @@ -160,6 +223,97 @@ def select_problems( return self._assign_problems_to_waypoints(selected) + def verify_problem_resolution(self, checkpoint: Checkpoint) -> None: + """Verify active problem delay is resolved in new schedule.""" + active_problem = checkpoint.active_problem + if active_problem is None or active_problem.resolved: + return + + failed_wp_i = checkpoint.get_effective_failed_wp_i() + new_schedule = self.expedition.schedule + + delay_duration = datetime.timedelta(hours=active_problem.delay_duration_hours) + problem_waypoint = new_schedule.waypoints[checkpoint.problem_wp_i] + failed_waypoint = new_schedule.waypoints[failed_wp_i] + + scheduled_time_diff = failed_waypoint.time - problem_waypoint.time + stationkeeping_time = ( + _calc_wp_stationkeeping_time(problem_waypoint.instrument, self.expedition) + if not isinstance(problem_waypoint, Port) + else datetime.timedelta(0) + ) + sail_time = _calc_sail_time( + problem_waypoint.location, + failed_waypoint.location, + ship_speed_knots=self.expedition.ship_config.ship_speed_knots, + projection=PROJECTION, + )[0] + + min_time_required = sail_time + delay_duration + stationkeeping_time + + if scheduled_time_diff >= min_time_required: + print("\n\nšŸŽ‰ Previous problem has been resolved in the schedule.\n") + active_problem.resolved = True + _save_checkpoint(checkpoint, self.expedition_dir) + + problems_path = self.problems_dir / SELECTED_PROBLEMS + if problems_path.exists(): + problems = self.load_selected_problems(problems_path) + problems.mark_resolved(active_problem.message) + self.cache_selected_problems(problems, problems_path) + + else: + public_problem_wp = _get_public_wp( + checkpoint.problem_wp_i, checkpoint.past_schedule.waypoints + ) + public_failed_wp = _get_public_wp( + failed_wp_i, checkpoint.past_schedule.waypoints + ) + if public_failed_wp is None: + public_failed_wp = "\b/Port of Arrival" + + problem_wp_str = ( + "in-port" + if checkpoint.problem_wp_i == 0 + else f"at waypoint {public_problem_wp}" + ) + time_elapsed = sail_time + delay_duration + stationkeeping_time + + raise CheckpointError( + f"The problem encountered in previous simulation has not been resolved in the schedule! " + f"Please adjust the schedule to account for delays caused by the problem...\n\n" + f"The problem was associated with a delay duration of {active_problem.delay_duration_hours} hours {problem_wp_str} " + f"(meaning waypoint {public_failed_wp} could not be reached in time). " + f"Currently, the ship would reach waypoint {public_failed_wp} at {problem_waypoint.time + time_elapsed}, but the scheduled time is {failed_waypoint.time}." + ) + + def create_post_expedition_report(self) -> None: + """Generate post-expedition report if any problems were selected.""" + if not self.problems: + return + + report_path = self.expedition_dir / RESULTS / REPORT + self.post_expedition_report(self.problems, report_path) + + print("\n----- RECORD OF PROBLEMS ENCOUNTERED ------") + print( + f"\nA post-expedition report of problems encountered is saved in: {report_path}" + ) + + def _load_or_select_problems( + self, difficulty_level: str + ) -> SelectedProblems | None: + """Load problems from JSON cache if available, otherwise select and cache new ones.""" + selected_problems_path = self.problems_dir / SELECTED_PROBLEMS + if selected_problems_path.exists(): + return self.load_selected_problems(selected_problems_path) + + instruments = self.expedition.get_instruments() + problems = self.select_problems(instruments, difficulty_level) + if problems: + self.cache_selected_problems(problems, selected_problems_path) + return problems + def _calculate_problem_count( self, difficulty_level: str, @@ -230,166 +384,73 @@ def _limit_pre_departure( def _assign_problems_to_waypoints( self, selected: list[ProblemType] - ) -> SelectedProblemsDict | None: + ) -> SelectedProblems | None: """Assign sampled problems to valid, non-port waypoint indices.""" - waypoints = self.waypoints avail_indices = [ - i for i, wp in enumerate(waypoints) if not isinstance(wp, Port) + i for i, wp in enumerate(self.waypoints) if not isinstance(wp, Port) ] random.shuffle(avail_indices) - assigned_problems: list[ProblemType] = [] - assigned_indices: list[int | None] = [] + assigned: list[ScheduledProblem] = [] for problem in selected: if getattr(problem, "pre_departure", False): - assigned_problems.append(problem) - assigned_indices.append(0) # noqa; pre-departure problem is always associated with the departure port (index 0) + assigned.append( + ScheduledProblem( + problem=problem, + waypoint_index=0, # pre-departure problem is always associated with the departure port (index 0) + ) + ) continue - if not avail_indices: - break - - # find matching waypoint or substitute with general problem - target_idx = None - for idx in avail_indices: - wp_instruments = waypoints[idx].instrument or [] - if ( - isinstance(problem, InstrumentProblem) - and problem.instrument_type not in wp_instruments - ): - continue - target_idx = idx - break - - if target_idx is not None: - avail_indices.remove(target_idx) - assigned_problems.append(problem) - assigned_indices.append(target_idx) - else: - # fall back to a general problem if instrument match fails - avail_general = [ - p - for p in GENERAL_PROBLEMS - if not p.pre_departure and p not in assigned_problems - ] - if avail_general and avail_indices: - substitute = random.choice(avail_general) - assigned_problems.append(substitute) - assigned_indices.append(avail_indices.pop()) + scheduled_item = self._match_problem_to_waypoint( + problem, avail_indices, assigned + ) + if scheduled_item: + assigned.append(scheduled_item) - if not assigned_problems: + if not assigned: return None - # sort chronologically (waypoint 0 first, then remaining waypoint index order) - paired = sorted( - zip(assigned_problems, assigned_indices, strict=True), - key=lambda x: 0 if x[1] == 0 else x[1], + assigned.sort( + key=lambda x: 0 if x.waypoint_index == 0 else (x.waypoint_index or 0) ) - return { - "problem_class": [p for p, _ in paired], - "waypoint_i": [w for _, w in paired], - "resolved": False, - } + return SelectedProblems(items=assigned) - def execute( + def _match_problem_to_waypoint( self, - problems: SelectedProblemsDict, - instrument_type_validation: InstrumentType | None, - log_dir: Path, - log_delay: float = 4.0, - ) -> None: - """Execute simulation problems and apply delay/schedule impacts.""" - if not problems or problems.get("resolved", False): - return - - for problem, wp_i in zip( - problems["problem_class"], problems["waypoint_i"], strict=True - ): - if getattr(problem, "resolved", False): - continue + problem: ProblemType, + avail_indices: list[int], + already_assigned: list[ScheduledProblem], + ) -> ScheduledProblem | None: + """Match a problem with an available waypoint or substitute with a general problem.""" + if not avail_indices: + return None + for idx in avail_indices: + wp_instruments = self.waypoints[idx].instrument or [] if ( isinstance(problem, InstrumentProblem) - and problem.instrument_type is not instrument_type_validation + and problem.instrument_type not in wp_instruments ): continue - problem_hash = _make_hash(problem.message + str(wp_i), 8) - hash_fpath = log_dir / f"problem_{problem_hash}.json" - if hash_fpath.exists(): - continue - - self._log_problem(problem, wp_i, problem_hash, hash_fpath, log_delay) - self._cache_original_expedition(self.expedition) - - def verify_problem_resolution(self, checkpoint: Checkpoint) -> None: - """Verify active problem delay is resolved in new schedule.""" - active_problem = checkpoint.active_problem - if active_problem is None or active_problem.resolved: - return - - failed_wp_i = checkpoint.get_effective_failed_wp_i() - new_schedule = self.expedition.schedule - - delay_duration = datetime.timedelta(hours=active_problem.delay_duration_hours) - problem_waypoint = new_schedule.waypoints[checkpoint.problem_wp_i] - failed_waypoint = new_schedule.waypoints[failed_wp_i] - - scheduled_time_diff = failed_waypoint.time - problem_waypoint.time - stationkeeping_time = ( - _calc_wp_stationkeeping_time(problem_waypoint.instrument, self.expedition) - if not isinstance(problem_waypoint, Port) - else datetime.timedelta(0) - ) - sail_time = _calc_sail_time( - problem_waypoint.location, - failed_waypoint.location, - ship_speed_knots=self.expedition.ship_config.ship_speed_knots, - projection=PROJECTION, - )[0] - - min_time_required = sail_time + delay_duration + stationkeeping_time - - if scheduled_time_diff >= min_time_required: - print("\n\nšŸŽ‰ Previous problem has been resolved in the schedule.\n") - active_problem.resolved = True - _save_checkpoint(checkpoint, self.expedition_dir) + avail_indices.remove(idx) + return ScheduledProblem(problem=problem, waypoint_index=idx) - problems_path = self.problems_dir / SELECTED_PROBLEMS - if problems_path.exists(): - problems = self.load_selected_problems(problems_path) - if isinstance(problems, dict): - problems["resolved"] = True - for p in problems.get("problem_class", []): - if getattr(p, "message", None) == active_problem.message: - p.resolved = True - self.cache_selected_problems(problems, problems_path) - - else: - public_problem_wp = _get_public_wp( - checkpoint.problem_wp_i, checkpoint.past_schedule.waypoints - ) - public_failed_wp = _get_public_wp( - failed_wp_i, checkpoint.past_schedule.waypoints - ) - if public_failed_wp is None: - public_failed_wp = "\b/Port of Arrival" - - problem_wp_str = ( - "in-port" - if checkpoint.problem_wp_i == 0 - else f"at waypoint {public_problem_wp}" + used_problems = {item.problem for item in already_assigned} + avail_general = [ + p + for p in GENERAL_PROBLEMS + if not p.pre_departure and p not in used_problems + ] + if avail_general and avail_indices: + substitute = random.choice(avail_general) + return ScheduledProblem( + problem=substitute, waypoint_index=avail_indices.pop() ) - time_elapsed = sail_time + delay_duration + stationkeeping_time - raise CheckpointError( - f"The problem encountered in previous simulation has not been resolved in the schedule! " - f"Please adjust the schedule to account for delays caused by the problem...\n\n" - f"The problem was associated with a delay duration of {active_problem.delay_duration_hours} hours {problem_wp_str} " - f"(meaning waypoint {public_failed_wp} could not be reached in time). " - f"Currently, the ship would reach waypoint {public_failed_wp} at {problem_waypoint.time + time_elapsed}, but the scheduled time is {failed_waypoint.time}." - ) + return None def _log_problem( self, @@ -407,8 +468,7 @@ def _log_problem( Use problem_wp_i for internal logic, but user-facing messages should use public_wp. Incidentally, problem_wp_i will often == public_wp (given 0-indexing), but this makes the logic explicit and clear. """ - waypoints = self.waypoints - public_wp = _get_public_wp(problem_wp_i, waypoints) + public_wp = _get_public_wp(problem_wp_i, self.waypoints) alert_msg = ( LOG_MESSAGING["pre_departure"] @@ -530,31 +590,18 @@ def _unique_id(self) -> str: return previous_id - def create_post_expedition_report(self) -> None: - """Generate post-expedition report if any problems were selected.""" - if not self.problems: - return - - report_path = self.expedition_dir / RESULTS / REPORT - self.post_expedition_report(self.problems, report_path) - - print("\n----- RECORD OF PROBLEMS ENCOUNTERED ------") - print( - f"\nA post-expedition report of problems encountered is saved in: {report_path}" - ) - @staticmethod def cache_selected_problems( - problems: SelectedProblemsDict, selected_problems_fpath: str | Path + problems: SelectedProblems, selected_problems_fpath: str | Path ) -> None: """Cache suite of selected problems to JSON.""" fpath = Path(selected_problems_fpath) fpath.parent.mkdir(parents=True, exist_ok=True) payload = { - "problem_class": [p.short_name for p in problems["problem_class"]], - "waypoint_i": problems["waypoint_i"], - "resolved": problems.get("resolved", False), + "problem_class": [item.problem.short_name for item in problems], + "waypoint_i": [item.waypoint_index for item in problems], + "resolved": [item.resolved for item in problems], "timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), } ProblemSimulator._write_json(fpath, payload) @@ -562,47 +609,51 @@ def cache_selected_problems( @staticmethod def load_selected_problems( selected_problems_fpath: str | Path, - ) -> SelectedProblemsDict: + ) -> SelectedProblems: """Load selected problems suite from a cached JSON file.""" data = ProblemSimulator._read_json(Path(selected_problems_fpath)) general_lookup = {cls.short_name: cls for cls in GENERAL_PROBLEMS} instrument_lookup = {cls.short_name: cls for cls in INSTRUMENT_PROBLEMS} - selected_classes, waypoint_indices = [], [] - for cls_name, wp_idx in zip( - data["problem_class"], data["waypoint_i"], strict=True + resolved_list = data.get("resolved", [False] * len(data["problem_class"])) + + items = [] + for cls_name, wp_idx, is_res in zip( + data["problem_class"], data["waypoint_i"], resolved_list, strict=True ): if cls_name in general_lookup: - selected_classes.append(general_lookup[cls_name]) + prob_cls = general_lookup[cls_name] elif cls_name in instrument_lookup: - selected_classes.append(instrument_lookup[cls_name]) + prob_cls = instrument_lookup[cls_name] else: raise ValueError( f"Problem class '{cls_name}' not found in known registries." ) - waypoint_indices.append(wp_idx) + items.append( + ScheduledProblem( + problem=prob_cls, waypoint_index=wp_idx, resolved=is_res + ) + ) - return { - "problem_class": selected_classes, - "waypoint_i": waypoint_indices, - "resolved": data.get("resolved", False), - } + return SelectedProblems(items=items) @staticmethod def post_expedition_report( - problems: SelectedProblemsDict, report_fpath: str | Path + problems: SelectedProblems, report_fpath: str | Path ) -> None: """Append human-readable report summary of all occurring problems.""" with open(report_fpath, "a", encoding="utf-8") as f: - for problem, wp_i in zip( - problems["problem_class"], problems["waypoint_i"], strict=True - ): - affected = "in-port" if wp_i is None else f"{wp_i + 1}" - delay_hrs = problem.delay_duration.total_seconds() / 3600.0 + for item in problems: + affected = ( + "in-port" + if item.waypoint_index in (0, None) + else f"{item.waypoint_index + 1}" + ) + delay_hrs = item.problem.delay_duration.total_seconds() / 3600.0 f.write( f"---\nWaypoint: {affected}\n" - f"Problem: {problem.message}\n" + f"Problem: {item.problem.message}\n" f"Delay caused: {delay_hrs} hours\n\n" ) From 8aa5bc220d53dd50c041afdf762e4c5db896334d Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:02:15 +0200 Subject: [PATCH 21/28] small fixes --- src/virtualship/cli/_run.py | 52 ++++--------------- .../make_realistic/problems/simulator.py | 17 ++---- src/virtualship/utils.py | 8 --- 3 files changed, 13 insertions(+), 64 deletions(-) diff --git a/src/virtualship/cli/_run.py b/src/virtualship/cli/_run.py index 3d1b489e..5913883e 100644 --- a/src/virtualship/cli/_run.py +++ b/src/virtualship/cli/_run.py @@ -3,7 +3,6 @@ import shutil import sys import time -from datetime import datetime from pathlib import Path import copernicusmarine @@ -22,8 +21,6 @@ CACHE, CHECKPOINT, EXPEDITION, - EXPEDITION_IDENTIFIER, - EXPEDITION_LATEST, PROBLEMS_ENCOUNTERED, PROJECTION, RESULTS, @@ -58,11 +55,10 @@ def _run( cache_dir = expedition_dir / CACHE results_dir = expedition_dir / RESULTS - expedition_id = _unique_id(expedition, cache_dir, expedition_dir) - problems_dir = cache_dir / PROBLEMS_ENCOUNTERED.format(expedition_id=expedition_id) - - expedition.instruments_config.verify(expedition) problem_simulator = ProblemSimulator(expedition, expedition_dir, difficulty_level) + problems_dir = cache_dir / PROBLEMS_ENCOUNTERED.format( + expedition_id=problem_simulator.expedition_id + ) checkpoint = _load_checkpoint(expedition_dir) if checkpoint is not None: @@ -76,6 +72,8 @@ def _run( from_data=data_path, ) + expedition.instruments_config.verify(expedition) + schedule_results = simulate_schedule( projection=PROJECTION, expedition=expedition, @@ -85,7 +83,7 @@ def _run( _handle_schedule_failure(schedule_results, expedition, expedition_dir) return - _prepare_results_directory(results_dir, is_new_run=(checkpoint is None)) + _prepare_results_directory(results_dir, is_new_run=checkpoint is None) print("\n----- EXPEDITION SUMMARY ------") _write_expedition_cost(expedition, schedule_results, expedition_dir) @@ -97,6 +95,7 @@ def _run( try: _simulate_measurements( expedition=expedition, + expedition_dir=expedition_dir, schedule_results=schedule_results, instruments=instruments_in_expedition, problem_simulator=problem_simulator, @@ -145,38 +144,6 @@ def _ensure_copernicus_auth() -> None: copernicusmarine.login() -def _unique_id(expedition: Expedition, cache_dir: Path, expedition_dir: Path) -> str: - cache_dir.mkdir(exist_ok=True) - id_path = cache_dir / EXPEDITION_IDENTIFIER - last_expedition_path = cache_dir / EXPEDITION_LATEST - checkpoint_path = expedition_dir / CHECKPOINT - new_id = datetime.now().strftime("%Y%m%d%H%M%S") - - if not id_path.exists(): - id_path.write_text(new_id) - return new_id - - previous_id = id_path.read_text().strip() - - if checkpoint_path.exists(): - return previous_id - - if not last_expedition_path.exists(): - id_path.write_text(new_id) - return new_id - - last_expedition = Expedition.from_yaml(last_expedition_path) - added_instruments = set(expedition.get_instruments()) - set( - last_expedition.get_instruments() - ) - - if added_instruments: - id_path.write_text(new_id) - return new_id - - return previous_id - - def _handle_schedule_failure( schedule_results: ScheduleProblem, expedition: Expedition, expedition_dir: Path ) -> None: @@ -203,6 +170,7 @@ def _prepare_results_directory(results_dir: Path, is_new_run: bool) -> None: def _simulate_measurements( expedition: Expedition, + expedition_dir: Path, schedule_results: ScheduleOk | ScheduleProblem, instruments: set[InstrumentType], problem_simulator: ProblemSimulator, @@ -220,9 +188,7 @@ def _simulate_measurements( instrument = instrument_class(expedition=expedition, from_data=data_path) instrument.execute( measurements=measurements, - out_path=expedition.expedition_dir - / RESULTS - / f"{itype.name.lower()}.parquet", + out_path=expedition_dir / RESULTS / f"{itype.name.lower()}.parquet", ) diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index abe626cc..b7b94fd0 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -43,7 +43,6 @@ _calc_sail_time, _calc_wp_stationkeeping_time, _get_public_wp, - _make_hash, _save_checkpoint, ) @@ -168,12 +167,7 @@ def execute( ): continue - problem_hash = _make_hash(problem.message + str(wp_i), 8) - hash_fpath = log_dir / f"problem_{problem_hash}.json" - if hash_fpath.exists(): - continue - - self._log_problem(problem, wp_i, problem_hash, hash_fpath, log_delay) + self._log_problem(problem, wp_i, log_delay) self._cache_original_expedition(self.expedition) def select_problems( @@ -429,6 +423,7 @@ def _match_problem_to_waypoint( for idx in avail_indices: wp_instruments = self.waypoints[idx].instrument or [] + # discount problem if it's an instrument problem and the instrument isn't present at this waypoint if ( isinstance(problem, InstrumentProblem) and problem.instrument_type not in wp_instruments @@ -438,7 +433,7 @@ def _match_problem_to_waypoint( avail_indices.remove(idx) return ScheduledProblem(problem=problem, waypoint_index=idx) - used_problems = {item.problem for item in already_assigned} + used_problems = [item.problem for item in already_assigned] avail_general = [ p for p in GENERAL_PROBLEMS @@ -456,8 +451,6 @@ def _log_problem( self, problem: ProblemType, problem_wp_i: int | None, - problem_hash: str, - hash_fpath: Path, log_delay: float, ) -> None: """ @@ -577,9 +570,7 @@ def _unique_id(self) -> str: id_path.write_text(new_id) return new_id - from virtualship.models.expedition import Expedition as ExpeditionModel - - last_expedition = ExpeditionModel.from_yaml(last_expedition_path) + last_expedition = Expedition.from_yaml(last_expedition_path) added_instruments = set(self.expedition.get_instruments()) - set( last_expedition.get_instruments() ) diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 58431b3e..cad50e7b 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -1,7 +1,6 @@ from __future__ import annotations import glob -import hashlib import re from datetime import datetime, timedelta from pathlib import Path @@ -506,13 +505,6 @@ def _calc_wp_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) - - def build_particle_class_from_sensors( sensors: list[SensorConfig], nonsensor_variables: list[Variable], From abb9750de54cc790b460610daa2a9466b502296c Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:11:19 +0200 Subject: [PATCH 22/28] further fixes: mark has_contingency problems with resolved and do not allow None type wp_i --- .../make_realistic/problems/simulator.py | 46 +++++++++++-------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index b7b94fd0..52454570 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -75,7 +75,7 @@ class ScheduledProblem: """Represents a single problem paired with its assigned waypoint index.""" problem: ProblemType - waypoint_index: int | None + waypoint_index: int resolved: bool = False @@ -159,7 +159,6 @@ def execute( continue problem = item.problem - wp_i = item.waypoint_index if ( isinstance(problem, InstrumentProblem) @@ -167,7 +166,7 @@ def execute( ): continue - self._log_problem(problem, wp_i, log_delay) + self._log_problem(item, log_delay) self._cache_original_expedition(self.expedition) def select_problems( @@ -189,7 +188,7 @@ def select_problems( items=[ ScheduledProblem( problem=random.choice(pre_departure), - waypoint_index=0, # noqa; pre-departure problem is always associated with the departure port (index 0) + waypoint_index=0, # pre-departure problem is always associated with the departure port (index 0) ) ] ) @@ -287,7 +286,7 @@ def create_post_expedition_report(self) -> None: return report_path = self.expedition_dir / RESULTS / REPORT - self.post_expedition_report(self.problems, report_path) + self.post_expedition_report(self.problems, report_path, self.waypoints) print("\n----- RECORD OF PROBLEMS ENCOUNTERED ------") print( @@ -449,8 +448,7 @@ def _match_problem_to_waypoint( def _log_problem( self, - problem: ProblemType, - problem_wp_i: int | None, + item: ScheduledProblem, log_delay: float, ) -> None: """ @@ -459,8 +457,9 @@ def _log_problem( Note, problem_wp_i is the index of the waypoint in the expedition schedule, but the user-facing message should be based on the index of the waypoint in the list of non-port waypoints. Use problem_wp_i for internal logic, but user-facing messages should use public_wp. - Incidentally, problem_wp_i will often == public_wp (given 0-indexing), but this makes the logic explicit and clear. """ + problem = item.problem + problem_wp_i = item.waypoint_index public_wp = _get_public_wp(problem_wp_i, self.waypoints) alert_msg = ( @@ -481,6 +480,13 @@ def _log_problem( impact_str = LOG_MESSAGING["problem_avoided"] result_str = "The expedition will carry on shortly as planned." active_problem = None + + # mark problem as resolved + item.resolved = True + if self.problems: + self.cache_selected_problems( + self.problems, self.problems_dir / SELECTED_PROBLEMS + ) else: affected = "in-port" if public_wp is None else f"at waypoint {public_wp}" impact_str = ( @@ -518,10 +524,8 @@ def _log_problem( def _has_contingency(self, problem: ProblemType, problem_wp_i: int | None) -> bool: """Check whether scheduled contingency covers expected delay duration.""" - curr_wp, next_wp = ( - self.waypoints[problem_wp_i], - self.waypoints[problem_wp_i + 1], - ) + curr_wp = self.waypoints[problem_wp_i] + next_wp = self.waypoints[problem_wp_i + 1] stationkeeping = ( _calc_wp_stationkeeping_time(curr_wp.instrument, self.expedition) @@ -631,16 +635,22 @@ def load_selected_problems( @staticmethod def post_expedition_report( - problems: SelectedProblems, report_fpath: str | Path + problems: SelectedProblems, + report_fpath: str | Path, + waypoints: list | None = None, ) -> None: """Append human-readable report summary of all occurring problems.""" with open(report_fpath, "a", encoding="utf-8") as f: for item in problems: - affected = ( - "in-port" - if item.waypoint_index in (0, None) - else f"{item.waypoint_index + 1}" - ) + if waypoints is not None: + public_wp = _get_public_wp(item.waypoint_index, waypoints) + affected = "in-port" if public_wp is None else f"{public_wp}" + else: + affected = ( + "in-port" + if item.waypoint_index == 0 + else f"{item.waypoint_index + 1}" + ) delay_hrs = item.problem.delay_duration.total_seconds() / 3600.0 f.write( f"---\nWaypoint: {affected}\n" From 6cd0b5fd958982059a60bea1e476599280d5ca1f Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:40:48 +0200 Subject: [PATCH 23/28] remove redundant parameter --- src/virtualship/make_realistic/problems/simulator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index 52454570..ea823291 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -147,7 +147,6 @@ def execute( self, problems: SelectedProblems, instrument_type_validation: InstrumentType | None, - log_dir: Path, log_delay: float = 4.0, ) -> None: """Execute simulation problems and apply delay/schedule impacts.""" From 8da772052496f8535e1d8a757afed0e0f3cc1ffd Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:13:12 +0100 Subject: [PATCH 24/28] fix imports --- src/virtualship/make_realistic/problems/simulator.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index 52454570..91a75d75 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -9,7 +9,7 @@ from dataclasses import dataclass, field from datetime import datetime as dt from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import Any from rich import box from rich.console import Console @@ -27,7 +27,7 @@ InstrumentProblem, ) from virtualship.models.checkpoint import ActiveProblem, Checkpoint -from virtualship.models.expedition import Port +from virtualship.models.expedition import Expedition, Port from virtualship.utils import ( CACHE, CHECKPOINT, @@ -46,9 +46,6 @@ _save_checkpoint, ) -if TYPE_CHECKING: - from virtualship.models.expedition import Expedition - 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}...!", From 95500d64f88516c3b98b5ae3564ba240e79fdac0 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:48:48 +0100 Subject: [PATCH 25/28] update public wp numbering now there are port waypoints --- .../expedition/simulate_schedule.py | 7 ++-- src/virtualship/models/expedition.py | 40 +++++++++---------- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index 516c1491..75ab9a10 100644 --- a/src/virtualship/expedition/simulate_schedule.py +++ b/src/virtualship/expedition/simulate_schedule.py @@ -20,7 +20,7 @@ Spacetime, Waypoint, ) -from virtualship.utils import _calc_sail_time +from virtualship.utils import _calc_sail_time, _get_public_wp @dataclass @@ -103,7 +103,7 @@ def __init__(self, projection: pyproj.Geod, expedition: Expedition) -> None: self._expedition = expedition assert self._expedition.schedule.waypoints[0].time is not None, ( - "First waypoint must have a time. This should have been verified before calling this function." + "Departure port must have a time." ) self._time = expedition.schedule.waypoints[0].time self._location = expedition.schedule.waypoints[0].location @@ -123,8 +123,9 @@ def simulate(self) -> ScheduleOk | ScheduleProblem: # check if waypoint was reached in time # TODO: already tested in schedule.verify(), re-check here for robustness but could be removed if deemed redundant if waypoint.time is not None and self._time > waypoint.time: + public_wp = _get_public_wp(wp_i, self._expedition.schedule.waypoints) print( - f"\nWaypoint {wp_i + 1} could not be reached in time. Current time: {self._time}. Waypoint time: {waypoint.time}." + f"\nWaypoint {public_wp} 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" ) return ScheduleProblem(self._time, wp_i) diff --git a/src/virtualship/models/expedition.py b/src/virtualship/models/expedition.py index 5b519316..13c24fd0 100644 --- a/src/virtualship/models/expedition.py +++ b/src/virtualship/models/expedition.py @@ -17,6 +17,7 @@ _calc_sail_time, _calc_wp_stationkeeping_time, _get_bathy_data, + _get_public_wp, _validate_numeric_to_timedelta, get_supported_sensors, register_instrument_config, @@ -130,24 +131,16 @@ def verify( *, from_data: Path | None = None, ) -> None: - """ - Verify the feasibility and correctness of the schedule's waypoints. - - This method checks various conditions to ensure the schedule is valid: - 1. At least one waypoint is provided. - 2. The first waypoint has a specified time. - 3. Waypoint times are in ascending order. - 4. All waypoints are in water (not on land). - 5. The ship can arrive on time at each waypoint given its speed. - """ + """Verify the feasibility and correctness of the schedule's waypoints.""" print("\nVerifying route... ") - if len(self.waypoints) == 0: - raise ScheduleError("At least one waypoint must be provided.") + # has at least one non-port waypoint + if not any(isinstance(wp, Waypoint) for wp in self.waypoints): + raise ScheduleError("At least one non-port waypoint must be provided.") - # check first waypoint has a time + # check departure port has a time if self.waypoints[0].time is None: - raise ScheduleError("First waypoint must have a specified time.") + raise ScheduleError("Departure port must have a specified time.") # check waypoint times are in ascending order timed_waypoints = [wp for wp in self.waypoints if wp.time is not None] @@ -156,11 +149,12 @@ def verify( ] if not all(checks): invalid_i = [i for i, c in enumerate(checks) if c] + public_wps = [_get_public_wp(i, self.waypoints) for i in invalid_i] raise ScheduleError( - f"Waypoint(s) {', '.join(f'#{i + 1}' for i in invalid_i)}: each waypoint should be timed after all previous waypoints", + f"Waypoint(s) {', '.join(f'#{i}' for i in public_wps)}: each waypoint should be timed after all previous waypoints", ) - # check if all waypoints are in water using bathymetry data + # check if all non-port waypoints are in water using bathymetry data land_waypoints = [] if not ignore_land_test: try: @@ -173,6 +167,7 @@ def verify( for wp_i, wp in enumerate(self.waypoints): if isinstance(wp, Port): continue # ports are in harbour; skip bathymetry land check + public_wp = _get_public_wp(wp_i, self.waypoints) try: value = bathymetry_field.eval( np.float64(0.0), # time @@ -181,15 +176,15 @@ def verify( wp.location.lon, ) if value == 0.0 or (isinstance(value, float) and np.isnan(value)): - land_waypoints.append((wp_i, wp)) + land_waypoints.append((public_wp, wp)) except Exception as e: raise ScheduleError( - f"Waypoint #{wp_i + 1} at location {wp.location} could not be evaluated against bathymetry data. \n\n Original error: {e}" + f"Waypoint #{public_wp} at location {wp.location} could not be evaluated against bathymetry data. \n\n Original error: {e}" ) from e if len(land_waypoints) > 0: raise ScheduleError( - f"The following waypoint(s) throw(s) error(s): {['#' + str(wp_i + 1) + ' ' + str(wp) for (wp_i, wp) in land_waypoints]}\n\nINFO: They are likely on land (bathymetry data cannot be interpolated to their location(s)).\n" + f"The following waypoint(s) throw(s) error(s): {['#' + str(public_wp) + ' ' + str(wp) for (public_wp, wp) in land_waypoints]}\n\nINFO: They are likely on land (bathymetry data cannot be interpolated to their location(s)).\n" ) # check that ship will arrive on time at each waypoint (in case no unexpected event happen) @@ -214,8 +209,13 @@ def verify( if wp_next.time is None: time = arrival_time elif arrival_time > wp_next.time: + affected = ( + f"waypoint {_get_public_wp(wp_i + 1, self.waypoints)}" # +1 to get next + if not isinstance(wp_next, Port) + else "the final port of arrival" + ) raise ScheduleError( - f"Waypoint planning is not valid: would arrive too late at waypoint {wp_i + 2}. " + f"Waypoint planning is not valid: would arrive too late at {affected}. " f"Location: {wp_next.location} Time: {wp_next.time}. " f"Currently projected to arrive at: {arrival_time}." ) From 4f682bbda7ead3ff666bbc69e5547babc087ba2b Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:54:29 +0100 Subject: [PATCH 26/28] add note for separate PR work --- src/virtualship/expedition/simulate_schedule.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index 75ab9a10..eaca26fa 100644 --- a/src/virtualship/expedition/simulate_schedule.py +++ b/src/virtualship/expedition/simulate_schedule.py @@ -122,6 +122,8 @@ def simulate(self) -> ScheduleOk | ScheduleProblem: # check if waypoint was reached in time # TODO: already tested in schedule.verify(), re-check here for robustness but could be removed if deemed redundant + #! TODO: however, schedule.verify() does not account for stationkeeping time, so move that to schedule.verify() as well + #! TODO: then can also address #249 properly with a depth/bathymetry lookup and enhanced messaging explaining that the schedule is infeasible due to stationkeeping time, not sailing time if waypoint.time is not None and self._time > waypoint.time: public_wp = _get_public_wp(wp_i, self._expedition.schedule.waypoints) print( From 8d81135a9c1921aed060c385dc4bbc2877f7b039 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:11:34 +0100 Subject: [PATCH 27/28] move original expedition caching so that comes before --- src/virtualship/make_realistic/problems/simulator.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index 91a75d75..e952f05d 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -137,14 +137,12 @@ def execute_for_instrument(self, instrument_type: InstrumentType) -> None: self.execute( self.problems, instrument_type_validation=instrument_type, - log_dir=self.problems_dir, ) def execute( self, problems: SelectedProblems, instrument_type_validation: InstrumentType | None, - log_dir: Path, log_delay: float = 4.0, ) -> None: """Execute simulation problems and apply delay/schedule impacts.""" @@ -164,7 +162,6 @@ def execute( continue self._log_problem(item, log_delay) - self._cache_original_expedition(self.expedition) def select_problems( self, @@ -517,6 +514,7 @@ def _log_problem( ) if not has_contingency: + self._cache_original_expedition(self.expedition) sys.exit(0) def _has_contingency(self, problem: ProblemType, problem_wp_i: int | None) -> bool: From 0525896f3bad2bd41ac1de4eb90d05bf8fbcd185 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:15:52 +0100 Subject: [PATCH 28/28] fix: in memory self.problems --- src/virtualship/make_realistic/problems/simulator.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index e952f05d..71978b4c 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -245,9 +245,8 @@ def verify_problem_resolution(self, checkpoint: Checkpoint) -> None: problems_path = self.problems_dir / SELECTED_PROBLEMS if problems_path.exists(): - problems = self.load_selected_problems(problems_path) - problems.mark_resolved(active_problem.message) - self.cache_selected_problems(problems, problems_path) + self.problems.mark_resolved(active_problem.message) + self.cache_selected_problems(self.problems, problems_path) else: public_problem_wp = _get_public_wp(