From b97909b7237cf7fab74009d5f71e99952ff270da Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 4 May 2026 14:14:41 +0200 Subject: [PATCH 01/13] mod ctd instrument to take bgc sampling --- src/virtualship/instruments/ctd.py | 45 +++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/src/virtualship/instruments/ctd.py b/src/virtualship/instruments/ctd.py index 122e1461..cc1239d4 100644 --- a/src/virtualship/instruments/ctd.py +++ b/src/virtualship/instruments/ctd.py @@ -4,8 +4,8 @@ from typing import TYPE_CHECKING, ClassVar import numpy as np -from parcels import JITParticle, ParticleSet, Variable +from parcels import JITParticle, ParticleSet, Variable from virtualship.instruments.base import Instrument from virtualship.instruments.sensors import SensorType from virtualship.instruments.types import InstrumentType @@ -49,6 +49,8 @@ class CTD: # SECTION: Kernels # ===================================================== +## physical variables + def _sample_temperature(particle, fieldset, time): particle.temperature = fieldset.T[time, particle.depth, particle.lat, particle.lon] @@ -58,6 +60,40 @@ def _sample_salinity(particle, fieldset, time): particle.salinity = fieldset.S[time, particle.depth, particle.lat, particle.lon] +## bgc variables + + +def _sample_o2(particle, fieldset, time): + particle.o2 = fieldset.o2[time, particle.depth, particle.lat, particle.lon] + + +def _sample_chlorophyll(particle, fieldset, time): + particle.chl = fieldset.chl[time, particle.depth, particle.lat, particle.lon] + + +def _sample_nitrate(particle, fieldset, time): + particle.no3 = fieldset.no3[time, particle.depth, particle.lat, particle.lon] + + +def _sample_phosphate(particle, fieldset, time): + particle.po4 = fieldset.po4[time, particle.depth, particle.lat, particle.lon] + + +def _sample_ph(particle, fieldset, time): + particle.ph = fieldset.ph[time, particle.depth, particle.lat, particle.lon] + + +def _sample_phytoplankton(particle, fieldset, time): + particle.phyc = fieldset.phyc[time, particle.depth, particle.lat, particle.lon] + + +def _sample_primary_production(particle, fieldset, time): + particle.nppv = fieldset.nppv[time, particle.depth, particle.lat, particle.lon] + + +## cast + + def _ctd_cast(particle, fieldset, time): # lowering if particle.raising == 0: @@ -84,6 +120,13 @@ class CTDInstrument(Instrument): sensor_kernels: ClassVar[dict[SensorType, Callable]] = { SensorType.TEMPERATURE: _sample_temperature, SensorType.SALINITY: _sample_salinity, + SensorType.OXYGEN: _sample_o2, + SensorType.CHLOROPHYLL: _sample_chlorophyll, + SensorType.NITRATE: _sample_nitrate, + SensorType.PHOSPHATE: _sample_phosphate, + SensorType.PH: _sample_ph, + SensorType.PHYTOPLANKTON: _sample_phytoplankton, + SensorType.PRIMARY_PRODUCTION: _sample_primary_production, } def __init__(self, expedition, from_data): From f2fed18f81ee5cb7ab49703c052c0fba4caef9d0 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 4 May 2026 14:15:02 +0200 Subject: [PATCH 02/13] delete ctd_bgc instrument --- src/virtualship/instruments/ctd_bgc.py | 229 ------------------------- 1 file changed, 229 deletions(-) delete mode 100644 src/virtualship/instruments/ctd_bgc.py diff --git a/src/virtualship/instruments/ctd_bgc.py b/src/virtualship/instruments/ctd_bgc.py deleted file mode 100644 index 3568d0a8..00000000 --- a/src/virtualship/instruments/ctd_bgc.py +++ /dev/null @@ -1,229 +0,0 @@ -from collections.abc import Callable -from dataclasses import dataclass -from datetime import timedelta -from typing import ClassVar - -import numpy as np -from parcels import JITParticle, ParticleSet, Variable - -from virtualship.instruments.base import Instrument -from virtualship.instruments.sensors import SensorType -from virtualship.instruments.types import InstrumentType -from virtualship.models.spacetime import Spacetime -from virtualship.utils import ( - add_dummy_UV, - build_particle_class_from_sensors, - register_instrument, -) - -# ===================================================== -# SECTION: Dataclass -# ===================================================== - - -@dataclass -class CTD_BGC: - """CTD_BGC configuration.""" - - name: ClassVar[str] = "CTD_BGC" - spacetime: Spacetime - min_depth: float - max_depth: float - - -# ===================================================== -# SECTION: non-sensor Particle Variables (non-sampling) -# ===================================================== - -_CTD_BGC_NONSENSOR_VARIABLES = [ - Variable("raising", dtype=np.int8, initial=0.0), # bool. 0 is False, 1 is True. - Variable("max_depth", dtype=np.float32), - Variable("min_depth", dtype=np.float32), - Variable("winch_speed", dtype=np.float32), -] - -# ===================================================== -# SECTION: Kernels -# ===================================================== - - -def _sample_o2(particle, fieldset, time): - particle.o2 = fieldset.o2[time, particle.depth, particle.lat, particle.lon] - - -def _sample_chlorophyll(particle, fieldset, time): - particle.chl = fieldset.chl[time, particle.depth, particle.lat, particle.lon] - - -def _sample_nitrate(particle, fieldset, time): - particle.no3 = fieldset.no3[time, particle.depth, particle.lat, particle.lon] - - -def _sample_phosphate(particle, fieldset, time): - particle.po4 = fieldset.po4[time, particle.depth, particle.lat, particle.lon] - - -def _sample_ph(particle, fieldset, time): - particle.ph = fieldset.ph[time, particle.depth, particle.lat, particle.lon] - - -def _sample_phytoplankton(particle, fieldset, time): - particle.phyc = fieldset.phyc[time, particle.depth, particle.lat, particle.lon] - - -def _sample_primary_production(particle, fieldset, time): - particle.nppv = fieldset.nppv[time, particle.depth, particle.lat, particle.lon] - - -def _ctd_bgc_cast(particle, fieldset, time): - # lowering - if particle.raising == 0: - particle_ddepth = -particle.winch_speed * particle.dt - if particle.depth + particle_ddepth < particle.max_depth: - particle.raising = 1 - particle_ddepth = -particle_ddepth - # raising - else: - particle_ddepth = particle.winch_speed * particle.dt - if particle.depth + particle_ddepth > particle.min_depth: - particle.delete() - - -# ===================================================== -# SECTION: Instrument Class -# ===================================================== - - -@register_instrument(InstrumentType.CTD_BGC) -class CTD_BGCInstrument(Instrument): - """CTD_BGC instrument class.""" - - sensor_kernels: ClassVar[dict[SensorType, Callable]] = { - SensorType.OXYGEN: _sample_o2, - SensorType.CHLOROPHYLL: _sample_chlorophyll, - SensorType.NITRATE: _sample_nitrate, - SensorType.PHOSPHATE: _sample_phosphate, - SensorType.PH: _sample_ph, - SensorType.PHYTOPLANKTON: _sample_phytoplankton, - SensorType.PRIMARY_PRODUCTION: _sample_primary_production, - } - - def __init__(self, expedition, from_data): - """Initialize CTD_BGCInstrument.""" - variables = expedition.instruments_config.ctd_bgc_config.active_variables() - limit_spec = { - "spatial": True - } # spatial limits; lat/lon constrained to waypoint locations + buffer - - super().__init__( - expedition, - variables, - add_bathymetry=True, - allow_time_extrapolation=True, - verbose_progress=False, - spacetime_buffer_size=None, - limit_spec=limit_spec, - from_data=from_data, - ) - - def simulate(self, measurements, out_path) -> None: - """Simulate BGC CTD measurements using Parcels.""" - WINCH_SPEED = 1.0 # sink and rise speed in m/s - DT = 10.0 # dt of CTD_BGC simulation integrator - OUTPUT_DT = timedelta(seconds=10) # output dt for CTD_BGC simulation - - if len(measurements) == 0: - print( - "No BGC CTDs provided. Parcels currently crashes when providing an empty particle set, so no BGC CTD simulation will be done and no files will be created." - ) - # TODO when Parcels supports it this check can be removed. - return - - fieldset = self.load_input_data() - - # add dummy U - add_dummy_UV(fieldset) # TODO: parcels v3 bodge; remove when parcels v4 is used - - # use first active field for time reference - _time_ref_key = next(iter(self.variables)) - _time_ref_field = getattr(fieldset, _time_ref_key) - fieldset_starttime = _time_ref_field.grid.time_origin.fulltime( - _time_ref_field.grid.time_full[0] - ) - fieldset_endtime = _time_ref_field.grid.time_origin.fulltime( - _time_ref_field.grid.time_full[-1] - ) - - # deploy time for all ctds should be later than fieldset start time - if not all( - [ - np.datetime64(ctd_bgc.spacetime.time) >= fieldset_starttime - for ctd_bgc in measurements - ] - ): - raise ValueError("BGC CTD deployed before fieldset starts.") - - # depth the bgc ctd will go to. shallowest between bgc ctd max depth and bathymetry. - max_depths = [ - max( - ctd_bgc.max_depth, - fieldset.bathymetry.eval( - z=0, - y=ctd_bgc.spacetime.location.lat, - x=ctd_bgc.spacetime.location.lon, - time=0, - ), - ) - for ctd_bgc in measurements - ] - - # 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]): - raise ValueError( - f"BGC CTD max_depth or bathymetry shallower than maximum {-DT * WINCH_SPEED}" - ) - - # build dynamic particle class from the active sensors - ctd_bgc_config = self.expedition.instruments_config.ctd_bgc_config - _CTD_BGCParticle = build_particle_class_from_sensors( - ctd_bgc_config.sensors, _CTD_BGC_NONSENSOR_VARIABLES, JITParticle - ) - - # define parcel particles - ctd_bgc_particleset = ParticleSet( - fieldset=fieldset, - pclass=_CTD_BGCParticle, - lon=[ctd_bgc.spacetime.location.lon for ctd_bgc in measurements], - lat=[ctd_bgc.spacetime.location.lat for ctd_bgc in measurements], - depth=[ctd_bgc.min_depth for ctd_bgc in measurements], - time=[ctd_bgc.spacetime.time for ctd_bgc in measurements], - max_depth=max_depths, - min_depth=[ctd_bgc.min_depth for ctd_bgc in measurements], - winch_speed=[WINCH_SPEED for _ in measurements], - ) - - # define output file for the simulation - out_file = ctd_bgc_particleset.ParticleFile(name=out_path, outputdt=OUTPUT_DT) - - # build kernel list from active sensors only - sampling_kernels = [ - self.sensor_kernels[sc.sensor_type] - for sc in ctd_bgc_config.sensors - if sc.enabled and sc.sensor_type in self.sensor_kernels - ] - - # execute simulation - ctd_bgc_particleset.execute( - [*sampling_kernels, _ctd_bgc_cast], - endtime=fieldset_endtime, - dt=DT, - verbose_progress=self.verbose_progress, - output_file=out_file, - ) - - # there should be no particles left, as they delete themselves when they resurface - if len(ctd_bgc_particleset.particledata) != 0: - raise ValueError( - "Simulation ended before BGC CTD resurfaced. This most likely means the field time dimension did not match the simulation time span." - ) From 0d4b7e919c935d346e846b51920bf325510f72e1 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 4 May 2026 14:23:40 +0200 Subject: [PATCH 03/13] remove rest of CTD_BGC logic from codebase --- src/virtualship/cli/_plan.py | 10 - .../expedition/simulate_schedule.py | 24 +- src/virtualship/instruments/__init__.py | 2 - src/virtualship/instruments/types.py | 1 - src/virtualship/models/expedition.py | 7 - src/virtualship/utils.py | 14 +- .../expedition/expedition_dir/expedition.yaml | 4 - .../expedition_dir/input_data/.gitignore | 1 - tests/expedition/test_expedition.py | 14 +- tests/expedition/test_simulate_schedule.py | 3 - tests/instruments/test_ctd_bgc.py | 297 ------------------ tests/test_utils.py | 6 +- 12 files changed, 5 insertions(+), 378 deletions(-) delete mode 100644 tests/instruments/test_ctd_bgc.py diff --git a/src/virtualship/cli/_plan.py b/src/virtualship/cli/_plan.py index 895abf29..e397aad7 100644 --- a/src/virtualship/cli/_plan.py +++ b/src/virtualship/cli/_plan.py @@ -32,7 +32,6 @@ from virtualship.models import ( ADCPConfig, ArgoFloatConfig, - CTD_BGCConfig, CTDConfig, DrifterConfig, Expedition, @@ -109,15 +108,6 @@ def log_exception_to_file( {"name": "stationkeeping_time", "minutes": True}, ], }, - "ctd_bgc_config": { - "class": CTD_BGCConfig, - "title": "CTD-BGC", - "attributes": [ - {"name": "max_depth_meter"}, - {"name": "min_depth_meter"}, - {"name": "stationkeeping_time", "minutes": True}, - ], - }, "xbt_config": { "class": XBTConfig, "title": "XBT", diff --git a/src/virtualship/expedition/simulate_schedule.py b/src/virtualship/expedition/simulate_schedule.py index d96efc91..6af9d80c 100644 --- a/src/virtualship/expedition/simulate_schedule.py +++ b/src/virtualship/expedition/simulate_schedule.py @@ -10,7 +10,6 @@ from virtualship.instruments.argo_float import ArgoFloat from virtualship.instruments.ctd import CTD -from virtualship.instruments.ctd_bgc import CTD_BGC from virtualship.instruments.drifter import Drifter from virtualship.instruments.types import InstrumentType from virtualship.instruments.xbt import XBT @@ -53,7 +52,6 @@ class MeasurementsToSimulate: InstrumentType.ARGO_FLOAT: "argo_floats", InstrumentType.DRIFTER: "drifters", InstrumentType.CTD: "ctds", - InstrumentType.CTD_BGC: "ctd_bgcs", InstrumentType.XBT: "xbts", } @@ -67,7 +65,6 @@ def get_attr_for_instrumenttype(cls, instrument_type): argo_floats: list[ArgoFloat] = field(default_factory=list, init=False) drifters: list[Drifter] = field(default_factory=list, init=False) ctds: list[CTD] = field(default_factory=list, init=False) - ctd_bgcs: list[CTD_BGC] = field(default_factory=list, init=False) xbts: list[XBT] = field(default_factory=list, init=False) @@ -265,12 +262,6 @@ def _make_measurements(self, waypoint: Waypoint) -> timedelta: # time costs of each measurement time_costs = [timedelta()] - # check if both CTD and CTD_BGC are present - # TODO: this can be avoided if CTD and CTD_BGC are merged into a single instrument - both_ctd_and_bgc = ( - InstrumentType.CTD in instruments and InstrumentType.CTD_BGC in instruments - ) - for instrument in instruments: if instrument is InstrumentType.ARGO_FLOAT: self._measurements_to_simulate.argo_floats.append( @@ -302,20 +293,7 @@ def _make_measurements(self, waypoint: Waypoint) -> timedelta: time_costs.append( self._expedition.instruments_config.ctd_config.stationkeeping_time ) - elif instrument is InstrumentType.CTD_BGC: - self._measurements_to_simulate.ctd_bgcs.append( - CTD_BGC( - spacetime=Spacetime(self._location, self._time), - min_depth=self._expedition.instruments_config.ctd_bgc_config.min_depth_meter, - max_depth=self._expedition.instruments_config.ctd_bgc_config.max_depth_meter, - ) - ) - if both_ctd_and_bgc: # only need to add time cost once if both CTD and CTD_BGC are being taken; in reality they would be done on the same instrument - pass - else: - time_costs.append( - self._expedition.instruments_config.ctd_bgc_config.stationkeeping_time - ) + elif instrument is InstrumentType.DRIFTER: self._measurements_to_simulate.drifters.append( Drifter( diff --git a/src/virtualship/instruments/__init__.py b/src/virtualship/instruments/__init__.py index b593ed38..0811a7d2 100644 --- a/src/virtualship/instruments/__init__.py +++ b/src/virtualship/instruments/__init__.py @@ -4,7 +4,6 @@ adcp, argo_float, ctd, - ctd_bgc, drifter, ship_underwater_st, xbt, @@ -14,7 +13,6 @@ "adcp", "argo_float", "ctd", - "ctd_bgc", "drifter", "ship_underwater_st", "xbt", diff --git a/src/virtualship/instruments/types.py b/src/virtualship/instruments/types.py index 489a331f..aa47bc6b 100644 --- a/src/virtualship/instruments/types.py +++ b/src/virtualship/instruments/types.py @@ -7,7 +7,6 @@ class InstrumentType(Enum): """Types of the instruments.""" CTD = "CTD" - CTD_BGC = "CTD_BGC" DRIFTER = "DRIFTER" ARGO_FLOAT = "ARGO_FLOAT" XBT = "XBT" diff --git a/src/virtualship/models/expedition.py b/src/virtualship/models/expedition.py index 32855fc9..0eb74530 100644 --- a/src/virtualship/models/expedition.py +++ b/src/virtualship/models/expedition.py @@ -488,13 +488,6 @@ class InstrumentsConfig(pydantic.BaseModel): If None, no CTDs can be cast. """ - ctd_bgc_config: CTD_BGCConfig | None = None - """ - CTD_BGC configuration. - - If None, no BGC CTDs can be cast. - """ - ship_underwater_st_config: ShipUnderwaterSTConfig | None = None """ Ship underwater salinity temperature measurementconfiguration. diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 37bb44c4..2f008dbc 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -15,8 +15,8 @@ import numpy as np import pyproj import xarray as xr -from parcels import FieldSet, Variable +from parcels import FieldSet, Variable from virtualship.errors import CopernicusCatalogueError if TYPE_CHECKING: @@ -641,14 +641,6 @@ def _calc_wp_stationkeeping_time( if not wp_instrument_types: wp_instrument_types = [] - # TODO: this can be removed if/when CTD and CTD_BGC are merged to a single instrument - from virtualship.instruments.types import InstrumentType - - both_ctd_and_bgc = ( - InstrumentType.CTD in wp_instrument_types - and InstrumentType.CTD_BGC in wp_instrument_types - ) - # extract configs for all instruments present in expedition valid_instrument_configs = [ iconfig for _, iconfig in instruments_config.__dict__.items() if iconfig @@ -669,10 +661,6 @@ def _calc_wp_stationkeeping_time( # get wp total stationkeeping time cumulative_stationkeeping_time = timedelta() for iconfig in wp_instrument_configs: - if both_ctd_and_bgc and iconfig.__class__.__name__ == instrument_config_map.get( - InstrumentType.CTD_BGC - ): - continue # only count stationkeeping once when both CTD and CTD_BGC are present; in reality they would be done on the same instrument if hasattr(iconfig, "stationkeeping_time"): cumulative_stationkeeping_time += iconfig.stationkeeping_time diff --git a/tests/expedition/expedition_dir/expedition.yaml b/tests/expedition/expedition_dir/expedition.yaml index 0d2bb155..6392076b 100644 --- a/tests/expedition/expedition_dir/expedition.yaml +++ b/tests/expedition/expedition_dir/expedition.yaml @@ -37,10 +37,6 @@ instruments_config: max_depth_meter: -2000.0 min_depth_meter: -11.0 stationkeeping_time_minutes: 50.0 - ctd_bgc_config: - max_depth_meter: -2000.0 - min_depth_meter: -11.0 - stationkeeping_time_minutes: 50.0 drifter_config: depth_meter: -1.0 lifetime_days: 28.0 diff --git a/tests/expedition/expedition_dir/input_data/.gitignore b/tests/expedition/expedition_dir/input_data/.gitignore index b323da5b..2235154f 100644 --- a/tests/expedition/expedition_dir/input_data/.gitignore +++ b/tests/expedition/expedition_dir/input_data/.gitignore @@ -7,4 +7,3 @@ !ship_uv.nc !drifter_t.nc !drifter_uv.nc -!ctd_bgc_*.nc diff --git a/tests/expedition/test_expedition.py b/tests/expedition/test_expedition.py index 6008309a..ddef53e7 100644 --- a/tests/expedition/test_expedition.py +++ b/tests/expedition/test_expedition.py @@ -6,8 +6,8 @@ import pyproj import pytest import xarray as xr -from parcels import FieldSet +from parcels import FieldSet from virtualship.errors import InstrumentsConfigError, ScheduleError from virtualship.models import ( Expedition, @@ -268,12 +268,6 @@ def instruments_config_no_ctd(expedition): return expedition.instruments_config -@pytest.fixture -def instruments_config_no_ctd_bgc(expedition): - delattr(expedition.instruments_config, "ctd_bgc_config") - return expedition.instruments_config - - @pytest.fixture def instruments_config_no_argo_float(expedition): delattr(expedition.instruments_config, "argo_float_config") @@ -321,12 +315,6 @@ def test_verify_instruments_config_no_instrument(expedition, expedition_no_xbt) "Expedition includes instrument 'CTD', but instruments_config does not provide configuration for it.", id="InstrumentsConfigNoCTD", ), - pytest.param( - "instruments_config_no_ctd_bgc", - InstrumentsConfigError, - "Expedition includes instrument 'CTD_BGC', but instruments_config does not provide configuration for it.", - id="InstrumentsConfigNoCTD_BGC", - ), pytest.param( "instruments_config_no_argo_float", InstrumentsConfigError, diff --git a/tests/expedition/test_simulate_schedule.py b/tests/expedition/test_simulate_schedule.py index f90eecc2..35dfbdea 100644 --- a/tests/expedition/test_simulate_schedule.py +++ b/tests/expedition/test_simulate_schedule.py @@ -56,9 +56,6 @@ def test_time_in_minutes_in_ship_schedule() -> None: ).instruments_config assert instruments_config.adcp_config.period == timedelta(minutes=5) assert instruments_config.ctd_config.stationkeeping_time == timedelta(minutes=50) - assert instruments_config.ctd_bgc_config.stationkeeping_time == timedelta( - minutes=50 - ) assert instruments_config.argo_float_config.stationkeeping_time == timedelta( minutes=20 ) diff --git a/tests/instruments/test_ctd_bgc.py b/tests/instruments/test_ctd_bgc.py deleted file mode 100644 index ad485617..00000000 --- a/tests/instruments/test_ctd_bgc.py +++ /dev/null @@ -1,297 +0,0 @@ -""" -Test the simulation of CTD_BGC instruments. - -Fields are kept static over time and time component of CTD_BGC measurements is not tested because it's tricky to provide expected measurements. -""" - -import datetime - -import numpy as np -import pydantic -import pytest -import xarray as xr - -from parcels import Field, FieldSet -from virtualship.instruments.ctd_bgc import CTD_BGC, CTD_BGCInstrument -from virtualship.instruments.sensors import SensorType -from virtualship.models import Location, Spacetime -from virtualship.models.expedition import ( - CTD_BGCConfig, - InstrumentsConfig, - SensorConfig, - Waypoint, -) - - -def test_simulate_ctd_bgcs(tmpdir) -> None: - # arbitrary time offset for the dummy fieldset - base_time = datetime.datetime.strptime("1950-01-01", "%Y-%m-%d") - - # where to cast CTD_BGCs - ctd_bgcs = [ - CTD_BGC( - spacetime=Spacetime( - location=Location(latitude=0, longitude=1), - time=base_time + datetime.timedelta(hours=0), - ), - min_depth=0, - max_depth=float("-inf"), - ), - CTD_BGC( - spacetime=Spacetime( - location=Location(latitude=1, longitude=0), - time=base_time, - ), - min_depth=0, - max_depth=float("-inf"), - ), - ] - - # expected observations for ctd_bgcs at surface and at maximum depth - ctd_bgc_exp = [ - { - "surface": { - "o2": 9, - "chl": 10, - "no3": 13, - "po4": 14, - "ph": 8.1, - "phyc": 15, - "nppv": 17, - "lat": ctd_bgcs[0].spacetime.location.lat, - "lon": ctd_bgcs[0].spacetime.location.lon, - }, - "maxdepth": { - "o2": 11, - "chl": 12, - "no3": 18, - "po4": 19, - "ph": 8.0, - "phyc": 20, - "nppv": 22, - "lat": ctd_bgcs[0].spacetime.location.lat, - "lon": ctd_bgcs[0].spacetime.location.lon, - }, - }, - { - "surface": { - "o2": 9, - "chl": 10, - "no3": 13, - "po4": 14, - "ph": 8.1, - "phyc": 15, - "nppv": 17, - "lat": ctd_bgcs[1].spacetime.location.lat, - "lon": ctd_bgcs[1].spacetime.location.lon, - }, - "maxdepth": { - "o2": 11, - "chl": 12, - "no3": 18, - "po4": 19, - "ph": 8.0, - "phyc": 20, - "nppv": 22, - "lat": ctd_bgcs[1].spacetime.location.lat, - "lon": ctd_bgcs[1].spacetime.location.lon, - }, - }, - ] - - # create fieldset based on the expected observations - # indices are time, depth, latitude, longitude - u = np.zeros((2, 2, 2, 2)) - v = np.zeros((2, 2, 2, 2)) - o2 = np.zeros((2, 2, 2, 2)) - chl = np.zeros((2, 2, 2, 2)) - no3 = np.zeros((2, 2, 2, 2)) - po4 = np.zeros((2, 2, 2, 2)) - ph = np.zeros((2, 2, 2, 2)) - phyc = np.zeros((2, 2, 2, 2)) - nppv = np.zeros((2, 2, 2, 2)) - - # Fill fields for both CTDs at surface and maxdepth - o2[:, 1, 0, 1] = ctd_bgc_exp[0]["surface"]["o2"] - o2[:, 0, 0, 1] = ctd_bgc_exp[0]["maxdepth"]["o2"] - o2[:, 1, 1, 0] = ctd_bgc_exp[1]["surface"]["o2"] - o2[:, 0, 1, 0] = ctd_bgc_exp[1]["maxdepth"]["o2"] - - chl[:, 1, 0, 1] = ctd_bgc_exp[0]["surface"]["chl"] - chl[:, 0, 0, 1] = ctd_bgc_exp[0]["maxdepth"]["chl"] - chl[:, 1, 1, 0] = ctd_bgc_exp[1]["surface"]["chl"] - chl[:, 0, 1, 0] = ctd_bgc_exp[1]["maxdepth"]["chl"] - - no3[:, 1, 0, 1] = ctd_bgc_exp[0]["surface"]["no3"] - no3[:, 0, 0, 1] = ctd_bgc_exp[0]["maxdepth"]["no3"] - no3[:, 1, 1, 0] = ctd_bgc_exp[1]["surface"]["no3"] - no3[:, 0, 1, 0] = ctd_bgc_exp[1]["maxdepth"]["no3"] - - po4[:, 1, 0, 1] = ctd_bgc_exp[0]["surface"]["po4"] - po4[:, 0, 0, 1] = ctd_bgc_exp[0]["maxdepth"]["po4"] - po4[:, 1, 1, 0] = ctd_bgc_exp[1]["surface"]["po4"] - po4[:, 0, 1, 0] = ctd_bgc_exp[1]["maxdepth"]["po4"] - - ph[:, 1, 0, 1] = ctd_bgc_exp[0]["surface"]["ph"] - ph[:, 0, 0, 1] = ctd_bgc_exp[0]["maxdepth"]["ph"] - ph[:, 1, 1, 0] = ctd_bgc_exp[1]["surface"]["ph"] - ph[:, 0, 1, 0] = ctd_bgc_exp[1]["maxdepth"]["ph"] - - phyc[:, 1, 0, 1] = ctd_bgc_exp[0]["surface"]["phyc"] - phyc[:, 0, 0, 1] = ctd_bgc_exp[0]["maxdepth"]["phyc"] - phyc[:, 1, 1, 0] = ctd_bgc_exp[1]["surface"]["phyc"] - phyc[:, 0, 1, 0] = ctd_bgc_exp[1]["maxdepth"]["phyc"] - - nppv[:, 1, 0, 1] = ctd_bgc_exp[0]["surface"]["nppv"] - nppv[:, 0, 0, 1] = ctd_bgc_exp[0]["maxdepth"]["nppv"] - nppv[:, 1, 1, 0] = ctd_bgc_exp[1]["surface"]["nppv"] - nppv[:, 0, 1, 0] = ctd_bgc_exp[1]["maxdepth"]["nppv"] - - fieldset = FieldSet.from_data( - { - "V": v, - "U": u, - "o2": o2, - "chl": chl, - "no3": no3, - "po4": po4, - "ph": ph, - "phyc": phyc, - "nppv": nppv, - }, - { - "time": [ - np.datetime64(base_time + datetime.timedelta(hours=0)), - np.datetime64(base_time + datetime.timedelta(hours=1)), - ], - "depth": [-1000, 0], - "lat": [0, 1], - "lon": [0, 1], - }, - ) - fieldset.add_field(Field("bathymetry", [-1000], lon=0, lat=0)) - - # dummy expedition for CTD_BGCInstrument - class DummyExpedition: - class schedule: - # ruff: noqa - waypoints = [ - Waypoint( - location=Location(1, 2), - time=base_time, - ), - ] - - instruments_config = InstrumentsConfig( - ctd_bgc_config=CTD_BGCConfig( - stationkeeping_time_minutes=50, - min_depth_meter=-11.0, - max_depth_meter=-2000.0, - sensors=[ - SensorConfig(sensor_type=SensorType.OXYGEN), - SensorConfig(sensor_type=SensorType.CHLOROPHYLL), - SensorConfig(sensor_type=SensorType.NITRATE), - SensorConfig(sensor_type=SensorType.PHOSPHATE), - SensorConfig(sensor_type=SensorType.PH), - SensorConfig(sensor_type=SensorType.PHYTOPLANKTON), - SensorConfig(sensor_type=SensorType.PRIMARY_PRODUCTION), - ], - ) - ) - - expedition = DummyExpedition() - from_data = None - - ctd_bgc_instrument = CTD_BGCInstrument(expedition, from_data) - out_path = tmpdir.join("out.zarr") - - ctd_bgc_instrument.load_input_data = lambda: fieldset - ctd_bgc_instrument.simulate(ctd_bgcs, out_path) - - # test if output is as expected - results = xr.open_zarr(out_path) - - assert len(results.trajectory) == len(ctd_bgcs) - - for ctd_i, (traj, exp_bothloc) in enumerate( - zip(results.trajectory, ctd_bgc_exp, strict=True) - ): - obs_surface = results.sel(trajectory=traj, obs=0) - min_index = np.argmin(results.sel(trajectory=traj)["z"].data) - obs_maxdepth = results.sel(trajectory=traj, obs=min_index) - - for obs, loc in [ - (obs_surface, "surface"), - (obs_maxdepth, "maxdepth"), - ]: - exp = exp_bothloc[loc] - for var in [ - "o2", - "chl", - "no3", - "po4", - "ph", - "phyc", - "nppv", - "lat", - "lon", - ]: - obs_value = obs[var].values.item() - exp_value = exp[var] - assert np.isclose(obs_value, exp_value), ( - f"Observation incorrect {ctd_i=} {loc=} {var=} {obs_value=} {exp_value=}." - ) - - -def test_ctd_bgc_sensor_config_active_variables() -> None: - """active_variables() only returns variables for enabled sensors.""" - config_all = CTD_BGCConfig( - stationkeeping_time_minutes=50, - min_depth_meter=-11.0, - max_depth_meter=-2000.0, - sensors=[ - SensorConfig(sensor_type=SensorType.OXYGEN), - SensorConfig(sensor_type=SensorType.CHLOROPHYLL), - SensorConfig(sensor_type=SensorType.NITRATE), - SensorConfig(sensor_type=SensorType.PHOSPHATE), - SensorConfig(sensor_type=SensorType.PH), - SensorConfig(sensor_type=SensorType.PHYTOPLANKTON), - SensorConfig(sensor_type=SensorType.PRIMARY_PRODUCTION), - ], - ) - assert config_all.active_variables() == { - "o2": "o2", - "chl": "chl", - "no3": "no3", - "po4": "po4", - "ph": "ph", - "phyc": "phyc", - "nppv": "nppv", - } - - config_o2_only = CTD_BGCConfig( - stationkeeping_time_minutes=50, - min_depth_meter=-11.0, - max_depth_meter=-2000.0, - sensors=[ - SensorConfig(sensor_type=SensorType.OXYGEN) - ], # all others omitted = disabled - ) - assert config_o2_only.active_variables() == {"o2": "o2"} - - -def test_ctd_bgc_sensor_config_yaml() -> None: - """CTD_BGCConfig sensors survive YAML serialisation.""" - config = CTD_BGCConfig( - stationkeeping_time_minutes=50, - min_depth_meter=-11.0, - max_depth_meter=-2000.0, - sensors=[ - SensorConfig(sensor_type=SensorType.OXYGEN) - ], # CHLOROPHYLL and others omitted = disabled - ) - dumped = config.model_dump(by_alias=True) - loaded = CTD_BGCConfig.model_validate(dumped) - assert len(loaded.sensors) == 1 - assert loaded.sensors[0].sensor_type == SensorType.OXYGEN - assert loaded.sensors[0].enabled is True diff --git a/tests/test_utils.py b/tests/test_utils.py index 4860f2f6..295ac6be 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,9 +4,9 @@ import numpy as np import pytest import xarray as xr -from parcels import FieldSet, JITParticle, ScipyParticle, Variable import virtualship.utils +from parcels import FieldSet, JITParticle, ScipyParticle, Variable from virtualship.instruments.sensors import SensorType from virtualship.instruments.types import InstrumentType from virtualship.models.expedition import Expedition, SensorConfig @@ -272,9 +272,8 @@ def test_calc_wp_stationkeeping_time(expedition, monkeypatch): """Test _calc_wp_stationkeeping_time for correct stationkeeping time calculation.""" class DummyInstrumentsConfig: - def __init__(self, ctd, ctd_bgc, argo, xbt, drifter): + def __init__(self, ctd, argo, xbt, drifter): self.ctd = ctd - self.ctd_bgc = ctd_bgc self.argo = argo self.xbt = xbt self.drifter = drifter @@ -308,7 +307,6 @@ class DrifterConfig: # Create a dummy expedition with instruments_config containing the dummy configs instruments_config = DummyInstrumentsConfig( ctd=CTDConfig(), - ctd_bgc=CTD_BGCConfig(), argo=ArgoFloatConfig(), xbt=XBTConfig(), drifter=DrifterConfig(), From 7e7efad6dff3d0b4ae433c819428934dfd4798b7 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 4 May 2026 14:24:11 +0200 Subject: [PATCH 04/13] further deletions and move bgc sensors to default CTD config --- src/virtualship/models/expedition.py | 1 - src/virtualship/static/expedition.yaml | 5 ----- 2 files changed, 6 deletions(-) diff --git a/src/virtualship/models/expedition.py b/src/virtualship/models/expedition.py index 0eb74530..c9fe4692 100644 --- a/src/virtualship/models/expedition.py +++ b/src/virtualship/models/expedition.py @@ -524,7 +524,6 @@ def verify(self, expedition: Expedition) -> None: InstrumentType.DRIFTER: "drifter_config", InstrumentType.XBT: "xbt_config", InstrumentType.CTD: "ctd_config", - InstrumentType.CTD_BGC: "ctd_bgc_config", InstrumentType.ADCP: "adcp_config", InstrumentType.UNDERWATER_ST: "ship_underwater_st_config", } diff --git a/src/virtualship/static/expedition.yaml b/src/virtualship/static/expedition.yaml index 8ab72f8a..b947c7d0 100644 --- a/src/virtualship/static/expedition.yaml +++ b/src/virtualship/static/expedition.yaml @@ -60,11 +60,6 @@ instruments_config: sensors: - TEMPERATURE - SALINITY - ctd_bgc_config: - max_depth_meter: -2000.0 - min_depth_meter: -11.0 - stationkeeping_time_minutes: 50.0 - sensors: - OXYGEN - CHLOROPHYLL - NITRATE From 8433a22015469133dbea9ef71b21083c52cc9131 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 4 May 2026 14:27:07 +0200 Subject: [PATCH 05/13] further deletions of CTD_BGC logic --- src/virtualship/models/__init__.py | 2 -- src/virtualship/models/expedition.py | 34 -------------------------- src/virtualship/static/expedition.yaml | 1 - tests/test_utils.py | 8 ------ 4 files changed, 45 deletions(-) diff --git a/src/virtualship/models/__init__.py b/src/virtualship/models/__init__.py index 7d3b0881..71f24211 100644 --- a/src/virtualship/models/__init__.py +++ b/src/virtualship/models/__init__.py @@ -4,7 +4,6 @@ from .expedition import ( ADCPConfig, ArgoFloatConfig, - CTD_BGCConfig, CTDConfig, DrifterConfig, Expedition, @@ -29,7 +28,6 @@ "ArgoFloatConfig", "ADCPConfig", "CTDConfig", - "CTD_BGCConfig", "ShipUnderwaterSTConfig", "DrifterConfig", "XBTConfig", diff --git a/src/virtualship/models/expedition.py b/src/virtualship/models/expedition.py index c9fe4692..f1c83037 100644 --- a/src/virtualship/models/expedition.py +++ b/src/virtualship/models/expedition.py @@ -359,40 +359,6 @@ class CTDConfig(_InstrumentConfigMixin, pydantic.BaseModel): model_config = pydantic.ConfigDict(populate_by_name=True) -@register_instrument_config(InstrumentType.CTD_BGC) -class CTD_BGCConfig(_InstrumentConfigMixin, pydantic.BaseModel): - """Configuration for CTD_BGC instrument.""" - - _instrument_type: ClassVar[InstrumentType] = InstrumentType.CTD_BGC - _instrument_name: ClassVar[str] = "CTD_BGC" - - 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) - - sensors: list[SensorConfig] = pydantic.Field( - default_factory=lambda: [ - SensorConfig(sensor_type=SensorType.OXYGEN), - SensorConfig(sensor_type=SensorType.CHLOROPHYLL), - SensorConfig(sensor_type=SensorType.NITRATE), - SensorConfig(sensor_type=SensorType.PHOSPHATE), - SensorConfig(sensor_type=SensorType.PH), - SensorConfig(sensor_type=SensorType.PHYTOPLANKTON), - SensorConfig(sensor_type=SensorType.PRIMARY_PRODUCTION), - ], - description=( - "Sensors fitted to the BGC CTD. " - "Supported: OXYGEN, CHLOROPHYLL, NITRATE, PHOSPHATE, PH, PHYTOPLANKTON, PRIMARY_PRODUCTION. " - ), - ) - - model_config = pydantic.ConfigDict(populate_by_name=True) - - @register_instrument_config(InstrumentType.UNDERWATER_ST) class ShipUnderwaterSTConfig(_InstrumentConfigMixin, pydantic.BaseModel): """Configuration for underwater ST.""" diff --git a/src/virtualship/static/expedition.yaml b/src/virtualship/static/expedition.yaml index b947c7d0..3be45c4d 100644 --- a/src/virtualship/static/expedition.yaml +++ b/src/virtualship/static/expedition.yaml @@ -6,7 +6,6 @@ schedule: waypoints: - instrument: - CTD - - CTD_BGC location: latitude: 0 longitude: 0 diff --git a/tests/test_utils.py b/tests/test_utils.py index 295ac6be..c0187caf 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -281,9 +281,6 @@ def __init__(self, ctd, argo, xbt, drifter): class CTDConfig: stationkeeping_time = datetime.timedelta(minutes=50) - class CTD_BGCConfig: - stationkeeping_time = datetime.timedelta(minutes=50) - class ArgoFloatConfig: stationkeeping_time = datetime.timedelta(minutes=20) @@ -297,7 +294,6 @@ class DrifterConfig: "virtualship.utils.INSTRUMENT_CONFIG_MAP", { InstrumentType.CTD: "CTDConfig", - InstrumentType.CTD_BGC: "CTD_BGCConfig", InstrumentType.ARGO_FLOAT: "ArgoFloatConfig", InstrumentType.XBT: "XBTConfig", InstrumentType.DRIFTER: "DrifterConfig", @@ -318,7 +314,6 @@ class DrifterConfig: # instruments at a given waypoint wp_instrument_types_all = [ InstrumentType.CTD, - InstrumentType.CTD_BGC, InstrumentType.ARGO_FLOAT, InstrumentType.XBT, InstrumentType.DRIFTER, @@ -332,9 +327,6 @@ class DrifterConfig: assert ( stationkeeping_time_all == CTDConfig.stationkeeping_time - + ( - CTD_BGCConfig.stationkeeping_time * 0.0 - ) # CTD(_BGC) counted once when both present + ArgoFloatConfig.stationkeeping_time + DrifterConfig.stationkeeping_time # drifter should only be counted once despite being present at wp twice ) From ae919ebb5e942c47bacac9a6b98a3404deb18420 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 4 May 2026 14:39:16 +0200 Subject: [PATCH 06/13] update ctd testing for both phys and bgc sampling capabilities --- tests/instruments/test_ctd.py | 119 +++++++++++++++++++++++++++++++--- 1 file changed, 111 insertions(+), 8 deletions(-) diff --git a/tests/instruments/test_ctd.py b/tests/instruments/test_ctd.py index 4fd6e28d..2f93dfa2 100644 --- a/tests/instruments/test_ctd.py +++ b/tests/instruments/test_ctd.py @@ -25,6 +25,7 @@ def test_simulate_ctds(tmpdir) -> None: + """Test that CTDInstrument simulates measurements correctly, incuding sampling physical and bgc variables.""" # arbitrary time offset for the dummy fieldset base_time = datetime.datetime.strptime("1950-01-01", "%Y-%m-%d") @@ -54,12 +55,18 @@ def test_simulate_ctds(tmpdir) -> None: "surface": { "salinity": 5, "temperature": 6, + "o2": 10.0, + "chl": 20.0, + "no3": 30.0, "lat": ctds[0].spacetime.location.lat, "lon": ctds[0].spacetime.location.lon, }, "maxdepth": { "salinity": 7, "temperature": 8, + "o2": 11.0, + "chl": 21.0, + "no3": 31.0, "lat": ctds[0].spacetime.location.lat, "lon": ctds[0].spacetime.location.lon, }, @@ -68,12 +75,18 @@ def test_simulate_ctds(tmpdir) -> None: "surface": { "salinity": 5, "temperature": 6, + "o2": 12.0, + "chl": 22.0, + "no3": 32.0, "lat": ctds[1].spacetime.location.lat, "lon": ctds[1].spacetime.location.lon, }, "maxdepth": { "salinity": 7, "temperature": 8, + "o2": 13.0, + "chl": 23.0, + "no3": 33.0, "lat": ctds[1].spacetime.location.lat, "lon": ctds[1].spacetime.location.lon, }, @@ -86,6 +99,9 @@ def test_simulate_ctds(tmpdir) -> None: v = np.zeros((2, 2, 2, 2)) t = np.zeros((2, 2, 2, 2)) s = np.zeros((2, 2, 2, 2)) + o2 = np.zeros((2, 2, 2, 2)) + chl = np.zeros((2, 2, 2, 2)) + no3 = np.zeros((2, 2, 2, 2)) t[:, 1, 0, 1] = ctd_exp[0]["surface"]["temperature"] t[:, 0, 0, 1] = ctd_exp[0]["maxdepth"]["temperature"] @@ -97,8 +113,23 @@ def test_simulate_ctds(tmpdir) -> None: s[:, 1, 1, 0] = ctd_exp[1]["surface"]["salinity"] s[:, 0, 1, 0] = ctd_exp[1]["maxdepth"]["salinity"] + o2[:, 1, 0, 1] = ctd_exp[0]["surface"]["o2"] + o2[:, 0, 0, 1] = ctd_exp[0]["maxdepth"]["o2"] + o2[:, 1, 1, 0] = ctd_exp[1]["surface"]["o2"] + o2[:, 0, 1, 0] = ctd_exp[1]["maxdepth"]["o2"] + + chl[:, 1, 0, 1] = ctd_exp[0]["surface"]["chl"] + chl[:, 0, 0, 1] = ctd_exp[0]["maxdepth"]["chl"] + chl[:, 1, 1, 0] = ctd_exp[1]["surface"]["chl"] + chl[:, 0, 1, 0] = ctd_exp[1]["maxdepth"]["chl"] + + no3[:, 1, 0, 1] = ctd_exp[0]["surface"]["no3"] + no3[:, 0, 0, 1] = ctd_exp[0]["maxdepth"]["no3"] + no3[:, 1, 1, 0] = ctd_exp[1]["surface"]["no3"] + no3[:, 0, 1, 0] = ctd_exp[1]["maxdepth"]["no3"] + fieldset = FieldSet.from_data( - {"V": v, "U": u, "T": t, "S": s}, + {"V": v, "U": u, "T": t, "S": s, "o2": o2, "chl": chl, "no3": no3}, { "time": [ np.datetime64(base_time + datetime.timedelta(hours=0)), @@ -130,6 +161,9 @@ class schedule: sensors=[ SensorConfig(sensor_type=SensorType.TEMPERATURE), SensorConfig(sensor_type=SensorType.SALINITY), + SensorConfig(sensor_type=SensorType.OXYGEN), + SensorConfig(sensor_type=SensorType.CHLOROPHYLL), + SensorConfig(sensor_type=SensorType.NITRATE), ], ) ) @@ -160,7 +194,7 @@ class schedule: (obs_maxdepth, "maxdepth"), ]: exp = exp_bothloc[loc] - for var in ["salinity", "temperature", "lat", "lon"]: + for var in ["salinity", "temperature", "o2", "chl", "no3", "lat", "lon"]: obs_value = obs[var].values.item() exp_value = exp[var] @@ -177,10 +211,10 @@ def test_ctd_sensor_config_active_variables() -> None: max_depth_meter=-2000.0, sensors=[ SensorConfig(sensor_type=SensorType.TEMPERATURE), - SensorConfig(sensor_type=SensorType.SALINITY), + SensorConfig(sensor_type=SensorType.OXYGEN), ], ) - assert config_both.active_variables() == {"T": "thetao", "S": "so"} + assert config_both.active_variables() == {"T": "thetao", "o2": "o2"} config_temp_only = CTDConfig( stationkeeping_time_minutes=50, @@ -200,15 +234,18 @@ def test_ctd_sensor_config_yaml() -> None: min_depth_meter=-11.0, max_depth_meter=-2000.0, sensors=[ - SensorConfig(sensor_type=SensorType.TEMPERATURE) + SensorConfig(sensor_type=SensorType.TEMPERATURE), + SensorConfig(sensor_type=SensorType.OXYGEN), ], # SALINITY omitted = disabled ) dumped = config.model_dump(by_alias=True) loaded = CTDConfig.model_validate(dumped) - assert len(loaded.sensors) == 1 + assert len(loaded.sensors) == 2 assert loaded.sensors[0].sensor_type == SensorType.TEMPERATURE assert loaded.sensors[0].enabled is True + assert loaded.sensors[1].sensor_type == SensorType.OXYGEN + assert loaded.sensors[1].enabled is True def test_ctd_disabled_sensor_absent(tmpdir) -> None: @@ -270,11 +307,21 @@ class schedule: def test_ctd_supported_sensors(): - """CTD supports TEMPERATURE and SALINITY.""" + """CTD supports TEMPERATURE, SALINITY and all BGC sensors.""" from virtualship.utils import get_supported_sensors assert get_supported_sensors(InstrumentType.CTD) == frozenset( - {SensorType.TEMPERATURE, SensorType.SALINITY} + { + SensorType.TEMPERATURE, + SensorType.SALINITY, + SensorType.OXYGEN, + SensorType.CHLOROPHYLL, + SensorType.NITRATE, + SensorType.PHOSPHATE, + SensorType.PH, + SensorType.PHYTOPLANKTON, + SensorType.PRIMARY_PRODUCTION, + } ) @@ -299,3 +346,59 @@ def test_ctd_config_unsupported_sensor_rejected(): max_depth_meter=-2000.0, sensors=[SensorConfig(sensor_type=SensorType.VELOCITY)], ) + + +def test_sensor_absent(tmpdir) -> None: + """A (BGC) sensor that is disabled must not appear in the zarr output.""" + base_time = datetime.datetime.strptime("1950-01-01", "%Y-%m-%d") + + ctds = [ + CTD( + spacetime=Spacetime( + location=Location(latitude=0, longitude=0), + time=base_time, + ), + min_depth=0, + max_depth=-20, + ), + ] + + o2_data = np.full((2, 2, 2), 5.0) + fieldset = FieldSet.from_data( + {"o2": o2_data}, + { + "lon": np.array([0.0, 1.0]), + "lat": np.array([0.0, 1.0]), + "time": [ + np.datetime64(base_time + datetime.timedelta(seconds=0)), + np.datetime64(base_time + datetime.timedelta(hours=4)), + ], + }, + ) + fieldset.add_field(Field("bathymetry", [-1000], lon=0, lat=0)) + + class DummyExpedition: + class schedule: + waypoints = [Waypoint(location=Location(1, 2), time=base_time)] + + instruments_config = InstrumentsConfig( + ctd_config=CTDConfig( + stationkeeping_time_minutes=50, + min_depth_meter=-11.0, + max_depth_meter=-2000.0, + sensors=[ + SensorConfig(sensor_type=SensorType.OXYGEN), + # CHLOROPHYLL omitted = disabled + ], + ) + ) + + expedition = DummyExpedition() + ctd_instrument = CTDInstrument(expedition, None) + out_path = tmpdir.join("out_bgc_disabled.zarr") + ctd_instrument.load_input_data = lambda: fieldset + ctd_instrument.simulate(ctds, out_path) + + results = xr.open_zarr(out_path) + assert "o2" in results, "Enabled BGC sensor variable must be present" + assert "chl" not in results, "Disabled sensor variable must be absent from output" From 245e7f0a7a0872903c99712a8091e357e3e333cc Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 4 May 2026 14:54:44 +0200 Subject: [PATCH 07/13] first round update docs, including removing some other redunant sections --- docs/user-guide/assignments/Research_Proposal_only.ipynb | 2 +- docs/user-guide/assignments/Research_proposal_intro.ipynb | 5 ----- docs/user-guide/assignments/Sail_the_ship.ipynb | 4 ---- .../assignments/Virtualship_research_proposal.ipynb | 2 +- docs/user-guide/tutorials/CTD_transects.ipynb | 4 ++-- 5 files changed, 4 insertions(+), 13 deletions(-) diff --git a/docs/user-guide/assignments/Research_Proposal_only.ipynb b/docs/user-guide/assignments/Research_Proposal_only.ipynb index 7b8e8691..ac8f057c 100644 --- a/docs/user-guide/assignments/Research_Proposal_only.ipynb +++ b/docs/user-guide/assignments/Research_Proposal_only.ipynb @@ -176,7 +176,7 @@ "\n", "Please provide a scheme with number of necessary work and in-transit days within the working areas and station times. This can be downloaded from [the NIOZ MFP website](https://nioz.marinefacilitiesplanning.com/cruiselocationplanning#) using the Export button on the right.\n", "\n", - "Please indicate at each station what instruments you want to deploy (CTD, Argo float, drifter, XBT) and take the deployment time into account. If you plan to use Argo floats, please give the required depth and cycle duration. In case of the CTD the deployment time depends on the depth of the ocean. \n", + "Please indicate at each station what instruments you want to deploy (CTD, Argo float, drifter, XBT) and take the deployment time into account. If you plan to use Argo floats, please give the required depth and cycle duration.\n", "\n", "Here is some sample code to sample the depth using the bathymetry data that the Virtual Ship will also use. " ] diff --git a/docs/user-guide/assignments/Research_proposal_intro.ipynb b/docs/user-guide/assignments/Research_proposal_intro.ipynb index 3f3a3f53..2f19e2b1 100644 --- a/docs/user-guide/assignments/Research_proposal_intro.ipynb +++ b/docs/user-guide/assignments/Research_proposal_intro.ipynb @@ -97,11 +97,6 @@ "\n", "In total, therefore, you can expect a CTD deployment to take approximately 50 minutes.\n", "\n", - "\n", - "
\n", - "**Note**: If you are deploying CTDs in both standard and biogeochemical configurations (`CTD` and `CTD_BGC`) in your [VirtualShip expeditions](./Sail_the_ship.ipynb), you only need to factor in the 50 minutes **once** per waypoint, as both can be deployed on the same cast.\n", - "
\n", - "\n", "" ] }, diff --git a/docs/user-guide/assignments/Sail_the_ship.ipynb b/docs/user-guide/assignments/Sail_the_ship.ipynb index 92a695fa..1e1f439d 100644 --- a/docs/user-guide/assignments/Sail_the_ship.ipynb +++ b/docs/user-guide/assignments/Sail_the_ship.ipynb @@ -212,10 +212,6 @@ "\n", "
\n", "**Note**: On pressing _Save Changes_ the tool will check the selections are valid (for example that the ship will be able to reach each waypoint in time). If they are, the changes will be saved to the `expedition.yaml` file, ready for the next steps. If your selections are invalid you should be provided with information on how to fix them.\n", - "
\n", - "\n", - "
\n", - "**Caution**: The `virtualship plan` tool will check that the ship can reach each waypoint according to the prescribed ship speed (10 knots). However, before the ultimate simulation step (i.e. step 6 below) there will be a final, automated check that the schedule also accounts for the time taken to conduct the measurements at each site (e.g. a CTD cast in deeper waters will take longer). Therefore, we recommend to take this extra time into account at this stage of the planning by estimating how long each measurement will take and adding this time on.\n", "
" ] }, diff --git a/docs/user-guide/assignments/Virtualship_research_proposal.ipynb b/docs/user-guide/assignments/Virtualship_research_proposal.ipynb index 31c8e127..87097baa 100644 --- a/docs/user-guide/assignments/Virtualship_research_proposal.ipynb +++ b/docs/user-guide/assignments/Virtualship_research_proposal.ipynb @@ -132,7 +132,7 @@ "source": [ "7. **Scientific work program**\n", "\n", - "Please upload a screenshot of the MFP website inlcuding port of departure and arrival, all stations (and transects) and total time. Please indicate at each station what instruments you want to deploy (CTD, Argo float, drifter, XBT) and take the deployment time into account. If you plan to use Argo floats, please give the required depth and cycle duration. In case of the CTD the deployment time depends on the depth of the ocean. Note that transit times from the port of departure to the first station and from the last station in the working area to the port of arrival are regarded as work days at sea, so they are deducted from your (three-week) availability. " + "Please upload a screenshot of the MFP website inlcuding port of departure and arrival, all stations (and transects) and total time. Please indicate at each station what instruments you want to deploy (CTD, Argo float, drifter, XBT) and take the deployment time into account. If you plan to use Argo floats, please give the required depth and cycle duration. Note that transit times from the port of departure to the first station and from the last station in the working area to the port of arrival are regarded as work days at sea, so they are deducted from your (three-week) availability. " ] }, { diff --git a/docs/user-guide/tutorials/CTD_transects.ipynb b/docs/user-guide/tutorials/CTD_transects.ipynb index 90f1ef2c..ed9a6e81 100644 --- a/docs/user-guide/tutorials/CTD_transects.ipynb +++ b/docs/user-guide/tutorials/CTD_transects.ipynb @@ -9,7 +9,7 @@ "\n", "This notebook demonstrates a simple plotting exercise for CTD data across a transect, using the output of a VirtualShip expedition. There are example plots embedded at the end, but these will ultimately be replaced by your own versions as you work through the notebook.\n", "\n", - "We can plot physical (temperature, salinity) or biogeochemical data (oxygen, chlorophyll, primary production, phytoplankton, nutrients, pH) as measured by the VirtualShip `CTD` and `CTD_BGC` instruments, respectively.\n", + "We can plot physical (temperature, salinity) or biogeochemical data (oxygen, chlorophyll, primary production, phytoplankton, nutrients, pH) as measured by the VirtualShip `CTD` instrument.\n", "\n", "The plot(s) we will produce are simple plots which follow the trajectory of the expedition as a function of distance from the first waypoint, and are intended to be a starting point for your analysis. \n", "\n", @@ -482,7 +482,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.9" + "version": "3.12.12" } }, "nbformat": 4, From 8398d04355edf2774fa8d935ca7f9052a19de242 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 4 May 2026 15:00:06 +0200 Subject: [PATCH 08/13] add bgc sensors to CTDConfig --- src/virtualship/models/expedition.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/virtualship/models/expedition.py b/src/virtualship/models/expedition.py index f1c83037..eadf9849 100644 --- a/src/virtualship/models/expedition.py +++ b/src/virtualship/models/expedition.py @@ -352,6 +352,13 @@ class CTDConfig(_InstrumentConfigMixin, pydantic.BaseModel): default_factory=lambda: [ SensorConfig(sensor_type=SensorType.TEMPERATURE), SensorConfig(sensor_type=SensorType.SALINITY), + SensorConfig(sensor_type=SensorType.OXYGEN), + SensorConfig(sensor_type=SensorType.CHLOROPHYLL), + SensorConfig(sensor_type=SensorType.NITRATE), + SensorConfig(sensor_type=SensorType.PHOSPHATE), + SensorConfig(sensor_type=SensorType.PH), + SensorConfig(sensor_type=SensorType.PHYTOPLANKTON), + SensorConfig(sensor_type=SensorType.PRIMARY_PRODUCTION), ], description=("Sensors fitted to the CTD. Supported: TEMPERATURE, SALINITY. "), ) From a1be03b26cf78cc4f0feb9591adc775945940526 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 4 May 2026 15:11:52 +0200 Subject: [PATCH 09/13] update CTDConfig description --- src/virtualship/models/expedition.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/virtualship/models/expedition.py b/src/virtualship/models/expedition.py index eadf9849..eef23c76 100644 --- a/src/virtualship/models/expedition.py +++ b/src/virtualship/models/expedition.py @@ -360,7 +360,9 @@ class CTDConfig(_InstrumentConfigMixin, pydantic.BaseModel): SensorConfig(sensor_type=SensorType.PHYTOPLANKTON), SensorConfig(sensor_type=SensorType.PRIMARY_PRODUCTION), ], - description=("Sensors fitted to the CTD. Supported: TEMPERATURE, SALINITY. "), + description=( + "Sensors fitted to the CTD. Supported: TEMPERATURE, SALINITY, OXYGEN, CHLOROPHYLL, NITRATE, PHOSPHATE, PH, PHYTOPLANKTON, PRIMARY_PRODUCTION. " + ), ) model_config = pydantic.ConfigDict(populate_by_name=True) From ba9eee15983ae4e3427a8b39001fa887b98044ad Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 4 May 2026 15:25:58 +0200 Subject: [PATCH 10/13] update ctd transect plotting tutorial to handle the unified ctd.zarr output --- docs/user-guide/tutorials/CTD_transects.ipynb | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/docs/user-guide/tutorials/CTD_transects.ipynb b/docs/user-guide/tutorials/CTD_transects.ipynb index ed9a6e81..bcefcb82 100644 --- a/docs/user-guide/tutorials/CTD_transects.ipynb +++ b/docs/user-guide/tutorials/CTD_transects.ipynb @@ -82,13 +82,11 @@ "source": [ "#### Variable choice\n", "\n", - "You should now consider which variable from your CTD casts you would like to plot. Which ones are available to you will depend on whether you have used the `CTD` (physical variables) or `CTD_BGC` (biogeochemical) instrument, or both. Below is a list of all valid variable choices for both instruments...\n", + "You should now consider which variable from your CTD casts you would like to plot. Which ones are available to you will depend on which sensors you deployed the `CTD` instrument with (via the `virtualship plan` tool and/or your `expedition.yaml` file). Below is the full list of valid variable choices...\n", "\n", - "`CTD` (physical):\n", + "`CTD`:\n", "- \"temperature\"\n", "- \"salinity\"\n", - "\n", - "`CTD_BGC` (biogeochemical):\n", "- \"oxygen\"\n", "- \"nitrate\"\n", "- \"phosphate\"\n", @@ -191,15 +189,13 @@ }, { "cell_type": "code", - "execution_count": 76, + "execution_count": null, "id": "13f4664b", "metadata": {}, "outputs": [], "source": [ "# load CTD data\n", - "filename = (\n", - " \"ctd.zarr\" if plot_variable in [\"temperature\", \"salinity\"] else \"ctd_bgc.zarr\"\n", - ")\n", + "filename = \"ctd.zarr\"\n", "ctd_ds = xr.open_dataset(f\"{data_dir}/{filename}\")\n", "if ctd_ds[\"trajectory\"].size <= 1:\n", " raise ValueError(\"Number of waypoints must be > 1\")" From 73e6f2992006b67d534a3196cdde4c187a22f647 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 4 May 2026 15:38:41 +0200 Subject: [PATCH 11/13] additional updates to the docs --- docs/user-guide/quickstart.md | 4 ---- docs/user-guide/tutorials/working_with_expedition_yaml.md | 4 +--- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/docs/user-guide/quickstart.md b/docs/user-guide/quickstart.md index 580b8db3..cc43b8a0 100644 --- a/docs/user-guide/quickstart.md +++ b/docs/user-guide/quickstart.md @@ -124,10 +124,6 @@ When you are happy with your ship configuration and schedule plan, press _Save C On pressing _Save Changes_ the tool will check the selections are valid (for example that the ship will be able to reach each waypoint in time). If they are, the changes will be saved to the `expedition.yaml` file, ready for the next steps. If your selections are invalid you should be provided with information on how to fix them. ``` -```{caution} -The `virtualship plan` tool will check that the ship can reach each waypoint according to the prescribed ship speed. However, before the ultimate [simulation step](#run-the-expedition) there will be a final, automated check that the schedule also accounts for the time taken to conduct the measurements at each site (e.g. a CTD cast in deeper waters will take longer). Therefore, we recommend to take this extra time into account at this stage of the planning by estimating how long each measurement will take and adding this time on. -``` - ## 4) Run the expedition You are now ready to run your virtual expedition! This stage will simulate the measurements taken by the instruments you selected at each waypoint in your expedition schedule, using input data sourced from the [Copernicus Marine Data Store](https://data.marine.copernicus.eu/products). diff --git a/docs/user-guide/tutorials/working_with_expedition_yaml.md b/docs/user-guide/tutorials/working_with_expedition_yaml.md index 15443a34..af059b3f 100644 --- a/docs/user-guide/tutorials/working_with_expedition_yaml.md +++ b/docs/user-guide/tutorials/working_with_expedition_yaml.md @@ -29,7 +29,6 @@ schedule: # <-- 1. expedition schedule section waypoints: - instrument: # <-- Waypoint 1 - CTD - - CTD_BGC - ARGO_FLOAT - DRIFTER location: @@ -53,7 +52,6 @@ instruments_config: # <-- 2. instrument configuration section ship_underwater_st_config: period_minutes: 5.0 argo_float_config: ... - ctd_bgc_config: ... ctd_config: ... drifter_config: ... xbt_config: ... @@ -75,7 +73,7 @@ This section contains a list of `waypoints` that define the expedition's route. - **Instruments (`instrument`)**: A list of instruments to be deployed at that waypoint. Add or remove instruments by adding or deleting entries on _new lines_. The instrument selection can also be left empty (i.e., no instruments deployed at that waypoint) by setting the parameter to: `instrument: null`. ```{tip} -Full list of instruments supported for deployment at waypoints (case-sensitive): `CTD`, `CTD_BGC`, `DRIFTER`, `ARGO_FLOAT`, `XBT` (or `null`). +Full list of instruments supported for deployment at waypoints (case-sensitive): `CTD`, `DRIFTER`, `ARGO_FLOAT`, `XBT` (or `null`). ``` ```{tip} From 0f4080bcb967d207733a622a0571fa51cd9cb488 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Mon, 4 May 2026 16:16:44 +0200 Subject: [PATCH 12/13] fix tests --- tests/instruments/test_ctd.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/instruments/test_ctd.py b/tests/instruments/test_ctd.py index 2f93dfa2..c080f0f5 100644 --- a/tests/instruments/test_ctd.py +++ b/tests/instruments/test_ctd.py @@ -326,14 +326,24 @@ def test_ctd_supported_sensors(): def test_ctd_config_default_sensors(): - """CTDConfig defaults to TEMPERATURE + SALINITY.""" + """CTDConfig defaults to all supported sensors (phys + bgc).""" config = CTDConfig( stationkeeping_time_minutes=50, min_depth_meter=-11.0, max_depth_meter=-2000.0, ) types = {sc.sensor_type for sc in config.sensors} - assert types == {SensorType.TEMPERATURE, SensorType.SALINITY} + assert types == { + SensorType.TEMPERATURE, + SensorType.SALINITY, + SensorType.OXYGEN, + SensorType.CHLOROPHYLL, + SensorType.NITRATE, + SensorType.PHOSPHATE, + SensorType.PH, + SensorType.PHYTOPLANKTON, + SensorType.PRIMARY_PRODUCTION, + } # TODO: may need to be removed if add ADCP to CTDs in future PR... From 9788e667191b0ca999d9e4ffe1bb6f6547fb8945 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 14:17:59 +0000 Subject: [PATCH 13/13] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/virtualship/instruments/ctd.py | 2 +- src/virtualship/utils.py | 2 +- tests/expedition/test_expedition.py | 2 +- tests/test_utils.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/virtualship/instruments/ctd.py b/src/virtualship/instruments/ctd.py index cc1239d4..583a099c 100644 --- a/src/virtualship/instruments/ctd.py +++ b/src/virtualship/instruments/ctd.py @@ -4,8 +4,8 @@ from typing import TYPE_CHECKING, ClassVar import numpy as np - from parcels import JITParticle, ParticleSet, Variable + from virtualship.instruments.base import Instrument from virtualship.instruments.sensors import SensorType from virtualship.instruments.types import InstrumentType diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 2f008dbc..7ad275cd 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -15,8 +15,8 @@ import numpy as np import pyproj import xarray as xr - from parcels import FieldSet, Variable + from virtualship.errors import CopernicusCatalogueError if TYPE_CHECKING: diff --git a/tests/expedition/test_expedition.py b/tests/expedition/test_expedition.py index ddef53e7..f6a84dfe 100644 --- a/tests/expedition/test_expedition.py +++ b/tests/expedition/test_expedition.py @@ -6,8 +6,8 @@ import pyproj import pytest import xarray as xr - from parcels import FieldSet + from virtualship.errors import InstrumentsConfigError, ScheduleError from virtualship.models import ( Expedition, diff --git a/tests/test_utils.py b/tests/test_utils.py index c0187caf..fde6796f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,9 +4,9 @@ import numpy as np import pytest import xarray as xr +from parcels import FieldSet, JITParticle, ScipyParticle, Variable import virtualship.utils -from parcels import FieldSet, JITParticle, ScipyParticle, Variable from virtualship.instruments.sensors import SensorType from virtualship.instruments.types import InstrumentType from virtualship.models.expedition import Expedition, SensorConfig