From 89ee96d8a2439149111ced5959fec777e9667602 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 15 May 2025 09:15:43 +0200 Subject: [PATCH 1/8] log filter class --- src/virtualship/log_filter.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/virtualship/log_filter.py diff --git a/src/virtualship/log_filter.py b/src/virtualship/log_filter.py new file mode 100644 index 00000000..142073b1 --- /dev/null +++ b/src/virtualship/log_filter.py @@ -0,0 +1,17 @@ +"""Class for suppressing duplicate log messages in Python logging.""" + +import logging + + +class DuplicateFilter(logging.Filter): + """Logging filter for suppressing duplicate log messages.""" + + def __init__(self): + self.last_log = None + + def filter(self, record): + current_log = record.getMessage() + if current_log != self.last_log: + self.last_log = current_log + return True + return False From 083ba240b30225b96d584fff81d0a5ee9331c607 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 15 May 2025 09:16:01 +0200 Subject: [PATCH 2/8] apply log filter to adcp and onboard measurements --- src/virtualship/instruments/adcp.py | 16 ++++++++++++++++ .../instruments/ship_underwater_st.py | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/virtualship/instruments/adcp.py b/src/virtualship/instruments/adcp.py index 5563bdd2..4cbae267 100644 --- a/src/virtualship/instruments/adcp.py +++ b/src/virtualship/instruments/adcp.py @@ -1,10 +1,12 @@ """ADCP instrument.""" +import logging from pathlib import Path import numpy as np from parcels import FieldSet, ParticleSet, ScipyParticle, Variable +from ..log_filter import DuplicateFilter from ..spacetime import Spacetime # we specifically use ScipyParticle because we have many small calls to execute @@ -30,6 +32,7 @@ def simulate_adcp( min_depth: float, num_bins: int, sample_points: list[Spacetime], + log_filter: bool = True, ) -> None: """ Use Parcels to simulate an ADCP in a fieldset. @@ -40,6 +43,7 @@ def simulate_adcp( :param min_depth: Minimum depth the ADCP can measure. :param num_bins: How many samples to take in the complete range between max_depth and min_depth. :param sample_points: The places and times to sample at. + :param log_filter: Whether to filter duplicate log messages (defaults to True). This is a bit of a hack, but it works and could be removed if changed in Parcels. """ sample_points.sort(key=lambda p: p.time) @@ -60,6 +64,12 @@ def simulate_adcp( # outputdt set to infinite as we just want to write at the end of every call to 'execute' out_file = particleset.ParticleFile(name=out_path, outputdt=np.inf) + # whether to filter parcels duplicate log messages + if log_filter: + external_logger = logging.getLogger("parcels.tools.loggers") + for handler in external_logger.handlers: + handler.addFilter(DuplicateFilter()) + for point in sample_points: particleset.lon_nextloop[:] = point.location.lon particleset.lat_nextloop[:] = point.location.lat @@ -76,3 +86,9 @@ def simulate_adcp( verbose_progress=False, output_file=out_file, ) + + # turn off log filter after .execute(), to prevent being applied universally to all loggers + # separate if statement from above to prevent error if log_filter is False + if log_filter: + for handler in external_logger.handlers: + handler.removeFilter(handler.filters[0]) diff --git a/src/virtualship/instruments/ship_underwater_st.py b/src/virtualship/instruments/ship_underwater_st.py index 407055ad..dcc11ba0 100644 --- a/src/virtualship/instruments/ship_underwater_st.py +++ b/src/virtualship/instruments/ship_underwater_st.py @@ -1,10 +1,12 @@ """Ship salinity and temperature.""" +import logging from pathlib import Path import numpy as np from parcels import FieldSet, ParticleSet, ScipyParticle, Variable +from ..log_filter import DuplicateFilter from ..spacetime import Spacetime # we specifically use ScipyParticle because we have many small calls to execute @@ -32,6 +34,7 @@ def simulate_ship_underwater_st( out_path: str | Path, depth: float, sample_points: list[Spacetime], + log_filter: bool = True, ) -> None: """ Use Parcels to simulate underway data, measuring salinity and temperature at the given depth along the ship track in a fieldset. @@ -40,6 +43,7 @@ def simulate_ship_underwater_st( :param out_path: The path to write the results to. :param depth: The depth at which to measure. 0 is water surface, negative is into the water. :param sample_points: The places and times to sample at. + :param log_filter: Whether to filter duplicate log messages (defaults to True). This is a bit of a hack, but it works and could be removed if changed in Parcels. """ sample_points.sort(key=lambda p: p.time) @@ -56,6 +60,12 @@ def simulate_ship_underwater_st( # outputdt set to infinie as we want to just want to write at the end of every call to 'execute' out_file = particleset.ParticleFile(name=out_path, outputdt=np.inf) + # whether to filter parcels duplicate log messages + if log_filter: + external_logger = logging.getLogger("parcels.tools.loggers") + for handler in external_logger.handlers: + handler.addFilter(DuplicateFilter()) + # iterate over each point, manually set lat lon time, then # execute the particle set for one step, performing one set of measurement for point in sample_points: @@ -74,3 +84,9 @@ def simulate_ship_underwater_st( verbose_progress=False, output_file=out_file, ) + + # turn off log filter after .execute(), to prevent being applied universally to all loggers + # separate if statement from above to prevent error if log_filter is False + if log_filter: + for handler in external_logger.handlers: + handler.removeFilter(handler.filters[0]) From 2b0c50b0d1e8ef5c14ceef864b88546062cf6fe4 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 20 May 2025 09:58:57 +0200 Subject: [PATCH 3/8] new log filter tool --- src/virtualship/log_filter.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/virtualship/log_filter.py b/src/virtualship/log_filter.py index 142073b1..c32d17e8 100644 --- a/src/virtualship/log_filter.py +++ b/src/virtualship/log_filter.py @@ -1,17 +1,12 @@ -"""Class for suppressing duplicate log messages in Python logging.""" - import logging +# get Parcels logger +external_logger = logging.getLogger("parcels.tools.loggers") -class DuplicateFilter(logging.Filter): - """Logging filter for suppressing duplicate log messages.""" - def __init__(self): - self.last_log = None +# filter class +class Filter(logging.Filter): + """Logging filter for all (Parcels) logging messages.""" def filter(self, record): - current_log = record.getMessage() - if current_log != self.last_log: - self.last_log = current_log - return True return False From 5debb8aa35b66be2c066dd134ad11b83e16bbed8 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 20 May 2025 09:59:33 +0200 Subject: [PATCH 4/8] apply log filtering to each instrument --- src/virtualship/instruments/__init__.py | 12 +++- src/virtualship/instruments/adcp.py | 49 +++++++-------- src/virtualship/instruments/argo_float.py | 37 ++++++++---- src/virtualship/instruments/ctd.py | 26 +++++--- src/virtualship/instruments/ctd_bgc.py | 26 +++++--- src/virtualship/instruments/drifter.py | 27 ++++++--- .../instruments/ship_underwater_st.py | 59 +++++++++---------- src/virtualship/instruments/xbt.py | 27 ++++++--- 8 files changed, 157 insertions(+), 106 deletions(-) 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/instruments/adcp.py b/src/virtualship/instruments/adcp.py index 4cbae267..b314758c 100644 --- a/src/virtualship/instruments/adcp.py +++ b/src/virtualship/instruments/adcp.py @@ -1,12 +1,11 @@ """ADCP instrument.""" -import logging from pathlib import Path import numpy as np from parcels import FieldSet, ParticleSet, ScipyParticle, Variable -from ..log_filter import DuplicateFilter +from ..log_filter import Filter, external_logger from ..spacetime import Spacetime # we specifically use ScipyParticle because we have many small calls to execute @@ -32,7 +31,6 @@ def simulate_adcp( min_depth: float, num_bins: int, sample_points: list[Spacetime], - log_filter: bool = True, ) -> None: """ Use Parcels to simulate an ADCP in a fieldset. @@ -43,7 +41,6 @@ def simulate_adcp( :param min_depth: Minimum depth the ADCP can measure. :param num_bins: How many samples to take in the complete range between max_depth and min_depth. :param sample_points: The places and times to sample at. - :param log_filter: Whether to filter duplicate log messages (defaults to True). This is a bit of a hack, but it works and could be removed if changed in Parcels. """ sample_points.sort(key=lambda p: p.time) @@ -64,31 +61,29 @@ def simulate_adcp( # outputdt set to infinite as we just want to write at the end of every call to 'execute' out_file = particleset.ParticleFile(name=out_path, outputdt=np.inf) - # whether to filter parcels duplicate log messages - if log_filter: - external_logger = logging.getLogger("parcels.tools.loggers") - for handler in external_logger.handlers: - handler.addFilter(DuplicateFilter()) + # filter out Parcels logging messages + for handler in external_logger.handlers: + handler.addFilter(Filter()) - for point in sample_points: - particleset.lon_nextloop[:] = point.location.lon - particleset.lat_nextloop[:] = point.location.lat - particleset.time_nextloop[:] = fieldset.time_origin.reltime( - np.datetime64(point.time) - ) + # try/finally to ensure filter is always removed even if .execute fails (to avoid filter being appled universally) + try: + for point in sample_points: + particleset.lon_nextloop[:] = point.location.lon + particleset.lat_nextloop[:] = point.location.lat + particleset.time_nextloop[:] = fieldset.time_origin.reltime( + np.datetime64(point.time) + ) - # perform one step using the particleset - # dt and runtime are set so exactly one step is made. - particleset.execute( - [_sample_velocity], - dt=1, - runtime=1, - verbose_progress=False, - output_file=out_file, - ) + # perform one step using the particleset + # dt and runtime are set so exactly one step is made. + particleset.execute( + [_sample_velocity], + dt=1, + runtime=1, + verbose_progress=False, + output_file=out_file, + ) - # turn off log filter after .execute(), to prevent being applied universally to all loggers - # separate if statement from above to prevent error if log_filter is False - if log_filter: + finally: for handler in external_logger.handlers: handler.removeFilter(handler.filters[0]) diff --git a/src/virtualship/instruments/argo_float.py b/src/virtualship/instruments/argo_float.py index 2091098e..ebfbd68d 100644 --- a/src/virtualship/instruments/argo_float.py +++ b/src/virtualship/instruments/argo_float.py @@ -15,6 +15,7 @@ Variable, ) +from ..log_filter import Filter, external_logger from ..spacetime import Spacetime @@ -171,16 +172,26 @@ def simulate_argo_floats( else: actual_endtime = np.timedelta64(endtime) - # execute simulation - argo_float_particleset.execute( - [ - _argo_float_vertical_movement, - AdvectionRK4, - _keep_at_surface, - _check_error, - ], - endtime=actual_endtime, - dt=DT, - output_file=out_file, - verbose_progress=True, - ) + # filter out Parcels logging messages + for handler in external_logger.handlers: + handler.addFilter(Filter()) + + # try/finally to ensure filter is always removed even if .execute fails (to avoid filter being appled universally) + try: + # execute simulation + argo_float_particleset.execute( + [ + _argo_float_vertical_movement, + AdvectionRK4, + _keep_at_surface, + _check_error, + ], + endtime=actual_endtime, + dt=DT, + output_file=out_file, + verbose_progress=False, + ) + + finally: + for handler in external_logger.handlers: + handler.removeFilter(handler.filters[0]) diff --git a/src/virtualship/instruments/ctd.py b/src/virtualship/instruments/ctd.py index 6f76b408..f2b80ee1 100644 --- a/src/virtualship/instruments/ctd.py +++ b/src/virtualship/instruments/ctd.py @@ -7,6 +7,7 @@ import numpy as np from parcels import FieldSet, JITParticle, ParticleSet, Variable +from ..log_filter import Filter, external_logger from ..spacetime import Spacetime @@ -121,14 +122,23 @@ def simulate_ctd( # define output file for the simulation out_file = ctd_particleset.ParticleFile(name=out_path, outputdt=outputdt) - # execute simulation - ctd_particleset.execute( - [_sample_salinity, _sample_temperature, _ctd_cast], - endtime=fieldset_endtime, - dt=DT, - verbose_progress=False, - output_file=out_file, - ) + # filter out Parcels logging messages + for handler in external_logger.handlers: + handler.addFilter(Filter()) + + # try/finally to ensure filter is always removed even if .execute fails (to avoid filter being appled universally) + try: + ctd_particleset.execute( + [_sample_salinity, _sample_temperature, _ctd_cast], + endtime=fieldset_endtime, + dt=DT, + verbose_progress=False, + output_file=out_file, + ) + + finally: + for handler in external_logger.handlers: + handler.removeFilter(handler.filters[0]) # there should be no particles left, as they delete themselves when they resurface if len(ctd_particleset.particledata) != 0: diff --git a/src/virtualship/instruments/ctd_bgc.py b/src/virtualship/instruments/ctd_bgc.py index cb218e3a..4a58d6eb 100644 --- a/src/virtualship/instruments/ctd_bgc.py +++ b/src/virtualship/instruments/ctd_bgc.py @@ -7,6 +7,7 @@ import numpy as np from parcels import FieldSet, JITParticle, ParticleSet, Variable +from ..log_filter import Filter, external_logger from ..spacetime import Spacetime @@ -127,14 +128,23 @@ def simulate_ctd_bgc( # define output file for the simulation out_file = ctd_bgc_particleset.ParticleFile(name=out_path, outputdt=outputdt) - # execute simulation - ctd_bgc_particleset.execute( - [_sample_o2, _sample_chlorophyll, _ctd_bgc_cast], - endtime=fieldset_endtime, - dt=DT, - verbose_progress=False, - output_file=out_file, - ) + # filter out Parcels logging messages + for handler in external_logger.handlers: + handler.addFilter(Filter()) + + # try/finally to ensure filter is always removed even if .execute fails (to avoid filter being appled universally) + try: + ctd_bgc_particleset.execute( + [_sample_o2, _sample_chlorophyll, _ctd_bgc_cast], + endtime=fieldset_endtime, + dt=DT, + verbose_progress=False, + output_file=out_file, + ) + + finally: + for handler in external_logger.handlers: + handler.removeFilter(handler.filters[0]) # there should be no particles left, as they delete themselves when they resurface if len(ctd_bgc_particleset.particledata) != 0: diff --git a/src/virtualship/instruments/drifter.py b/src/virtualship/instruments/drifter.py index 2fd4180b..ebcbe53a 100644 --- a/src/virtualship/instruments/drifter.py +++ b/src/virtualship/instruments/drifter.py @@ -7,6 +7,7 @@ import numpy as np from parcels import AdvectionRK4, FieldSet, JITParticle, ParticleSet, Variable +from ..log_filter import Filter, external_logger from ..spacetime import Spacetime @@ -95,14 +96,24 @@ def simulate_drifters( else: actual_endtime = np.timedelta64(endtime) - # execute simulation - drifter_particleset.execute( - [AdvectionRK4, _sample_temperature, _check_lifetime], - endtime=actual_endtime, - dt=dt, - output_file=out_file, - verbose_progress=True, - ) + # filter out Parcels logging messages + for handler in external_logger.handlers: + handler.addFilter(Filter()) + + # try/finally to ensure filter is always removed even if .execute fails (to avoid filter being appled universally) + try: + # execute simulation + drifter_particleset.execute( + [AdvectionRK4, _sample_temperature, _check_lifetime], + endtime=actual_endtime, + dt=dt, + output_file=out_file, + verbose_progress=False, + ) + + finally: + for handler in external_logger.handlers: + handler.removeFilter(handler.filters[0]) # if there are more particles left than the number of drifters with an indefinite endtime, warn the user if len(drifter_particleset.particledata) > len( diff --git a/src/virtualship/instruments/ship_underwater_st.py b/src/virtualship/instruments/ship_underwater_st.py index dcc11ba0..81baf300 100644 --- a/src/virtualship/instruments/ship_underwater_st.py +++ b/src/virtualship/instruments/ship_underwater_st.py @@ -1,12 +1,11 @@ """Ship salinity and temperature.""" -import logging from pathlib import Path import numpy as np from parcels import FieldSet, ParticleSet, ScipyParticle, Variable -from ..log_filter import DuplicateFilter +from ..log_filter import Filter, external_logger from ..spacetime import Spacetime # we specifically use ScipyParticle because we have many small calls to execute @@ -34,7 +33,6 @@ def simulate_ship_underwater_st( out_path: str | Path, depth: float, sample_points: list[Spacetime], - log_filter: bool = True, ) -> None: """ Use Parcels to simulate underway data, measuring salinity and temperature at the given depth along the ship track in a fieldset. @@ -43,7 +41,6 @@ def simulate_ship_underwater_st( :param out_path: The path to write the results to. :param depth: The depth at which to measure. 0 is water surface, negative is into the water. :param sample_points: The places and times to sample at. - :param log_filter: Whether to filter duplicate log messages (defaults to True). This is a bit of a hack, but it works and could be removed if changed in Parcels. """ sample_points.sort(key=lambda p: p.time) @@ -60,33 +57,31 @@ def simulate_ship_underwater_st( # outputdt set to infinie as we want to just want to write at the end of every call to 'execute' out_file = particleset.ParticleFile(name=out_path, outputdt=np.inf) - # whether to filter parcels duplicate log messages - if log_filter: - external_logger = logging.getLogger("parcels.tools.loggers") - for handler in external_logger.handlers: - handler.addFilter(DuplicateFilter()) - - # iterate over each point, manually set lat lon time, then - # execute the particle set for one step, performing one set of measurement - for point in sample_points: - particleset.lon_nextloop[:] = point.location.lon - particleset.lat_nextloop[:] = point.location.lat - particleset.time_nextloop[:] = fieldset.time_origin.reltime( - np.datetime64(point.time) - ) - - # perform one step using the particleset - # dt and runtime are set so exactly one step is made. - particleset.execute( - [_sample_salinity, _sample_temperature], - dt=1, - runtime=1, - verbose_progress=False, - output_file=out_file, - ) - - # turn off log filter after .execute(), to prevent being applied universally to all loggers - # separate if statement from above to prevent error if log_filter is False - if log_filter: + # filter out Parcels logging messages + for handler in external_logger.handlers: + handler.addFilter(Filter()) + + # try/finally to ensure filter is always removed even if .execute fails (to avoid filter being appled universally) + try: + # iterate over each point, manually set lat lon time, then + # execute the particle set for one step, performing one set of measurement + for point in sample_points: + particleset.lon_nextloop[:] = point.location.lon + particleset.lat_nextloop[:] = point.location.lat + particleset.time_nextloop[:] = fieldset.time_origin.reltime( + np.datetime64(point.time) + ) + + # perform one step using the particleset + # dt and runtime are set so exactly one step is made. + particleset.execute( + [_sample_salinity, _sample_temperature], + dt=1, + runtime=1, + verbose_progress=False, + output_file=out_file, + ) + + finally: for handler in external_logger.handlers: handler.removeFilter(handler.filters[0]) diff --git a/src/virtualship/instruments/xbt.py b/src/virtualship/instruments/xbt.py index 36a28471..c5b4844f 100644 --- a/src/virtualship/instruments/xbt.py +++ b/src/virtualship/instruments/xbt.py @@ -7,6 +7,7 @@ import numpy as np from parcels import FieldSet, JITParticle, ParticleSet, Variable +from ..log_filter import Filter, external_logger from ..spacetime import Spacetime @@ -125,14 +126,24 @@ def simulate_xbt( # define output file for the simulation out_file = xbt_particleset.ParticleFile(name=out_path, outputdt=outputdt) - # execute simulation - xbt_particleset.execute( - [_sample_temperature, _xbt_cast], - endtime=fieldset_endtime, - dt=DT, - verbose_progress=False, - output_file=out_file, - ) + # filter out Parcels logging messages + for handler in external_logger.handlers: + handler.addFilter(Filter()) + + # try/finally to ensure filter is always removed even if .execute fails (to avoid filter being appled universally) + try: + # execute simulation + xbt_particleset.execute( + [_sample_temperature, _xbt_cast], + endtime=fieldset_endtime, + dt=DT, + verbose_progress=False, + output_file=out_file, + ) + + finally: + for handler in external_logger.handlers: + handler.removeFilter(handler.filters[0]) # there should be no particles left, as they delete themselves when they finish profiling if len(xbt_particleset.particledata) != 0: From 0b08bc63e5c1ca3d605b56b1b0ad68c8d83f6f9e Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 20 May 2025 16:16:17 +0200 Subject: [PATCH 5/8] add rotating dial class --- src/virtualship/utils.py | 47 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 6dbbb49c..2de9e85d 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -1,6 +1,10 @@ from __future__ import annotations +import itertools import os +import sys +import threading +import time import warnings from datetime import timedelta from functools import lru_cache @@ -247,3 +251,46 @@ def _get_ship_config(expedition_dir: Path) -> ShipConfig: raise FileNotFoundError( f'Ship config not found. Save it to "{file_path}".' ) from e + + +class RotatePrint: + """A rotating symbol for the end of simulation print statements, to indicate progress.""" + + def __init__(self, message="Processing...", delay=0.18): + self.spinner_symbols = itertools.cycle(["-", "\\", "|", "/"]) + self.delay = delay + self.message = message + self.running = False + self.spinner_thread = None + self.final_message_printed = False + + def _spinner_task(self): + while self.running: + sys.stdout.write(f"\r{self.message} {next(self.spinner_symbols)}") + sys.stdout.flush() + time.sleep(self.delay) + + # overwrite with the final message with completion message + if not self.final_message_printed: + sys.stdout.write(f"\r{self.message} [COMPLETED]\n") + sys.stdout.flush() + self.final_message_printed = True + + def start(self): + self.running = True + self.final_message_printed = False + self.spinner_thread = threading.Thread( + target=self._spinner_task + ) # threading allows main function to complete and prevents rotator from hanging + self.spinner_thread.daemon = True + self.spinner_thread.start() + + def stop(self): + self.running = False + if self.spinner_thread and self.spinner_thread.is_alive(): + self.spinner_thread.join() + + if not self.final_message_printed: + sys.stdout.write(f"\r{self.message} [COMPLETED]\n") + sys.stdout.flush() + self.final_message_printed = True From a0da379b0aa6f057db2b20754864f57a7ae81a74 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 20 May 2025 16:17:27 +0200 Subject: [PATCH 6/8] add rotating dial to instrument progress monitoring --- src/virtualship/expedition/simulate_measurements.py | 7 ------- src/virtualship/instruments/adcp.py | 7 +++++++ src/virtualship/instruments/argo_float.py | 5 +++-- src/virtualship/instruments/ctd.py | 7 +++++++ src/virtualship/instruments/ctd_bgc.py | 7 +++++++ src/virtualship/instruments/drifter.py | 6 ++++-- src/virtualship/instruments/ship_underwater_st.py | 8 +++++++- src/virtualship/instruments/xbt.py | 8 +++++++- 8 files changed, 42 insertions(+), 13 deletions(-) diff --git a/src/virtualship/expedition/simulate_measurements.py b/src/virtualship/expedition/simulate_measurements.py index c7cd8746..ada626a8 100644 --- a/src/virtualship/expedition/simulate_measurements.py +++ b/src/virtualship/expedition/simulate_measurements.py @@ -36,7 +36,6 @@ 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: @@ -49,7 +48,6 @@ def simulate_measurements( ) 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: @@ -64,7 +62,6 @@ def simulate_measurements( ) 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: @@ -77,7 +74,6 @@ def simulate_measurements( ) 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: @@ -90,7 +86,6 @@ def simulate_measurements( ) if len(measurements.drifters) > 0: - print("Simulating drifters") if ship_config.drifter_config is None: raise RuntimeError("No configuration for drifters provided.") if input_data.drifter_fieldset is None: @@ -105,7 +100,6 @@ def simulate_measurements( ) if len(measurements.argo_floats) > 0: - 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: @@ -119,7 +113,6 @@ def simulate_measurements( ) 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: diff --git a/src/virtualship/instruments/adcp.py b/src/virtualship/instruments/adcp.py index b314758c..dbfa9eea 100644 --- a/src/virtualship/instruments/adcp.py +++ b/src/virtualship/instruments/adcp.py @@ -7,6 +7,7 @@ from ..log_filter import Filter, external_logger from ..spacetime import Spacetime +from ..utils import RotatePrint # we specifically use ScipyParticle because we have many small calls to execute # there is some overhead with JITParticle and this ends up being significantly faster @@ -42,6 +43,8 @@ def simulate_adcp( :param num_bins: How many samples to take in the complete range between max_depth and min_depth. :param sample_points: The places and times to sample at. """ + rotator = RotatePrint("Simulating onboard ADCP...") + sample_points.sort(key=lambda p: p.time) bins = np.linspace(max_depth, min_depth, num_bins) @@ -66,7 +69,10 @@ def simulate_adcp( handler.addFilter(Filter()) # try/finally to ensure filter is always removed even if .execute fails (to avoid filter being appled universally) + # also suits starting and ending the rotator for custom log message try: + rotator.start() + for point in sample_points: particleset.lon_nextloop[:] = point.location.lon particleset.lat_nextloop[:] = point.location.lat @@ -85,5 +91,6 @@ def simulate_adcp( ) finally: + rotator.stop() for handler in external_logger.handlers: handler.removeFilter(handler.filters[0]) diff --git a/src/virtualship/instruments/argo_float.py b/src/virtualship/instruments/argo_float.py index ebfbd68d..d638598f 100644 --- a/src/virtualship/instruments/argo_float.py +++ b/src/virtualship/instruments/argo_float.py @@ -132,6 +132,7 @@ def simulate_argo_floats( :param outputdt: Interval which dictates the update frequency of file output during simulation :param endtime: Stop at this time, or if None, continue until the end of the fieldset. """ + print("Simulating argo floats...") DT = 10.0 # dt of Argo float simulation integrator if len(argo_floats) == 0: @@ -178,7 +179,6 @@ def simulate_argo_floats( # try/finally to ensure filter is always removed even if .execute fails (to avoid filter being appled universally) try: - # execute simulation argo_float_particleset.execute( [ _argo_float_vertical_movement, @@ -189,9 +189,10 @@ def simulate_argo_floats( endtime=actual_endtime, dt=DT, output_file=out_file, - verbose_progress=False, + verbose_progress=True, ) finally: + print("... [COMPLETED]") for handler in external_logger.handlers: handler.removeFilter(handler.filters[0]) diff --git a/src/virtualship/instruments/ctd.py b/src/virtualship/instruments/ctd.py index f2b80ee1..0b37022a 100644 --- a/src/virtualship/instruments/ctd.py +++ b/src/virtualship/instruments/ctd.py @@ -9,6 +9,7 @@ from ..log_filter import Filter, external_logger from ..spacetime import Spacetime +from ..utils import RotatePrint @dataclass @@ -69,6 +70,8 @@ def simulate_ctd( :param outputdt: Interval which dictates the update frequency of file output during simulation :raises ValueError: Whenever provided CTDs, fieldset, are not compatible with this function. """ + rotator = RotatePrint("Simulating CTD casts...") + WINCH_SPEED = 1.0 # sink and rise speed in m/s DT = 10.0 # dt of CTD simulation integrator @@ -127,7 +130,10 @@ def simulate_ctd( handler.addFilter(Filter()) # try/finally to ensure filter is always removed even if .execute fails (to avoid filter being appled universally) + # also suits starting and ending the rotator for custom log message try: + rotator.start() + ctd_particleset.execute( [_sample_salinity, _sample_temperature, _ctd_cast], endtime=fieldset_endtime, @@ -137,6 +143,7 @@ def simulate_ctd( ) finally: + rotator.stop() for handler in external_logger.handlers: handler.removeFilter(handler.filters[0]) diff --git a/src/virtualship/instruments/ctd_bgc.py b/src/virtualship/instruments/ctd_bgc.py index 4a58d6eb..0313f677 100644 --- a/src/virtualship/instruments/ctd_bgc.py +++ b/src/virtualship/instruments/ctd_bgc.py @@ -9,6 +9,7 @@ from ..log_filter import Filter, external_logger from ..spacetime import Spacetime +from ..utils import RotatePrint @dataclass @@ -69,6 +70,8 @@ def simulate_ctd_bgc( :param outputdt: Interval which dictates the update frequency of file output during simulation :raises ValueError: Whenever provided BGC CTDs, fieldset, are not compatible with this function. """ + rotator = RotatePrint("Simulating BGC CTD casts...") + WINCH_SPEED = 1.0 # sink and rise speed in m/s DT = 10.0 # dt of CTD simulation integrator @@ -133,7 +136,10 @@ def simulate_ctd_bgc( handler.addFilter(Filter()) # try/finally to ensure filter is always removed even if .execute fails (to avoid filter being appled universally) + # also suits starting and ending the rotator for custom log message try: + rotator.start() + ctd_bgc_particleset.execute( [_sample_o2, _sample_chlorophyll, _ctd_bgc_cast], endtime=fieldset_endtime, @@ -143,6 +149,7 @@ def simulate_ctd_bgc( ) finally: + rotator.stop() for handler in external_logger.handlers: handler.removeFilter(handler.filters[0]) diff --git a/src/virtualship/instruments/drifter.py b/src/virtualship/instruments/drifter.py index ebcbe53a..801b5e54 100644 --- a/src/virtualship/instruments/drifter.py +++ b/src/virtualship/instruments/drifter.py @@ -59,6 +59,8 @@ def simulate_drifters( :param dt: Dt for integration. :param endtime: Stop at this time, or if None, continue until the end of the fieldset or until all drifters ended. If this is earlier than the last drifter ended or later than the end of the fieldset, a warning will be printed. """ + print("Simulating drifters...") + if len(drifters) == 0: print( "No drifters provided. Parcels currently crashes when providing an empty particle set, so no drifter simulation will be done and no files will be created." @@ -102,16 +104,16 @@ def simulate_drifters( # try/finally to ensure filter is always removed even if .execute fails (to avoid filter being appled universally) try: - # execute simulation drifter_particleset.execute( [AdvectionRK4, _sample_temperature, _check_lifetime], endtime=actual_endtime, dt=dt, output_file=out_file, - verbose_progress=False, + verbose_progress=True, ) finally: + print("... [COMPLETED]") for handler in external_logger.handlers: handler.removeFilter(handler.filters[0]) diff --git a/src/virtualship/instruments/ship_underwater_st.py b/src/virtualship/instruments/ship_underwater_st.py index 81baf300..ee0de70f 100644 --- a/src/virtualship/instruments/ship_underwater_st.py +++ b/src/virtualship/instruments/ship_underwater_st.py @@ -7,6 +7,7 @@ from ..log_filter import Filter, external_logger from ..spacetime import Spacetime +from ..utils import RotatePrint # we specifically use ScipyParticle because we have many small calls to execute # there is some overhead with JITParticle and this ends up being significantly faster @@ -42,6 +43,8 @@ def simulate_ship_underwater_st( :param depth: The depth at which to measure. 0 is water surface, negative is into the water. :param sample_points: The places and times to sample at. """ + rotator = RotatePrint("Simulating onboard salinity and temperature measurements...") + sample_points.sort(key=lambda p: p.time) particleset = ParticleSet.from_list( @@ -53,7 +56,6 @@ def simulate_ship_underwater_st( time=0, # same for time ) - # define output file for the simulation # outputdt set to infinie as we want to just want to write at the end of every call to 'execute' out_file = particleset.ParticleFile(name=out_path, outputdt=np.inf) @@ -62,7 +64,10 @@ def simulate_ship_underwater_st( handler.addFilter(Filter()) # try/finally to ensure filter is always removed even if .execute fails (to avoid filter being appled universally) + # also suits starting and ending the rotator for custom log message try: + rotator.start() + # iterate over each point, manually set lat lon time, then # execute the particle set for one step, performing one set of measurement for point in sample_points: @@ -83,5 +88,6 @@ def simulate_ship_underwater_st( ) finally: + rotator.stop() for handler in external_logger.handlers: handler.removeFilter(handler.filters[0]) diff --git a/src/virtualship/instruments/xbt.py b/src/virtualship/instruments/xbt.py index c5b4844f..e63998f5 100644 --- a/src/virtualship/instruments/xbt.py +++ b/src/virtualship/instruments/xbt.py @@ -9,6 +9,7 @@ from ..log_filter import Filter, external_logger from ..spacetime import Spacetime +from ..utils import RotatePrint @dataclass @@ -70,6 +71,8 @@ def simulate_xbt( :param outputdt: Interval which dictates the update frequency of file output during simulation :raises ValueError: Whenever provided XBTs, fieldset, are not compatible with this function. """ + rotator = RotatePrint("Simulating XBTs") + DT = 10.0 # dt of XBT simulation integrator if len(xbts) == 0: @@ -131,8 +134,10 @@ def simulate_xbt( handler.addFilter(Filter()) # try/finally to ensure filter is always removed even if .execute fails (to avoid filter being appled universally) + # also suits starting and ending the rotator for custom log message try: - # execute simulation + rotator.start() + xbt_particleset.execute( [_sample_temperature, _xbt_cast], endtime=fieldset_endtime, @@ -142,6 +147,7 @@ def simulate_xbt( ) finally: + rotator.stop() for handler in external_logger.handlers: handler.removeFilter(handler.filters[0]) From cd9d1b0a3800acdbb2a4e7add787088642e23699 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 20 May 2025 16:18:16 +0200 Subject: [PATCH 7/8] update expedition output printing --- src/virtualship/expedition/do_expedition.py | 24 ++++++++++++++++----- src/virtualship/expedition/schedule.py | 4 ++-- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/virtualship/expedition/do_expedition.py b/src/virtualship/expedition/do_expedition.py index a4243361..4dab2181 100644 --- a/src/virtualship/expedition/do_expedition.py +++ b/src/virtualship/expedition/do_expedition.py @@ -32,6 +32,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) @@ -57,6 +61,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, input_data) @@ -83,6 +89,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." @@ -91,20 +99,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, 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/schedule.py b/src/virtualship/expedition/schedule.py index 79f14758..678945d5 100644 --- a/src/virtualship/expedition/schedule.py +++ b/src/virtualship/expedition/schedule.py @@ -139,7 +139,7 @@ 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..") + print("\nVerifying all waypoints are on water...") # get all available fieldsets available_fieldsets = [] @@ -178,7 +178,7 @@ 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.") + 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 From 4dcd3121acea5e2cdb35209e0664150a8fbcedbf Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 20 May 2025 16:36:29 +0200 Subject: [PATCH 8/8] update test to reflect new do_expedition print statements --- tests/expedition/test_do_expedition.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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." + )