diff --git a/environment.yml b/environment.yml index 0146e277..c5317c60 100644 --- a/environment.yml +++ b/environment.yml @@ -13,6 +13,7 @@ dependencies: - pyyaml - copernicusmarine >= 2 - openpyxl + - yaspin # linting - pre-commit diff --git a/pyproject.toml b/pyproject.toml index 34431572..1932d563 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "pydantic >=2, <3", "PyYAML", "copernicusmarine >= 2", + "yaspin", ] [project.urls] diff --git a/src/virtualship/expedition/do_expedition.py b/src/virtualship/expedition/do_expedition.py index 84150de8..56ee79fa 100644 --- a/src/virtualship/expedition/do_expedition.py +++ b/src/virtualship/expedition/do_expedition.py @@ -31,6 +31,10 @@ def do_expedition(expedition_dir: str | Path, input_data: Path | None = None) -> :param expedition_dir: The base directory for the expedition. :param input_data: Input data folder (override used for testing). """ + print("\n╔═════════════════════════════════════════════════╗") + print("║ VIRTUALSHIP EXPEDITION STATUS ║") + print("╚═════════════════════════════════════════════════╝") + if isinstance(expedition_dir, str): expedition_dir = Path(expedition_dir) @@ -56,6 +60,8 @@ def do_expedition(expedition_dir: str | Path, input_data: Path | None = None) -> input_data=input_data, ) + print("\n---- WAYPOINT VERIFICATION ----") + # verify schedule is valid schedule.verify(ship_config.ship_speed_knots, loaded_input_data) @@ -82,6 +88,8 @@ def do_expedition(expedition_dir: str | Path, input_data: Path | None = None) -> shutil.rmtree(expedition_dir.joinpath("results")) os.makedirs(expedition_dir.joinpath("results")) + print("\n----- EXPEDITION SUMMARY ------") + # calculate expedition cost in US$ assert schedule.waypoints[0].time is not None, ( "First waypoint has no time. This should not be possible as it should have been verified before." @@ -90,20 +98,26 @@ def do_expedition(expedition_dir: str | Path, input_data: Path | None = None) -> cost = expedition_cost(schedule_results, time_past) with open(expedition_dir.joinpath("results", "cost.txt"), "w") as file: file.writelines(f"cost: {cost} US$") - print(f"This expedition took {time_past} and would have cost {cost:,.0f} US$.") + print(f"\nExpedition duration: {time_past}\nExpedition cost: US$ {cost:,.0f}.") + + print("\n--- MEASUREMENT SIMULATIONS ---") # simulate measurements - print("Simulating measurements. This may take a while..") + print("\nSimulating measurements. This may take a while...\n") simulate_measurements( expedition_dir, ship_config, loaded_input_data, schedule_results.measurements_to_simulate, ) - print("Done simulating measurements.") + print("\nAll measurement simulations are complete.") - print("Your expedition has concluded successfully!") - print("Your measurements can be found in the results directory.") + print("\n----- EXPEDITION RESULTS ------") + print("\nYour expedition has concluded successfully!") + print( + f"Your measurements can be found in the '{expedition_dir}/results' directory." + ) + print("\n------------- END -------------\n") def _load_input_data( diff --git a/src/virtualship/expedition/simulate_measurements.py b/src/virtualship/expedition/simulate_measurements.py index 31610332..20ba2cdb 100644 --- a/src/virtualship/expedition/simulate_measurements.py +++ b/src/virtualship/expedition/simulate_measurements.py @@ -2,10 +2,13 @@ from __future__ import annotations +import logging from datetime import timedelta from pathlib import Path from typing import TYPE_CHECKING +from yaspin import yaspin + from virtualship.instruments.adcp import simulate_adcp from virtualship.instruments.argo_float import simulate_argo_floats from virtualship.instruments.ctd import simulate_ctd @@ -14,12 +17,17 @@ from virtualship.instruments.ship_underwater_st import simulate_ship_underwater_st from virtualship.instruments.xbt import simulate_xbt from virtualship.models import ShipConfig +from virtualship.utils import ship_spinner from .simulate_schedule import MeasurementsToSimulate if TYPE_CHECKING: from .input_data import InputData +# parcels logger (suppress INFO messages to prevent log being flooded) +external_logger = logging.getLogger("parcels.tools.loggers") +external_logger.setLevel(logging.WARNING) + def simulate_measurements( expedition_dir: str | Path, @@ -42,61 +50,91 @@ def simulate_measurements( expedition_dir = Path(expedition_dir) if len(measurements.ship_underwater_sts) > 0: - print("Simulating onboard salinity and temperature measurements.") if ship_config.ship_underwater_st_config is None: raise RuntimeError("No configuration for ship underwater ST provided.") if input_data.ship_underwater_st_fieldset is None: raise RuntimeError("No fieldset for ship underwater ST provided.") - simulate_ship_underwater_st( - fieldset=input_data.ship_underwater_st_fieldset, - out_path=expedition_dir.joinpath("results", "ship_underwater_st.zarr"), - depth=-2, - sample_points=measurements.ship_underwater_sts, - ) + with yaspin( + text="Simulating onboard temperature and salinity measurements... ", + side="right", + spinner=ship_spinner, + ) as spinner: + simulate_ship_underwater_st( + fieldset=input_data.ship_underwater_st_fieldset, + out_path=expedition_dir.joinpath("results", "ship_underwater_st.zarr"), + depth=-2, + sample_points=measurements.ship_underwater_sts, + ) + spinner.ok("✅") if len(measurements.adcps) > 0: - print("Simulating onboard ADCP.") if ship_config.adcp_config is None: raise RuntimeError("No configuration for ADCP provided.") if input_data.adcp_fieldset is None: raise RuntimeError("No fieldset for ADCP provided.") - simulate_adcp( - fieldset=input_data.adcp_fieldset, - out_path=expedition_dir.joinpath("results", "adcp.zarr"), - max_depth=ship_config.adcp_config.max_depth_meter, - min_depth=-5, - num_bins=ship_config.adcp_config.num_bins, - sample_points=measurements.adcps, - ) + with yaspin( + text="Simulating onboard ADCP... ", side="right", spinner=ship_spinner + ) as spinner: + simulate_adcp( + fieldset=input_data.adcp_fieldset, + out_path=expedition_dir.joinpath("results", "adcp.zarr"), + max_depth=ship_config.adcp_config.max_depth_meter, + min_depth=-5, + num_bins=ship_config.adcp_config.num_bins, + sample_points=measurements.adcps, + ) + spinner.ok("✅") if len(measurements.ctds) > 0: - print("Simulating CTD casts.") if ship_config.ctd_config is None: raise RuntimeError("No configuration for CTD provided.") if input_data.ctd_fieldset is None: raise RuntimeError("No fieldset for CTD provided.") - simulate_ctd( - out_path=expedition_dir.joinpath("results", "ctd.zarr"), - fieldset=input_data.ctd_fieldset, - ctds=measurements.ctds, - outputdt=timedelta(seconds=10), - ) + with yaspin( + text="Simulating CTD casts... ", side="right", spinner=ship_spinner + ) as spinner: + simulate_ctd( + out_path=expedition_dir.joinpath("results", "ctd.zarr"), + fieldset=input_data.ctd_fieldset, + ctds=measurements.ctds, + outputdt=timedelta(seconds=10), + ) + spinner.ok("✅") if len(measurements.ctd_bgcs) > 0: - print("Simulating BGC CTD casts.") if ship_config.ctd_bgc_config is None: raise RuntimeError("No configuration for CTD_BGC provided.") if input_data.ctd_bgc_fieldset is None: raise RuntimeError("No fieldset for CTD_BGC provided.") - simulate_ctd_bgc( - out_path=expedition_dir.joinpath("results", "ctd_bgc.zarr"), - fieldset=input_data.ctd_bgc_fieldset, - ctd_bgcs=measurements.ctd_bgcs, - outputdt=timedelta(seconds=10), - ) + with yaspin( + text="Simulating BGC CTD casts... ", side="right", spinner=ship_spinner + ) as spinner: + simulate_ctd_bgc( + out_path=expedition_dir.joinpath("results", "ctd_bgc.zarr"), + fieldset=input_data.ctd_bgc_fieldset, + ctd_bgcs=measurements.ctd_bgcs, + outputdt=timedelta(seconds=10), + ) + spinner.ok("✅") + + if len(measurements.xbts) > 0: + if ship_config.xbt_config is None: + raise RuntimeError("No configuration for XBTs provided.") + if input_data.xbt_fieldset is None: + raise RuntimeError("No fieldset for XBTs provided.") + with yaspin( + text="Simulating XBTs... ", side="right", spinner=ship_spinner + ) as spinner: + simulate_xbt( + out_path=expedition_dir.joinpath("results", "xbts.zarr"), + fieldset=input_data.xbt_fieldset, + xbts=measurements.xbts, + outputdt=timedelta(seconds=1), + ) + spinner.ok("✅") if len(measurements.drifters) > 0: - print("Simulating drifters") + print("Simulating drifters... ") if ship_config.drifter_config is None: raise RuntimeError("No configuration for drifters provided.") if input_data.drifter_fieldset is None: @@ -111,7 +149,7 @@ def simulate_measurements( ) if len(measurements.argo_floats) > 0: - print("Simulating argo floats") + print("Simulating argo floats... ") if ship_config.argo_float_config is None: raise RuntimeError("No configuration for argo floats provided.") if input_data.argo_float_fieldset is None: @@ -123,16 +161,3 @@ def simulate_measurements( outputdt=timedelta(minutes=5), endtime=None, ) - - if len(measurements.xbts) > 0: - print("Simulating XBTs") - if ship_config.xbt_config is None: - raise RuntimeError("No configuration for XBTs provided.") - if input_data.xbt_fieldset is None: - raise RuntimeError("No fieldset for XBTs provided.") - simulate_xbt( - out_path=expedition_dir.joinpath("results", "xbts.zarr"), - fieldset=input_data.xbt_fieldset, - xbts=measurements.xbts, - outputdt=timedelta(seconds=1), - ) diff --git a/src/virtualship/instruments/__init__.py b/src/virtualship/instruments/__init__.py index c09be448..6a6ffbca 100644 --- a/src/virtualship/instruments/__init__.py +++ b/src/virtualship/instruments/__init__.py @@ -1,5 +1,13 @@ """Measurement instrument that can be used with Parcels.""" -from . import adcp, argo_float, ctd, drifter, ship_underwater_st, xbt +from . import adcp, argo_float, ctd, ctd_bgc, drifter, ship_underwater_st, xbt -__all__ = ["adcp", "argo_float", "ctd", "drifter", "ship_underwater_st", "xbt"] +__all__ = [ + "adcp", + "argo_float", + "ctd", + "ctd_bgc", + "drifter", + "ship_underwater_st", + "xbt", +] diff --git a/src/virtualship/models/schedule.py b/src/virtualship/models/schedule.py index c29cd4c9..0118bbd0 100644 --- a/src/virtualship/models/schedule.py +++ b/src/virtualship/models/schedule.py @@ -121,6 +121,8 @@ def verify( :raises NotImplementedError: If an instrument in the schedule is not implemented. :return: None. The method doesn't return a value but raises exceptions if verification fails. """ + print("\nVerifying route... ") + if check_space_time_region and self.space_time_region is None: raise ScheduleError( "space_time_region not found in schedule, please define it to fetch the data." @@ -145,8 +147,6 @@ def verify( # check if all waypoints are in water # this is done by picking an arbitrary provided fieldset and checking if UV is not zero - print("Verifying all waypoints are on water..") - # get all available fieldsets available_fieldsets = [] if input_data is not None: @@ -184,7 +184,6 @@ def verify( raise ScheduleError( f"The following waypoints are on land: {['#' + str(wp_i) + ' ' + str(wp) for (wp_i, wp) in land_waypoints]}" ) - print("Good, all waypoints are on water.") # check that ship will arrive on time at each waypoint (in case no unexpected event happen) time = self.waypoints[0].time @@ -215,6 +214,8 @@ def verify( else: time = wp_next.time + print("... All good to go!") + def _is_on_land_zero_uv(fieldset: FieldSet, waypoint: Waypoint) -> bool: """ diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index c494df96..3b53a68d 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -8,6 +8,8 @@ from pathlib import Path from typing import TYPE_CHECKING, TextIO +from yaspin import Spinner + if TYPE_CHECKING: from virtualship.models import Schedule, ShipConfig @@ -247,3 +249,21 @@ def _get_ship_config(expedition_dir: Path) -> ShipConfig: raise FileNotFoundError( f'Ship config not found. Save it to "{file_path}".' ) from e + + +# custom ship spinner +ship_spinner = Spinner( + interval=240, + frames=[ + " 🚢 ", + " 🚢 ", + " 🚢 ", + " 🚢 ", + " 🚢", + " 🚢 ", + " 🚢 ", + " 🚢 ", + " 🚢 ", + "🚢 ", + ], +) diff --git a/tests/expedition/test_do_expedition.py b/tests/expedition/test_do_expedition.py index 143249ca..0dbcd99a 100644 --- a/tests/expedition/test_do_expedition.py +++ b/tests/expedition/test_do_expedition.py @@ -8,4 +8,6 @@ def test_do_expedition(capfd: CaptureFixture) -> None: do_expedition("expedition_dir", input_data=Path("expedition_dir/input_data")) out, _ = capfd.readouterr() - assert "This expedition took" in out, "Expedition did not complete successfully." + assert "Your expedition has concluded successfully!" in out, ( + "Expedition did not complete successfully." + )