diff --git a/docs/user-guide/assignments/Research_proposal_intro.ipynb b/docs/user-guide/assignments/Research_proposal_intro.ipynb index aac53036..52e74651 100644 --- a/docs/user-guide/assignments/Research_proposal_intro.ipynb +++ b/docs/user-guide/assignments/Research_proposal_intro.ipynb @@ -45,11 +45,11 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### Underway Data \n", + "### Underway Temperature and Salinity measurements\n", "\n", "Underway data collection, also known as underway sampling, refers to the process of collecting oceanographic and environmental data while a research vessel is in motion. This method allows scientists to continuously gather data on various parameters such as sea surface temperature and salinity. The water inlet is located on the hull of the ship at approximately 2 meters under the surface.\n", "\n", - "You can collect underway data during your entire cruise or on specific transects and may assume it doesn’t take any time to start or stop taking measurements." + "You can collect underway data during your entire cruise. These measurements can be taken continuously without interrupting the ship's speed. It doesn’t take any time to start or stop the ship for these measurements." ] }, { @@ -60,7 +60,9 @@ "\n", "An Acoustic Doppler Current Profiler (ADCP) is an instrument used to measure the speed and direction of ocean currents at various depths. It operates based on the principle of Doppler shift, where sound waves emitted by the instrument are reflected off moving particles in the water, such as plankton or sediment, and the frequency shift of the returning waves is used to calculate the velocity of the water.\n", "\n", - "You can deploy the ADCP on transects and choose between a shallow profiler capable of providing information to a depth of 150 m every 4 meters (the 300kHz seaSeven), or a long-range profiler capable of providing about 1000m of range every 24 meters (the 38kHz Ocean Observer). You can collect ADCP data during your entire cruise or on specific transects and may assume it doesn’t take any time to start or end ADCP measurements.\n", + "You can choose between a shallow profiler capable of providing information to a depth of 150 m every 4 meters (the 300kHz seaSeven), or a long-range profiler capable of providing about 1000m of range every 24 meters (the 38kHz Ocean Observer). \n", + "\n", + "You can collect ADCP data during your entire cruise. These measurements can be taken continuously without interrupting the ship's speed. It doesn’t take any time to start or stop the ship for these measurements.\n", "\n", "" ] @@ -73,7 +75,7 @@ "\n", "An eXpendable BathyThermograph (XBT) is a probe that is dropped from a ship and measures the temperature as it falls through the water. The probe is designed to fall at a known rate, so that the depth of the temperature profile can be inferred from the time since it enters the water.\n", "\n", - "You may assume it doesn’t take any time to deploy XBTs.\n", + "You may assume it doesn’t take any time to deploy XBTs. They can be deployed while the ship is moving at full speed, so there is no time cost for slowing down or speeding up the ship.\n", "\n", "" ] @@ -87,15 +89,12 @@ "\n", "A Conductivity, Temperature, and Depth (CTD) sensor is an instrument used to measure the physical (or biogeochemical) properties of seawater in oceanographic and environmental research. It typically consists of sensors for conductivity (used to derive salinity), temperature, and pressure (used to derive depth). CTDs can also be equipped with additional sensors to measure biogeochemical parameters such as dissolved oxygen, chlorophyll, and nutrients. \n", "\n", - "Throughout a CTD deployment, the research vessels needs to stay in the same location. To calculate the time needed for a CTD deployment, take the following information into account:\n", - "\n", - "\n", - "* Add time to lower the vessels speed to zero and set the vessel to the appropriate direction against swell, current, wind, etc = 15 minutes \n", - "* Add time to deploy the CTD, and to recover = 10 minutes (2*5 min) \n", - "* Add time to raise the CTD down and up, usually at 1 m/s (winch capacity). We make an assumption in VirtualShip that the average CTD downcast/upcast takes 15 minutes.\n", - "* Add time to speed the ship back up again and set course to the next waypoint = 10 minutes\n", + "Throughout a CTD deployment, the research vessels needs to stay in the same location. To calculate the time needed for a CTD deployment at a given waypoint, take the following information into account:\n", "\n", - "In total, therefore, you can expect a CTD deployment to take approximately 50 minutes.\n", + "* The ship needs to slow down to a stop, which takes 15 minutes (default in VirtualShip)\n", + "* The CTD is lowered to the configured maximum depth or the sea floor, whichever is shallowest. The time taken therefore depends on the depth in that location and the winch speed (default is 1 m/s in VirtualShip). Remember that the time is doubled as well because the CTD needs to be brought back up.\n", + " * For example, if the CTD is lowered to 1000 m at a speed of 1 m/s, it will take approximately 16.7 minutes to reach that depth, and the same amount of time to raise it back up, so in total: approx. 33.4 minutes.\n", + "* The ship needs to speed back up again and set course to the next waypoint, which takes another 15 minutes (default in VirtualShip).\n", "\n", "" ] @@ -108,7 +107,7 @@ "\n", "A surface drifter is an oceanographic instrument used to study surface currents and ocean circulation patterns. These devices are designed to drift passively with the surface currents while transmitting data on their position and sea surface temperature.\n", "\n", - "You should assume it takes a combined 20 minutes to both slow down and speed the ship back up, in order to safely deploy drifters from the ship. Make sure to account for this time in your planning.\n", + "For deployment, the ship needs to slow down to a stop, which takes 15 minutes (default in VirtualShip). You can assume the actual release takes no time but then after the deployment the ship needs to speed back up again and set course to the next waypoint, which takes another 15 minutes (default in VirtualShip).\n", "\n", "" ] @@ -123,7 +122,7 @@ "\n", "The floats periodically descend to a predetermined depth, typically around 1000 meters, and then ascend to the surface, measuring temperature and salinity profiles along the way.\n", "\n", - "You should assume it takes a combined 20 minutes to both slow down and speed the ship back up, in order to safely deploy Argo floats from the ship. Make sure to account for this time in your planning.\n", + "For deployment, the ship needs to slow down to a stop, which takes 15 minutes (default in VirtualShip). You can assume the actual release takes no time but then after the deployment the ship needs to speed back up again and set course to the next waypoint, which takes another 15 minutes (default in VirtualShip).\n", " \n", "" ] diff --git a/docs/user-guide/assignments/sail_the_ship.md b/docs/user-guide/assignments/sail_the_ship.md index bd60aa67..d5e0e25b 100644 --- a/docs/user-guide/assignments/sail_the_ship.md +++ b/docs/user-guide/assignments/sail_the_ship.md @@ -92,6 +92,12 @@ The `{CruiseDataExport}.xlsx` in the command above refers to the `.xlsx` file ex The `--start-date` should be set to your intended departure date/time. MFP's estimated travel and station durations are then used to automatically fill in a time for each waypoint relative to this start date. ``` +```{important} +MFP usually allows a default of **45 minutes** stationkeeping time at each station. This may be enough for your expedition, but it may not be, so expect to adjust some of your timings once you have chosen your instruments (in step 5) [below](#waypoint-datetimes)). + +Hint: pay particular attention to CTDs deployments in deep waters. A CTD is lowered on a winch all the way to the seabed and brought back up, so the deeper the water, the longer the ship has to sit still. +``` + This will create a folder/directory called `EXPEDITION_NAME` (or what you have changed this to) with a single file: `expedition.yaml`. This file contains details on the ship and instrument configurations, as well as the expedition schedule based on the sampling site coordinates (and, if specified, ports of departure/arrival) from your MFP export. The `--from-mfp` flag indicates that the exported route will be used, and `--start-date` is required alongside it. ## 5) Expedition scheduling & ship configuration @@ -149,7 +155,15 @@ Your Port of Departure and Port of Arrival (if set in MFP) appear as special way ``` ```{note} -It is important to ensure that the timings for each station are realistic. There must be enough time for the ship to travel to each site at the prescribed speed (10 knots). The expedition schedule will be automatically verified when you press _Save Changes_ in the planning tool. +It is important to ensure that the timings for each station are realistic. There must be enough time for the ship to travel to each site at the prescribed speed (10 knots), **and** enough time to actually carry out your deployments once you arrive. The expedition schedule will be automatically verified when you press _Save Changes_ in the planning tool. +``` + +```{important} +Remember that MFP's default of 45 minutes per station may not be enough once your instruments are selected. Time at a station is made up of slowing the ship down (15 minutes by default), carrying out the deployments (which depends on which instruments you deploy), and getting back up to speed again (15 minutes by default). + +Deployments can take place side by side, so only the slowest one matters for the total stationkeeping time at each waypoint. Drifters and Argo floats are simply released into the water and left behind, so they have no time cost apart from slowing down and speeding up the ship. A CTD is different. It takes longer because it has to be lowered and brought back up, so the ship waits for the full round trip, and a cast at a deep station can easily take longer than the MFP default. An XBT is different in the opposite way. It doesn't even need the ship to slow down, so it can be deployed while the ship is moving at full speed. + +**See [here](../assignments/Research_proposal_intro.ipynb) for more information on the times taken for each instrument deployment.** ``` ### Instrument selection diff --git a/docs/user-guide/quickstart.md b/docs/user-guide/quickstart.md index 257b04b4..948b3666 100644 --- a/docs/user-guide/quickstart.md +++ b/docs/user-guide/quickstart.md @@ -56,6 +56,11 @@ The `CruiseDataExport.xlsx` in the `virtualship init` command refers to the .xls The `--start-date` should be set to your intended departure date/time. MFP's estimated travel and station durations are then used to automatically fill in a time for each waypoint relative to this start date. ``` +```{important} + +MFP usually allows a default of **45 minutes** stationkeeping time at each station, and that default is what determines the waypoint times written into your `expedition.yaml`. That may well be enough for your expedition, but it may not be, so be prepared to adjust the times once you have chosen your instruments and come to [adjusting the waypoint datetimes](#waypoint-datetimes). +``` + This will create a folder/directory called `EXPEDITION_NAME` with a single file: `expedition.yaml` containing details on the ship and instrument configurations, as well as the expedition schedule based on the sampling site coordinates (and, if specified, ports of departure/arrival) from your MFP export. The `--from-mfp` flag indicates that the exported route will be used, and `--start-date` is required alongside it. ```{note} @@ -122,6 +127,13 @@ Your Port of Departure and Port of Arrival (if set in MFP) appear as special way ```{note} It is important to ensure that the timings for each station are realistic. There must be enough time for the ship to travel to each site at a realistic speed (~ 10 knots). The expedition schedule (and the ship's configuration) will be automatically verified when you press _Save Changes_ in the planning tool. + +Remember that MFP's default of 45 minutes per station may not be enough once your instruments are selected (see [below](#instrument-selection)). Time at a station is made up of slowing the ship down (15 minutes by default), carrying out the deployments (which depends on which instruments you deploy), and getting back up to speed again (15 minutes by default). + +Deployments can take place side by side, so only the slowest one matters for the total stationkeeping time at each waypoint. Drifters and Argo floats are simply released into the water and left behind, so they have no time cost apart from slowing down and speeding up the ship. A CTD is different. It takes longer because it has to be lowered and brought back up, so the ship waits for the full round trip, and a cast at a deep station can easily take longer than the MFP default. An XBT is different in the opposite way. It doesn't even need the ship to slow down, so it can be deployed while the ship is moving at full speed. + +**See [here](../assignments/Research_proposal_intro.ipynb) for more information on the times taken for each instrument deployment.** + ``` ### Instrument selection diff --git a/docs/user-guide/tutorials/working_with_expedition_yaml.md b/docs/user-guide/tutorials/working_with_expedition_yaml.md index f6ef699c..8e5d078d 100644 --- a/docs/user-guide/tutorials/working_with_expedition_yaml.md +++ b/docs/user-guide/tutorials/working_with_expedition_yaml.md @@ -40,6 +40,7 @@ schedule: # <-- 1. expedition schedule section location: latitude: 45.604174 longitude: -43.886739 + sea_depth_meter: -4400.0 time: 1998-03-08 03:37:00 # Waypoint 2 - instrument: @@ -49,6 +50,7 @@ schedule: # <-- 1. expedition schedule section location: latitude: 48.185988 longitude: -32.988302 + sea_depth_meter: -3200.0 time: 1998-03-10 03:05:00 # Port of Arrival - location: @@ -75,6 +77,8 @@ instruments_config: # <-- 2. instrument configuration section # ship_config: # <-- 3. ship configuration section ship_speed_knots: 10.0 + waypoint_slowdown_time_minutes: 15.0 + waypoint_speedup_time_minutes: 15.0 ``` ```{note} @@ -113,9 +117,7 @@ You can do multiple `DRIFTER` deployments at the same waypoint by adding multipl - **Time (`time`)**: The scheduled time for reaching the waypoint, specifically in YYYY-MM-DD HH:MM:SS format. -##### Comments - -You may notice comments such as `# Port of Departure`, `# Waypoint 1`, `# Waypoint 2`, etc. next to each entry in the `waypoints` list, as in the example above. These are added automatically whenever `virtualship init` or `virtualship plan` writes the file, purely to aid readability. They are not required when editing the file by hand and won't be automatically added/renumbered by doing so. +- **Sea depth (`sea_depth_meter`)**: The estimated depth of the water column at the waypoint. This value matters because it determines how a long a deployment takes if there is a depth dependence, e.g. for a CTD that has to be lowered and raised. If you leave it as `null`, VirtualShip doesn't know how deep the water is and assumes the CTD descends all the way to its configured `max_depth_meter`. That is the slowest possible cast, so your schedule is verified against a worst case rather than an optimistic one in the simulations. #### 2. `instruments_config` @@ -129,7 +131,7 @@ For most users, the most important instrument configuration setting to consider ctd_config: max_depth_meter: -2000.0 min_depth_meter: -11.0 - stationkeeping_time_minutes: 50.0 + winch_speed_meter_per_second: 1.0 sensors: - TEMPERATURE - SALINITY @@ -158,4 +160,4 @@ This section contains setting related to the ship itself, specifically: - **Ship speed (`ship_speed_knots`)**: The speed of the ship in knots (nautical miles per hour; where 1 knot = 1.852 km/h). Note in most cases this should be left as the default value unless there is a specific reason to change it. - +- **Waypoint slow-down and speed-up times (`waypoint_slowdown_time_minutes`, `waypoint_speedup_time_minutes`)**: How long the ship takes to slow to a stop before deployments begin at a waypoint, and to get back up to cruising speed afterwards, respectively. These are added once at every waypoint where you deploy instruments which require the ship to slow down. diff --git a/src/virtualship/cli/_plan.py b/src/virtualship/cli/_plan.py index 9e60a32b..7cc8fa48 100644 --- a/src/virtualship/cli/_plan.py +++ b/src/virtualship/cli/_plan.py @@ -195,6 +195,9 @@ def _raise_logged_unexpected(e: Exception, path, context_message: str): DEFAULT_TS_CONFIG = {"period_minutes": 5.0} +# ship config timings, edited in minutes in their own (collapsed by default) section +SHIP_TIME_ATTRS = ("waypoint_slowdown_time", "waypoint_speedup_time") + DEFAULT_ADCP_CONFIG = { "num_bins": 40, "period_minutes": 5.0, @@ -232,7 +235,7 @@ def _raise_logged_unexpected(e: Exception, path, context_message: str): "attributes": [ {"name": "max_depth_meter"}, {"name": "min_depth_meter"}, - {"name": "stationkeeping_time", "minutes": True}, + {"name": "winch_speed_meter_per_second"}, ], }, "xbt_config": { @@ -257,7 +260,6 @@ def _raise_logged_unexpected(e: Exception, path, context_message: str): {"name": "vertical_speed_meter_per_second"}, {"name": "cycle_days"}, {"name": "drift_days"}, - {"name": "stationkeeping_time", "minutes": True}, {"name": "lifetime", "days": True}, ], }, @@ -268,7 +270,6 @@ def _raise_logged_unexpected(e: Exception, path, context_message: str): "attributes": [ {"name": "depth_meter"}, {"name": "lifetime", "days": True}, - {"name": "stationkeeping_time", "minutes": True}, ], }, } @@ -354,6 +355,27 @@ def compose(self) -> ComposeResult: ) yield Label("", id="validation-failure-label-speed", classes="-hidden") + # advanced settings, which most users can leave as the defaults + with Collapsible( + title="Ship slow-down & speed-up times at waypoints", + id="ship_advanced_collapsible", + collapsed=True, + ): + with Container(classes="instrument-config"): + for attr in SHIP_TIME_ATTRS: + yield Label( + f"{attr.replace('_', ' ').capitalize()} (minutes):" + ) + yield Input( + id=attr, + type=type_to_textual(get_field_type(ShipConfig, attr)), + validators=_field_validators(ShipConfig, attr), + value=_config_display_value( + ship_config, {"name": attr, "minutes": True} + ), + ) + yield _failure_label(attr) + with Horizontal(classes="ts-section"): yield Label("[b]Onboard Temperature/Salinity:[/b]") yield Switch( @@ -543,10 +565,21 @@ def _update_ship_speed(self): attr = "ship_speed_knots" field_type = get_field_type(type(self.expedition.ship_config), attr) value = field_type(self.query_one("#speed").value) + + # entered in minutes, stored as timedeltas + times = { + time_attr: datetime.timedelta( + minutes=float(self.query_one(f"#{time_attr}").value) + ) + for time_attr in SHIP_TIME_ATTRS + } + ShipConfig.model_validate( - {**self.expedition.ship_config.model_dump(), attr: value} + {**self.expedition.ship_config.model_dump(), attr: value, **times} ) self.expedition.ship_config.ship_speed_knots = value + for time_attr, time_value in times.items(): + setattr(self.expedition.ship_config, time_attr, time_value) def _update_instrument_configs(self): instruments_config = self.expedition.instruments_config @@ -1310,6 +1343,26 @@ class PlanApp(App): margin: 0 4; } + #ship_advanced_collapsible { + margin: 2 0; + padding-left: 0; + padding-bottom: 0; + border-top: none; + background: transparent; + } + + #ship_advanced_collapsible > CollapsibleTitle { + padding: 0; + } + + #ship_advanced_collapsible > Contents { + padding-top: 0; + } + + #ship_advanced_collapsible .instrument-config { + margin: 0 1; + } + .instrument-config { margin: 1; padding: 0 2; diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index cbf8a430..48dca355 100644 --- a/src/virtualship/expedition/simulate_schedule.py +++ b/src/virtualship/expedition/simulate_schedule.py @@ -231,23 +231,8 @@ def _get_underway_stationary_times( ] def _get_instrument_timescosts(self, waypoint: Waypoint | Port) -> timedelta: - # port stops have no instruments; if there are no instruments, there is no time cost - if isinstance(waypoint, Port): - return timedelta() - - # if proper waypoint but there are no instruments, there is no time cost - if isinstance(waypoint, Waypoint) and waypoint.instrument is None: - return timedelta() - - # make instruments a list even if it's only a single one - instruments = ( - waypoint.instrument - if isinstance(waypoint.instrument, list) - else [waypoint.instrument] - ) - - # time costs of each measurement - time_costs = [timedelta()] + """Queue waypoint's deployments, report how long the ship is held there.""" + instruments = self._expedition._wp_instruments(waypoint) for instrument in instruments: if instrument is InstrumentType.ARGO_FLOAT: @@ -262,12 +247,6 @@ def _get_instrument_timescosts(self, waypoint: Waypoint | Port) -> timedelta: drift_days=self._expedition.instruments_config.argo_float_config.drift_days, ) ) - # TODO: would be good to avoid having to twice make sure that stationkeeping time is factored in; i.e. in schedule validity checks and here (and for CTDs and Drifters) - # TODO: makes it easy to forget to update both... - # TODO: this is likely to fall under refactoring simulate_schedule.py (i.e. #236) - time_costs.append( - self._expedition.instruments_config.argo_float_config.stationkeeping_time - ) elif instrument is InstrumentType.CTD: self._measurements_to_simulate.ctds.append( @@ -277,9 +256,6 @@ def _get_instrument_timescosts(self, waypoint: Waypoint | Port) -> timedelta: max_depth=self._expedition.instruments_config.ctd_config.max_depth_meter, ) ) - time_costs.append( - self._expedition.instruments_config.ctd_config.stationkeeping_time - ) elif instrument is InstrumentType.DRIFTER: self._measurements_to_simulate.drifters.append( @@ -289,9 +265,6 @@ def _get_instrument_timescosts(self, waypoint: Waypoint | Port) -> timedelta: lifetime=self._expedition.instruments_config.drifter_config.lifetime, ) ) - time_costs.append( - self._expedition.instruments_config.drifter_config.stationkeeping_time - ) elif instrument is InstrumentType.XBT: self._measurements_to_simulate.xbts.append( @@ -306,7 +279,4 @@ def _get_instrument_timescosts(self, waypoint: Waypoint | Port) -> timedelta: else: raise NotImplementedError("Instrument type not supported.") - # measurements are done simultaneously onboard, so return time of longest one - # TODO: docs suggest that add individual instrument stationkeeping times are cumulative, which is at odds with measurements being done simultaneously onboard here - # TODO: update one or the other? - return max(time_costs) + return self._expedition.stationkeeping_time(waypoint) diff --git a/src/virtualship/instruments/argo_float.py b/src/virtualship/instruments/argo_float.py index 8f4a4a50..7b8a5faf 100644 --- a/src/virtualship/instruments/argo_float.py +++ b/src/virtualship/instruments/argo_float.py @@ -231,6 +231,9 @@ def _format_log_metadata(ptcls_subset, mask, fieldset): class ArgoFloatInstrument(Instrument): """ArgoFloat instrument class.""" + # the ship has to stop for this instrument + requires_ship_to_stop: ClassVar[bool] = True + sensor_kernels: ClassVar[dict[SensorType, Callable]] = { SensorType.TEMPERATURE: _argo_sample_temperature, SensorType.SALINITY: _argo_sample_salinity, @@ -263,6 +266,11 @@ def __init__(self, expedition, from_data): from_data=from_data, ) + @classmethod + def deployment_time(cls, config, seabed_depth: float | None) -> timedelta: + """Released and left behind, so no additional stationkeeping time.""" + return timedelta() + def simulate(self, measurements, out_path) -> None: """Simulate Argo float measurements.""" DT = 60.0 * 5 # dt of Argo float simulation integrator [seconds] diff --git a/src/virtualship/instruments/base.py b/src/virtualship/instruments/base.py index ff974996..ec5b950d 100644 --- a/src/virtualship/instruments/base.py +++ b/src/virtualship/instruments/base.py @@ -2,7 +2,6 @@ import abc import collections -import inspect import itertools from dataclasses import dataclass from datetime import datetime, timedelta @@ -35,7 +34,7 @@ if TYPE_CHECKING: from virtualship.instruments.sensors import SensorType - from virtualship.models import Expedition, Waypoint + from virtualship.models import Expedition, Waypoint, _InstrumentConfigMixin @dataclass(frozen=True) @@ -90,18 +89,12 @@ class Instrument(abc.ABC): """Base class for instruments and their simulation.""" # all instruments have sensor_kernels dict, mapping SensorType to sampling kernel + # (required of every registered instrument; checked by @register_instrument) sensor_kernels: ClassVar[dict[SensorType, collections.abc.Callable]] - def __init_subclass__(cls, **kwargs: object) -> None: - """Ensure concrete instrument subclasses define required class attributes.""" - super().__init_subclass__(**kwargs) - if inspect.isabstract(cls): - return - - if "sensor_kernels" not in cls.__dict__: - raise TypeError( - f"Instrument subclass '{cls.__name__}' must define 'sensor_kernels' as a class attribute." - ) + # all instruments declare whether the ship has to slow to a stop to deploy them (and so adds the waypoint slow-down and speed-up times) + # (required of every registered instrument; checked by @register_instrument) + requires_ship_to_stop: ClassVar[bool] def __init__( self, @@ -171,6 +164,13 @@ def load_input_data(self) -> parcels.FieldSet: return fieldset + @classmethod + @abc.abstractmethod + def deployment_time( + cls, config: _InstrumentConfigMixin, seabed_depth: float | None + ) -> timedelta: + """How long one deployment holds the ship on station, beyond slowing down and speeding up.""" + @abc.abstractmethod def simulate( self, @@ -413,6 +413,16 @@ def __post_init__(self): class UnderwayInstrument(Instrument): """Intermediate base class for underway instruments, which perform variable sampling without ParticleSets.""" + # samples continuously while the ship is under way, so never needs it to stop + requires_ship_to_stop: ClassVar[bool] = False + + @classmethod + def deployment_time( + cls, config: _InstrumentConfigMixin, seabed_depth: float | None + ) -> timedelta: + """Samples continuously while the ship is under way, so never holds it at a waypoint.""" + return timedelta() + def _sample_underway( self, config_sensors: list, diff --git a/src/virtualship/instruments/ctd.py b/src/virtualship/instruments/ctd.py index 66c40a86..7699bcac 100644 --- a/src/virtualship/instruments/ctd.py +++ b/src/virtualship/instruments/ctd.py @@ -119,6 +119,9 @@ def _ctd_cast(particles, fieldset): class CTDInstrument(Instrument): """CTD instrument class.""" + # the ship has to stop for this instrument + requires_ship_to_stop: ClassVar[bool] = True + sensor_kernels: ClassVar[dict[SensorType, Callable]] = { SensorType.TEMPERATURE: _sample_temperature, SensorType.SALINITY: _sample_salinity, @@ -148,9 +151,24 @@ def __init__(self, expedition, from_data): from_data=from_data, ) + @classmethod + def deployment_time(cls, config, seabed_depth: float | None) -> timedelta: + """Winched down to the seabed or its maximum depth, whichever is shallowest, and brought back up.""" + # depths are negative elevations, so the greater value is the shallower one + max_depth = ( + config.max_depth_meter + if seabed_depth is None + else max(config.max_depth_meter, seabed_depth) + ) + cast_distance = abs(max_depth - config.min_depth_meter) + return timedelta( + seconds=2 * cast_distance / config.winch_speed_meter_per_second + ) # 2 * for up and down + def simulate(self, measurements, out_path) -> None: """Simulate CTD measurements.""" - WINCH_SPEED = 1.0 # sink and rise speed in m/s + ctd_config = self.expedition.instruments_config.ctd_config + winch_speed = ctd_config.winch_speed_meter_per_second # sink/rise speed in m/s DT = 10.0 # dt of CTD simulation integrator OUTPUT_DT = timedelta(seconds=10) # output dt for CTD simulation @@ -180,13 +198,12 @@ def simulate(self, measurements, out_path) -> None: # CTD depth can not be too shallow, because kernel would break. # This shallow is not useful anyway, no need to support. - if not all([max_depth <= -DT * WINCH_SPEED for max_depth in max_depths]): + if not all([max_depth <= -DT * winch_speed for max_depth in max_depths]): raise ValueError( - f"CTD max_depth or bathymetry shallower than maximum {-DT * WINCH_SPEED}" + f"CTD max_depth or bathymetry shallower than maximum {-DT * winch_speed}" ) # build dynamic particle class from the active sensors - ctd_config = self.expedition.instruments_config.ctd_config _CTDParticle = build_particle_class_from_sensors( ctd_config.sensors, _CTD_NONSENSOR_VARIABLES ) @@ -201,7 +218,7 @@ def simulate(self, measurements, out_path) -> None: t=[ctd.spacetime.time for ctd in measurements], max_depth=max_depths, min_depth=[ctd.min_depth for ctd in measurements], - winch_speed=[WINCH_SPEED for _ in measurements], + winch_speed=[winch_speed for _ in measurements], ) # add initial conditions to sampling variables diff --git a/src/virtualship/instruments/drifter.py b/src/virtualship/instruments/drifter.py index 440fc5ee..3fa780eb 100644 --- a/src/virtualship/instruments/drifter.py +++ b/src/virtualship/instruments/drifter.py @@ -69,6 +69,9 @@ def _check_lifetime(particles, fieldset): class DrifterInstrument(Instrument): """Drifter instrument class.""" + # the ship has to stop for this instrument + requires_ship_to_stop: ClassVar[bool] = True + sensor_kernels: ClassVar[dict[SensorType, Callable]] = { SensorType.TEMPERATURE: _sample_temperature, } @@ -100,6 +103,11 @@ def __init__(self, expedition, from_data): from_data=from_data, ) + @classmethod + def deployment_time(cls, config, seabed_depth: float | None) -> timedelta: + """Released and left behind, so no additional stationkeeping time.""" + return timedelta() + def simulate(self, measurements, out_path) -> None: """Simulate Drifter measurements.""" OUTPUT_DT = timedelta(hours=5) diff --git a/src/virtualship/instruments/xbt.py b/src/virtualship/instruments/xbt.py index a3fa33a4..dbf40be6 100644 --- a/src/virtualship/instruments/xbt.py +++ b/src/virtualship/instruments/xbt.py @@ -82,6 +82,9 @@ def _xbt_cast(particles, fieldset): class XBTInstrument(Instrument): """XBT instrument class.""" + # the ship can deploy this instrument whilst moving, so never needs to stop + requires_ship_to_stop: ClassVar[bool] = False + sensor_kernels: ClassVar[dict[SensorType, Callable]] = { SensorType.TEMPERATURE: _sample_temperature, } @@ -103,6 +106,11 @@ def __init__(self, expedition, from_data): from_data=from_data, ) + @classmethod + def deployment_time(cls, config, seabed_depth: float | None) -> timedelta: + """Dropped over the side and not recovered, so no additional stationkeeping times.""" + return timedelta() + def simulate(self, measurements, out_path) -> None: """Simulate XBT measurements.""" DT = 10.0 # dt of XBT simulation integrator diff --git a/src/virtualship/make_realistic/problems/simulator.py b/src/virtualship/make_realistic/problems/simulator.py index b02486c5..8dd98afe 100644 --- a/src/virtualship/make_realistic/problems/simulator.py +++ b/src/virtualship/make_realistic/problems/simulator.py @@ -3,7 +3,6 @@ import random import sys import time -from datetime import timedelta from pathlib import Path from typing import TYPE_CHECKING @@ -380,11 +379,7 @@ def _has_contingency(self, problem: ProblemType, problem_wp_i: int | None) -> bo self.waypoints[problem_wp_i + 1], ) - stationkeeping = ( - curr_wp.stationkeeping_time(self.expedition.instruments_config) - if not isinstance(curr_wp, Port) - else timedelta(0) - ) + stationkeeping = self.expedition.stationkeeping_time(curr_wp) sail_time = _calc_sail_time( curr_wp.location, next_wp.location, diff --git a/src/virtualship/models/checkpoint.py b/src/virtualship/models/checkpoint.py index 85625f3d..2b340b71 100644 --- a/src/virtualship/models/checkpoint.py +++ b/src/virtualship/models/checkpoint.py @@ -10,7 +10,7 @@ from virtualship.errors import CheckpointError from virtualship.instruments.types import InstrumentType -from virtualship.models.expedition import Expedition, Port, Schedule +from virtualship.models.expedition import Expedition, Schedule from virtualship.utils import ( EXPEDITION, PROJECTION, @@ -100,11 +100,7 @@ def verify(self, expedition: Expedition, problems_dir: Path) -> None: if has_problem_location: problem_waypoint = new_schedule.waypoints[problem_wp_i] - stationkeeping_time = ( - problem_waypoint.stationkeeping_time(expedition.instruments_config) - if not isinstance(problem_waypoint, Port) - else timedelta(0) - ) + stationkeeping_time = expedition.stationkeeping_time(problem_waypoint) sail_time = _calc_sail_time( problem_waypoint.location, diff --git a/src/virtualship/models/expedition.py b/src/virtualship/models/expedition.py index 8045206e..29700f1e 100644 --- a/src/virtualship/models/expedition.py +++ b/src/virtualship/models/expedition.py @@ -15,13 +15,12 @@ from virtualship.instruments.sensors import SENSOR_REGISTRY, SensorType, _Sensor from virtualship.instruments.types import InstrumentType from virtualship.utils import ( - INSTRUMENT_CONFIG_MAP, _calc_sail_time, _get_bathy_data, _get_public_wp, _validate_numeric_to_timedelta, + get_instrument_class, get_supported_sensors, - register_instrument_config, ) from .location import Location @@ -139,11 +138,7 @@ def _first_overrun( for wp_i, (wp, wp_next) in enumerate( itertools.pairwise(wps_in_use), start=wps_in_use_offset ): - stationkeeping_time = ( - wp.stationkeeping_time(self.instruments_config) - if isinstance(wp, Waypoint) - else timedelta() - ) + stationkeeping_time = self.stationkeeping_time(wp) time_to_reach = _calc_sail_time( wp.location, @@ -170,6 +165,57 @@ def _describe_waypoint(self, wp_i: int, wp_next: Port | Waypoint) -> str: # +1 to get next return f"waypoint {_get_public_wp(wp_i + 1, self.schedule.waypoints)}" + def stationkeeping_time(self, waypoint: Port | Waypoint) -> timedelta: + """ + Calculate how long the ship is stationary at waypoint. + + Stationkeeping time = slow-down time + deployment time + speed-up time. + Assumes insturments are deployed concurrently, so the longest deployment time is used. + The ship only slows down once, and only if at least one instrument needs it to stop. + Waypoints with no instruments, or only instruments which can be deployed without slowing (e.g. XBT), add no time at all. + """ + instruments = self._wp_instruments(waypoint) + if not any( + get_instrument_class(instrument).requires_ship_to_stop + for instrument in instruments + ): + return timedelta() + + deployment_time = max( + self._deployment_time(instrument, waypoint) for instrument in instruments + ) + + return ( + self.ship_config.waypoint_slowdown_time + + deployment_time + + self.ship_config.waypoint_speedup_time + ) + + def _deployment_time( + self, + instrument: InstrumentType, + waypoint: Waypoint, + ) -> timedelta: + """Time an instrument holds the ship on station.""" + config = self.instruments_config.config_for(instrument) + if config is None: + return timedelta() + + return get_instrument_class(instrument).deployment_time( + config, waypoint.sea_depth_meter + ) + + @staticmethod + def _wp_instruments(waypoint: Port | Waypoint) -> list[InstrumentType]: + """Instruments deployed at a waypoint.""" + if isinstance(waypoint, Port) or not waypoint.instrument: + return [] + return ( + waypoint.instrument + if isinstance(waypoint.instrument, list) + else [waypoint.instrument] + ) + def _annotate(self): """ Add port/waypoint comments/annotations to the expedition.yaml file. @@ -219,9 +265,31 @@ class ShipConfig(pydantic.BaseModel): ship_speed_knots: float = pydantic.Field(gt=0.0) + waypoint_slowdown_time: timedelta = pydantic.Field( + serialization_alias="waypoint_slowdown_time_minutes", + validation_alias="waypoint_slowdown_time_minutes", + ge=timedelta(), + ) + waypoint_speedup_time: timedelta = pydantic.Field( + serialization_alias="waypoint_speedup_time_minutes", + validation_alias="waypoint_speedup_time_minutes", + ge=timedelta(), + ) + # TODO: room here for adding more ship config options in future PRs (e.g. max_days_at_sea)... - model_config = pydantic.ConfigDict(extra="forbid") + model_config = pydantic.ConfigDict(extra="forbid", populate_by_name=True) + + @pydantic.field_serializer("waypoint_slowdown_time", "waypoint_speedup_time") + def _serialize_minutes(self, value: timedelta, _info) -> float: + return value.total_seconds() / 60.0 + + @pydantic.field_validator( + "waypoint_slowdown_time", "waypoint_speedup_time", mode="before" + ) + @classmethod + def _validate_minutes(cls, value: int | float | timedelta) -> timedelta: + return _validate_numeric_to_timedelta(value, "minutes") class Schedule(pydantic.BaseModel): @@ -359,6 +427,9 @@ class Waypoint(pydantic.BaseModel): location: Location time: datetime | None = None instrument: InstrumentType | list[InstrumentType] | None = None + sea_depth_meter: float | None = pydantic.Field( + default=None, le=0.0 + ) # estimated depth from MFP, if available. If None, instrument deployment times costed against configured max depth, thus over-estimateing rather than under-estimating than under-estimating. @pydantic.field_serializer("instrument") def serialize_instrument(self, instrument): @@ -367,39 +438,6 @@ def serialize_instrument(self, instrument): return [inst.value for inst in instrument] return instrument.value if instrument else None - def stationkeeping_time( - self, - instruments_config: InstrumentsConfig, - instrument_config_map: dict = INSTRUMENT_CONFIG_MAP, - ) -> timedelta: - """Calculate how much time is required to carry out all instrument deployments at this waypoint.""" - wp_instruments = ( - self.instrument - if isinstance(self.instrument, list) - else ([self.instrument] if self.instrument else []) # safe handling of None - ) - - valid_instrument_configs = [ - iconfig for _, iconfig in instruments_config.__dict__.items() if iconfig - ] - - wp_instrument_configs = [] - for iconfig in valid_instrument_configs: - for instr in wp_instruments: - if ( - instrument_config_map.get(instr) == iconfig.__class__.__name__ - and iconfig - not in wp_instrument_configs # avoid duplicates (would happen when multiple drifter deployments at same wp) - ): - wp_instrument_configs.append(iconfig) - - cumulative_stationkeeping_time = timedelta() - for iconfig in wp_instrument_configs: - if hasattr(iconfig, "stationkeeping_time"): - cumulative_stationkeeping_time += iconfig.stationkeeping_time - - return cumulative_stationkeeping_time - ## @@ -425,13 +463,11 @@ def active_variables(self) -> dict[str, str]: """FieldSet-key → Copernicus-variable mapping for enabled sensors.""" return SensorConfig.build_variables(self.sensors) - @pydantic.field_serializer("stationkeeping_time", "period", check_fields=False) + @pydantic.field_serializer("period", check_fields=False) def _serialize_minutes(self, value: timedelta, _info) -> float: return value.total_seconds() / 60.0 - @pydantic.field_validator( - "stationkeeping_time", "period", mode="before", check_fields=False - ) + @pydantic.field_validator("period", mode="before", check_fields=False) @classmethod def _validate_minutes(cls, value: int | float | timedelta) -> timedelta: return _validate_numeric_to_timedelta(value, "minutes") @@ -446,7 +482,6 @@ def _validate_lifetime(cls, value: int | float | timedelta) -> timedelta: return _validate_numeric_to_timedelta(value, "days") -@register_instrument_config(InstrumentType.ARGO_FLOAT) class ArgoFloatConfig(_InstrumentConfigMixin, pydantic.BaseModel): """Configuration for argos floats.""" @@ -465,12 +500,6 @@ class ArgoFloatConfig(_InstrumentConfigMixin, pydantic.BaseModel): gt=timedelta(), ) - stationkeeping_time: timedelta = pydantic.Field( - serialization_alias="stationkeeping_time_minutes", - validation_alias="stationkeeping_time_minutes", - gt=timedelta(), - ) - sensors: list[SensorConfig] = pydantic.Field( default_factory=lambda: [ SensorConfig(sensor_type=SensorType.TEMPERATURE), @@ -481,7 +510,7 @@ class ArgoFloatConfig(_InstrumentConfigMixin, pydantic.BaseModel): ), ) - model_config = pydantic.ConfigDict(populate_by_name=True) + model_config = pydantic.ConfigDict(populate_by_name=True, extra="forbid") @pydantic.model_validator(mode="after") def _drift_days_less_than_cycle_days(self) -> ArgoFloatConfig: @@ -493,7 +522,6 @@ def _drift_days_less_than_cycle_days(self) -> ArgoFloatConfig: return self -@register_instrument_config(InstrumentType.ADCP) class ADCPConfig(_InstrumentConfigMixin, pydantic.BaseModel): """Configuration for ADCP instrument.""" @@ -516,7 +544,7 @@ class ADCPConfig(_InstrumentConfigMixin, pydantic.BaseModel): ), ) - model_config = pydantic.ConfigDict(populate_by_name=True) + model_config = pydantic.ConfigDict(populate_by_name=True, extra="forbid") def active_variables(self) -> dict[str, str]: """ @@ -532,20 +560,15 @@ def active_variables(self) -> dict[str, str]: return variables -@register_instrument_config(InstrumentType.CTD) class CTDConfig(_InstrumentConfigMixin, pydantic.BaseModel): """Configuration for CTD instrument.""" _instrument_type: ClassVar[InstrumentType] = InstrumentType.CTD _instrument_name: ClassVar[str] = "CTD" - stationkeeping_time: timedelta = pydantic.Field( - serialization_alias="stationkeeping_time_minutes", - validation_alias="stationkeeping_time_minutes", - gt=timedelta(), - ) min_depth_meter: float = pydantic.Field(le=0.0) max_depth_meter: float = pydantic.Field(le=0.0) + winch_speed_meter_per_second: float = pydantic.Field(gt=0.0) sensors: list[SensorConfig] = pydantic.Field( default_factory=lambda: [ @@ -564,10 +587,9 @@ class CTDConfig(_InstrumentConfigMixin, pydantic.BaseModel): ), ) - model_config = pydantic.ConfigDict(populate_by_name=True) + model_config = pydantic.ConfigDict(populate_by_name=True, extra="forbid") -@register_instrument_config(InstrumentType.UNDERWATER_ST) class ShipUnderwaterSTConfig(_InstrumentConfigMixin, pydantic.BaseModel): """Configuration for underwater ST.""" @@ -590,10 +612,9 @@ class ShipUnderwaterSTConfig(_InstrumentConfigMixin, pydantic.BaseModel): ), ) - model_config = pydantic.ConfigDict(populate_by_name=True) + model_config = pydantic.ConfigDict(populate_by_name=True, extra="forbid") -@register_instrument_config(InstrumentType.DRIFTER) class DrifterConfig(_InstrumentConfigMixin, pydantic.BaseModel): """Configuration for drifters.""" @@ -606,21 +627,14 @@ class DrifterConfig(_InstrumentConfigMixin, pydantic.BaseModel): validation_alias="lifetime_days", gt=timedelta(), ) - stationkeeping_time: timedelta = pydantic.Field( - serialization_alias="stationkeeping_time_minutes", - validation_alias="stationkeeping_time_minutes", - gt=timedelta(), - ) - sensors: list[SensorConfig] = pydantic.Field( default_factory=lambda: [SensorConfig(sensor_type=SensorType.TEMPERATURE)], description=("Sensors fitted to the drifter. Supported: TEMPERATURE. "), ) - model_config = pydantic.ConfigDict(populate_by_name=True) + model_config = pydantic.ConfigDict(populate_by_name=True, extra="forbid") -@register_instrument_config(InstrumentType.XBT) class XBTConfig(_InstrumentConfigMixin, pydantic.BaseModel): """Configuration for xbt instrument.""" @@ -637,6 +651,8 @@ class XBTConfig(_InstrumentConfigMixin, pydantic.BaseModel): description=("Sensors fitted to the XBT. Supported: TEMPERATURE. "), ) + model_config = pydantic.ConfigDict(populate_by_name=True, extra="forbid") + class InstrumentsConfig(pydantic.BaseModel): """Configuration of instruments.""" @@ -685,6 +701,21 @@ class InstrumentsConfig(pydantic.BaseModel): model_config = pydantic.ConfigDict(extra="forbid") + _CONFIG_FIELDS: ClassVar[dict[InstrumentType, str]] = { + InstrumentType.ARGO_FLOAT: "argo_float_config", + InstrumentType.DRIFTER: "drifter_config", + InstrumentType.XBT: "xbt_config", + InstrumentType.CTD: "ctd_config", + InstrumentType.ADCP: "adcp_config", + InstrumentType.UNDERWATER_ST: "ship_underwater_st_config", + } + + def config_for( + self, instrument_type: InstrumentType + ) -> _InstrumentConfigMixin | None: + """The configuration for an instrument type, or None if it is not configured.""" + return getattr(self, self._CONFIG_FIELDS[instrument_type]) + def verify(self, instruments_in_expedition: Collection[InstrumentType]) -> None: """ Verify instrument configurations against the instruments actually being deployed. @@ -692,14 +723,7 @@ def verify(self, instruments_in_expedition: Collection[InstrumentType]) -> None: Removes instrument configs not present in the schedule and checks that all scheduled instruments are configured. Raises ConfigError if any scheduled instrument is missing a config. """ - instrument_config_map = { - InstrumentType.ARGO_FLOAT: "argo_float_config", - InstrumentType.DRIFTER: "drifter_config", - InstrumentType.XBT: "xbt_config", - InstrumentType.CTD: "ctd_config", - InstrumentType.ADCP: "adcp_config", - InstrumentType.UNDERWATER_ST: "ship_underwater_st_config", - } + instrument_config_map = self._CONFIG_FIELDS # Remove configs for unused instruments for inst_type, config_attr in instrument_config_map.items(): if ( diff --git a/src/virtualship/static/expedition.yaml b/src/virtualship/static/expedition.yaml index 1e201543..7cf239d6 100644 --- a/src/virtualship/static/expedition.yaml +++ b/src/virtualship/static/expedition.yaml @@ -14,7 +14,6 @@ instruments_config: max_depth_meter: -2000.0 min_depth_meter: 0.0 vertical_speed_meter_per_second: -0.1 - stationkeeping_time_minutes: 20.0 lifetime_days: 63.0 sensors: - TEMPERATURE @@ -22,7 +21,7 @@ instruments_config: ctd_config: max_depth_meter: -2000.0 min_depth_meter: -11.0 - stationkeeping_time_minutes: 50.0 + winch_speed_meter_per_second: 1.0 sensors: - TEMPERATURE - SALINITY @@ -36,7 +35,6 @@ instruments_config: drifter_config: depth_meter: -1.0 lifetime_days: 42.0 - stationkeeping_time_minutes: 20.0 sensors: - TEMPERATURE xbt_config: @@ -64,6 +62,7 @@ schedule: location: latitude: 0.01 longitude: 0.01 + sea_depth_meter: null time: 1998-01-02 00:00:00 # Waypoint 2 - instrument: @@ -72,6 +71,7 @@ schedule: location: latitude: 0.02 longitude: 0.02 + sea_depth_meter: null time: 1998-01-03 01:00:00 # Waypoint 3 - instrument: @@ -79,6 +79,7 @@ schedule: location: latitude: 0.03 longitude: 0.03 + sea_depth_meter: null time: 1998-01-04 02:00:00 # Waypoint 4 - instrument: @@ -86,12 +87,14 @@ schedule: location: latitude: 0.04 longitude: 0.04 + sea_depth_meter: null time: 1998-01-05 03:00:00 # Waypoint 5 - instrument: [] location: latitude: 0.05 longitude: 0.05 + sea_depth_meter: null time: 1998-01-06 04:00:00 # Port of Arrival - location: @@ -100,3 +103,5 @@ schedule: time: 1998-01-07 05:00:00 ship_config: ship_speed_knots: 10.0 + waypoint_slowdown_time_minutes: 15.0 + waypoint_speedup_time_minutes: 15.0 diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index aeca9082..7a74d869 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -2,6 +2,7 @@ import glob import hashlib +import inspect import json import re import sys @@ -129,12 +130,24 @@ def register_instrument(instrument_type): + """Register an Instrument subclass (e.g. CTD, Argo Float etc.), refusing if doesn't meet the full Instrument parent class specifications.""" + def decorator(cls): - INSTRUMENT_CLASS_MAP[instrument_type] = cls - if hasattr(cls, "sensor_kernels"): # derive supported kernels from class attr - SUPPORTED_SENSORS_MAP[instrument_type] = frozenset( - cls.sensor_kernels.keys() + if inspect.isabstract(cls): + missing = ", ".join(sorted(cls.__abstractmethods__)) + raise TypeError(f"Instrument '{cls.__name__}' must implement: {missing}.") + if "sensor_kernels" not in vars(cls): + raise TypeError( + f"Instrument '{cls.__name__}' must define 'sensor_kernels' as a class attribute." + ) + + if not isinstance(getattr(cls, "requires_ship_to_stop", None), bool): + raise TypeError( + f"Instrument '{cls.__name__}' must define 'requires_ship_to_stop' as a bool class attribute." ) + + INSTRUMENT_CLASS_MAP[instrument_type] = cls + SUPPORTED_SENSORS_MAP[instrument_type] = frozenset(cls.sensor_kernels) return cls return decorator @@ -150,23 +163,11 @@ def get_supported_sensors(instrument_type): if supported is None: raise KeyError( f"No supported sensors registered for {instrument_type!r}. " - f"Does the instrument class define a `sensor_kernels` attribute?" + f"Is the instrument class decorated with @register_instrument?" ) return supported -# map for instrument type to instrument config (pydantic basemodel) names -INSTRUMENT_CONFIG_MAP = {} - - -def register_instrument_config(instrument_type): - def decorator(cls): - INSTRUMENT_CONFIG_MAP[instrument_type] = cls.__name__ - return cls - - return decorator - - # ===================================================== # SECTION: helper functions # ===================================================== diff --git a/tests/cli/test_plan.py b/tests/cli/test_plan.py index 70d5b3f8..efc0c32a 100644 --- a/tests/cli/test_plan.py +++ b/tests/cli/test_plan.py @@ -93,7 +93,8 @@ async def _expand_waypoint(expedition_editor, pilot, index: int) -> WaypointWidg return widget -def _four_waypoint_schedule() -> list: +def _four_waypoint_schedule(hours_apart: int = 1) -> list: + """N.B. a full-depth CTD cast keeps the ship on station for ~1.5 h, so use hours_apart >= 2 for a schedule that passes verification (e.g. on save).""" return [ Port(location=None, time=None), Waypoint( @@ -103,12 +104,12 @@ def _four_waypoint_schedule() -> list: ), Waypoint( location=Location(0.01, 0.01), - time=datetime(2022, 1, 1, 1, 0, 0), + time=datetime(2022, 1, 1, hours_apart, 0, 0), instrument=["CTD"], ), Waypoint( location=Location(0.02, 0.02), - time=datetime(2022, 1, 1, 2, 0, 0), + time=datetime(2022, 1, 1, 2 * hours_apart, 0, 0), instrument=["CTD"], ), Port(location=None, time=None), @@ -130,12 +131,12 @@ async def test_UI_changes(tmp_path): ), Waypoint( location=Location(0.01, 0.01), - time=datetime(2022, 1, 1, 1, 0, 0), + time=datetime(2022, 1, 1, 3, 0, 0), instrument=["CTD"], ), Waypoint( location=Location(0.02, 0.02), - time=datetime(2022, 1, 1, 2, 0, 0), + time=datetime(2022, 1, 1, 6, 0, 0), instrument=["CTD"], ), Port( @@ -214,7 +215,11 @@ async def test_UI_changes(tmp_path): assert saved_wps[2].location.lon == float(NEW_LON) assert saved_wps[1].instrument == [InstrumentType.XBT] - # check schedule.verify() methods are working by purposefully making invalid schedule (i.e. ship speed too slow to reach waypoints) + # the ship's slow-down / speed-up times survive a round trip through the UI, untouched + assert saved_expedition["ship_config"]["waypoint_slowdown_time_minutes"] == 15.0 + assert saved_expedition["ship_config"]["waypoint_speedup_time_minutes"] == 15.0 + + # check verifiction methods are working by purposefully making invalid schedule (i.e. ship speed too slow to reach waypoints) invalid_speed = "0.0001" await simulate_input(pilot, ship_speed_input, invalid_speed) await pilot.click(save_button) @@ -224,6 +229,72 @@ async def test_UI_changes(tmp_path): assert "*** Error saving changes ***" in args[0] +@pytest.mark.asyncio +async def test_ship_decel_accel_editable_and_saved(tmp_path): + """The ship's waypoint slow-down and speed-up times sit in their own collapsed section, and can be edited there and saved.""" + _make_expedition( + tmp_path, + [ + Port(location=None, time=None), + Waypoint( + location=Location(0, 0), + time=datetime(2022, 1, 1, 0, 0, 0), + instrument=["CTD"], + ), + Waypoint( + location=Location(0.01, 0.01), + time=datetime(2022, 1, 1, 6, 0, 0), + instrument=None, + ), + Port(location=None, time=None), + ], + ) + + app = PlanApp(path=tmp_path) + async with app.run_test(size=(160, 140)) as pilot: + await pilot.pause(0.5) + plan_screen = pilot.app.screen + plan_screen.notify = MagicMock() + expedition_editor = plan_screen.query_one(ExpeditionEditor) + + # collapsed by default + advanced = expedition_editor.query_one( + "#ship_advanced_collapsible", Collapsible + ) + assert advanced.collapsed + assert ( + expedition_editor.query_one("#waypoint_slowdown_time", Input).value + == "15.0" + ) + + advanced.collapsed = False + await pilot.pause() + + await simulate_input( + pilot, expedition_editor.query_one("#waypoint_slowdown_time", Input), "20" + ) + await simulate_input( + pilot, expedition_editor.query_one("#waypoint_speedup_time", Input), "25" + ) + assert expedition_editor.has_unsaved_changes() + + await pilot.click(plan_screen.query_one("#save_button", Button)) + await pilot.pause(0.5) + + calls = plan_screen.notify.call_args_list + assert any( + call[0][0] == "Changes saved successfully" + and call[1].get("severity") == "information" + for call in calls + ) + + with open(tmp_path / EXPEDITION) as f: + saved = yaml.safe_load(f) + + assert saved["ship_config"]["waypoint_slowdown_time_minutes"] == 20.0 + assert saved["ship_config"]["waypoint_speedup_time_minutes"] == 25.0 + + @pytest.mark.asyncio async def test_UI_opens_with_null_time_and_instrument(tmp_path): """Test that the UI opens correctly when waypoints have time: null and instrument: null.""" @@ -274,7 +345,7 @@ async def test_sensor_toggle_saved_to_yaml(tmp_path): ), Waypoint( location=Location(0.01, 0.01), - time=datetime(2022, 1, 1, 1, 0, 0), + time=datetime(2022, 1, 1, 3, 0, 0), instrument=None, ), Port(location=None, time=None), @@ -282,7 +353,7 @@ async def test_sensor_toggle_saved_to_yaml(tmp_path): ) app = PlanApp(path=tmp_path) - async with app.run_test(size=(160, 120)) as pilot: + async with app.run_test(size=(160, 140)) as pilot: await pilot.pause(0.5) plan_screen = pilot.app.screen plan_screen.notify = MagicMock() @@ -325,7 +396,7 @@ async def test_deselecting_all_sensors_on_active_instrument_blocks_save(tmp_path ), Waypoint( location=Location(0.01, 0.01), - time=datetime(2022, 1, 1, 1, 0, 0), + time=datetime(2022, 1, 1, 3, 0, 0), instrument=None, ), Port(location=None, time=None), @@ -370,7 +441,7 @@ async def test_deselecting_all_sensors_on_inactive_instrument(tmp_path): ), Waypoint( location=Location(0.01, 0.01), - time=datetime(2022, 1, 1, 1, 0, 0), + time=datetime(2022, 1, 1, 3, 0, 0), instrument=None, ), Port(location=None, time=None), @@ -407,7 +478,7 @@ async def test_deselecting_all_sensors_on_inactive_instrument(tmp_path): async def test_sensor_initial_state_reflects_config(tmp_path): """Sensor switches should reflect the enabled sensors already stored in the config.""" ctd_config = CTDConfig( - stationkeeping_time_minutes=50, + winch_speed_meter_per_second=1.0, min_depth_meter=-11.0, max_depth_meter=-2000.0, sensors=[SensorConfig(sensor_type=SensorType.TEMPERATURE)], @@ -427,7 +498,7 @@ async def test_sensor_initial_state_reflects_config(tmp_path): ), Waypoint( location=Location(0.01, 0.01), - time=datetime(2022, 1, 1, 1, 0, 0), + time=datetime(2022, 1, 1, 3, 0, 0), instrument=None, ), Port(location=None, time=None), @@ -645,7 +716,7 @@ async def test_waypoint_time_entry_and_adjust(tmp_path): @pytest.mark.asyncio async def test_save_shortcut_and_unsaved_changes_prompt(tmp_path): """ctrl+s saves, quitting with unsaved changes asks first, quitting after saving does not.""" - _make_expedition(tmp_path, _four_waypoint_schedule()) + _make_expedition(tmp_path, _four_waypoint_schedule(hours_apart=3)) app = PlanApp(path=tmp_path) async with app.run_test(size=(120, 100)) as pilot: diff --git a/tests/expedition/expedition_dir/expedition.yaml b/tests/expedition/expedition_dir/expedition.yaml index 630d7342..3d937764 100644 --- a/tests/expedition/expedition_dir/expedition.yaml +++ b/tests/expedition/expedition_dir/expedition.yaml @@ -39,17 +39,17 @@ instruments_config: max_depth_meter: -2000.0 min_depth_meter: 0.0 vertical_speed_meter_per_second: -0.1 - stationkeeping_time_minutes: 20.0 lifetime_days: 63.0 ctd_config: max_depth_meter: -2000.0 min_depth_meter: -11.0 - stationkeeping_time_minutes: 50.0 + winch_speed_meter_per_second: 1.0 drifter_config: depth_meter: -1.0 lifetime_days: 28.0 - stationkeeping_time_minutes: 20.0 ship_underwater_st_config: period_minutes: 5.0 ship_config: ship_speed_knots: 10.0 + waypoint_slowdown_time_minutes: 15.0 + waypoint_speedup_time_minutes: 15.0 diff --git a/tests/expedition/test_expedition.py b/tests/expedition/test_expedition.py index a7465cbb..81d73aab 100644 --- a/tests/expedition/test_expedition.py +++ b/tests/expedition/test_expedition.py @@ -82,51 +82,95 @@ def test_get_instruments(base_expedition) -> None: } -def test_waypoint_stationkeeping_time(expedition) -> None: - """Waypoint.stationkeeping_time sums the stationkeeping time of each unique instrument config present and not duplicating repeated instruments (e.g. multiple drifters).""" - instruments_config = expedition.instruments_config +def test_stationkeeping_time_uses_slowest_deployment(expedition) -> None: + """Deployments run concurrently while the ship is stopped, so the slowest one governs.""" + ship_config = expedition.ship_config + ctd_config = expedition.instruments_config.ctd_config waypoint = Waypoint( location=Location(0, 0), + sea_depth_meter=-500.0, instrument=[ InstrumentType.CTD, InstrumentType.ARGO_FLOAT, - InstrumentType.XBT, # has no stationkeeping time, should be ignored + InstrumentType.XBT, InstrumentType.DRIFTER, InstrumentType.DRIFTER, # two drifter deployments ], ) + # only the CTD holds the ship; it is winched to the seabed at 500 m and hauled back + cast = timedelta( + seconds=2 + * abs(-500.0 - ctd_config.min_depth_meter) + / ctd_config.winch_speed_meter_per_second + ) expected = ( - instruments_config.ctd_config.stationkeeping_time - + instruments_config.argo_float_config.stationkeeping_time - + instruments_config.drifter_config.stationkeeping_time + ship_config.waypoint_slowdown_time + cast + ship_config.waypoint_speedup_time + ) + + assert expedition.stationkeeping_time(waypoint) == expected + + +def test_stationkeeping_time_capped_by_sea_depth(expedition) -> None: + """A CTD cannot be winched below the seabed, so a shallow waypoint is quicker.""" + deep = Waypoint( + location=Location(0, 0), instrument=InstrumentType.CTD, sea_depth_meter=-3000.0 + ) + shallow = Waypoint( + location=Location(0, 0), instrument=InstrumentType.CTD, sea_depth_meter=-100.0 + ) + + # deep waypoint is limited by the CTD's own configured maximum, not the seabed + unbounded = Waypoint(location=Location(0, 0), instrument=InstrumentType.CTD) + + assert expedition.stationkeeping_time(shallow) < expedition.stationkeeping_time( + deep + ) + assert expedition.stationkeeping_time(deep) == expedition.stationkeeping_time( + unbounded ) - assert waypoint.stationkeeping_time(instruments_config) == expected -def test_waypoint_stationkeeping_time_single_instrument(expedition) -> None: - """A bare (non-list) InstrumentType value on a waypoint is handled the same as a single element list.""" - instruments_config = expedition.instruments_config +def test_stationkeeping_time_single_instrument(expedition) -> None: + """A bare (non-list) InstrumentType value is handled the same as a single element list.""" + bare = Waypoint( + location=Location(0, 0), instrument=InstrumentType.CTD, sea_depth_meter=-500.0 + ) + listed = Waypoint( + location=Location(0, 0), instrument=[InstrumentType.CTD], sea_depth_meter=-500.0 + ) + + assert expedition.stationkeeping_time(bare) == expedition.stationkeeping_time( + listed + ) - waypoint = Waypoint(location=Location(0, 0), instrument=InstrumentType.CTD) - assert ( - waypoint.stationkeeping_time(instruments_config) - == instruments_config.ctd_config.stationkeeping_time +def test_stationkeeping_time_xbt_does_not_stop_ship(expedition) -> None: + """An XBT is launched from a moving ship, so alone it costs no stationkeeping time.""" + ship_config = expedition.ship_config + xbt_only = Waypoint(location=Location(0, 0), instrument=InstrumentType.XBT) + xbt_and_drifter = Waypoint( + location=Location(0, 0), + instrument=[InstrumentType.XBT, InstrumentType.DRIFTER], ) + assert expedition.stationkeeping_time(xbt_only) == timedelta(0) + # another instrument at the same waypoint still makes the ship stop, and the XBT adds nothing on top + assert expedition.stationkeeping_time(xbt_and_drifter) == ( + ship_config.waypoint_slowdown_time + ship_config.waypoint_speedup_time + ) -def test_waypoint_stationkeeping_time_no_instruments(expedition) -> None: - """Waypoints with no instruments (None or empty list) have zero stationkeeping time.""" - instruments_config = expedition.instruments_config - assert Waypoint(location=Location(0, 0), instrument=None).stationkeeping_time( - instruments_config +def test_stationkeeping_time_no_instruments(expedition) -> None: + """The ship doesn't slows down where nothing is deployed, therefore ports and bare waypoints cost nothing.""" + assert expedition.stationkeeping_time( + Waypoint(location=Location(0, 0), instrument=None) ) == timedelta(0) - assert Waypoint(location=Location(0, 0), instrument=[]).stationkeeping_time( - instruments_config + assert expedition.stationkeeping_time( + Waypoint(location=Location(0, 0), instrument=[]) ) == timedelta(0) + assert expedition.stationkeeping_time(Port(location=Location(0, 0))) == timedelta(0) def test_verify_on_land(base_expedition): diff --git a/tests/expedition/test_simulate_schedule.py b/tests/expedition/test_simulate_schedule.py index f33e3b52..8b2f1f53 100644 --- a/tests/expedition/test_simulate_schedule.py +++ b/tests/expedition/test_simulate_schedule.py @@ -11,13 +11,15 @@ from virtualship.models import Expedition, Location, Schedule, Waypoint from virtualship.models.expedition import Port +TEST_EXPEDITION_YAML = "expedition_dir/expedition.yaml" + def test_simulate_schedule_feasible() -> None: """Test schedule with two waypoints that can be reached within time is OK.""" base_time = datetime.strptime("2022-01-01T00:00:00", "%Y-%m-%dT%H:%M:%S") projection = pyproj.Geod(ellps="WGS84") - expedition = Expedition.from_yaml("expedition_dir/expedition.yaml") + expedition = Expedition.from_yaml(TEST_EXPEDITION_YAML) expedition.ship_config.ship_speed_knots = 10.0 expedition.schedule = Schedule( waypoints=[ @@ -37,26 +39,21 @@ def test_simulate_schedule_feasible() -> None: def test_time_in_minutes_in_ship_schedule() -> None: """Test whether the pydantic serializer picks up the time *in minutes* in the ship schedule.""" - instruments_config = Expedition.from_yaml( - "expedition_dir/expedition.yaml" - ).instruments_config + instruments_config = Expedition.from_yaml(TEST_EXPEDITION_YAML).instruments_config assert instruments_config.adcp_config.period == timedelta(minutes=5) - assert instruments_config.ctd_config.stationkeeping_time == timedelta(minutes=50) - assert instruments_config.argo_float_config.stationkeeping_time == timedelta( - minutes=20 - ) - assert instruments_config.drifter_config.stationkeeping_time == timedelta( - minutes=20 - ) assert instruments_config.ship_underwater_st_config.period == timedelta(minutes=5) + ship_config = Expedition.from_yaml(TEST_EXPEDITION_YAML).ship_config + assert ship_config.waypoint_slowdown_time == timedelta(minutes=15) + assert ship_config.waypoint_speedup_time == timedelta(minutes=15) + def test_ship_path_inside_domain() -> None: """Test that the ship path (here represented by underway ADCP measurement sites) is inside the domain defined by the waypoints (which determines the fieldset bounds).""" base_time = datetime.strptime("2022-01-01T00:00:00", "%Y-%m-%dT%H:%M:%S") projection = pyproj.Geod(ellps="WGS84") - expedition = Expedition.from_yaml("expedition_dir/expedition.yaml") + expedition = Expedition.from_yaml(TEST_EXPEDITION_YAML) expedition.ship_config.ship_speed_knots = 10.0 wp1 = Location(-63.0, -57.7) # most southern @@ -123,7 +120,7 @@ def test_does_not_simulate_unverified(): base_time = datetime.strptime("2022-01-01T00:00:00", "%Y-%m-%dT%H:%M:%S") projection = pyproj.Geod(ellps="WGS84") - expedition = Expedition.from_yaml("expedition_dir/expedition.yaml") + expedition = Expedition.from_yaml(TEST_EXPEDITION_YAML) expedition.ship_config.ship_speed_knots = 10.0 expedition.schedule = Schedule( waypoints=[ diff --git a/tests/instruments/test_argo_float.py b/tests/instruments/test_argo_float.py index b52ae766..5ca001f8 100644 --- a/tests/instruments/test_argo_float.py +++ b/tests/instruments/test_argo_float.py @@ -36,7 +36,6 @@ VERTICAL_SPEED = -0.10 CYCLE_DAYS = 10 DRIFT_DAYS = 9 -STATIONKEEPING_TIME = 10 @pytest.fixture @@ -49,7 +48,6 @@ def argo_config_kwargs(): "vertical_speed_meter_per_second": VERTICAL_SPEED, "cycle_days": CYCLE_DAYS, "drift_days": DRIFT_DAYS, - "stationkeeping_time_minutes": STATIONKEEPING_TIME, } @@ -162,7 +160,6 @@ class DummyExpedition: cycle_days=CYCLE_DAYS, drift_days=DRIFT_DAYS, lifetime=lifetime, - stationkeeping_time_minutes=STATIONKEEPING_TIME, sensors=sensors, ) ) diff --git a/tests/instruments/test_base.py b/tests/instruments/test_base.py index 0d5979f8..c1eceb1a 100644 --- a/tests/instruments/test_base.py +++ b/tests/instruments/test_base.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timedelta from typing import ClassVar from unittest.mock import MagicMock, PropertyMock, patch @@ -19,7 +19,11 @@ from virtualship.instruments.sensors import SensorType from virtualship.instruments.types import InstrumentType from virtualship.models.expedition import SensorConfig -from virtualship.utils import MAX_CACHE_BYTES, get_instrument_class +from virtualship.utils import ( + MAX_CACHE_BYTES, + get_instrument_class, + register_instrument, +) # ============================================================================= # Fixtures @@ -149,6 +153,13 @@ class DummyInstrument(Instrument): sensor_kernels = {} # noqa + requires_ship_to_stop = False + + @classmethod + def deployment_time(cls, config, seabed_depth): + """Dummy deployment time for test.""" + return timedelta() + def simulate(self, measurements, out_path): """Dummy simulate implementation for test.""" self.simulate_called = True @@ -398,12 +409,54 @@ def test_load_input_data_error(mock_expedition, monkeypatch): dummy.load_input_data() -def test_instrument_subclass_without_sensor_kernels_error(): - """Defining a concrete Instrument subclass without sensor_kernels raises TypeError.""" +@pytest.fixture +def isolated_registry(monkeypatch): + """Keep test registrations out of the real instrument registry.""" + monkeypatch.setattr("virtualship.utils.INSTRUMENT_CLASS_MAP", {}) + monkeypatch.setattr("virtualship.utils.SUPPORTED_SENSORS_MAP", {}) + + +def test_register_instrument_without_sensor_kernels_error(isolated_registry): + """Registering an instrument without sensor_kernels raises TypeError.""" with pytest.raises(TypeError, match="sensor_kernels"): + @register_instrument(InstrumentType.CTD) class ErrorInstrument(Instrument): - def simulate(self, data_dir, measurements, out_path): + requires_ship_to_stop = False + + @classmethod + def deployment_time(cls, config, seabed_depth): + return timedelta() + + def simulate(self, measurements, out_path): + pass + + +def test_register_instrument_without_deployment_time_error(isolated_registry): + """Registering an instrument that leaves deployment_time unimplemented raises TypeError.""" + with pytest.raises(TypeError, match="deployment_time"): + + @register_instrument(InstrumentType.CTD) + class ErrorInstrument(Instrument): + sensor_kernels = {} # noqa + + def simulate(self, measurements, out_path): + pass + + +def test_register_instrument_without_requires_ship_to_stop_error(isolated_registry): + """Registering an instrument that leaves requires_ship_to_stop unimplemented raises TypeError.""" + with pytest.raises(TypeError, match="requires_ship_to_stop"): + + @register_instrument(InstrumentType.CTD) + class ErrorInstrument(Instrument): + sensor_kernels = {} # noqa + + @classmethod + def deployment_time(cls, config, seabed_depth): + return timedelta() + + def simulate(self, measurements, out_path): pass diff --git a/tests/instruments/test_ctd.py b/tests/instruments/test_ctd.py index 76aa647b..5aeca9b0 100644 --- a/tests/instruments/test_ctd.py +++ b/tests/instruments/test_ctd.py @@ -28,7 +28,6 @@ BASE_TIME = datetime.datetime.strptime("1950-01-01", "%Y-%m-%d") MIN_DEPTH = -11 MAX_DEPTH = -2000 -STATIONKEEPING_TIME = 50 def create_dummy_expedition( @@ -53,7 +52,7 @@ class DummyExpedition: instruments_config = InstrumentsConfig( ctd_config=CTDConfig( - stationkeeping_time_minutes=STATIONKEEPING_TIME, + winch_speed_meter_per_second=1.0, min_depth_meter=MIN_DEPTH, max_depth_meter=MAX_DEPTH, sensors=sensors, @@ -328,7 +327,7 @@ def test_simulate_ctds_multiple_waypoints(tmpdir) -> None: def test_ctd_sensor_config_active_variables() -> None: """active_variables() only returns variables for enabled sensors.""" config_both = CTDConfig( - stationkeeping_time_minutes=50, + winch_speed_meter_per_second=1.0, min_depth_meter=-11.0, max_depth_meter=-2000.0, sensors=[ @@ -339,7 +338,7 @@ def test_ctd_sensor_config_active_variables() -> None: assert config_both.active_variables() == {"T": "thetao", "o2": "o2"} config_temp_only = CTDConfig( - stationkeeping_time_minutes=50, + winch_speed_meter_per_second=1.0, min_depth_meter=-11.0, max_depth_meter=-2000.0, sensors=[ @@ -352,7 +351,7 @@ def test_ctd_sensor_config_active_variables() -> None: def test_ctd_sensor_config_yaml() -> None: """CTDConfig sensors survive YAML serialisation.""" config = CTDConfig( - stationkeeping_time_minutes=50, + winch_speed_meter_per_second=1.0, min_depth_meter=-11.0, max_depth_meter=-2000.0, sensors=[ @@ -436,7 +435,7 @@ def test_ctd_supported_sensors(): def test_ctd_config_default_sensors(): """CTDConfig defaults to all supported sensors (phys + bgc).""" config = CTDConfig( - stationkeeping_time_minutes=50, + winch_speed_meter_per_second=1.0, min_depth_meter=-11.0, max_depth_meter=-2000.0, ) @@ -459,7 +458,7 @@ def test_ctd_config_unsupported_sensor_rejected(): """Unsupported sensor on CTD is rejected.""" with pytest.raises(pydantic.ValidationError, match="does not support"): CTDConfig( - stationkeeping_time_minutes=50, + winch_speed_meter_per_second=1.0, min_depth_meter=-11.0, max_depth_meter=-2000.0, sensors=[SensorConfig(sensor_type=SensorType.VELOCITY)], diff --git a/tests/instruments/test_drifter.py b/tests/instruments/test_drifter.py index 211a7f55..86e096b8 100644 --- a/tests/instruments/test_drifter.py +++ b/tests/instruments/test_drifter.py @@ -54,7 +54,6 @@ class DummyExpedition: drifter_config=DrifterConfig( lifetime=lifetime, depth_meter=depth, - stationkeeping_time_minutes=10, sensors=sensors, ) ) @@ -344,7 +343,6 @@ def test_drifter_disabled_sensor_absent_from_output(tmpdir) -> None: DrifterConfig( lifetime=LIFETIME, depth_meter=DEPLOY_DEPTH, - stationkeeping_time_minutes=10, sensors=[], ) @@ -354,7 +352,6 @@ def test_drifter_config_default_sensors(): config = DrifterConfig( lifetime=LIFETIME, depth_meter=DEPLOY_DEPTH, - stationkeeping_time_minutes=10, ) assert config.sensors[0].sensor_type is SensorType.TEMPERATURE @@ -365,7 +362,6 @@ def test_drifter_config_unsupported_sensor_rejected(): DrifterConfig( lifetime=LIFETIME, depth_meter=DEPLOY_DEPTH, - stationkeeping_time_minutes=10, sensors=[SensorConfig(sensor_type=SensorType.VELOCITY)], ) diff --git a/tests/make_realistic/problems/test_simulator.py b/tests/make_realistic/problems/test_simulator.py index 6432185d..91bdbffc 100644 --- a/tests/make_realistic/problems/test_simulator.py +++ b/tests/make_realistic/problems/test_simulator.py @@ -57,7 +57,11 @@ def _make_simple_expedition( schedule = Schedule(waypoints=waypoints) instruments = InstrumentsConfig() - ship = ShipConfig(ship_speed_knots=10.0) + ship = ShipConfig( + ship_speed_knots=10.0, + waypoint_slowdown_time_minutes=15.0, + waypoint_speedup_time_minutes=15.0, + ) return Expedition( schedule=schedule, instruments_config=instruments, ship_config=ship ) diff --git a/tests/test_utils.py b/tests/test_utils.py index 788d64fb..6dd3c053 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -9,6 +9,7 @@ from parcels import FieldSet, ParticleClass, Variable import virtualship.utils +from virtualship.instruments.base import Instrument from virtualship.instruments.sensors import SensorType from virtualship.instruments.types import InstrumentType from virtualship.models.expedition import Expedition, Port, SensorConfig, Waypoint @@ -35,10 +36,19 @@ def expedition(tmp_file): @pytest.fixture def dummy_instrument(): - class DummyInstrument: - pass + class DummyInstrument(Instrument): + sensor_kernels = {} # noqa - return DummyInstrument() + requires_ship_to_stop = False + + @classmethod + def deployment_time(cls, config, seabed_depth): + return datetime.timedelta() + + def simulate(self, measurements, out_path): + pass + + return DummyInstrument @pytest.fixture @@ -69,9 +79,11 @@ def fake_open_dataset(*args, **kwargs): yield -def test_instrument_registry_updates(dummy_instrument): +def test_instrument_registry_updates(dummy_instrument, monkeypatch): from virtualship import utils + monkeypatch.setattr(utils, "INSTRUMENT_CLASS_MAP", {}) + monkeypatch.setattr(utils, "SUPPORTED_SENSORS_MAP", {}) utils.register_instrument("DUMMY_TYPE")(dummy_instrument) assert utils.INSTRUMENT_CLASS_MAP["DUMMY_TYPE"] is dummy_instrument