From ba585c2a537fe447e924735100594c20fd3528bf Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:31:32 +0100 Subject: [PATCH 01/53] remove CTD_BGC instrument type from InstrumentType enum, add SensorType enum --- src/virtualship/instruments/types.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/virtualship/instruments/types.py b/src/virtualship/instruments/types.py index 9ae221e9..8452d1fe 100644 --- a/src/virtualship/instruments/types.py +++ b/src/virtualship/instruments/types.py @@ -5,7 +5,6 @@ class InstrumentType(Enum): """Types of the instruments.""" CTD = "CTD" - CTD_BGC = "CTD_BGC" DRIFTER = "DRIFTER" ARGO_FLOAT = "ARGO_FLOAT" XBT = "XBT" @@ -16,3 +15,17 @@ class InstrumentType(Enum): def is_underway(self) -> bool: """Return True if instrument is an underway instrument (ADCP, UNDERWATER_ST).""" return self in {InstrumentType.ADCP, InstrumentType.UNDERWATER_ST} + + +class SensorType(str, Enum): + """Sensors available (to instruments with configurable sensors, e.g. CTDs). #TODO: and soon Argo floats, drifters.""" + + TEMPERATURE = "TEMPERATURE" + SALINITY = "SALINITY" + OXYGEN = "OXYGEN" + CHLOROPHYLL = "CHLOROPHYLL" + NITRATE = "NITRATE" + PHOSPHATE = "PHOSPHATE" + PH = "PH" + PHYTOPLANKTON = "PHYTOPLANKTON" + PRIMARY_PRODUCTION = "PRIMARY_PRODUCTION" From 1f7e9b867c70747999f73c3c48e390457f10cb98 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:31:06 +0100 Subject: [PATCH 02/53] update utils: add sensor def mapping and remove old references to ctd_bgc --- src/virtualship/utils.py | 66 +++++++++++++++++++++++++++++----------- 1 file changed, 49 insertions(+), 17 deletions(-) diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 204e9e8f..e19f7d5f 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -15,9 +15,10 @@ import numpy as np import pyproj import xarray as xr -from parcels import FieldSet +from parcels import FieldSet from virtualship.errors import CopernicusCatalogueError +from virtualship.instruments.types import SensorType if TYPE_CHECKING: from virtualship.expedition.simulate_schedule import ( @@ -131,6 +132,52 @@ def decorator(cls): return decorator +# ===================================================== +# SECTION: optional sensors and variable mapping (e.g. for CTD) +# TODO: and soon also Argo floats... +# ===================================================== + + +SENSOR_DEFS: dict[SensorType, dict] = { + SensorType.TEMPERATURE: { + "fs_key": "T", + "copernicus_var": "thetao", + }, + SensorType.SALINITY: { + "fs_key": "S", + "copernicus_var": "so", + }, + SensorType.OXYGEN: { + "fs_key": "o2", + "copernicus_var": "o2", + }, + SensorType.CHLOROPHYLL: { + "fs_key": "chl", + "copernicus_var": "chl", + }, + SensorType.NITRATE: { + "fs_key": "no3", + "copernicus_var": "no3", + }, + SensorType.PHOSPHATE: { + "fs_key": "po4", + "copernicus_var": "po4", + }, + SensorType.PH: { + "fs_key": "ph", + "copernicus_var": "ph", + }, + SensorType.PHYTOPLANKTON: { + "fs_key": "phyc", + "copernicus_var": "phyc", + }, + SensorType.PRIMARY_PRODUCTION: { + "fs_key": "nppv", + "copernicus_var": "nppv", + }, +} + + # ===================================================== # SECTION: helper functions # ===================================================== @@ -617,18 +664,10 @@ def _calc_wp_stationkeeping_time( instrument_config_map: dict = INSTRUMENT_CONFIG_MAP, ) -> timedelta: """For a given waypoint (and the instruments present at this waypoint), calculate how much time is required to carry out all instrument deployments.""" - from virtualship.instruments.types import InstrumentType # avoid circular imports - # to empty list if wp instruments set to 'null' 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 - 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 @@ -639,7 +678,7 @@ def _calc_wp_stationkeeping_time( for iconfig in valid_instrument_configs: for itype in wp_instrument_types: if ( - instrument_config_map[itype] == iconfig.__class__.__name__ + instrument_config_map.get(itype) == iconfig.__class__.__name__ and ( iconfig not in wp_instrument_configs ) # avoid duplicates (would happen when multiple drifter deployments at same waypoint) @@ -649,13 +688,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[InstrumentType.CTD_BGC] - ): - continue # 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 - if hasattr(iconfig, "stationkeeping_time"): cumulative_stationkeeping_time += iconfig.stationkeeping_time From dbaa319ea065864e005142211fd4aaa7d0222741 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:50:57 +0100 Subject: [PATCH 03/53] refactor: update SensorType enum and add source-truth for supported sensors for instruments --- src/virtualship/instruments/types.py | 43 +++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/src/virtualship/instruments/types.py b/src/virtualship/instruments/types.py index 8452d1fe..d365f4bd 100644 --- a/src/virtualship/instruments/types.py +++ b/src/virtualship/instruments/types.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from enum import Enum @@ -18,10 +20,15 @@ def is_underway(self) -> bool: class SensorType(str, Enum): - """Sensors available (to instruments with configurable sensors, e.g. CTDs). #TODO: and soon Argo floats, drifters.""" + """ + Sensors available. Different intstruments mix and match these sensors as needed. + + Each entry has a corresponding entry in `SENSOR_REGISTRY` which carries the centralised metadata (e.g. FieldSet key, Copernicus var name). + """ TEMPERATURE = "TEMPERATURE" SALINITY = "SALINITY" + VELOCITY = "VELOCITY" OXYGEN = "OXYGEN" CHLOROPHYLL = "CHLOROPHYLL" NITRATE = "NITRATE" @@ -29,3 +36,37 @@ class SensorType(str, Enum): PH = "PH" PHYTOPLANKTON = "PHYTOPLANKTON" PRIMARY_PRODUCTION = "PRIMARY_PRODUCTION" + + +# per-instrument allowlists of supported sensors (source truth for validation for which sensors each instrument supports) + +ARGO_FLOAT_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset( + {SensorType.TEMPERATURE, SensorType.SALINITY} +) + +# TODO: CTD and CTD_BGC will be consoidated in future PR... +CTD_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset( + {SensorType.TEMPERATURE, SensorType.SALINITY} +) + +CTD_BGC_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset( + { + SensorType.OXYGEN, + SensorType.CHLOROPHYLL, + SensorType.NITRATE, + SensorType.PHOSPHATE, + SensorType.PH, + SensorType.PHYTOPLANKTON, + SensorType.PRIMARY_PRODUCTION, + } +) + +DRIFTER_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset({SensorType.TEMPERATURE}) + +ADCP_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset({SensorType.VELOCITY}) + +UNDERWATER_ST_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset( + {SensorType.TEMPERATURE, SensorType.SALINITY} +) + +XBT_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset({SensorType.TEMPERATURE}) From a2a7c8111c491a76ffe7b41dd47e0c907c6e5e3d Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:52:05 +0100 Subject: [PATCH 04/53] add sensors configuration for various instruments --- src/virtualship/static/expedition.yaml | 28 +++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/virtualship/static/expedition.yaml b/src/virtualship/static/expedition.yaml index c6db10bd..8ab72f8a 100644 --- a/src/virtualship/static/expedition.yaml +++ b/src/virtualship/static/expedition.yaml @@ -1,5 +1,7 @@ # see https://virtualship.readthedocs.io/en/latest/user-guide/tutorials/working_with_expedition_yaml.html for more details on how to edit this file # +# TODO: add a link to docs where lists what sensors are supported for each instrument +# schedule: waypoints: - instrument: @@ -37,6 +39,8 @@ instruments_config: num_bins: 40 max_depth_meter: -1000.0 period_minutes: 5.0 + sensors: + - VELOCITY argo_float_config: cycle_days: 10.0 drift_days: 9.0 @@ -46,23 +50,45 @@ instruments_config: vertical_speed_meter_per_second: -0.1 stationkeeping_time_minutes: 20.0 lifetime_days: 63.0 + sensors: + - TEMPERATURE + - SALINITY ctd_config: max_depth_meter: -2000.0 min_depth_meter: -11.0 stationkeeping_time_minutes: 50.0 + 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 + - PHOSPHATE + - PH + - PHYTOPLANKTON + - PRIMARY_PRODUCTION drifter_config: depth_meter: -1.0 lifetime_days: 42.0 stationkeeping_time_minutes: 20.0 + sensors: + - TEMPERATURE xbt_config: max_depth_meter: -285.0 min_depth_meter: -2.0 fall_speed_meter_per_second: 6.7 deceleration_coefficient: 0.00225 - ship_underwater_st_config: null + sensors: + - TEMPERATURE + ship_underwater_st_config: + period_minutes: 5.0 + sensors: + - TEMPERATURE + - SALINITY ship_config: ship_speed_knots: 10.0 From 67a04d8f5dbd9152e34b1df15d3c87d8009e3093 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:37:20 +0100 Subject: [PATCH 05/53] new registries and helper functions --- src/virtualship/utils.py | 143 ++++++++++++++++++++++++++------------- 1 file changed, 96 insertions(+), 47 deletions(-) diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index e19f7d5f..590d6b47 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -5,6 +5,7 @@ import os import re import warnings +from dataclasses import dataclass from datetime import datetime, timedelta from functools import lru_cache from importlib.resources import files @@ -16,7 +17,7 @@ import pyproj import xarray as xr -from parcels import FieldSet +from parcels import FieldSet, JITParticle, Variable from virtualship.errors import CopernicusCatalogueError from virtualship.instruments.types import SensorType @@ -26,6 +27,7 @@ ) from virtualship.models import Expedition, InstrumentsConfig, Location from virtualship.models.checkpoint import Checkpoint + from virtualship.models.expedition import SensorConfig import pandas as pd import yaml @@ -53,6 +55,86 @@ EXPEDITION_ORIGINAL = "expedition_original.yaml" EXPEDITION_LATEST = "expedition_latest.yaml" +# ===================================================== +# SECTION: sensor and variable metadata and registries +# ===================================================== + + +@dataclass(frozen=True) +class _SensorMeta: + fs_key: str # map to Parcels fieldset variables + copernicus_var: str # map to Copernicus Marine Service variable names + category: Literal[ + "phys", "bgc" + ] # physical vs. biogeochemical variable, used for product ID selection logic + particle_var: str # map to variable name in the Parcels Particle class + + +# the copernicus_var field below is the bridge between this registry the Copernicus product-ID selection logic (PRODUCT_IDS, BGC_ANALYSIS_IDS, MONTHLY_BGC_REANALYSIS_IDS, etc.) +SENSOR_REGISTRY: dict[SensorType, _SensorMeta] = { + SensorType.TEMPERATURE: _SensorMeta( + fs_key="T", + copernicus_var="thetao", + category="phys", + particle_var="temperature", + ), + SensorType.SALINITY: _SensorMeta( + fs_key="S", + copernicus_var="so", + category="phys", + particle_var="salinity", + ), + SensorType.VELOCITY: _SensorMeta( + fs_key="UV", + copernicus_var="uo", # primary; active_variables() in ADCPConfig expands to both uo and vo + category="phys", + particle_var="U", # primary; adcp.py adds V explicitly for VELOCITY + ), + SensorType.OXYGEN: _SensorMeta( + fs_key="o2", + copernicus_var="o2", + category="bgc", + particle_var="o2", + ), + SensorType.CHLOROPHYLL: _SensorMeta( + fs_key="chl", + copernicus_var="chl", + category="bgc", + particle_var="chl", + ), + SensorType.NITRATE: _SensorMeta( + fs_key="no3", + copernicus_var="no3", + category="bgc", + particle_var="no3", + ), + SensorType.PHOSPHATE: _SensorMeta( + fs_key="po4", + copernicus_var="po4", + category="bgc", + particle_var="po4", + ), + SensorType.PH: _SensorMeta( + fs_key="ph", + copernicus_var="ph", + category="bgc", + particle_var="ph", + ), + SensorType.PHYTOPLANKTON: _SensorMeta( + fs_key="phyc", + copernicus_var="phyc", + category="bgc", + particle_var="phyc", + ), + SensorType.PRIMARY_PRODUCTION: _SensorMeta( + fs_key="nppv", + copernicus_var="nppv", + category="bgc", + particle_var="nppv", + ), +} + + # ===================================================== # SECTION: Copernicus Marine Service constants # ===================================================== @@ -132,52 +214,6 @@ def decorator(cls): return decorator -# ===================================================== -# SECTION: optional sensors and variable mapping (e.g. for CTD) -# TODO: and soon also Argo floats... -# ===================================================== - - -SENSOR_DEFS: dict[SensorType, dict] = { - SensorType.TEMPERATURE: { - "fs_key": "T", - "copernicus_var": "thetao", - }, - SensorType.SALINITY: { - "fs_key": "S", - "copernicus_var": "so", - }, - SensorType.OXYGEN: { - "fs_key": "o2", - "copernicus_var": "o2", - }, - SensorType.CHLOROPHYLL: { - "fs_key": "chl", - "copernicus_var": "chl", - }, - SensorType.NITRATE: { - "fs_key": "no3", - "copernicus_var": "no3", - }, - SensorType.PHOSPHATE: { - "fs_key": "po4", - "copernicus_var": "po4", - }, - SensorType.PH: { - "fs_key": "ph", - "copernicus_var": "ph", - }, - SensorType.PHYTOPLANKTON: { - "fs_key": "phyc", - "copernicus_var": "phyc", - }, - SensorType.PRIMARY_PRODUCTION: { - "fs_key": "nppv", - "copernicus_var": "nppv", - }, -} - - # ===================================================== # SECTION: helper functions # ===================================================== @@ -701,6 +737,19 @@ def _make_hash(s: str, length: int) -> str: return hashlib.shake_128(s.encode("utf-8")).hexdigest(half_length) +def build_particle_class_from_sensors( + sensors: list[SensorConfig], + fixed_variables: list, +) -> type: + """Build a JITParticle class from fixed variables and active sensors. ScipyParticle classes are built in instrument sub-classes where used.""" + sensor_variables = [ + Variable(sc.meta.particle_var, dtype=np.float32, initial=np.nan) + for sc in sensors + if sc.enabled + ] + return JITParticle.add_variables(fixed_variables + sensor_variables) + + # ===================================================== # SECTION: misc. # ===================================================== From 057d9f936017128387c1409958002ec21e52c74f Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:37:58 +0100 Subject: [PATCH 06/53] update expedition models, now including SensorConfig model and associated validations for each instrument --- src/virtualship/models/expedition.py | 246 ++++++++++++++++++++++++++- 1 file changed, 245 insertions(+), 1 deletion(-) diff --git a/src/virtualship/models/expedition.py b/src/virtualship/models/expedition.py index 5d16ecf5..18b16017 100644 --- a/src/virtualship/models/expedition.py +++ b/src/virtualship/models/expedition.py @@ -3,6 +3,7 @@ import itertools from datetime import datetime, timedelta from pathlib import Path +from typing import Literal import numpy as np import pydantic @@ -10,12 +11,24 @@ import yaml from virtualship.errors import InstrumentsConfigError, ScheduleError -from virtualship.instruments.types import InstrumentType +from virtualship.instruments.types import ( + ADCP_SUPPORTED_SENSORS, + ARGO_FLOAT_SUPPORTED_SENSORS, + CTD_BGC_SUPPORTED_SENSORS, + CTD_SUPPORTED_SENSORS, + DRIFTER_SUPPORTED_SENSORS, + UNDERWATER_ST_SUPPORTED_SENSORS, + XBT_SUPPORTED_SENSORS, + InstrumentType, + SensorType, +) from virtualship.utils import ( + SENSOR_REGISTRY, _calc_sail_time, _calc_wp_stationkeeping_time, _get_bathy_data, _get_waypoint_latlons, + _SensorMeta, _validate_numeric_to_timedelta, register_instrument_config, ) @@ -208,6 +221,36 @@ def serialize_instrument(self, instrument): return instrument.value if instrument else None +## + + +def _serialize_sensor_list(sensors: list[SensorConfig]) -> list[str]: + """Serialise enabled sensors to a compact list of sensor-type name strings.""" + return [sc.sensor_type.value for sc in sensors if sc.enabled] + + +def _check_sensor_compatibility( + sensors: list[SensorConfig], + supported: frozenset[SensorType], + instrument_name: str, +) -> list[SensorConfig]: + """Raise ``ValueError`` if any sensor in `sensors` is not in `supported`. Used as a Pydantic field_validator for each instrument config class.""" + unsupported = {sc.sensor_type for sc in sensors} - supported + if unsupported: + names = ", ".join(sorted(s.value for s in unsupported)) + valid = ", ".join(sorted(s.value for s in supported)) + raise ValueError( + f"{instrument_name} does not support sensor(s): {names}. " + f"Supported sensors: {valid}." + ) + return sensors + + +def build_variables_from_sensors(sensors: list[SensorConfig]) -> dict[str, str]: + """Build variables dict (FieldSet key → Copernicus-variable).""" + return {sc.fs_key: sc.copernicus_var for sc in sensors if sc.enabled} + + @register_instrument_config(InstrumentType.ARGO_FLOAT) class ArgoFloatConfig(pydantic.BaseModel): """Configuration for argos floats.""" @@ -230,6 +273,16 @@ class ArgoFloatConfig(pydantic.BaseModel): gt=timedelta(), ) + sensors: list[SensorConfig] = pydantic.Field( + default_factory=lambda: [ + SensorConfig(sensor_type=SensorType.TEMPERATURE), + SensorConfig(sensor_type=SensorType.SALINITY), + ], + description=( + "Sensors fitted to the Argo float. Supported: TEMPERATURE, SALINITY. " + ), + ) + @pydantic.field_serializer("lifetime") def _serialize_lifetime(self, value: timedelta, _info): return value.total_seconds() / 86400.0 # [days] @@ -246,8 +299,23 @@ def _serialize_stationkeeping_time(self, value: timedelta, _info): def _validate_stationkeeping_time(cls, value: int | float | timedelta) -> timedelta: return _validate_numeric_to_timedelta(value, "minutes") + @pydantic.field_validator("sensors", mode="after") + @classmethod + def _check_sensor_compatibility(cls, value) -> list[SensorConfig]: + return _check_sensor_compatibility( + value, ARGO_FLOAT_SUPPORTED_SENSORS, "ArgoFloat" + ) + + @pydantic.field_serializer("sensors") + def _serialize_sensors(self, value: list[SensorConfig], _info): + return _serialize_sensor_list(value) + model_config = pydantic.ConfigDict(populate_by_name=True) + def active_variables(self) -> dict[str, str]: + """FieldSet-key → Copernicus-variable mapping for enabled sensors.""" + return build_variables_from_sensors(self.sensors) + @register_instrument_config(InstrumentType.ADCP) class ADCPConfig(pydantic.BaseModel): @@ -261,6 +329,14 @@ class ADCPConfig(pydantic.BaseModel): gt=timedelta(), ) + sensors: list[SensorConfig] = pydantic.Field( + default_factory=lambda: [SensorConfig(sensor_type=SensorType.VELOCITY)], + description=( + "Sensors fitted to the ADCP. " + "Supported: VELOCITY (samples both U and V components in one go)." + ), + ) + model_config = pydantic.ConfigDict(populate_by_name=True) @pydantic.field_serializer("period") @@ -271,6 +347,28 @@ def _serialize_period(self, value: timedelta, _info): def _validate_period(cls, value: int | float | timedelta) -> timedelta: return _validate_numeric_to_timedelta(value, "minutes") + @pydantic.field_validator("sensors", mode="after") + @classmethod + def _check_sensor_compatibility(cls, value) -> list[SensorConfig]: + return _check_sensor_compatibility(value, ADCP_SUPPORTED_SENSORS, "ADCP") + + @pydantic.field_serializer("sensors") + def _serialize_sensors(self, value: list[SensorConfig], _info): + return _serialize_sensor_list(value) + + def active_variables(self) -> dict[str, str]: + """ + FieldSet-key → Copernicus-variable mapping for enabled sensors. + + VELOCITY is a special case: one sensor provides two FieldSet variables (U and V). + """ + variables = {} + for sc in self.sensors: + if sc.enabled and sc.sensor_type == SensorType.VELOCITY: + variables["U"] = "uo" + variables["V"] = "vo" + return variables + @register_instrument_config(InstrumentType.CTD) class CTDConfig(pydantic.BaseModel): @@ -284,6 +382,14 @@ class CTDConfig(pydantic.BaseModel): 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.TEMPERATURE), + SensorConfig(sensor_type=SensorType.SALINITY), + ], + description=("Sensors fitted to the CTD. Supported: TEMPERATURE, SALINITY. "), + ) + model_config = pydantic.ConfigDict(populate_by_name=True) @pydantic.field_serializer("stationkeeping_time") @@ -294,6 +400,19 @@ def _serialize_stationkeeping_time(self, value: timedelta, _info): def _validate_stationkeeping_time(cls, value: int | float | timedelta) -> timedelta: return _validate_numeric_to_timedelta(value, "minutes") + @pydantic.field_validator("sensors", mode="after") + @classmethod + def _check_sensor_compatibility(cls, value) -> list[SensorConfig]: + return _check_sensor_compatibility(value, CTD_SUPPORTED_SENSORS, "CTD") + + @pydantic.field_serializer("sensors") + def _serialize_sensors(self, value: list[SensorConfig], _info): + return _serialize_sensor_list(value) + + def active_variables(self) -> dict[str, str]: + """FieldSet-key → Copernicus-variable mapping for enabled sensors.""" + return build_variables_from_sensors(self.sensors) + @register_instrument_config(InstrumentType.CTD_BGC) class CTD_BGCConfig(pydantic.BaseModel): @@ -307,6 +426,22 @@ class CTD_BGCConfig(pydantic.BaseModel): 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: CHLOROPHYLL, NITRATE, OXYGEN, PH, PHOSPHATE, PHYTOPLANKTON, PRIMARY_PRODUCTION. " + ), + ) + model_config = pydantic.ConfigDict(populate_by_name=True) @pydantic.field_serializer("stationkeeping_time") @@ -317,6 +452,19 @@ def _serialize_stationkeeping_time(self, value: timedelta, _info): def _validate_stationkeeping_time(cls, value: int | float | timedelta) -> timedelta: return _validate_numeric_to_timedelta(value, "minutes") + @pydantic.field_validator("sensors", mode="after") + @classmethod + def _check_sensor_compatibility(cls, value) -> list[SensorConfig]: + return _check_sensor_compatibility(value, CTD_BGC_SUPPORTED_SENSORS, "CTD_BGC") + + @pydantic.field_serializer("sensors") + def _serialize_sensors(self, value: list[SensorConfig], _info): + return _serialize_sensor_list(value) + + def active_variables(self) -> dict[str, str]: + """FieldSet-key → Copernicus-variable mapping for enabled sensors.""" + return build_variables_from_sensors(self.sensors) + @register_instrument_config(InstrumentType.UNDERWATER_ST) class ShipUnderwaterSTConfig(pydantic.BaseModel): @@ -328,6 +476,16 @@ class ShipUnderwaterSTConfig(pydantic.BaseModel): gt=timedelta(), ) + sensors: list[SensorConfig] = pydantic.Field( + default_factory=lambda: [ + SensorConfig(sensor_type=SensorType.TEMPERATURE), + SensorConfig(sensor_type=SensorType.SALINITY), + ], + description=( + "Sensors fitted to the underway ST. Supported: TEMPERATURE, SALINITY. " + ), + ) + model_config = pydantic.ConfigDict(populate_by_name=True) @pydantic.field_serializer("period") @@ -338,6 +496,21 @@ def _serialize_period(self, value: timedelta, _info): def _validate_period(cls, value: int | float | timedelta) -> timedelta: return _validate_numeric_to_timedelta(value, "minutes") + @pydantic.field_validator("sensors", mode="after") + @classmethod + def _check_sensor_compatibility(cls, value) -> list[SensorConfig]: + return _check_sensor_compatibility( + value, UNDERWATER_ST_SUPPORTED_SENSORS, "Underwater ST" + ) + + @pydantic.field_serializer("sensors") + def _serialize_sensors(self, value: list[SensorConfig], _info): + return _serialize_sensor_list(value) + + def active_variables(self) -> dict[str, str]: + """FieldSet-key → Copernicus-variable mapping for enabled sensors.""" + return build_variables_from_sensors(self.sensors) + @register_instrument_config(InstrumentType.DRIFTER) class DrifterConfig(pydantic.BaseModel): @@ -355,6 +528,11 @@ class DrifterConfig(pydantic.BaseModel): 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) @pydantic.field_serializer("lifetime") @@ -373,6 +551,19 @@ def _serialize_stationkeeping_time(self, value: timedelta, _info): def _validate_stationkeeping_time(cls, value: int | float | timedelta) -> timedelta: return _validate_numeric_to_timedelta(value, "minutes") + @pydantic.field_validator("sensors", mode="after") + @classmethod + def _check_sensor_compatibility(cls, value) -> list[SensorConfig]: + return _check_sensor_compatibility(value, DRIFTER_SUPPORTED_SENSORS, "Drifter") + + @pydantic.field_serializer("sensors") + def _serialize_sensors(self, value: list[SensorConfig], _info): + return _serialize_sensor_list(value) + + def active_variables(self) -> dict[str, str]: + """FieldSet-key → Copernicus-variable mapping for enabled sensors.""" + return build_variables_from_sensors(self.sensors) + @register_instrument_config(InstrumentType.XBT) class XBTConfig(pydantic.BaseModel): @@ -383,6 +574,24 @@ class XBTConfig(pydantic.BaseModel): fall_speed_meter_per_second: float = pydantic.Field(gt=0.0) deceleration_coefficient: float = pydantic.Field(gt=0.0) + sensors: list[SensorConfig] = pydantic.Field( + default_factory=lambda: [SensorConfig(sensor_type=SensorType.TEMPERATURE)], + description=("Sensors fitted to the XBT. Supported: TEMPERATURE. "), + ) + + @pydantic.field_validator("sensors", mode="after") + @classmethod + def _check_sensor_compatibility(cls, value) -> list[SensorConfig]: + return _check_sensor_compatibility(value, XBT_SUPPORTED_SENSORS, "XBT") + + @pydantic.field_serializer("sensors") + def _serialize_sensors(self, value: list[SensorConfig], _info): + return _serialize_sensor_list(value) + + def active_variables(self) -> dict[str, str]: + """FieldSet-key → Copernicus-variable mapping for enabled sensors.""" + return build_variables_from_sensors(self.sensors) + class InstrumentsConfig(pydantic.BaseModel): """Configuration of instruments.""" @@ -473,3 +682,38 @@ def verify(self, expedition: Expedition) -> None: raise InstrumentsConfigError( f"Expedition includes instrument '{inst_type.value}', but instruments_config does not provide configuration for it." ) + + +class SensorConfig(pydantic.BaseModel): + """Configuration for a single sensor fitted to an instrument.""" + + sensor_type: SensorType + enabled: bool = True + + @pydantic.field_validator("sensor_type", mode="before") + @classmethod + def _take_sensor_type(cls, value: str | SensorType) -> SensorType: + """Accept a sensor-type string or SensorType class.""" + if isinstance(value, SensorType): + return value + return SensorType(value) + + @property + def meta(self) -> _SensorMeta: + """Metadata for this sensor.""" + return SENSOR_REGISTRY[self.sensor_type] + + @property + def fs_key(self) -> str: + """FieldSet key (e.g. T, o2).""" + return self.meta.fs_key + + @property + def copernicus_var(self) -> str: + """Copernicus Marine variable name (e.g. thetao, o2).""" + return self.meta.copernicus_var + + @property + def category(self) -> Literal["phys", "bgc"]: + """Physical (phys) or biogeochemical (bgc).""" + return self.meta.category From b82118d2637d455360fb0fd7a1534757a99237bb Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:09:30 +0100 Subject: [PATCH 07/53] modify adcp instrument class, also abstract expansion to u and v to higher level for scalability --- src/virtualship/instruments/adcp.py | 47 +++++++++++++++++++++-------- src/virtualship/utils.py | 27 +++++++++-------- 2 files changed, 48 insertions(+), 26 deletions(-) diff --git a/src/virtualship/instruments/adcp.py b/src/virtualship/instruments/adcp.py index 17797a41..603d9a2e 100644 --- a/src/virtualship/instruments/adcp.py +++ b/src/virtualship/instruments/adcp.py @@ -2,13 +2,14 @@ from typing import ClassVar import numpy as np -from parcels import ParticleSet, ScipyParticle, Variable +from parcels import ParticleSet, ScipyParticle from virtualship.instruments.base import Instrument -from virtualship.instruments.types import InstrumentType -from virtualship.utils import ( - register_instrument, +from virtualship.instruments.types import ( + InstrumentType, + SensorType, ) +from virtualship.utils import register_instrument # ===================================================== # SECTION: Dataclass @@ -23,16 +24,12 @@ class ADCP: # ===================================================== -# SECTION: Particle Class +# SECTION: fixed/mechanical Particle Variables (non-sampling) # ===================================================== +# ADCP has no fixed/mechanical variables, only sensor variables. +_ADCP_FIXED_VARIABLES: list = [] -_ADCPParticle = ScipyParticle.add_variables( - [ - Variable("U", dtype=np.float32, initial=np.nan), - Variable("V", dtype=np.float32, initial=np.nan), - ] -) # ===================================================== # SECTION: Kernels @@ -45,6 +42,11 @@ def _sample_velocity(particle, fieldset, time): ) +_ADCP_SENSOR_KERNELS: dict[SensorType, callable] = { + SensorType.VELOCITY: _sample_velocity, +} + + # ===================================================== # SECTION: Instrument Class # ===================================================== @@ -56,7 +58,7 @@ class ADCPInstrument(Instrument): def __init__(self, expedition, from_data): """Initialize ADCPInstrument.""" - variables = {"U": "uo", "V": "vo"} + variables = expedition.instruments_config.adcp_config.active_variables() limit_spec = { "spatial": True } # spatial limits; lat/lon constrained to waypoint locations + buffer @@ -93,6 +95,18 @@ def simulate(self, measurements, out_path) -> None: fieldset = self.load_input_data() + # build dynamic particle class from the active sensors + adcp_config = self.expedition.instruments_config.adcp_config + sensor_variables = [ + var + for sc in adcp_config.sensors + if sc.enabled + for var in sc.particle_variables() + ] + _ADCPParticle = ScipyParticle.add_variables( + _ADCP_FIXED_VARIABLES + sensor_variables + ) + bins = np.linspace(MAX_DEPTH, MIN_DEPTH, NUM_BINS) num_particles = len(bins) particleset = ParticleSet.from_list( @@ -108,6 +122,13 @@ def simulate(self, measurements, out_path) -> None: out_file = particleset.ParticleFile(name=out_path, outputdt=np.inf) + # build kernel list from active sensors only + sample_kernels = [ + _ADCP_SENSOR_KERNELS[sc.sensor_type] + for sc in adcp_config.sensors + if sc.enabled and sc.sensor_type in _ADCP_SENSOR_KERNELS + ] + for point in measurements: particleset.lon_nextloop[:] = point.location.lon particleset.lat_nextloop[:] = point.location.lat @@ -116,7 +137,7 @@ def simulate(self, measurements, out_path) -> None: ) particleset.execute( - [_sample_velocity], + sample_kernels, dt=1, runtime=1, verbose_progress=self.verbose_progress, diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 590d6b47..48b63a57 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -67,7 +67,7 @@ class _SensorMeta: category: Literal[ "phys", "bgc" ] # physical vs. biogeochemical variable, used for product ID selection logic - particle_var: str # map to variable name in the Parcels Particle class + particle_vars: list[str] # particle variable name(s) produced by this sensor # the copernicus_var field below is the bridge between this registry the Copernicus product-ID selection logic (PRODUCT_IDS, BGC_ANALYSIS_IDS, MONTHLY_BGC_REANALYSIS_IDS, etc.) @@ -76,61 +76,61 @@ class _SensorMeta: fs_key="T", copernicus_var="thetao", category="phys", - particle_var="temperature", + particle_vars=["temperature"], ), SensorType.SALINITY: _SensorMeta( fs_key="S", copernicus_var="so", category="phys", - particle_var="salinity", + particle_vars=["salinity"], ), SensorType.VELOCITY: _SensorMeta( fs_key="UV", - copernicus_var="uo", # primary; active_variables() in ADCPConfig expands to both uo and vo + copernicus_var="uo", # primary var... active_variables() in ADCPConfig expands to both uo and vo category="phys", - particle_var="U", # primary; adcp.py adds V explicitly for VELOCITY + particle_vars=["U", "V"], # two particle variables associated with one sensor ), SensorType.OXYGEN: _SensorMeta( fs_key="o2", copernicus_var="o2", category="bgc", - particle_var="o2", + particle_vars=["o2"], ), SensorType.CHLOROPHYLL: _SensorMeta( fs_key="chl", copernicus_var="chl", category="bgc", - particle_var="chl", + particle_vars=["chl"], ), SensorType.NITRATE: _SensorMeta( fs_key="no3", copernicus_var="no3", category="bgc", - particle_var="no3", + particle_vars=["no3"], ), SensorType.PHOSPHATE: _SensorMeta( fs_key="po4", copernicus_var="po4", category="bgc", - particle_var="po4", + particle_vars=["po4"], ), SensorType.PH: _SensorMeta( fs_key="ph", copernicus_var="ph", category="bgc", - particle_var="ph", + particle_vars=["ph"], ), SensorType.PHYTOPLANKTON: _SensorMeta( fs_key="phyc", copernicus_var="phyc", category="bgc", - particle_var="phyc", + particle_vars=["phyc"], ), SensorType.PRIMARY_PRODUCTION: _SensorMeta( fs_key="nppv", copernicus_var="nppv", category="bgc", - particle_var="nppv", + particle_vars=["nppv"], ), } @@ -743,9 +743,10 @@ def build_particle_class_from_sensors( ) -> type: """Build a JITParticle class from fixed variables and active sensors. ScipyParticle classes are built in instrument sub-classes where used.""" sensor_variables = [ - Variable(sc.meta.particle_var, dtype=np.float32, initial=np.nan) + Variable(var_name, dtype=np.float32, initial=np.nan) for sc in sensors if sc.enabled + for var_name in sc.meta.particle_vars ] return JITParticle.add_variables(fixed_variables + sensor_variables) From 8d96d8fa42d0cbd90fc95e1e7b11e87b805fda4b Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:36:33 +0100 Subject: [PATCH 08/53] dynamic particle class building takes JIT or Scipy particle --- src/virtualship/utils.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 48b63a57..363e9e6a 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -17,7 +17,7 @@ import pyproj import xarray as xr -from parcels import FieldSet, JITParticle, Variable +from parcels import FieldSet, Variable from virtualship.errors import CopernicusCatalogueError from virtualship.instruments.types import SensorType @@ -740,15 +740,16 @@ def _make_hash(s: str, length: int) -> str: def build_particle_class_from_sensors( sensors: list[SensorConfig], fixed_variables: list, + particle_class: type, ) -> type: - """Build a JITParticle class from fixed variables and active sensors. ScipyParticle classes are built in instrument sub-classes where used.""" + """Build a Particle class (JITParticle or ScipyParticle) from fixed variables and active sensors.""" sensor_variables = [ Variable(var_name, dtype=np.float32, initial=np.nan) for sc in sensors if sc.enabled for var_name in sc.meta.particle_vars ] - return JITParticle.add_variables(fixed_variables + sensor_variables) + return particle_class.add_variables(fixed_variables + sensor_variables) # ===================================================== From 818e8f8f69d4446320e85c1fef7c3542ebceb4aa Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:37:03 +0100 Subject: [PATCH 09/53] raise error when instrument has zero sensors enabled --- src/virtualship/models/expedition.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/virtualship/models/expedition.py b/src/virtualship/models/expedition.py index 18b16017..0a351191 100644 --- a/src/virtualship/models/expedition.py +++ b/src/virtualship/models/expedition.py @@ -234,7 +234,7 @@ def _check_sensor_compatibility( supported: frozenset[SensorType], instrument_name: str, ) -> list[SensorConfig]: - """Raise ``ValueError`` if any sensor in `sensors` is not in `supported`. Used as a Pydantic field_validator for each instrument config class.""" + """Raise ``ValueError`` if any sensor in `sensors` is not in `supported`, or if no sensors are enabled. Used as a Pydantic field_validator for each instrument config class.""" unsupported = {sc.sensor_type for sc in sensors} - supported if unsupported: names = ", ".join(sorted(s.value for s in unsupported)) @@ -243,6 +243,11 @@ def _check_sensor_compatibility( f"{instrument_name} does not support sensor(s): {names}. " f"Supported sensors: {valid}." ) + if not any(sc.enabled for sc in sensors): + raise ValueError( + f"{instrument_name} has no enabled sensors. " + f"At least one sensor must be enabled." + ) return sensors From 07c8461d8e3e96a9dc51598746690380a9194cc8 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:37:36 +0100 Subject: [PATCH 10/53] use centralised particle class builder for ADCP now as well --- src/virtualship/instruments/adcp.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/virtualship/instruments/adcp.py b/src/virtualship/instruments/adcp.py index 603d9a2e..beeda96a 100644 --- a/src/virtualship/instruments/adcp.py +++ b/src/virtualship/instruments/adcp.py @@ -9,7 +9,7 @@ InstrumentType, SensorType, ) -from virtualship.utils import register_instrument +from virtualship.utils import build_particle_class_from_sensors, register_instrument # ===================================================== # SECTION: Dataclass @@ -97,14 +97,8 @@ def simulate(self, measurements, out_path) -> None: # build dynamic particle class from the active sensors adcp_config = self.expedition.instruments_config.adcp_config - sensor_variables = [ - var - for sc in adcp_config.sensors - if sc.enabled - for var in sc.particle_variables() - ] - _ADCPParticle = ScipyParticle.add_variables( - _ADCP_FIXED_VARIABLES + sensor_variables + _ADCPParticle = build_particle_class_from_sensors( + adcp_config.sensors, _ADCP_FIXED_VARIABLES, ScipyParticle ) bins = np.linspace(MAX_DEPTH, MIN_DEPTH, NUM_BINS) From 1bf517e7e0562dbdb63c8b0ab0352ad887f46fd8 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:45:03 +0100 Subject: [PATCH 11/53] batch update instrument subclasses adapted to refactored sensor logic --- src/virtualship/instruments/ctd.py | 62 +++++++++---- src/virtualship/instruments/ctd_bgc.py | 92 ++++++++++--------- src/virtualship/instruments/drifter.py | 54 ++++++++--- .../instruments/ship_underwater_st.py | 46 +++++++--- src/virtualship/instruments/xbt.py | 61 ++++++++---- 5 files changed, 208 insertions(+), 107 deletions(-) diff --git a/src/virtualship/instruments/ctd.py b/src/virtualship/instruments/ctd.py index eb780d3e..12a5ca2f 100644 --- a/src/virtualship/instruments/ctd.py +++ b/src/virtualship/instruments/ctd.py @@ -3,14 +3,18 @@ from typing import TYPE_CHECKING, ClassVar import numpy as np -from parcels import JITParticle, ParticleSet, Variable +from parcels import ParticleSet, Variable from virtualship.instruments.base import Instrument -from virtualship.instruments.types import InstrumentType +from virtualship.instruments.types import ( + InstrumentType, + SensorType, + build_particle_class_from_sensors, +) +from virtualship.utils import add_dummy_UV, register_instrument if TYPE_CHECKING: from virtualship.models.spacetime import Spacetime -from virtualship.utils import add_dummy_UV, register_instrument # ===================================================== # SECTION: Dataclass @@ -28,19 +32,15 @@ class CTD: # ===================================================== -# SECTION: Particle Class +# SECTION: fixed/mechanical Particle Variables (non-sampling) # ===================================================== -_CTDParticle = JITParticle.add_variables( - [ - Variable("salinity", dtype=np.float32, initial=np.nan), - Variable("temperature", dtype=np.float32, initial=np.nan), - 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), - ] -) +_CTD_FIXED_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), +] # ===================================================== @@ -70,6 +70,12 @@ def _ctd_cast(particle, fieldset, time): particle.delete() +_CTD_SENSOR_KERNELS: dict[SensorType, callable] = { + SensorType.TEMPERATURE: _sample_temperature, + SensorType.SALINITY: _sample_salinity, +} + + # ===================================================== # SECTION: Instrument Class # ===================================================== @@ -81,7 +87,7 @@ class CTDInstrument(Instrument): def __init__(self, expedition, from_data): """Initialize CTDInstrument.""" - variables = {"S": "so", "T": "thetao"} + variables = expedition.instruments_config.ctd_config.active_variables() limit_spec = { "spatial": True } # spatial limits; lat/lon constrained to waypoint locations + buffer @@ -115,11 +121,14 @@ def simulate(self, measurements, out_path) -> None: # add dummy U add_dummy_UV(fieldset) # TODO: parcels v3 bodge; remove when parcels v4 is used - fieldset_starttime = fieldset.T.grid.time_origin.fulltime( - fieldset.T.grid.time_full[0] + # 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 = fieldset.T.grid.time_origin.fulltime( - fieldset.T.grid.time_full[-1] + 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 @@ -152,6 +161,12 @@ def simulate(self, measurements, out_path) -> None: 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_FIXED_VARIABLES + ) + # define parcel particles ctd_particleset = ParticleSet( fieldset=fieldset, @@ -168,9 +183,16 @@ def simulate(self, measurements, out_path) -> None: # define output file for the simulation out_file = ctd_particleset.ParticleFile(name=out_path, outputdt=OUTPUT_DT) + # build kernel list from active sensors only + sample_kernels = [ + _CTD_SENSOR_KERNELS[sc.sensor_type] + for sc in ctd_config.sensors + if sc.enabled and sc.sensor_type in _CTD_SENSOR_KERNELS + ] + # execute simulation ctd_particleset.execute( - [_sample_salinity, _sample_temperature, _ctd_cast], + [*sample_kernels, _ctd_cast], endtime=fieldset_endtime, dt=DT, verbose_progress=self.verbose_progress, diff --git a/src/virtualship/instruments/ctd_bgc.py b/src/virtualship/instruments/ctd_bgc.py index 221cfa12..cbba0c14 100644 --- a/src/virtualship/instruments/ctd_bgc.py +++ b/src/virtualship/instruments/ctd_bgc.py @@ -3,12 +3,19 @@ from typing import 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.types import InstrumentType +from virtualship.instruments.types import ( + InstrumentType, + SensorType, +) from virtualship.models.spacetime import Spacetime -from virtualship.utils import add_dummy_UV, register_instrument +from virtualship.utils import ( + add_dummy_UV, + build_particle_class_from_sensors, + register_instrument, +) # ===================================================== # SECTION: Dataclass @@ -26,24 +33,15 @@ class CTD_BGC: # ===================================================== -# SECTION: Particle Class +# SECTION: fixed/mechanical Particle Variables (non-sampling) # ===================================================== -_CTD_BGCParticle = JITParticle.add_variables( - [ - Variable("o2", dtype=np.float32, initial=np.nan), - Variable("chl", dtype=np.float32, initial=np.nan), - Variable("no3", dtype=np.float32, initial=np.nan), - Variable("po4", dtype=np.float32, initial=np.nan), - Variable("ph", dtype=np.float32, initial=np.nan), - Variable("phyc", dtype=np.float32, initial=np.nan), - Variable("nppv", dtype=np.float32, initial=np.nan), - 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), - ] -) +_CTD_BGC_FIXED_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 @@ -92,6 +90,17 @@ def _ctd_bgc_cast(particle, fieldset, time): particle.delete() +_CTD_BGC_SENSOR_KERNELS: 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, +} + + # ===================================================== # SECTION: Instrument Class # ===================================================== @@ -103,15 +112,7 @@ class CTD_BGCInstrument(Instrument): def __init__(self, expedition, from_data): """Initialize CTD_BGCInstrument.""" - variables = { - "o2": "o2", - "chl": "chl", - "no3": "no3", - "po4": "po4", - "ph": "ph", - "phyc": "phyc", - "nppv": "nppv", - } + variables = expedition.instruments_config.ctd_bgc_config.active_variables() limit_spec = { "spatial": True } # spatial limits; lat/lon constrained to waypoint locations + buffer @@ -145,11 +146,14 @@ def simulate(self, measurements, out_path) -> None: # add dummy U add_dummy_UV(fieldset) # TODO: parcels v3 bodge; remove when parcels v4 is used - fieldset_starttime = fieldset.o2.grid.time_origin.fulltime( - fieldset.o2.grid.time_full[0] + # 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 = fieldset.o2.grid.time_origin.fulltime( - fieldset.o2.grid.time_full[-1] + 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 @@ -182,6 +186,12 @@ def simulate(self, measurements, out_path) -> None: 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_FIXED_VARIABLES, JITParticle + ) + # define parcel particles ctd_bgc_particleset = ParticleSet( fieldset=fieldset, @@ -198,18 +208,16 @@ def simulate(self, measurements, out_path) -> None: # 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 + sample_kernels = [ + _CTD_BGC_SENSOR_KERNELS[sc.sensor_type] + for sc in ctd_bgc_config.sensors + if sc.enabled and sc.sensor_type in _CTD_BGC_SENSOR_KERNELS + ] + # execute simulation ctd_bgc_particleset.execute( - [ - _sample_o2, - _sample_chlorophyll, - _sample_nitrate, - _sample_phosphate, - _sample_ph, - _sample_phytoplankton, - _sample_primary_production, - _ctd_bgc_cast, - ], + [*sample_kernels, _ctd_bgc_cast], endtime=fieldset_endtime, dt=DT, verbose_progress=self.verbose_progress, diff --git a/src/virtualship/instruments/drifter.py b/src/virtualship/instruments/drifter.py index a58c4bef..38e217a7 100644 --- a/src/virtualship/instruments/drifter.py +++ b/src/virtualship/instruments/drifter.py @@ -3,12 +3,16 @@ from typing import ClassVar import numpy as np -from parcels import AdvectionRK4, JITParticle, ParticleSet, Variable +from parcels import AdvectionRK4, ParticleSet, Variable from virtualship.instruments.base import Instrument -from virtualship.instruments.types import InstrumentType +from virtualship.instruments.types import InstrumentType, SensorType from virtualship.models.spacetime import Spacetime -from virtualship.utils import _random_noise, register_instrument +from virtualship.utils import ( + _random_noise, + build_particle_class_from_sensors, + register_instrument, +) # ===================================================== # SECTION: Dataclass @@ -26,17 +30,14 @@ class Drifter: # ===================================================== -# SECTION: Particle Class +# SECTION: fixed/mechanical Particle Variables (non-sampling) # ===================================================== -_DrifterParticle = JITParticle.add_variables( - [ - Variable("temperature", dtype=np.float32, initial=np.nan), - Variable("has_lifetime", dtype=np.int8), # bool - Variable("age", dtype=np.float32, initial=0.0), - Variable("lifetime", dtype=np.float32), - ] -) +_DRIFTER_FIXED_VARIABLES = [ + Variable("has_lifetime", dtype=np.int8), # bool + Variable("age", dtype=np.float32, initial=0.0), + Variable("lifetime", dtype=np.float32), +] # ===================================================== # SECTION: Kernels @@ -54,6 +55,11 @@ def _check_lifetime(particle, fieldset, time): particle.delete() +_DRIFTER_SENSOR_KERNELS: dict[SensorType, callable] = { + SensorType.TEMPERATURE: _sample_temperature, +} + + # ===================================================== # SECTION: Instrument Class # ===================================================== @@ -65,7 +71,14 @@ class DrifterInstrument(Instrument): def __init__(self, expedition, from_data): """Initialize DrifterInstrument.""" - variables = {"U": "uo", "V": "vo", "T": "thetao"} + sensor_variables = ( + expedition.instruments_config.drifter_config.active_variables() + ) + variables = { + "U": "uo", + "V": "vo", + **sensor_variables, + } # advection variables (U and V) are always required for argo float simulation; sensor variables come from config spacetime_buffer_size = { "latlon": None, "time": expedition.instruments_config.drifter_config.lifetime.total_seconds() @@ -106,6 +119,12 @@ def simulate(self, measurements, out_path) -> None: fieldset = self.load_input_data() + # build dynamic particle class from the active sensors + drifter_config = self.expedition.instruments_config.drifter_config + _DrifterParticle = build_particle_class_from_sensors( + drifter_config.sensors, _DRIFTER_FIXED_VARIABLES + ) + # define parcel particles lat_release = [ drifter.spacetime.location.lat + _random_noise() for drifter in measurements @@ -140,9 +159,16 @@ def simulate(self, measurements, out_path) -> None: # determine end time for simulation, from fieldset (which itself is controlled by drifter lifetimes) endtime = fieldset.time_origin.fulltime(fieldset.U.grid.time_full[-1]) + # build kernel list from active sensors only + sample_kernels = [ + _DRIFTER_SENSOR_KERNELS[sc.sensor_type] + for sc in drifter_config.sensors + if sc.enabled and sc.sensor_type in _DRIFTER_SENSOR_KERNELS + ] + # execute simulation drifter_particleset.execute( - [AdvectionRK4, _sample_temperature, _check_lifetime], + [AdvectionRK4, *sample_kernels, _check_lifetime], endtime=endtime, dt=DT, output_file=out_file, diff --git a/src/virtualship/instruments/ship_underwater_st.py b/src/virtualship/instruments/ship_underwater_st.py index 8b7ef96d..a18ab9c5 100644 --- a/src/virtualship/instruments/ship_underwater_st.py +++ b/src/virtualship/instruments/ship_underwater_st.py @@ -2,11 +2,15 @@ from typing import ClassVar import numpy as np -from parcels import ParticleSet, ScipyParticle, Variable +from parcels import ParticleSet, ScipyParticle from virtualship.instruments.base import Instrument -from virtualship.instruments.types import InstrumentType -from virtualship.utils import add_dummy_UV, register_instrument +from virtualship.instruments.types import InstrumentType, SensorType +from virtualship.utils import ( + add_dummy_UV, + build_particle_class_from_sensors, + register_instrument, +) # ===================================================== # SECTION: Dataclass @@ -21,15 +25,12 @@ class Underwater_ST: # ===================================================== -# SECTION: Particle Class +# SECTION: fixed/mechanical Particle Variables (non-sampling) # ===================================================== -_ShipSTParticle = ScipyParticle.add_variables( - [ - Variable("S", dtype=np.float32, initial=np.nan), - Variable("T", dtype=np.float32, initial=np.nan), - ] -) +# Underwater ST has no fixed/mechanical variables, only sensor variables. +_ST_FIXED_VARIABLES: list = [] + # ===================================================== # SECTION: Kernels @@ -46,6 +47,12 @@ def _sample_temperature(particle, fieldset, time): particle.T = fieldset.T[time, particle.depth, particle.lat, particle.lon] +_ST_SENSOR_KERNELS: dict[SensorType, callable] = { + SensorType.TEMPERATURE: _sample_temperature, + SensorType.SALINITY: _sample_salinity, +} + + # ===================================================== # SECTION: Instrument Class # ===================================================== @@ -57,7 +64,9 @@ class Underwater_STInstrument(Instrument): def __init__(self, expedition, from_data): """Initialize Underwater_STInstrument.""" - variables = {"S": "so", "T": "thetao"} + variables = ( + expedition.instruments_config.ship_underwater_st_config.active_variables() + ) spacetime_buffer_size = { "latlon": 0.25, # [degrees] "time": 0.0, # [days] @@ -88,6 +97,12 @@ def simulate(self, measurements, out_path) -> None: # add dummy U add_dummy_UV(fieldset) # TODO: parcels v3 bodge; remove when parcels v4 is used + # build dynamic particle class from the active sensors + st_config = self.expedition.instruments_config.ship_underwater_st_config + _ShipSTParticle = build_particle_class_from_sensors( + st_config.sensors, _ST_FIXED_VARIABLES, ScipyParticle + ) + particleset = ParticleSet.from_list( fieldset=fieldset, pclass=_ShipSTParticle, @@ -99,6 +114,13 @@ def simulate(self, measurements, out_path) -> None: out_file = particleset.ParticleFile(name=out_path, outputdt=np.inf) + # build kernel list from active sensors only + sample_kernels = [ + _ST_SENSOR_KERNELS[sc.sensor_type] + for sc in st_config.sensors + if sc.enabled and sc.sensor_type in _ST_SENSOR_KERNELS + ] + for point in measurements: particleset.lon_nextloop[:] = point.location.lon particleset.lat_nextloop[:] = point.location.lat @@ -107,7 +129,7 @@ def simulate(self, measurements, out_path) -> None: ) particleset.execute( - [_sample_salinity, _sample_temperature], + sample_kernels, dt=1, runtime=1, verbose_progress=self.verbose_progress, diff --git a/src/virtualship/instruments/xbt.py b/src/virtualship/instruments/xbt.py index 2412306f..19b8509c 100644 --- a/src/virtualship/instruments/xbt.py +++ b/src/virtualship/instruments/xbt.py @@ -3,12 +3,16 @@ from typing import ClassVar import numpy as np -from parcels import JITParticle, ParticleSet, Variable +from parcels import ParticleSet, Variable from virtualship.instruments.base import Instrument -from virtualship.instruments.types import InstrumentType +from virtualship.instruments.types import InstrumentType, SensorType from virtualship.models.spacetime import Spacetime -from virtualship.utils import add_dummy_UV, register_instrument +from virtualship.utils import ( + add_dummy_UV, + build_particle_class_from_sensors, + register_instrument, +) # ===================================================== # SECTION: Dataclass @@ -28,18 +32,16 @@ class XBT: # ===================================================== -# SECTION: Particle Class +# SECTION: fixed/mechanical Particle Variables (non-sampling) # ===================================================== -_XBTParticle = JITParticle.add_variables( - [ - Variable("temperature", dtype=np.float32, initial=np.nan), - Variable("max_depth", dtype=np.float32), - Variable("min_depth", dtype=np.float32), - Variable("fall_speed", dtype=np.float32), - Variable("deceleration_coefficient", dtype=np.float32), - ] -) +_XBT_FIXED_VARIABLES = [ + Variable("max_depth", dtype=np.float32), + Variable("min_depth", dtype=np.float32), + Variable("fall_speed", dtype=np.float32), + Variable("deceleration_coefficient", dtype=np.float32), +] + # ===================================================== # SECTION: Kernels @@ -50,6 +52,11 @@ def _sample_temperature(particle, fieldset, time): particle.temperature = fieldset.T[time, particle.depth, particle.lat, particle.lon] +_XBT_SENSOR_KERNELS: dict[SensorType, callable] = { + SensorType.TEMPERATURE: _sample_temperature, +} + + def _xbt_cast(particle, fieldset, time): particle_ddepth = -particle.fall_speed * particle.dt @@ -79,7 +86,7 @@ class XBTInstrument(Instrument): def __init__(self, expedition, from_data): """Initialize XBTInstrument.""" - variables = {"T": "thetao"} + variables = expedition.instruments_config.xbt_config.active_variables() limit_spec = { "spatial": True } # spatial limits; lat/lon constrained to waypoint locations + buffer @@ -112,11 +119,14 @@ def simulate(self, measurements, out_path) -> None: # add dummy U add_dummy_UV(fieldset) # TODO: parcels v3 bodge; remove when parcels v4 is used - fieldset_starttime = fieldset.T.grid.time_origin.fulltime( - fieldset.T.grid.time_full[0] + # 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 = fieldset.T.grid.time_origin.fulltime( - fieldset.T.grid.time_full[-1] + fieldset_endtime = _time_ref_field.grid.time_origin.fulltime( + _time_ref_field.grid.time_full[-1] ) # deploy time for all xbts should be later than fieldset start time @@ -152,6 +162,12 @@ def simulate(self, measurements, out_path) -> None: f"XBT max_depth or bathymetry shallower than minimum {-DT * fall_speed}. It is likely the XBT cannot be deployed in this area, which is too shallow." ) + # build dynamic particle class from the active sensors + xbt_config = self.expedition.instruments_config.xbt_config + _XBTParticle = build_particle_class_from_sensors( + xbt_config.sensors, _XBT_FIXED_VARIABLES + ) + # define xbt particles xbt_particleset = ParticleSet( fieldset=fieldset, @@ -167,8 +183,15 @@ def simulate(self, measurements, out_path) -> None: out_file = xbt_particleset.ParticleFile(name=out_path, outputdt=OUTPUT_DT) + # build kernel list from active sensors only + sample_kernels = [ + _XBT_SENSOR_KERNELS[sc.sensor_type] + for sc in xbt_config.sensors + if sc.enabled and sc.sensor_type in _XBT_SENSOR_KERNELS + ] + xbt_particleset.execute( - [_sample_temperature, _xbt_cast], + [*sample_kernels, _xbt_cast], endtime=fieldset_endtime, dt=DT, verbose_progress=self.verbose_progress, From 961f1fe84a2d9a8b62920e3bf8216ff0b699c8a1 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:50:15 +0100 Subject: [PATCH 12/53] rename list --- src/virtualship/instruments/adcp.py | 4 ++-- src/virtualship/instruments/ctd.py | 4 ++-- src/virtualship/instruments/ctd_bgc.py | 4 ++-- src/virtualship/instruments/drifter.py | 4 ++-- src/virtualship/instruments/ship_underwater_st.py | 4 ++-- src/virtualship/instruments/xbt.py | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/virtualship/instruments/adcp.py b/src/virtualship/instruments/adcp.py index beeda96a..1140604c 100644 --- a/src/virtualship/instruments/adcp.py +++ b/src/virtualship/instruments/adcp.py @@ -117,7 +117,7 @@ def simulate(self, measurements, out_path) -> None: out_file = particleset.ParticleFile(name=out_path, outputdt=np.inf) # build kernel list from active sensors only - sample_kernels = [ + sampling_kernels = [ _ADCP_SENSOR_KERNELS[sc.sensor_type] for sc in adcp_config.sensors if sc.enabled and sc.sensor_type in _ADCP_SENSOR_KERNELS @@ -131,7 +131,7 @@ def simulate(self, measurements, out_path) -> None: ) particleset.execute( - sample_kernels, + sampling_kernels, dt=1, runtime=1, verbose_progress=self.verbose_progress, diff --git a/src/virtualship/instruments/ctd.py b/src/virtualship/instruments/ctd.py index 12a5ca2f..eb19d491 100644 --- a/src/virtualship/instruments/ctd.py +++ b/src/virtualship/instruments/ctd.py @@ -184,7 +184,7 @@ def simulate(self, measurements, out_path) -> None: out_file = ctd_particleset.ParticleFile(name=out_path, outputdt=OUTPUT_DT) # build kernel list from active sensors only - sample_kernels = [ + sampling_kernels = [ _CTD_SENSOR_KERNELS[sc.sensor_type] for sc in ctd_config.sensors if sc.enabled and sc.sensor_type in _CTD_SENSOR_KERNELS @@ -192,7 +192,7 @@ def simulate(self, measurements, out_path) -> None: # execute simulation ctd_particleset.execute( - [*sample_kernels, _ctd_cast], + [*sampling_kernels, _ctd_cast], endtime=fieldset_endtime, dt=DT, verbose_progress=self.verbose_progress, diff --git a/src/virtualship/instruments/ctd_bgc.py b/src/virtualship/instruments/ctd_bgc.py index cbba0c14..f0fac8c3 100644 --- a/src/virtualship/instruments/ctd_bgc.py +++ b/src/virtualship/instruments/ctd_bgc.py @@ -209,7 +209,7 @@ def simulate(self, measurements, out_path) -> None: out_file = ctd_bgc_particleset.ParticleFile(name=out_path, outputdt=OUTPUT_DT) # build kernel list from active sensors only - sample_kernels = [ + sampling_kernels = [ _CTD_BGC_SENSOR_KERNELS[sc.sensor_type] for sc in ctd_bgc_config.sensors if sc.enabled and sc.sensor_type in _CTD_BGC_SENSOR_KERNELS @@ -217,7 +217,7 @@ def simulate(self, measurements, out_path) -> None: # execute simulation ctd_bgc_particleset.execute( - [*sample_kernels, _ctd_bgc_cast], + [*sampling_kernels, _ctd_bgc_cast], endtime=fieldset_endtime, dt=DT, verbose_progress=self.verbose_progress, diff --git a/src/virtualship/instruments/drifter.py b/src/virtualship/instruments/drifter.py index 38e217a7..87b81853 100644 --- a/src/virtualship/instruments/drifter.py +++ b/src/virtualship/instruments/drifter.py @@ -160,7 +160,7 @@ def simulate(self, measurements, out_path) -> None: endtime = fieldset.time_origin.fulltime(fieldset.U.grid.time_full[-1]) # build kernel list from active sensors only - sample_kernels = [ + sampling_kernels = [ _DRIFTER_SENSOR_KERNELS[sc.sensor_type] for sc in drifter_config.sensors if sc.enabled and sc.sensor_type in _DRIFTER_SENSOR_KERNELS @@ -168,7 +168,7 @@ def simulate(self, measurements, out_path) -> None: # execute simulation drifter_particleset.execute( - [AdvectionRK4, *sample_kernels, _check_lifetime], + [AdvectionRK4, *sampling_kernels, _check_lifetime], endtime=endtime, dt=DT, output_file=out_file, diff --git a/src/virtualship/instruments/ship_underwater_st.py b/src/virtualship/instruments/ship_underwater_st.py index a18ab9c5..fe68e6b7 100644 --- a/src/virtualship/instruments/ship_underwater_st.py +++ b/src/virtualship/instruments/ship_underwater_st.py @@ -115,7 +115,7 @@ def simulate(self, measurements, out_path) -> None: out_file = particleset.ParticleFile(name=out_path, outputdt=np.inf) # build kernel list from active sensors only - sample_kernels = [ + sampling_kernels = [ _ST_SENSOR_KERNELS[sc.sensor_type] for sc in st_config.sensors if sc.enabled and sc.sensor_type in _ST_SENSOR_KERNELS @@ -129,7 +129,7 @@ def simulate(self, measurements, out_path) -> None: ) particleset.execute( - sample_kernels, + sampling_kernels, dt=1, runtime=1, verbose_progress=self.verbose_progress, diff --git a/src/virtualship/instruments/xbt.py b/src/virtualship/instruments/xbt.py index 19b8509c..9d01fcbd 100644 --- a/src/virtualship/instruments/xbt.py +++ b/src/virtualship/instruments/xbt.py @@ -184,14 +184,14 @@ def simulate(self, measurements, out_path) -> None: out_file = xbt_particleset.ParticleFile(name=out_path, outputdt=OUTPUT_DT) # build kernel list from active sensors only - sample_kernels = [ + sampling_kernels = [ _XBT_SENSOR_KERNELS[sc.sensor_type] for sc in xbt_config.sensors if sc.enabled and sc.sensor_type in _XBT_SENSOR_KERNELS ] xbt_particleset.execute( - [*sample_kernels, _xbt_cast], + [*sampling_kernels, _xbt_cast], endtime=fieldset_endtime, dt=DT, verbose_progress=self.verbose_progress, From ee002d76e98daaa60885e5bb97b7a3cb5a5dd871 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:56:53 +0100 Subject: [PATCH 13/53] adapt argo subclass to sensor refactoring, also separate the sampling kernels from the argo vertical movement kernel to enable easier scalability --- src/virtualship/instruments/argo_float.py | 110 ++++++++++++++-------- 1 file changed, 72 insertions(+), 38 deletions(-) diff --git a/src/virtualship/instruments/argo_float.py b/src/virtualship/instruments/argo_float.py index 1c697852..8252205e 100644 --- a/src/virtualship/instruments/argo_float.py +++ b/src/virtualship/instruments/argo_float.py @@ -4,18 +4,15 @@ from typing import ClassVar import numpy as np -from parcels import ( - AdvectionRK4, - JITParticle, - ParticleSet, - StatusCode, - Variable, -) +from parcels import AdvectionRK4, JITParticle, ParticleSet, StatusCode, Variable from virtualship.instruments.base import Instrument -from virtualship.instruments.types import InstrumentType +from virtualship.instruments.types import ( + InstrumentType, + SensorType, +) from virtualship.models.spacetime import Spacetime -from virtualship.utils import register_instrument +from virtualship.utils import build_particle_class_from_sensors, register_instrument # ===================================================== # SECTION: Dataclass @@ -37,25 +34,21 @@ class ArgoFloat: # ===================================================== -# SECTION: Particle Class +# SECTION: fixed/mechanical Particle Variables (non-sampling) # ===================================================== -_ArgoParticle = JITParticle.add_variables( - [ - Variable("cycle_phase", dtype=np.int32, initial=0.0), - Variable("cycle_age", dtype=np.float32, initial=0.0), - Variable("drift_age", dtype=np.float32, initial=0.0), - Variable("salinity", dtype=np.float32, initial=np.nan), - Variable("temperature", dtype=np.float32, initial=np.nan), - Variable("min_depth", dtype=np.float32), - Variable("max_depth", dtype=np.float32), - Variable("drift_depth", dtype=np.float32), - Variable("vertical_speed", dtype=np.float32), - Variable("cycle_days", dtype=np.int32), - Variable("drift_days", dtype=np.int32), - Variable("grounded", dtype=np.int32, initial=0), - ] -) +_ARGO_FIXED_VARIABLES = [ + Variable("cycle_phase", dtype=np.int32, initial=0.0), + Variable("cycle_age", dtype=np.float32, initial=0.0), + Variable("drift_age", dtype=np.float32, initial=0.0), + Variable("min_depth", dtype=np.float32), + Variable("max_depth", dtype=np.float32), + Variable("drift_depth", dtype=np.float32), + Variable("vertical_speed", dtype=np.float32), + Variable("cycle_days", dtype=np.int32), + Variable("drift_days", dtype=np.int32), + Variable("grounded", dtype=np.int32, initial=0), +] # ===================================================== # SECTION: Kernels @@ -118,18 +111,7 @@ def _argo_float_vertical_movement(particle, fieldset, time): particle.grounded = 0 if particle.depth + particle_ddepth >= particle.min_depth: particle_ddepth = particle.min_depth - particle.depth - particle.temperature = ( - math.nan - ) # reset temperature to NaN at end of sampling cycle - particle.salinity = math.nan # idem particle.cycle_phase = 4 - else: - particle.temperature = fieldset.T[ - time, particle.depth, particle.lat, particle.lon - ] - particle.salinity = fieldset.S[ - time, particle.depth, particle.lat, particle.lon - ] elif particle.cycle_phase == 4: # Phase 4: Transmitting at surface until cycletime is reached @@ -153,6 +135,35 @@ def _check_error(particle, fieldset, time): particle.delete() +# TODO: ensure the behaviour is still the same as previously now that the sampling is extracted from the main vertical movement kernels +def _argo_sample_temperature(particle, fieldset, time): + # Phase 3: ascending — sample temperature; reset to NaN when cycle ends + if particle.cycle_phase == 3: + if particle.depth > particle.min_depth: + particle.temperature = fieldset.T[ + time, particle.depth, particle.lat, particle.lon + ] + else: + particle.temperature = math.nan # reset at surface + + +def _argo_sample_salinity(particle, fieldset, time): + # Phase 3: ascending — sample salinity; reset to NaN when cycle ends + if particle.cycle_phase == 3: + if particle.depth > particle.min_depth: + particle.salinity = fieldset.S[ + time, particle.depth, particle.lat, particle.lon + ] + else: + particle.salinity = math.nan # reset at surface + + +_ARGO_SENSOR_KERNELS: dict[SensorType, callable] = { + SensorType.TEMPERATURE: _argo_sample_temperature, + SensorType.SALINITY: _argo_sample_salinity, +} + + # ===================================================== # SECTION: Instrument Class # ===================================================== @@ -164,7 +175,14 @@ class ArgoFloatInstrument(Instrument): def __init__(self, expedition, from_data): """Initialize ArgoFloatInstrument.""" - variables = {"U": "uo", "V": "vo", "S": "so", "T": "thetao"} + sensor_variables = ( + expedition.instruments_config.argo_float_config.active_variables() + ) + variables = { + "U": "uo", + "V": "vo", + **sensor_variables, + } # advection variables (U and V) are always required for argo float simulation; sensor variables come from config spacetime_buffer_size = { "latlon": 3.0, # [degrees] "time": expedition.instruments_config.argo_float_config.lifetime.total_seconds() @@ -215,6 +233,14 @@ def simulate(self, measurements, out_path) -> None: f"{self.__class__.__name__} cannot be deployed in waters shallower than 50m. The following waypoints are too shallow: {shallow_waypoints}." ) + # build dynamic particle class from the active sensors + argo_float_config = self.expedition.instruments_config.argo_float_config + _ArgoParticle = build_particle_class_from_sensors( + argo_float_config.sensors, + _ARGO_FIXED_VARIABLES, + JITParticle, + ) + # define parcel particles argo_float_particleset = ParticleSet( fieldset=fieldset, @@ -241,10 +267,18 @@ def simulate(self, measurements, out_path) -> None: # endtime endtime = fieldset.time_origin.fulltime(fieldset.U.grid.time_full[-1]) + # build kernel list from active sensors only + sampling_kernels = [ + _ARGO_SENSOR_KERNELS[sc.sensor_type] + for sc in argo_float_config.sensors + if sc.enabled and sc.sensor_type in _ARGO_SENSOR_KERNELS + ] + # execute simulation argo_float_particleset.execute( [ _argo_float_vertical_movement, + *sampling_kernels, AdvectionRK4, _keep_at_surface, _check_error, From 47772174355d9376001e42e9d2ae8c66f8516e97 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:03:07 +0100 Subject: [PATCH 14/53] consistent particle variable naming --- src/virtualship/instruments/ship_underwater_st.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/virtualship/instruments/ship_underwater_st.py b/src/virtualship/instruments/ship_underwater_st.py index fe68e6b7..e505b896 100644 --- a/src/virtualship/instruments/ship_underwater_st.py +++ b/src/virtualship/instruments/ship_underwater_st.py @@ -39,12 +39,12 @@ class Underwater_ST: # define function sampling Salinity def _sample_salinity(particle, fieldset, time): - particle.S = fieldset.S[time, particle.depth, particle.lat, particle.lon] + particle.salinity = fieldset.S[time, particle.depth, particle.lat, particle.lon] # define function sampling Temperature def _sample_temperature(particle, fieldset, time): - particle.T = fieldset.T[time, particle.depth, particle.lat, particle.lon] + particle.temperature = fieldset.T[time, particle.depth, particle.lat, particle.lon] _ST_SENSOR_KERNELS: dict[SensorType, callable] = { From c419399f6d514d08ce596efbee8664cb2c7ee88c Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:04:47 +0100 Subject: [PATCH 15/53] add back in ctd_bgc for now --- src/virtualship/instruments/types.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/virtualship/instruments/types.py b/src/virtualship/instruments/types.py index d365f4bd..fbaaa2e3 100644 --- a/src/virtualship/instruments/types.py +++ b/src/virtualship/instruments/types.py @@ -7,6 +7,7 @@ class InstrumentType(Enum): """Types of the instruments.""" CTD = "CTD" + CTD_BGC = "CTD_BGC" DRIFTER = "DRIFTER" ARGO_FLOAT = "ARGO_FLOAT" XBT = "XBT" From daabfc402ac764ff8409d3a5f1627fa36a664c3c Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:05:21 +0100 Subject: [PATCH 16/53] fix import --- src/virtualship/instruments/ctd.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/virtualship/instruments/ctd.py b/src/virtualship/instruments/ctd.py index eb19d491..7d5d5d29 100644 --- a/src/virtualship/instruments/ctd.py +++ b/src/virtualship/instruments/ctd.py @@ -9,9 +9,12 @@ from virtualship.instruments.types import ( InstrumentType, SensorType, +) +from virtualship.utils import ( + add_dummy_UV, build_particle_class_from_sensors, + register_instrument, ) -from virtualship.utils import add_dummy_UV, register_instrument if TYPE_CHECKING: from virtualship.models.spacetime import Spacetime From cece7bb8914cd9712369f2e9b87de4a3e1d8754d Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:14:45 +0100 Subject: [PATCH 17/53] move sensor information to new sensors.py file --- src/virtualship/instruments/sensors.py | 52 +++++++++++++++++++++++++ src/virtualship/instruments/types.py | 53 -------------------------- 2 files changed, 52 insertions(+), 53 deletions(-) create mode 100644 src/virtualship/instruments/sensors.py diff --git a/src/virtualship/instruments/sensors.py b/src/virtualship/instruments/sensors.py new file mode 100644 index 00000000..1edaed01 --- /dev/null +++ b/src/virtualship/instruments/sensors.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from enum import Enum + + +class SensorType(str, Enum): + """Sensors available. Different intstruments mix and match these sensors as needed.""" + + TEMPERATURE = "TEMPERATURE" + SALINITY = "SALINITY" + VELOCITY = "VELOCITY" + OXYGEN = "OXYGEN" + CHLOROPHYLL = "CHLOROPHYLL" + NITRATE = "NITRATE" + PHOSPHATE = "PHOSPHATE" + PH = "PH" + PHYTOPLANKTON = "PHYTOPLANKTON" + PRIMARY_PRODUCTION = "PRIMARY_PRODUCTION" + + +# per-instrument allowlists of supported sensors (source truth for validation for which sensors each instrument supports) + +ARGO_FLOAT_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset( + {SensorType.TEMPERATURE, SensorType.SALINITY} +) + +# TODO: CTD and CTD_BGC will be consoidated in future PR... +CTD_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset( + {SensorType.TEMPERATURE, SensorType.SALINITY} +) + +CTD_BGC_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset( + { + SensorType.OXYGEN, + SensorType.CHLOROPHYLL, + SensorType.NITRATE, + SensorType.PHOSPHATE, + SensorType.PH, + SensorType.PHYTOPLANKTON, + SensorType.PRIMARY_PRODUCTION, + } +) + +DRIFTER_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset({SensorType.TEMPERATURE}) + +ADCP_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset({SensorType.VELOCITY}) + +UNDERWATER_ST_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset( + {SensorType.TEMPERATURE, SensorType.SALINITY} +) + +XBT_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset({SensorType.TEMPERATURE}) diff --git a/src/virtualship/instruments/types.py b/src/virtualship/instruments/types.py index fbaaa2e3..489a331f 100644 --- a/src/virtualship/instruments/types.py +++ b/src/virtualship/instruments/types.py @@ -18,56 +18,3 @@ class InstrumentType(Enum): def is_underway(self) -> bool: """Return True if instrument is an underway instrument (ADCP, UNDERWATER_ST).""" return self in {InstrumentType.ADCP, InstrumentType.UNDERWATER_ST} - - -class SensorType(str, Enum): - """ - Sensors available. Different intstruments mix and match these sensors as needed. - - Each entry has a corresponding entry in `SENSOR_REGISTRY` which carries the centralised metadata (e.g. FieldSet key, Copernicus var name). - """ - - TEMPERATURE = "TEMPERATURE" - SALINITY = "SALINITY" - VELOCITY = "VELOCITY" - OXYGEN = "OXYGEN" - CHLOROPHYLL = "CHLOROPHYLL" - NITRATE = "NITRATE" - PHOSPHATE = "PHOSPHATE" - PH = "PH" - PHYTOPLANKTON = "PHYTOPLANKTON" - PRIMARY_PRODUCTION = "PRIMARY_PRODUCTION" - - -# per-instrument allowlists of supported sensors (source truth for validation for which sensors each instrument supports) - -ARGO_FLOAT_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset( - {SensorType.TEMPERATURE, SensorType.SALINITY} -) - -# TODO: CTD and CTD_BGC will be consoidated in future PR... -CTD_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset( - {SensorType.TEMPERATURE, SensorType.SALINITY} -) - -CTD_BGC_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset( - { - SensorType.OXYGEN, - SensorType.CHLOROPHYLL, - SensorType.NITRATE, - SensorType.PHOSPHATE, - SensorType.PH, - SensorType.PHYTOPLANKTON, - SensorType.PRIMARY_PRODUCTION, - } -) - -DRIFTER_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset({SensorType.TEMPERATURE}) - -ADCP_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset({SensorType.VELOCITY}) - -UNDERWATER_ST_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset( - {SensorType.TEMPERATURE, SensorType.SALINITY} -) - -XBT_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset({SensorType.TEMPERATURE}) From b42933193f43e49c5f4614165a2583feb9bafbe3 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:15:10 +0100 Subject: [PATCH 18/53] update imports across codebase --- src/virtualship/instruments/adcp.py | 6 ++---- src/virtualship/instruments/argo_float.py | 6 ++---- src/virtualship/instruments/ctd.py | 6 ++---- src/virtualship/instruments/ctd_bgc.py | 6 ++---- src/virtualship/instruments/drifter.py | 3 ++- src/virtualship/instruments/ship_underwater_st.py | 3 ++- src/virtualship/instruments/xbt.py | 3 ++- src/virtualship/models/expedition.py | 4 ++-- src/virtualship/utils.py | 2 +- 9 files changed, 17 insertions(+), 22 deletions(-) diff --git a/src/virtualship/instruments/adcp.py b/src/virtualship/instruments/adcp.py index 1140604c..e1c404ef 100644 --- a/src/virtualship/instruments/adcp.py +++ b/src/virtualship/instruments/adcp.py @@ -5,10 +5,8 @@ from parcels import ParticleSet, ScipyParticle from virtualship.instruments.base import Instrument -from virtualship.instruments.types import ( - InstrumentType, - SensorType, -) +from virtualship.instruments.sensors import SensorType +from virtualship.instruments.types import InstrumentType from virtualship.utils import build_particle_class_from_sensors, register_instrument # ===================================================== diff --git a/src/virtualship/instruments/argo_float.py b/src/virtualship/instruments/argo_float.py index 8252205e..c8853f3e 100644 --- a/src/virtualship/instruments/argo_float.py +++ b/src/virtualship/instruments/argo_float.py @@ -7,10 +7,8 @@ from parcels import AdvectionRK4, JITParticle, ParticleSet, StatusCode, Variable from virtualship.instruments.base import Instrument -from virtualship.instruments.types import ( - InstrumentType, - SensorType, -) +from virtualship.instruments.sensors import SensorType +from virtualship.instruments.types import InstrumentType from virtualship.models.spacetime import Spacetime from virtualship.utils import build_particle_class_from_sensors, register_instrument diff --git a/src/virtualship/instruments/ctd.py b/src/virtualship/instruments/ctd.py index 7d5d5d29..dd397341 100644 --- a/src/virtualship/instruments/ctd.py +++ b/src/virtualship/instruments/ctd.py @@ -6,10 +6,8 @@ from parcels import ParticleSet, Variable from virtualship.instruments.base import Instrument -from virtualship.instruments.types import ( - InstrumentType, - SensorType, -) +from virtualship.instruments.sensors import SensorType +from virtualship.instruments.types import InstrumentType from virtualship.utils import ( add_dummy_UV, build_particle_class_from_sensors, diff --git a/src/virtualship/instruments/ctd_bgc.py b/src/virtualship/instruments/ctd_bgc.py index f0fac8c3..88968fb0 100644 --- a/src/virtualship/instruments/ctd_bgc.py +++ b/src/virtualship/instruments/ctd_bgc.py @@ -6,10 +6,8 @@ from parcels import JITParticle, ParticleSet, Variable from virtualship.instruments.base import Instrument -from virtualship.instruments.types import ( - InstrumentType, - SensorType, -) +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, diff --git a/src/virtualship/instruments/drifter.py b/src/virtualship/instruments/drifter.py index 87b81853..7ad2de4e 100644 --- a/src/virtualship/instruments/drifter.py +++ b/src/virtualship/instruments/drifter.py @@ -6,7 +6,8 @@ from parcels import AdvectionRK4, ParticleSet, Variable from virtualship.instruments.base import Instrument -from virtualship.instruments.types import InstrumentType, SensorType +from virtualship.instruments.sensors import SensorType +from virtualship.instruments.types import InstrumentType from virtualship.models.spacetime import Spacetime from virtualship.utils import ( _random_noise, diff --git a/src/virtualship/instruments/ship_underwater_st.py b/src/virtualship/instruments/ship_underwater_st.py index e505b896..73456ff3 100644 --- a/src/virtualship/instruments/ship_underwater_st.py +++ b/src/virtualship/instruments/ship_underwater_st.py @@ -5,7 +5,8 @@ from parcels import ParticleSet, ScipyParticle from virtualship.instruments.base import Instrument -from virtualship.instruments.types import InstrumentType, SensorType +from virtualship.instruments.sensors import SensorType +from virtualship.instruments.types import InstrumentType from virtualship.utils import ( add_dummy_UV, build_particle_class_from_sensors, diff --git a/src/virtualship/instruments/xbt.py b/src/virtualship/instruments/xbt.py index 9d01fcbd..94d2e291 100644 --- a/src/virtualship/instruments/xbt.py +++ b/src/virtualship/instruments/xbt.py @@ -6,7 +6,8 @@ from parcels import ParticleSet, Variable from virtualship.instruments.base import Instrument -from virtualship.instruments.types import InstrumentType, SensorType +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, diff --git a/src/virtualship/models/expedition.py b/src/virtualship/models/expedition.py index 0a351191..e5c397ef 100644 --- a/src/virtualship/models/expedition.py +++ b/src/virtualship/models/expedition.py @@ -11,7 +11,7 @@ import yaml from virtualship.errors import InstrumentsConfigError, ScheduleError -from virtualship.instruments.types import ( +from virtualship.instruments.sensors import ( ADCP_SUPPORTED_SENSORS, ARGO_FLOAT_SUPPORTED_SENSORS, CTD_BGC_SUPPORTED_SENSORS, @@ -19,9 +19,9 @@ DRIFTER_SUPPORTED_SENSORS, UNDERWATER_ST_SUPPORTED_SENSORS, XBT_SUPPORTED_SENSORS, - InstrumentType, SensorType, ) +from virtualship.instruments.types import InstrumentType from virtualship.utils import ( SENSOR_REGISTRY, _calc_sail_time, diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 363e9e6a..60563d26 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -19,7 +19,7 @@ from parcels import FieldSet, Variable from virtualship.errors import CopernicusCatalogueError -from virtualship.instruments.types import SensorType +from virtualship.instruments.sensors import SensorType if TYPE_CHECKING: from virtualship.expedition.simulate_schedule import ( From 882a4192d07e3642bc81e0aece931ba7fd30ef4e Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:46:06 +0100 Subject: [PATCH 19/53] add validator/serialiser for reading from YAML, remove unnecessary property shorthands --- src/virtualship/models/expedition.py | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/src/virtualship/models/expedition.py b/src/virtualship/models/expedition.py index e5c397ef..80692af3 100644 --- a/src/virtualship/models/expedition.py +++ b/src/virtualship/models/expedition.py @@ -3,7 +3,6 @@ import itertools from datetime import datetime, timedelta from pathlib import Path -from typing import Literal import numpy as np import pydantic @@ -253,7 +252,7 @@ def _check_sensor_compatibility( def build_variables_from_sensors(sensors: list[SensorConfig]) -> dict[str, str]: """Build variables dict (FieldSet key → Copernicus-variable).""" - return {sc.fs_key: sc.copernicus_var for sc in sensors if sc.enabled} + return {sc.meta.fs_key: sc.meta.copernicus_var for sc in sensors if sc.enabled} @register_instrument_config(InstrumentType.ARGO_FLOAT) @@ -695,6 +694,15 @@ class SensorConfig(pydantic.BaseModel): sensor_type: SensorType enabled: bool = True + # validator/serialiser for allowing the compact, single-string notation for sensors in YAML (e.g. "TEMPERATURE" instead of sensor_type: TEMPERATURE in each instance + @pydantic.model_validator(mode="before") + @classmethod + def _from_string(cls, value): + """Allow a bare sensor-type string (e.g. "TEMPERATURE") as shorthand for {"sensor_type": "TEMPERATURE"}.""" + if isinstance(value, str): + return {"sensor_type": value} + return value + @pydantic.field_validator("sensor_type", mode="before") @classmethod def _take_sensor_type(cls, value: str | SensorType) -> SensorType: @@ -707,18 +715,3 @@ def _take_sensor_type(cls, value: str | SensorType) -> SensorType: def meta(self) -> _SensorMeta: """Metadata for this sensor.""" return SENSOR_REGISTRY[self.sensor_type] - - @property - def fs_key(self) -> str: - """FieldSet key (e.g. T, o2).""" - return self.meta.fs_key - - @property - def copernicus_var(self) -> str: - """Copernicus Marine variable name (e.g. thetao, o2).""" - return self.meta.copernicus_var - - @property - def category(self) -> Literal["phys", "bgc"]: - """Physical (phys) or biogeochemical (bgc).""" - return self.meta.category From 126ecc20c6824ef3b57543be1bdfbe7af277df89 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:46:40 +0100 Subject: [PATCH 20/53] re-add JITParticle to particle class when creating instruments --- src/virtualship/instruments/ctd.py | 4 ++-- src/virtualship/instruments/drifter.py | 4 ++-- src/virtualship/instruments/xbt.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/virtualship/instruments/ctd.py b/src/virtualship/instruments/ctd.py index dd397341..ef1f6969 100644 --- a/src/virtualship/instruments/ctd.py +++ b/src/virtualship/instruments/ctd.py @@ -4,7 +4,7 @@ import numpy as np -from parcels import 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 @@ -165,7 +165,7 @@ def simulate(self, measurements, out_path) -> None: # 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_FIXED_VARIABLES + ctd_config.sensors, _CTD_FIXED_VARIABLES, JITParticle ) # define parcel particles diff --git a/src/virtualship/instruments/drifter.py b/src/virtualship/instruments/drifter.py index 7ad2de4e..88d9779a 100644 --- a/src/virtualship/instruments/drifter.py +++ b/src/virtualship/instruments/drifter.py @@ -4,7 +4,7 @@ import numpy as np -from parcels import AdvectionRK4, ParticleSet, Variable +from parcels import AdvectionRK4, JITParticle, ParticleSet, Variable from virtualship.instruments.base import Instrument from virtualship.instruments.sensors import SensorType from virtualship.instruments.types import InstrumentType @@ -123,7 +123,7 @@ def simulate(self, measurements, out_path) -> None: # build dynamic particle class from the active sensors drifter_config = self.expedition.instruments_config.drifter_config _DrifterParticle = build_particle_class_from_sensors( - drifter_config.sensors, _DRIFTER_FIXED_VARIABLES + drifter_config.sensors, _DRIFTER_FIXED_VARIABLES, JITParticle ) # define parcel particles diff --git a/src/virtualship/instruments/xbt.py b/src/virtualship/instruments/xbt.py index 94d2e291..b53b5824 100644 --- a/src/virtualship/instruments/xbt.py +++ b/src/virtualship/instruments/xbt.py @@ -4,7 +4,7 @@ import numpy as np -from parcels import 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 @@ -166,7 +166,7 @@ def simulate(self, measurements, out_path) -> None: # build dynamic particle class from the active sensors xbt_config = self.expedition.instruments_config.xbt_config _XBTParticle = build_particle_class_from_sensors( - xbt_config.sensors, _XBT_FIXED_VARIABLES + xbt_config.sensors, _XBT_FIXED_VARIABLES, JITParticle ) # define xbt particles From 9011b2da3438eec0c029262899d0ade69fae492a Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:31:32 +0100 Subject: [PATCH 21/53] remove CTD_BGC instrument type from InstrumentType enum, add SensorType enum --- src/virtualship/instruments/types.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/virtualship/instruments/types.py b/src/virtualship/instruments/types.py index 9ae221e9..8452d1fe 100644 --- a/src/virtualship/instruments/types.py +++ b/src/virtualship/instruments/types.py @@ -5,7 +5,6 @@ class InstrumentType(Enum): """Types of the instruments.""" CTD = "CTD" - CTD_BGC = "CTD_BGC" DRIFTER = "DRIFTER" ARGO_FLOAT = "ARGO_FLOAT" XBT = "XBT" @@ -16,3 +15,17 @@ class InstrumentType(Enum): def is_underway(self) -> bool: """Return True if instrument is an underway instrument (ADCP, UNDERWATER_ST).""" return self in {InstrumentType.ADCP, InstrumentType.UNDERWATER_ST} + + +class SensorType(str, Enum): + """Sensors available (to instruments with configurable sensors, e.g. CTDs). #TODO: and soon Argo floats, drifters.""" + + TEMPERATURE = "TEMPERATURE" + SALINITY = "SALINITY" + OXYGEN = "OXYGEN" + CHLOROPHYLL = "CHLOROPHYLL" + NITRATE = "NITRATE" + PHOSPHATE = "PHOSPHATE" + PH = "PH" + PHYTOPLANKTON = "PHYTOPLANKTON" + PRIMARY_PRODUCTION = "PRIMARY_PRODUCTION" From 464b3e93515069c6a54e49247e0e1acb0a3be00b Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:31:06 +0100 Subject: [PATCH 22/53] update utils: add sensor def mapping and remove old references to ctd_bgc --- src/virtualship/utils.py | 66 +++++++++++++++++++++++++++++----------- 1 file changed, 49 insertions(+), 17 deletions(-) diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 204e9e8f..e19f7d5f 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -15,9 +15,10 @@ import numpy as np import pyproj import xarray as xr -from parcels import FieldSet +from parcels import FieldSet from virtualship.errors import CopernicusCatalogueError +from virtualship.instruments.types import SensorType if TYPE_CHECKING: from virtualship.expedition.simulate_schedule import ( @@ -131,6 +132,52 @@ def decorator(cls): return decorator +# ===================================================== +# SECTION: optional sensors and variable mapping (e.g. for CTD) +# TODO: and soon also Argo floats... +# ===================================================== + + +SENSOR_DEFS: dict[SensorType, dict] = { + SensorType.TEMPERATURE: { + "fs_key": "T", + "copernicus_var": "thetao", + }, + SensorType.SALINITY: { + "fs_key": "S", + "copernicus_var": "so", + }, + SensorType.OXYGEN: { + "fs_key": "o2", + "copernicus_var": "o2", + }, + SensorType.CHLOROPHYLL: { + "fs_key": "chl", + "copernicus_var": "chl", + }, + SensorType.NITRATE: { + "fs_key": "no3", + "copernicus_var": "no3", + }, + SensorType.PHOSPHATE: { + "fs_key": "po4", + "copernicus_var": "po4", + }, + SensorType.PH: { + "fs_key": "ph", + "copernicus_var": "ph", + }, + SensorType.PHYTOPLANKTON: { + "fs_key": "phyc", + "copernicus_var": "phyc", + }, + SensorType.PRIMARY_PRODUCTION: { + "fs_key": "nppv", + "copernicus_var": "nppv", + }, +} + + # ===================================================== # SECTION: helper functions # ===================================================== @@ -617,18 +664,10 @@ def _calc_wp_stationkeeping_time( instrument_config_map: dict = INSTRUMENT_CONFIG_MAP, ) -> timedelta: """For a given waypoint (and the instruments present at this waypoint), calculate how much time is required to carry out all instrument deployments.""" - from virtualship.instruments.types import InstrumentType # avoid circular imports - # to empty list if wp instruments set to 'null' 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 - 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 @@ -639,7 +678,7 @@ def _calc_wp_stationkeeping_time( for iconfig in valid_instrument_configs: for itype in wp_instrument_types: if ( - instrument_config_map[itype] == iconfig.__class__.__name__ + instrument_config_map.get(itype) == iconfig.__class__.__name__ and ( iconfig not in wp_instrument_configs ) # avoid duplicates (would happen when multiple drifter deployments at same waypoint) @@ -649,13 +688,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[InstrumentType.CTD_BGC] - ): - continue # 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 - if hasattr(iconfig, "stationkeeping_time"): cumulative_stationkeeping_time += iconfig.stationkeeping_time From 2f7d82d945b75b3b6640261665504c7a5dc9aaeb Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:50:57 +0100 Subject: [PATCH 23/53] refactor: update SensorType enum and add source-truth for supported sensors for instruments --- src/virtualship/instruments/types.py | 43 +++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/src/virtualship/instruments/types.py b/src/virtualship/instruments/types.py index 8452d1fe..d365f4bd 100644 --- a/src/virtualship/instruments/types.py +++ b/src/virtualship/instruments/types.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from enum import Enum @@ -18,10 +20,15 @@ def is_underway(self) -> bool: class SensorType(str, Enum): - """Sensors available (to instruments with configurable sensors, e.g. CTDs). #TODO: and soon Argo floats, drifters.""" + """ + Sensors available. Different intstruments mix and match these sensors as needed. + + Each entry has a corresponding entry in `SENSOR_REGISTRY` which carries the centralised metadata (e.g. FieldSet key, Copernicus var name). + """ TEMPERATURE = "TEMPERATURE" SALINITY = "SALINITY" + VELOCITY = "VELOCITY" OXYGEN = "OXYGEN" CHLOROPHYLL = "CHLOROPHYLL" NITRATE = "NITRATE" @@ -29,3 +36,37 @@ class SensorType(str, Enum): PH = "PH" PHYTOPLANKTON = "PHYTOPLANKTON" PRIMARY_PRODUCTION = "PRIMARY_PRODUCTION" + + +# per-instrument allowlists of supported sensors (source truth for validation for which sensors each instrument supports) + +ARGO_FLOAT_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset( + {SensorType.TEMPERATURE, SensorType.SALINITY} +) + +# TODO: CTD and CTD_BGC will be consoidated in future PR... +CTD_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset( + {SensorType.TEMPERATURE, SensorType.SALINITY} +) + +CTD_BGC_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset( + { + SensorType.OXYGEN, + SensorType.CHLOROPHYLL, + SensorType.NITRATE, + SensorType.PHOSPHATE, + SensorType.PH, + SensorType.PHYTOPLANKTON, + SensorType.PRIMARY_PRODUCTION, + } +) + +DRIFTER_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset({SensorType.TEMPERATURE}) + +ADCP_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset({SensorType.VELOCITY}) + +UNDERWATER_ST_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset( + {SensorType.TEMPERATURE, SensorType.SALINITY} +) + +XBT_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset({SensorType.TEMPERATURE}) From 1d7c1589637ee102dc189dd0da96b929ab8b2d67 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:52:05 +0100 Subject: [PATCH 24/53] add sensors configuration for various instruments --- src/virtualship/static/expedition.yaml | 28 +++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/virtualship/static/expedition.yaml b/src/virtualship/static/expedition.yaml index c6db10bd..8ab72f8a 100644 --- a/src/virtualship/static/expedition.yaml +++ b/src/virtualship/static/expedition.yaml @@ -1,5 +1,7 @@ # see https://virtualship.readthedocs.io/en/latest/user-guide/tutorials/working_with_expedition_yaml.html for more details on how to edit this file # +# TODO: add a link to docs where lists what sensors are supported for each instrument +# schedule: waypoints: - instrument: @@ -37,6 +39,8 @@ instruments_config: num_bins: 40 max_depth_meter: -1000.0 period_minutes: 5.0 + sensors: + - VELOCITY argo_float_config: cycle_days: 10.0 drift_days: 9.0 @@ -46,23 +50,45 @@ instruments_config: vertical_speed_meter_per_second: -0.1 stationkeeping_time_minutes: 20.0 lifetime_days: 63.0 + sensors: + - TEMPERATURE + - SALINITY ctd_config: max_depth_meter: -2000.0 min_depth_meter: -11.0 stationkeeping_time_minutes: 50.0 + 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 + - PHOSPHATE + - PH + - PHYTOPLANKTON + - PRIMARY_PRODUCTION drifter_config: depth_meter: -1.0 lifetime_days: 42.0 stationkeeping_time_minutes: 20.0 + sensors: + - TEMPERATURE xbt_config: max_depth_meter: -285.0 min_depth_meter: -2.0 fall_speed_meter_per_second: 6.7 deceleration_coefficient: 0.00225 - ship_underwater_st_config: null + sensors: + - TEMPERATURE + ship_underwater_st_config: + period_minutes: 5.0 + sensors: + - TEMPERATURE + - SALINITY ship_config: ship_speed_knots: 10.0 From f01bf0ecac62f20447a829d21f2b89b71339e6f8 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:37:20 +0100 Subject: [PATCH 25/53] new registries and helper functions --- src/virtualship/utils.py | 143 ++++++++++++++++++++++++++------------- 1 file changed, 96 insertions(+), 47 deletions(-) diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index e19f7d5f..590d6b47 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -5,6 +5,7 @@ import os import re import warnings +from dataclasses import dataclass from datetime import datetime, timedelta from functools import lru_cache from importlib.resources import files @@ -16,7 +17,7 @@ import pyproj import xarray as xr -from parcels import FieldSet +from parcels import FieldSet, JITParticle, Variable from virtualship.errors import CopernicusCatalogueError from virtualship.instruments.types import SensorType @@ -26,6 +27,7 @@ ) from virtualship.models import Expedition, InstrumentsConfig, Location from virtualship.models.checkpoint import Checkpoint + from virtualship.models.expedition import SensorConfig import pandas as pd import yaml @@ -53,6 +55,86 @@ EXPEDITION_ORIGINAL = "expedition_original.yaml" EXPEDITION_LATEST = "expedition_latest.yaml" +# ===================================================== +# SECTION: sensor and variable metadata and registries +# ===================================================== + + +@dataclass(frozen=True) +class _SensorMeta: + fs_key: str # map to Parcels fieldset variables + copernicus_var: str # map to Copernicus Marine Service variable names + category: Literal[ + "phys", "bgc" + ] # physical vs. biogeochemical variable, used for product ID selection logic + particle_var: str # map to variable name in the Parcels Particle class + + +# the copernicus_var field below is the bridge between this registry the Copernicus product-ID selection logic (PRODUCT_IDS, BGC_ANALYSIS_IDS, MONTHLY_BGC_REANALYSIS_IDS, etc.) +SENSOR_REGISTRY: dict[SensorType, _SensorMeta] = { + SensorType.TEMPERATURE: _SensorMeta( + fs_key="T", + copernicus_var="thetao", + category="phys", + particle_var="temperature", + ), + SensorType.SALINITY: _SensorMeta( + fs_key="S", + copernicus_var="so", + category="phys", + particle_var="salinity", + ), + SensorType.VELOCITY: _SensorMeta( + fs_key="UV", + copernicus_var="uo", # primary; active_variables() in ADCPConfig expands to both uo and vo + category="phys", + particle_var="U", # primary; adcp.py adds V explicitly for VELOCITY + ), + SensorType.OXYGEN: _SensorMeta( + fs_key="o2", + copernicus_var="o2", + category="bgc", + particle_var="o2", + ), + SensorType.CHLOROPHYLL: _SensorMeta( + fs_key="chl", + copernicus_var="chl", + category="bgc", + particle_var="chl", + ), + SensorType.NITRATE: _SensorMeta( + fs_key="no3", + copernicus_var="no3", + category="bgc", + particle_var="no3", + ), + SensorType.PHOSPHATE: _SensorMeta( + fs_key="po4", + copernicus_var="po4", + category="bgc", + particle_var="po4", + ), + SensorType.PH: _SensorMeta( + fs_key="ph", + copernicus_var="ph", + category="bgc", + particle_var="ph", + ), + SensorType.PHYTOPLANKTON: _SensorMeta( + fs_key="phyc", + copernicus_var="phyc", + category="bgc", + particle_var="phyc", + ), + SensorType.PRIMARY_PRODUCTION: _SensorMeta( + fs_key="nppv", + copernicus_var="nppv", + category="bgc", + particle_var="nppv", + ), +} + + # ===================================================== # SECTION: Copernicus Marine Service constants # ===================================================== @@ -132,52 +214,6 @@ def decorator(cls): return decorator -# ===================================================== -# SECTION: optional sensors and variable mapping (e.g. for CTD) -# TODO: and soon also Argo floats... -# ===================================================== - - -SENSOR_DEFS: dict[SensorType, dict] = { - SensorType.TEMPERATURE: { - "fs_key": "T", - "copernicus_var": "thetao", - }, - SensorType.SALINITY: { - "fs_key": "S", - "copernicus_var": "so", - }, - SensorType.OXYGEN: { - "fs_key": "o2", - "copernicus_var": "o2", - }, - SensorType.CHLOROPHYLL: { - "fs_key": "chl", - "copernicus_var": "chl", - }, - SensorType.NITRATE: { - "fs_key": "no3", - "copernicus_var": "no3", - }, - SensorType.PHOSPHATE: { - "fs_key": "po4", - "copernicus_var": "po4", - }, - SensorType.PH: { - "fs_key": "ph", - "copernicus_var": "ph", - }, - SensorType.PHYTOPLANKTON: { - "fs_key": "phyc", - "copernicus_var": "phyc", - }, - SensorType.PRIMARY_PRODUCTION: { - "fs_key": "nppv", - "copernicus_var": "nppv", - }, -} - - # ===================================================== # SECTION: helper functions # ===================================================== @@ -701,6 +737,19 @@ def _make_hash(s: str, length: int) -> str: return hashlib.shake_128(s.encode("utf-8")).hexdigest(half_length) +def build_particle_class_from_sensors( + sensors: list[SensorConfig], + fixed_variables: list, +) -> type: + """Build a JITParticle class from fixed variables and active sensors. ScipyParticle classes are built in instrument sub-classes where used.""" + sensor_variables = [ + Variable(sc.meta.particle_var, dtype=np.float32, initial=np.nan) + for sc in sensors + if sc.enabled + ] + return JITParticle.add_variables(fixed_variables + sensor_variables) + + # ===================================================== # SECTION: misc. # ===================================================== From d9f9d109c96f84dd5a3401a3ff654639bdddb772 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:37:58 +0100 Subject: [PATCH 26/53] update expedition models, now including SensorConfig model and associated validations for each instrument --- src/virtualship/models/expedition.py | 246 ++++++++++++++++++++++++++- 1 file changed, 245 insertions(+), 1 deletion(-) diff --git a/src/virtualship/models/expedition.py b/src/virtualship/models/expedition.py index 5d16ecf5..18b16017 100644 --- a/src/virtualship/models/expedition.py +++ b/src/virtualship/models/expedition.py @@ -3,6 +3,7 @@ import itertools from datetime import datetime, timedelta from pathlib import Path +from typing import Literal import numpy as np import pydantic @@ -10,12 +11,24 @@ import yaml from virtualship.errors import InstrumentsConfigError, ScheduleError -from virtualship.instruments.types import InstrumentType +from virtualship.instruments.types import ( + ADCP_SUPPORTED_SENSORS, + ARGO_FLOAT_SUPPORTED_SENSORS, + CTD_BGC_SUPPORTED_SENSORS, + CTD_SUPPORTED_SENSORS, + DRIFTER_SUPPORTED_SENSORS, + UNDERWATER_ST_SUPPORTED_SENSORS, + XBT_SUPPORTED_SENSORS, + InstrumentType, + SensorType, +) from virtualship.utils import ( + SENSOR_REGISTRY, _calc_sail_time, _calc_wp_stationkeeping_time, _get_bathy_data, _get_waypoint_latlons, + _SensorMeta, _validate_numeric_to_timedelta, register_instrument_config, ) @@ -208,6 +221,36 @@ def serialize_instrument(self, instrument): return instrument.value if instrument else None +## + + +def _serialize_sensor_list(sensors: list[SensorConfig]) -> list[str]: + """Serialise enabled sensors to a compact list of sensor-type name strings.""" + return [sc.sensor_type.value for sc in sensors if sc.enabled] + + +def _check_sensor_compatibility( + sensors: list[SensorConfig], + supported: frozenset[SensorType], + instrument_name: str, +) -> list[SensorConfig]: + """Raise ``ValueError`` if any sensor in `sensors` is not in `supported`. Used as a Pydantic field_validator for each instrument config class.""" + unsupported = {sc.sensor_type for sc in sensors} - supported + if unsupported: + names = ", ".join(sorted(s.value for s in unsupported)) + valid = ", ".join(sorted(s.value for s in supported)) + raise ValueError( + f"{instrument_name} does not support sensor(s): {names}. " + f"Supported sensors: {valid}." + ) + return sensors + + +def build_variables_from_sensors(sensors: list[SensorConfig]) -> dict[str, str]: + """Build variables dict (FieldSet key → Copernicus-variable).""" + return {sc.fs_key: sc.copernicus_var for sc in sensors if sc.enabled} + + @register_instrument_config(InstrumentType.ARGO_FLOAT) class ArgoFloatConfig(pydantic.BaseModel): """Configuration for argos floats.""" @@ -230,6 +273,16 @@ class ArgoFloatConfig(pydantic.BaseModel): gt=timedelta(), ) + sensors: list[SensorConfig] = pydantic.Field( + default_factory=lambda: [ + SensorConfig(sensor_type=SensorType.TEMPERATURE), + SensorConfig(sensor_type=SensorType.SALINITY), + ], + description=( + "Sensors fitted to the Argo float. Supported: TEMPERATURE, SALINITY. " + ), + ) + @pydantic.field_serializer("lifetime") def _serialize_lifetime(self, value: timedelta, _info): return value.total_seconds() / 86400.0 # [days] @@ -246,8 +299,23 @@ def _serialize_stationkeeping_time(self, value: timedelta, _info): def _validate_stationkeeping_time(cls, value: int | float | timedelta) -> timedelta: return _validate_numeric_to_timedelta(value, "minutes") + @pydantic.field_validator("sensors", mode="after") + @classmethod + def _check_sensor_compatibility(cls, value) -> list[SensorConfig]: + return _check_sensor_compatibility( + value, ARGO_FLOAT_SUPPORTED_SENSORS, "ArgoFloat" + ) + + @pydantic.field_serializer("sensors") + def _serialize_sensors(self, value: list[SensorConfig], _info): + return _serialize_sensor_list(value) + model_config = pydantic.ConfigDict(populate_by_name=True) + def active_variables(self) -> dict[str, str]: + """FieldSet-key → Copernicus-variable mapping for enabled sensors.""" + return build_variables_from_sensors(self.sensors) + @register_instrument_config(InstrumentType.ADCP) class ADCPConfig(pydantic.BaseModel): @@ -261,6 +329,14 @@ class ADCPConfig(pydantic.BaseModel): gt=timedelta(), ) + sensors: list[SensorConfig] = pydantic.Field( + default_factory=lambda: [SensorConfig(sensor_type=SensorType.VELOCITY)], + description=( + "Sensors fitted to the ADCP. " + "Supported: VELOCITY (samples both U and V components in one go)." + ), + ) + model_config = pydantic.ConfigDict(populate_by_name=True) @pydantic.field_serializer("period") @@ -271,6 +347,28 @@ def _serialize_period(self, value: timedelta, _info): def _validate_period(cls, value: int | float | timedelta) -> timedelta: return _validate_numeric_to_timedelta(value, "minutes") + @pydantic.field_validator("sensors", mode="after") + @classmethod + def _check_sensor_compatibility(cls, value) -> list[SensorConfig]: + return _check_sensor_compatibility(value, ADCP_SUPPORTED_SENSORS, "ADCP") + + @pydantic.field_serializer("sensors") + def _serialize_sensors(self, value: list[SensorConfig], _info): + return _serialize_sensor_list(value) + + def active_variables(self) -> dict[str, str]: + """ + FieldSet-key → Copernicus-variable mapping for enabled sensors. + + VELOCITY is a special case: one sensor provides two FieldSet variables (U and V). + """ + variables = {} + for sc in self.sensors: + if sc.enabled and sc.sensor_type == SensorType.VELOCITY: + variables["U"] = "uo" + variables["V"] = "vo" + return variables + @register_instrument_config(InstrumentType.CTD) class CTDConfig(pydantic.BaseModel): @@ -284,6 +382,14 @@ class CTDConfig(pydantic.BaseModel): 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.TEMPERATURE), + SensorConfig(sensor_type=SensorType.SALINITY), + ], + description=("Sensors fitted to the CTD. Supported: TEMPERATURE, SALINITY. "), + ) + model_config = pydantic.ConfigDict(populate_by_name=True) @pydantic.field_serializer("stationkeeping_time") @@ -294,6 +400,19 @@ def _serialize_stationkeeping_time(self, value: timedelta, _info): def _validate_stationkeeping_time(cls, value: int | float | timedelta) -> timedelta: return _validate_numeric_to_timedelta(value, "minutes") + @pydantic.field_validator("sensors", mode="after") + @classmethod + def _check_sensor_compatibility(cls, value) -> list[SensorConfig]: + return _check_sensor_compatibility(value, CTD_SUPPORTED_SENSORS, "CTD") + + @pydantic.field_serializer("sensors") + def _serialize_sensors(self, value: list[SensorConfig], _info): + return _serialize_sensor_list(value) + + def active_variables(self) -> dict[str, str]: + """FieldSet-key → Copernicus-variable mapping for enabled sensors.""" + return build_variables_from_sensors(self.sensors) + @register_instrument_config(InstrumentType.CTD_BGC) class CTD_BGCConfig(pydantic.BaseModel): @@ -307,6 +426,22 @@ class CTD_BGCConfig(pydantic.BaseModel): 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: CHLOROPHYLL, NITRATE, OXYGEN, PH, PHOSPHATE, PHYTOPLANKTON, PRIMARY_PRODUCTION. " + ), + ) + model_config = pydantic.ConfigDict(populate_by_name=True) @pydantic.field_serializer("stationkeeping_time") @@ -317,6 +452,19 @@ def _serialize_stationkeeping_time(self, value: timedelta, _info): def _validate_stationkeeping_time(cls, value: int | float | timedelta) -> timedelta: return _validate_numeric_to_timedelta(value, "minutes") + @pydantic.field_validator("sensors", mode="after") + @classmethod + def _check_sensor_compatibility(cls, value) -> list[SensorConfig]: + return _check_sensor_compatibility(value, CTD_BGC_SUPPORTED_SENSORS, "CTD_BGC") + + @pydantic.field_serializer("sensors") + def _serialize_sensors(self, value: list[SensorConfig], _info): + return _serialize_sensor_list(value) + + def active_variables(self) -> dict[str, str]: + """FieldSet-key → Copernicus-variable mapping for enabled sensors.""" + return build_variables_from_sensors(self.sensors) + @register_instrument_config(InstrumentType.UNDERWATER_ST) class ShipUnderwaterSTConfig(pydantic.BaseModel): @@ -328,6 +476,16 @@ class ShipUnderwaterSTConfig(pydantic.BaseModel): gt=timedelta(), ) + sensors: list[SensorConfig] = pydantic.Field( + default_factory=lambda: [ + SensorConfig(sensor_type=SensorType.TEMPERATURE), + SensorConfig(sensor_type=SensorType.SALINITY), + ], + description=( + "Sensors fitted to the underway ST. Supported: TEMPERATURE, SALINITY. " + ), + ) + model_config = pydantic.ConfigDict(populate_by_name=True) @pydantic.field_serializer("period") @@ -338,6 +496,21 @@ def _serialize_period(self, value: timedelta, _info): def _validate_period(cls, value: int | float | timedelta) -> timedelta: return _validate_numeric_to_timedelta(value, "minutes") + @pydantic.field_validator("sensors", mode="after") + @classmethod + def _check_sensor_compatibility(cls, value) -> list[SensorConfig]: + return _check_sensor_compatibility( + value, UNDERWATER_ST_SUPPORTED_SENSORS, "Underwater ST" + ) + + @pydantic.field_serializer("sensors") + def _serialize_sensors(self, value: list[SensorConfig], _info): + return _serialize_sensor_list(value) + + def active_variables(self) -> dict[str, str]: + """FieldSet-key → Copernicus-variable mapping for enabled sensors.""" + return build_variables_from_sensors(self.sensors) + @register_instrument_config(InstrumentType.DRIFTER) class DrifterConfig(pydantic.BaseModel): @@ -355,6 +528,11 @@ class DrifterConfig(pydantic.BaseModel): 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) @pydantic.field_serializer("lifetime") @@ -373,6 +551,19 @@ def _serialize_stationkeeping_time(self, value: timedelta, _info): def _validate_stationkeeping_time(cls, value: int | float | timedelta) -> timedelta: return _validate_numeric_to_timedelta(value, "minutes") + @pydantic.field_validator("sensors", mode="after") + @classmethod + def _check_sensor_compatibility(cls, value) -> list[SensorConfig]: + return _check_sensor_compatibility(value, DRIFTER_SUPPORTED_SENSORS, "Drifter") + + @pydantic.field_serializer("sensors") + def _serialize_sensors(self, value: list[SensorConfig], _info): + return _serialize_sensor_list(value) + + def active_variables(self) -> dict[str, str]: + """FieldSet-key → Copernicus-variable mapping for enabled sensors.""" + return build_variables_from_sensors(self.sensors) + @register_instrument_config(InstrumentType.XBT) class XBTConfig(pydantic.BaseModel): @@ -383,6 +574,24 @@ class XBTConfig(pydantic.BaseModel): fall_speed_meter_per_second: float = pydantic.Field(gt=0.0) deceleration_coefficient: float = pydantic.Field(gt=0.0) + sensors: list[SensorConfig] = pydantic.Field( + default_factory=lambda: [SensorConfig(sensor_type=SensorType.TEMPERATURE)], + description=("Sensors fitted to the XBT. Supported: TEMPERATURE. "), + ) + + @pydantic.field_validator("sensors", mode="after") + @classmethod + def _check_sensor_compatibility(cls, value) -> list[SensorConfig]: + return _check_sensor_compatibility(value, XBT_SUPPORTED_SENSORS, "XBT") + + @pydantic.field_serializer("sensors") + def _serialize_sensors(self, value: list[SensorConfig], _info): + return _serialize_sensor_list(value) + + def active_variables(self) -> dict[str, str]: + """FieldSet-key → Copernicus-variable mapping for enabled sensors.""" + return build_variables_from_sensors(self.sensors) + class InstrumentsConfig(pydantic.BaseModel): """Configuration of instruments.""" @@ -473,3 +682,38 @@ def verify(self, expedition: Expedition) -> None: raise InstrumentsConfigError( f"Expedition includes instrument '{inst_type.value}', but instruments_config does not provide configuration for it." ) + + +class SensorConfig(pydantic.BaseModel): + """Configuration for a single sensor fitted to an instrument.""" + + sensor_type: SensorType + enabled: bool = True + + @pydantic.field_validator("sensor_type", mode="before") + @classmethod + def _take_sensor_type(cls, value: str | SensorType) -> SensorType: + """Accept a sensor-type string or SensorType class.""" + if isinstance(value, SensorType): + return value + return SensorType(value) + + @property + def meta(self) -> _SensorMeta: + """Metadata for this sensor.""" + return SENSOR_REGISTRY[self.sensor_type] + + @property + def fs_key(self) -> str: + """FieldSet key (e.g. T, o2).""" + return self.meta.fs_key + + @property + def copernicus_var(self) -> str: + """Copernicus Marine variable name (e.g. thetao, o2).""" + return self.meta.copernicus_var + + @property + def category(self) -> Literal["phys", "bgc"]: + """Physical (phys) or biogeochemical (bgc).""" + return self.meta.category From c50c43fb3c3b5b1b13d04769157a011d63c0632e Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:09:30 +0100 Subject: [PATCH 27/53] modify adcp instrument class, also abstract expansion to u and v to higher level for scalability --- src/virtualship/instruments/adcp.py | 47 +++++++++++++++++++++-------- src/virtualship/utils.py | 27 +++++++++-------- 2 files changed, 48 insertions(+), 26 deletions(-) diff --git a/src/virtualship/instruments/adcp.py b/src/virtualship/instruments/adcp.py index 17797a41..603d9a2e 100644 --- a/src/virtualship/instruments/adcp.py +++ b/src/virtualship/instruments/adcp.py @@ -2,13 +2,14 @@ from typing import ClassVar import numpy as np -from parcels import ParticleSet, ScipyParticle, Variable +from parcels import ParticleSet, ScipyParticle from virtualship.instruments.base import Instrument -from virtualship.instruments.types import InstrumentType -from virtualship.utils import ( - register_instrument, +from virtualship.instruments.types import ( + InstrumentType, + SensorType, ) +from virtualship.utils import register_instrument # ===================================================== # SECTION: Dataclass @@ -23,16 +24,12 @@ class ADCP: # ===================================================== -# SECTION: Particle Class +# SECTION: fixed/mechanical Particle Variables (non-sampling) # ===================================================== +# ADCP has no fixed/mechanical variables, only sensor variables. +_ADCP_FIXED_VARIABLES: list = [] -_ADCPParticle = ScipyParticle.add_variables( - [ - Variable("U", dtype=np.float32, initial=np.nan), - Variable("V", dtype=np.float32, initial=np.nan), - ] -) # ===================================================== # SECTION: Kernels @@ -45,6 +42,11 @@ def _sample_velocity(particle, fieldset, time): ) +_ADCP_SENSOR_KERNELS: dict[SensorType, callable] = { + SensorType.VELOCITY: _sample_velocity, +} + + # ===================================================== # SECTION: Instrument Class # ===================================================== @@ -56,7 +58,7 @@ class ADCPInstrument(Instrument): def __init__(self, expedition, from_data): """Initialize ADCPInstrument.""" - variables = {"U": "uo", "V": "vo"} + variables = expedition.instruments_config.adcp_config.active_variables() limit_spec = { "spatial": True } # spatial limits; lat/lon constrained to waypoint locations + buffer @@ -93,6 +95,18 @@ def simulate(self, measurements, out_path) -> None: fieldset = self.load_input_data() + # build dynamic particle class from the active sensors + adcp_config = self.expedition.instruments_config.adcp_config + sensor_variables = [ + var + for sc in adcp_config.sensors + if sc.enabled + for var in sc.particle_variables() + ] + _ADCPParticle = ScipyParticle.add_variables( + _ADCP_FIXED_VARIABLES + sensor_variables + ) + bins = np.linspace(MAX_DEPTH, MIN_DEPTH, NUM_BINS) num_particles = len(bins) particleset = ParticleSet.from_list( @@ -108,6 +122,13 @@ def simulate(self, measurements, out_path) -> None: out_file = particleset.ParticleFile(name=out_path, outputdt=np.inf) + # build kernel list from active sensors only + sample_kernels = [ + _ADCP_SENSOR_KERNELS[sc.sensor_type] + for sc in adcp_config.sensors + if sc.enabled and sc.sensor_type in _ADCP_SENSOR_KERNELS + ] + for point in measurements: particleset.lon_nextloop[:] = point.location.lon particleset.lat_nextloop[:] = point.location.lat @@ -116,7 +137,7 @@ def simulate(self, measurements, out_path) -> None: ) particleset.execute( - [_sample_velocity], + sample_kernels, dt=1, runtime=1, verbose_progress=self.verbose_progress, diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 590d6b47..48b63a57 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -67,7 +67,7 @@ class _SensorMeta: category: Literal[ "phys", "bgc" ] # physical vs. biogeochemical variable, used for product ID selection logic - particle_var: str # map to variable name in the Parcels Particle class + particle_vars: list[str] # particle variable name(s) produced by this sensor # the copernicus_var field below is the bridge between this registry the Copernicus product-ID selection logic (PRODUCT_IDS, BGC_ANALYSIS_IDS, MONTHLY_BGC_REANALYSIS_IDS, etc.) @@ -76,61 +76,61 @@ class _SensorMeta: fs_key="T", copernicus_var="thetao", category="phys", - particle_var="temperature", + particle_vars=["temperature"], ), SensorType.SALINITY: _SensorMeta( fs_key="S", copernicus_var="so", category="phys", - particle_var="salinity", + particle_vars=["salinity"], ), SensorType.VELOCITY: _SensorMeta( fs_key="UV", - copernicus_var="uo", # primary; active_variables() in ADCPConfig expands to both uo and vo + copernicus_var="uo", # primary var... active_variables() in ADCPConfig expands to both uo and vo category="phys", - particle_var="U", # primary; adcp.py adds V explicitly for VELOCITY + particle_vars=["U", "V"], # two particle variables associated with one sensor ), SensorType.OXYGEN: _SensorMeta( fs_key="o2", copernicus_var="o2", category="bgc", - particle_var="o2", + particle_vars=["o2"], ), SensorType.CHLOROPHYLL: _SensorMeta( fs_key="chl", copernicus_var="chl", category="bgc", - particle_var="chl", + particle_vars=["chl"], ), SensorType.NITRATE: _SensorMeta( fs_key="no3", copernicus_var="no3", category="bgc", - particle_var="no3", + particle_vars=["no3"], ), SensorType.PHOSPHATE: _SensorMeta( fs_key="po4", copernicus_var="po4", category="bgc", - particle_var="po4", + particle_vars=["po4"], ), SensorType.PH: _SensorMeta( fs_key="ph", copernicus_var="ph", category="bgc", - particle_var="ph", + particle_vars=["ph"], ), SensorType.PHYTOPLANKTON: _SensorMeta( fs_key="phyc", copernicus_var="phyc", category="bgc", - particle_var="phyc", + particle_vars=["phyc"], ), SensorType.PRIMARY_PRODUCTION: _SensorMeta( fs_key="nppv", copernicus_var="nppv", category="bgc", - particle_var="nppv", + particle_vars=["nppv"], ), } @@ -743,9 +743,10 @@ def build_particle_class_from_sensors( ) -> type: """Build a JITParticle class from fixed variables and active sensors. ScipyParticle classes are built in instrument sub-classes where used.""" sensor_variables = [ - Variable(sc.meta.particle_var, dtype=np.float32, initial=np.nan) + Variable(var_name, dtype=np.float32, initial=np.nan) for sc in sensors if sc.enabled + for var_name in sc.meta.particle_vars ] return JITParticle.add_variables(fixed_variables + sensor_variables) From bb91f0c11b1eb97bf0dba5a638eb50dacca6333a Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:36:33 +0100 Subject: [PATCH 28/53] dynamic particle class building takes JIT or Scipy particle --- src/virtualship/utils.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 48b63a57..363e9e6a 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -17,7 +17,7 @@ import pyproj import xarray as xr -from parcels import FieldSet, JITParticle, Variable +from parcels import FieldSet, Variable from virtualship.errors import CopernicusCatalogueError from virtualship.instruments.types import SensorType @@ -740,15 +740,16 @@ def _make_hash(s: str, length: int) -> str: def build_particle_class_from_sensors( sensors: list[SensorConfig], fixed_variables: list, + particle_class: type, ) -> type: - """Build a JITParticle class from fixed variables and active sensors. ScipyParticle classes are built in instrument sub-classes where used.""" + """Build a Particle class (JITParticle or ScipyParticle) from fixed variables and active sensors.""" sensor_variables = [ Variable(var_name, dtype=np.float32, initial=np.nan) for sc in sensors if sc.enabled for var_name in sc.meta.particle_vars ] - return JITParticle.add_variables(fixed_variables + sensor_variables) + return particle_class.add_variables(fixed_variables + sensor_variables) # ===================================================== From b584d703cef8dabbbebb1a3aba59a9388014c6cb Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:37:03 +0100 Subject: [PATCH 29/53] raise error when instrument has zero sensors enabled --- src/virtualship/models/expedition.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/virtualship/models/expedition.py b/src/virtualship/models/expedition.py index 18b16017..0a351191 100644 --- a/src/virtualship/models/expedition.py +++ b/src/virtualship/models/expedition.py @@ -234,7 +234,7 @@ def _check_sensor_compatibility( supported: frozenset[SensorType], instrument_name: str, ) -> list[SensorConfig]: - """Raise ``ValueError`` if any sensor in `sensors` is not in `supported`. Used as a Pydantic field_validator for each instrument config class.""" + """Raise ``ValueError`` if any sensor in `sensors` is not in `supported`, or if no sensors are enabled. Used as a Pydantic field_validator for each instrument config class.""" unsupported = {sc.sensor_type for sc in sensors} - supported if unsupported: names = ", ".join(sorted(s.value for s in unsupported)) @@ -243,6 +243,11 @@ def _check_sensor_compatibility( f"{instrument_name} does not support sensor(s): {names}. " f"Supported sensors: {valid}." ) + if not any(sc.enabled for sc in sensors): + raise ValueError( + f"{instrument_name} has no enabled sensors. " + f"At least one sensor must be enabled." + ) return sensors From 6fb628458f349c1a6ebf7d41051b2e364abe244e Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:37:36 +0100 Subject: [PATCH 30/53] use centralised particle class builder for ADCP now as well --- src/virtualship/instruments/adcp.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/virtualship/instruments/adcp.py b/src/virtualship/instruments/adcp.py index 603d9a2e..beeda96a 100644 --- a/src/virtualship/instruments/adcp.py +++ b/src/virtualship/instruments/adcp.py @@ -9,7 +9,7 @@ InstrumentType, SensorType, ) -from virtualship.utils import register_instrument +from virtualship.utils import build_particle_class_from_sensors, register_instrument # ===================================================== # SECTION: Dataclass @@ -97,14 +97,8 @@ def simulate(self, measurements, out_path) -> None: # build dynamic particle class from the active sensors adcp_config = self.expedition.instruments_config.adcp_config - sensor_variables = [ - var - for sc in adcp_config.sensors - if sc.enabled - for var in sc.particle_variables() - ] - _ADCPParticle = ScipyParticle.add_variables( - _ADCP_FIXED_VARIABLES + sensor_variables + _ADCPParticle = build_particle_class_from_sensors( + adcp_config.sensors, _ADCP_FIXED_VARIABLES, ScipyParticle ) bins = np.linspace(MAX_DEPTH, MIN_DEPTH, NUM_BINS) From 243fb0d551fe937bdb46ad20fc6c022470911991 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:45:03 +0100 Subject: [PATCH 31/53] batch update instrument subclasses adapted to refactored sensor logic --- src/virtualship/instruments/ctd.py | 62 +++++++++---- src/virtualship/instruments/ctd_bgc.py | 92 ++++++++++--------- src/virtualship/instruments/drifter.py | 54 ++++++++--- .../instruments/ship_underwater_st.py | 46 +++++++--- src/virtualship/instruments/xbt.py | 61 ++++++++---- 5 files changed, 208 insertions(+), 107 deletions(-) diff --git a/src/virtualship/instruments/ctd.py b/src/virtualship/instruments/ctd.py index eb780d3e..12a5ca2f 100644 --- a/src/virtualship/instruments/ctd.py +++ b/src/virtualship/instruments/ctd.py @@ -3,14 +3,18 @@ from typing import TYPE_CHECKING, ClassVar import numpy as np -from parcels import JITParticle, ParticleSet, Variable +from parcels import ParticleSet, Variable from virtualship.instruments.base import Instrument -from virtualship.instruments.types import InstrumentType +from virtualship.instruments.types import ( + InstrumentType, + SensorType, + build_particle_class_from_sensors, +) +from virtualship.utils import add_dummy_UV, register_instrument if TYPE_CHECKING: from virtualship.models.spacetime import Spacetime -from virtualship.utils import add_dummy_UV, register_instrument # ===================================================== # SECTION: Dataclass @@ -28,19 +32,15 @@ class CTD: # ===================================================== -# SECTION: Particle Class +# SECTION: fixed/mechanical Particle Variables (non-sampling) # ===================================================== -_CTDParticle = JITParticle.add_variables( - [ - Variable("salinity", dtype=np.float32, initial=np.nan), - Variable("temperature", dtype=np.float32, initial=np.nan), - 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), - ] -) +_CTD_FIXED_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), +] # ===================================================== @@ -70,6 +70,12 @@ def _ctd_cast(particle, fieldset, time): particle.delete() +_CTD_SENSOR_KERNELS: dict[SensorType, callable] = { + SensorType.TEMPERATURE: _sample_temperature, + SensorType.SALINITY: _sample_salinity, +} + + # ===================================================== # SECTION: Instrument Class # ===================================================== @@ -81,7 +87,7 @@ class CTDInstrument(Instrument): def __init__(self, expedition, from_data): """Initialize CTDInstrument.""" - variables = {"S": "so", "T": "thetao"} + variables = expedition.instruments_config.ctd_config.active_variables() limit_spec = { "spatial": True } # spatial limits; lat/lon constrained to waypoint locations + buffer @@ -115,11 +121,14 @@ def simulate(self, measurements, out_path) -> None: # add dummy U add_dummy_UV(fieldset) # TODO: parcels v3 bodge; remove when parcels v4 is used - fieldset_starttime = fieldset.T.grid.time_origin.fulltime( - fieldset.T.grid.time_full[0] + # 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 = fieldset.T.grid.time_origin.fulltime( - fieldset.T.grid.time_full[-1] + 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 @@ -152,6 +161,12 @@ def simulate(self, measurements, out_path) -> None: 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_FIXED_VARIABLES + ) + # define parcel particles ctd_particleset = ParticleSet( fieldset=fieldset, @@ -168,9 +183,16 @@ def simulate(self, measurements, out_path) -> None: # define output file for the simulation out_file = ctd_particleset.ParticleFile(name=out_path, outputdt=OUTPUT_DT) + # build kernel list from active sensors only + sample_kernels = [ + _CTD_SENSOR_KERNELS[sc.sensor_type] + for sc in ctd_config.sensors + if sc.enabled and sc.sensor_type in _CTD_SENSOR_KERNELS + ] + # execute simulation ctd_particleset.execute( - [_sample_salinity, _sample_temperature, _ctd_cast], + [*sample_kernels, _ctd_cast], endtime=fieldset_endtime, dt=DT, verbose_progress=self.verbose_progress, diff --git a/src/virtualship/instruments/ctd_bgc.py b/src/virtualship/instruments/ctd_bgc.py index 221cfa12..cbba0c14 100644 --- a/src/virtualship/instruments/ctd_bgc.py +++ b/src/virtualship/instruments/ctd_bgc.py @@ -3,12 +3,19 @@ from typing import 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.types import InstrumentType +from virtualship.instruments.types import ( + InstrumentType, + SensorType, +) from virtualship.models.spacetime import Spacetime -from virtualship.utils import add_dummy_UV, register_instrument +from virtualship.utils import ( + add_dummy_UV, + build_particle_class_from_sensors, + register_instrument, +) # ===================================================== # SECTION: Dataclass @@ -26,24 +33,15 @@ class CTD_BGC: # ===================================================== -# SECTION: Particle Class +# SECTION: fixed/mechanical Particle Variables (non-sampling) # ===================================================== -_CTD_BGCParticle = JITParticle.add_variables( - [ - Variable("o2", dtype=np.float32, initial=np.nan), - Variable("chl", dtype=np.float32, initial=np.nan), - Variable("no3", dtype=np.float32, initial=np.nan), - Variable("po4", dtype=np.float32, initial=np.nan), - Variable("ph", dtype=np.float32, initial=np.nan), - Variable("phyc", dtype=np.float32, initial=np.nan), - Variable("nppv", dtype=np.float32, initial=np.nan), - 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), - ] -) +_CTD_BGC_FIXED_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 @@ -92,6 +90,17 @@ def _ctd_bgc_cast(particle, fieldset, time): particle.delete() +_CTD_BGC_SENSOR_KERNELS: 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, +} + + # ===================================================== # SECTION: Instrument Class # ===================================================== @@ -103,15 +112,7 @@ class CTD_BGCInstrument(Instrument): def __init__(self, expedition, from_data): """Initialize CTD_BGCInstrument.""" - variables = { - "o2": "o2", - "chl": "chl", - "no3": "no3", - "po4": "po4", - "ph": "ph", - "phyc": "phyc", - "nppv": "nppv", - } + variables = expedition.instruments_config.ctd_bgc_config.active_variables() limit_spec = { "spatial": True } # spatial limits; lat/lon constrained to waypoint locations + buffer @@ -145,11 +146,14 @@ def simulate(self, measurements, out_path) -> None: # add dummy U add_dummy_UV(fieldset) # TODO: parcels v3 bodge; remove when parcels v4 is used - fieldset_starttime = fieldset.o2.grid.time_origin.fulltime( - fieldset.o2.grid.time_full[0] + # 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 = fieldset.o2.grid.time_origin.fulltime( - fieldset.o2.grid.time_full[-1] + 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 @@ -182,6 +186,12 @@ def simulate(self, measurements, out_path) -> None: 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_FIXED_VARIABLES, JITParticle + ) + # define parcel particles ctd_bgc_particleset = ParticleSet( fieldset=fieldset, @@ -198,18 +208,16 @@ def simulate(self, measurements, out_path) -> None: # 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 + sample_kernels = [ + _CTD_BGC_SENSOR_KERNELS[sc.sensor_type] + for sc in ctd_bgc_config.sensors + if sc.enabled and sc.sensor_type in _CTD_BGC_SENSOR_KERNELS + ] + # execute simulation ctd_bgc_particleset.execute( - [ - _sample_o2, - _sample_chlorophyll, - _sample_nitrate, - _sample_phosphate, - _sample_ph, - _sample_phytoplankton, - _sample_primary_production, - _ctd_bgc_cast, - ], + [*sample_kernels, _ctd_bgc_cast], endtime=fieldset_endtime, dt=DT, verbose_progress=self.verbose_progress, diff --git a/src/virtualship/instruments/drifter.py b/src/virtualship/instruments/drifter.py index a58c4bef..38e217a7 100644 --- a/src/virtualship/instruments/drifter.py +++ b/src/virtualship/instruments/drifter.py @@ -3,12 +3,16 @@ from typing import ClassVar import numpy as np -from parcels import AdvectionRK4, JITParticle, ParticleSet, Variable +from parcels import AdvectionRK4, ParticleSet, Variable from virtualship.instruments.base import Instrument -from virtualship.instruments.types import InstrumentType +from virtualship.instruments.types import InstrumentType, SensorType from virtualship.models.spacetime import Spacetime -from virtualship.utils import _random_noise, register_instrument +from virtualship.utils import ( + _random_noise, + build_particle_class_from_sensors, + register_instrument, +) # ===================================================== # SECTION: Dataclass @@ -26,17 +30,14 @@ class Drifter: # ===================================================== -# SECTION: Particle Class +# SECTION: fixed/mechanical Particle Variables (non-sampling) # ===================================================== -_DrifterParticle = JITParticle.add_variables( - [ - Variable("temperature", dtype=np.float32, initial=np.nan), - Variable("has_lifetime", dtype=np.int8), # bool - Variable("age", dtype=np.float32, initial=0.0), - Variable("lifetime", dtype=np.float32), - ] -) +_DRIFTER_FIXED_VARIABLES = [ + Variable("has_lifetime", dtype=np.int8), # bool + Variable("age", dtype=np.float32, initial=0.0), + Variable("lifetime", dtype=np.float32), +] # ===================================================== # SECTION: Kernels @@ -54,6 +55,11 @@ def _check_lifetime(particle, fieldset, time): particle.delete() +_DRIFTER_SENSOR_KERNELS: dict[SensorType, callable] = { + SensorType.TEMPERATURE: _sample_temperature, +} + + # ===================================================== # SECTION: Instrument Class # ===================================================== @@ -65,7 +71,14 @@ class DrifterInstrument(Instrument): def __init__(self, expedition, from_data): """Initialize DrifterInstrument.""" - variables = {"U": "uo", "V": "vo", "T": "thetao"} + sensor_variables = ( + expedition.instruments_config.drifter_config.active_variables() + ) + variables = { + "U": "uo", + "V": "vo", + **sensor_variables, + } # advection variables (U and V) are always required for argo float simulation; sensor variables come from config spacetime_buffer_size = { "latlon": None, "time": expedition.instruments_config.drifter_config.lifetime.total_seconds() @@ -106,6 +119,12 @@ def simulate(self, measurements, out_path) -> None: fieldset = self.load_input_data() + # build dynamic particle class from the active sensors + drifter_config = self.expedition.instruments_config.drifter_config + _DrifterParticle = build_particle_class_from_sensors( + drifter_config.sensors, _DRIFTER_FIXED_VARIABLES + ) + # define parcel particles lat_release = [ drifter.spacetime.location.lat + _random_noise() for drifter in measurements @@ -140,9 +159,16 @@ def simulate(self, measurements, out_path) -> None: # determine end time for simulation, from fieldset (which itself is controlled by drifter lifetimes) endtime = fieldset.time_origin.fulltime(fieldset.U.grid.time_full[-1]) + # build kernel list from active sensors only + sample_kernels = [ + _DRIFTER_SENSOR_KERNELS[sc.sensor_type] + for sc in drifter_config.sensors + if sc.enabled and sc.sensor_type in _DRIFTER_SENSOR_KERNELS + ] + # execute simulation drifter_particleset.execute( - [AdvectionRK4, _sample_temperature, _check_lifetime], + [AdvectionRK4, *sample_kernels, _check_lifetime], endtime=endtime, dt=DT, output_file=out_file, diff --git a/src/virtualship/instruments/ship_underwater_st.py b/src/virtualship/instruments/ship_underwater_st.py index 8b7ef96d..a18ab9c5 100644 --- a/src/virtualship/instruments/ship_underwater_st.py +++ b/src/virtualship/instruments/ship_underwater_st.py @@ -2,11 +2,15 @@ from typing import ClassVar import numpy as np -from parcels import ParticleSet, ScipyParticle, Variable +from parcels import ParticleSet, ScipyParticle from virtualship.instruments.base import Instrument -from virtualship.instruments.types import InstrumentType -from virtualship.utils import add_dummy_UV, register_instrument +from virtualship.instruments.types import InstrumentType, SensorType +from virtualship.utils import ( + add_dummy_UV, + build_particle_class_from_sensors, + register_instrument, +) # ===================================================== # SECTION: Dataclass @@ -21,15 +25,12 @@ class Underwater_ST: # ===================================================== -# SECTION: Particle Class +# SECTION: fixed/mechanical Particle Variables (non-sampling) # ===================================================== -_ShipSTParticle = ScipyParticle.add_variables( - [ - Variable("S", dtype=np.float32, initial=np.nan), - Variable("T", dtype=np.float32, initial=np.nan), - ] -) +# Underwater ST has no fixed/mechanical variables, only sensor variables. +_ST_FIXED_VARIABLES: list = [] + # ===================================================== # SECTION: Kernels @@ -46,6 +47,12 @@ def _sample_temperature(particle, fieldset, time): particle.T = fieldset.T[time, particle.depth, particle.lat, particle.lon] +_ST_SENSOR_KERNELS: dict[SensorType, callable] = { + SensorType.TEMPERATURE: _sample_temperature, + SensorType.SALINITY: _sample_salinity, +} + + # ===================================================== # SECTION: Instrument Class # ===================================================== @@ -57,7 +64,9 @@ class Underwater_STInstrument(Instrument): def __init__(self, expedition, from_data): """Initialize Underwater_STInstrument.""" - variables = {"S": "so", "T": "thetao"} + variables = ( + expedition.instruments_config.ship_underwater_st_config.active_variables() + ) spacetime_buffer_size = { "latlon": 0.25, # [degrees] "time": 0.0, # [days] @@ -88,6 +97,12 @@ def simulate(self, measurements, out_path) -> None: # add dummy U add_dummy_UV(fieldset) # TODO: parcels v3 bodge; remove when parcels v4 is used + # build dynamic particle class from the active sensors + st_config = self.expedition.instruments_config.ship_underwater_st_config + _ShipSTParticle = build_particle_class_from_sensors( + st_config.sensors, _ST_FIXED_VARIABLES, ScipyParticle + ) + particleset = ParticleSet.from_list( fieldset=fieldset, pclass=_ShipSTParticle, @@ -99,6 +114,13 @@ def simulate(self, measurements, out_path) -> None: out_file = particleset.ParticleFile(name=out_path, outputdt=np.inf) + # build kernel list from active sensors only + sample_kernels = [ + _ST_SENSOR_KERNELS[sc.sensor_type] + for sc in st_config.sensors + if sc.enabled and sc.sensor_type in _ST_SENSOR_KERNELS + ] + for point in measurements: particleset.lon_nextloop[:] = point.location.lon particleset.lat_nextloop[:] = point.location.lat @@ -107,7 +129,7 @@ def simulate(self, measurements, out_path) -> None: ) particleset.execute( - [_sample_salinity, _sample_temperature], + sample_kernels, dt=1, runtime=1, verbose_progress=self.verbose_progress, diff --git a/src/virtualship/instruments/xbt.py b/src/virtualship/instruments/xbt.py index 2412306f..19b8509c 100644 --- a/src/virtualship/instruments/xbt.py +++ b/src/virtualship/instruments/xbt.py @@ -3,12 +3,16 @@ from typing import ClassVar import numpy as np -from parcels import JITParticle, ParticleSet, Variable +from parcels import ParticleSet, Variable from virtualship.instruments.base import Instrument -from virtualship.instruments.types import InstrumentType +from virtualship.instruments.types import InstrumentType, SensorType from virtualship.models.spacetime import Spacetime -from virtualship.utils import add_dummy_UV, register_instrument +from virtualship.utils import ( + add_dummy_UV, + build_particle_class_from_sensors, + register_instrument, +) # ===================================================== # SECTION: Dataclass @@ -28,18 +32,16 @@ class XBT: # ===================================================== -# SECTION: Particle Class +# SECTION: fixed/mechanical Particle Variables (non-sampling) # ===================================================== -_XBTParticle = JITParticle.add_variables( - [ - Variable("temperature", dtype=np.float32, initial=np.nan), - Variable("max_depth", dtype=np.float32), - Variable("min_depth", dtype=np.float32), - Variable("fall_speed", dtype=np.float32), - Variable("deceleration_coefficient", dtype=np.float32), - ] -) +_XBT_FIXED_VARIABLES = [ + Variable("max_depth", dtype=np.float32), + Variable("min_depth", dtype=np.float32), + Variable("fall_speed", dtype=np.float32), + Variable("deceleration_coefficient", dtype=np.float32), +] + # ===================================================== # SECTION: Kernels @@ -50,6 +52,11 @@ def _sample_temperature(particle, fieldset, time): particle.temperature = fieldset.T[time, particle.depth, particle.lat, particle.lon] +_XBT_SENSOR_KERNELS: dict[SensorType, callable] = { + SensorType.TEMPERATURE: _sample_temperature, +} + + def _xbt_cast(particle, fieldset, time): particle_ddepth = -particle.fall_speed * particle.dt @@ -79,7 +86,7 @@ class XBTInstrument(Instrument): def __init__(self, expedition, from_data): """Initialize XBTInstrument.""" - variables = {"T": "thetao"} + variables = expedition.instruments_config.xbt_config.active_variables() limit_spec = { "spatial": True } # spatial limits; lat/lon constrained to waypoint locations + buffer @@ -112,11 +119,14 @@ def simulate(self, measurements, out_path) -> None: # add dummy U add_dummy_UV(fieldset) # TODO: parcels v3 bodge; remove when parcels v4 is used - fieldset_starttime = fieldset.T.grid.time_origin.fulltime( - fieldset.T.grid.time_full[0] + # 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 = fieldset.T.grid.time_origin.fulltime( - fieldset.T.grid.time_full[-1] + fieldset_endtime = _time_ref_field.grid.time_origin.fulltime( + _time_ref_field.grid.time_full[-1] ) # deploy time for all xbts should be later than fieldset start time @@ -152,6 +162,12 @@ def simulate(self, measurements, out_path) -> None: f"XBT max_depth or bathymetry shallower than minimum {-DT * fall_speed}. It is likely the XBT cannot be deployed in this area, which is too shallow." ) + # build dynamic particle class from the active sensors + xbt_config = self.expedition.instruments_config.xbt_config + _XBTParticle = build_particle_class_from_sensors( + xbt_config.sensors, _XBT_FIXED_VARIABLES + ) + # define xbt particles xbt_particleset = ParticleSet( fieldset=fieldset, @@ -167,8 +183,15 @@ def simulate(self, measurements, out_path) -> None: out_file = xbt_particleset.ParticleFile(name=out_path, outputdt=OUTPUT_DT) + # build kernel list from active sensors only + sample_kernels = [ + _XBT_SENSOR_KERNELS[sc.sensor_type] + for sc in xbt_config.sensors + if sc.enabled and sc.sensor_type in _XBT_SENSOR_KERNELS + ] + xbt_particleset.execute( - [_sample_temperature, _xbt_cast], + [*sample_kernels, _xbt_cast], endtime=fieldset_endtime, dt=DT, verbose_progress=self.verbose_progress, From 21f3b8bd54920324382d96774ae78110a9578bb5 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:50:15 +0100 Subject: [PATCH 32/53] rename list --- src/virtualship/instruments/adcp.py | 4 ++-- src/virtualship/instruments/ctd.py | 4 ++-- src/virtualship/instruments/ctd_bgc.py | 4 ++-- src/virtualship/instruments/drifter.py | 4 ++-- src/virtualship/instruments/ship_underwater_st.py | 4 ++-- src/virtualship/instruments/xbt.py | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/virtualship/instruments/adcp.py b/src/virtualship/instruments/adcp.py index beeda96a..1140604c 100644 --- a/src/virtualship/instruments/adcp.py +++ b/src/virtualship/instruments/adcp.py @@ -117,7 +117,7 @@ def simulate(self, measurements, out_path) -> None: out_file = particleset.ParticleFile(name=out_path, outputdt=np.inf) # build kernel list from active sensors only - sample_kernels = [ + sampling_kernels = [ _ADCP_SENSOR_KERNELS[sc.sensor_type] for sc in adcp_config.sensors if sc.enabled and sc.sensor_type in _ADCP_SENSOR_KERNELS @@ -131,7 +131,7 @@ def simulate(self, measurements, out_path) -> None: ) particleset.execute( - sample_kernels, + sampling_kernels, dt=1, runtime=1, verbose_progress=self.verbose_progress, diff --git a/src/virtualship/instruments/ctd.py b/src/virtualship/instruments/ctd.py index 12a5ca2f..eb19d491 100644 --- a/src/virtualship/instruments/ctd.py +++ b/src/virtualship/instruments/ctd.py @@ -184,7 +184,7 @@ def simulate(self, measurements, out_path) -> None: out_file = ctd_particleset.ParticleFile(name=out_path, outputdt=OUTPUT_DT) # build kernel list from active sensors only - sample_kernels = [ + sampling_kernels = [ _CTD_SENSOR_KERNELS[sc.sensor_type] for sc in ctd_config.sensors if sc.enabled and sc.sensor_type in _CTD_SENSOR_KERNELS @@ -192,7 +192,7 @@ def simulate(self, measurements, out_path) -> None: # execute simulation ctd_particleset.execute( - [*sample_kernels, _ctd_cast], + [*sampling_kernels, _ctd_cast], endtime=fieldset_endtime, dt=DT, verbose_progress=self.verbose_progress, diff --git a/src/virtualship/instruments/ctd_bgc.py b/src/virtualship/instruments/ctd_bgc.py index cbba0c14..f0fac8c3 100644 --- a/src/virtualship/instruments/ctd_bgc.py +++ b/src/virtualship/instruments/ctd_bgc.py @@ -209,7 +209,7 @@ def simulate(self, measurements, out_path) -> None: out_file = ctd_bgc_particleset.ParticleFile(name=out_path, outputdt=OUTPUT_DT) # build kernel list from active sensors only - sample_kernels = [ + sampling_kernels = [ _CTD_BGC_SENSOR_KERNELS[sc.sensor_type] for sc in ctd_bgc_config.sensors if sc.enabled and sc.sensor_type in _CTD_BGC_SENSOR_KERNELS @@ -217,7 +217,7 @@ def simulate(self, measurements, out_path) -> None: # execute simulation ctd_bgc_particleset.execute( - [*sample_kernels, _ctd_bgc_cast], + [*sampling_kernels, _ctd_bgc_cast], endtime=fieldset_endtime, dt=DT, verbose_progress=self.verbose_progress, diff --git a/src/virtualship/instruments/drifter.py b/src/virtualship/instruments/drifter.py index 38e217a7..87b81853 100644 --- a/src/virtualship/instruments/drifter.py +++ b/src/virtualship/instruments/drifter.py @@ -160,7 +160,7 @@ def simulate(self, measurements, out_path) -> None: endtime = fieldset.time_origin.fulltime(fieldset.U.grid.time_full[-1]) # build kernel list from active sensors only - sample_kernels = [ + sampling_kernels = [ _DRIFTER_SENSOR_KERNELS[sc.sensor_type] for sc in drifter_config.sensors if sc.enabled and sc.sensor_type in _DRIFTER_SENSOR_KERNELS @@ -168,7 +168,7 @@ def simulate(self, measurements, out_path) -> None: # execute simulation drifter_particleset.execute( - [AdvectionRK4, *sample_kernels, _check_lifetime], + [AdvectionRK4, *sampling_kernels, _check_lifetime], endtime=endtime, dt=DT, output_file=out_file, diff --git a/src/virtualship/instruments/ship_underwater_st.py b/src/virtualship/instruments/ship_underwater_st.py index a18ab9c5..fe68e6b7 100644 --- a/src/virtualship/instruments/ship_underwater_st.py +++ b/src/virtualship/instruments/ship_underwater_st.py @@ -115,7 +115,7 @@ def simulate(self, measurements, out_path) -> None: out_file = particleset.ParticleFile(name=out_path, outputdt=np.inf) # build kernel list from active sensors only - sample_kernels = [ + sampling_kernels = [ _ST_SENSOR_KERNELS[sc.sensor_type] for sc in st_config.sensors if sc.enabled and sc.sensor_type in _ST_SENSOR_KERNELS @@ -129,7 +129,7 @@ def simulate(self, measurements, out_path) -> None: ) particleset.execute( - sample_kernels, + sampling_kernels, dt=1, runtime=1, verbose_progress=self.verbose_progress, diff --git a/src/virtualship/instruments/xbt.py b/src/virtualship/instruments/xbt.py index 19b8509c..9d01fcbd 100644 --- a/src/virtualship/instruments/xbt.py +++ b/src/virtualship/instruments/xbt.py @@ -184,14 +184,14 @@ def simulate(self, measurements, out_path) -> None: out_file = xbt_particleset.ParticleFile(name=out_path, outputdt=OUTPUT_DT) # build kernel list from active sensors only - sample_kernels = [ + sampling_kernels = [ _XBT_SENSOR_KERNELS[sc.sensor_type] for sc in xbt_config.sensors if sc.enabled and sc.sensor_type in _XBT_SENSOR_KERNELS ] xbt_particleset.execute( - [*sample_kernels, _xbt_cast], + [*sampling_kernels, _xbt_cast], endtime=fieldset_endtime, dt=DT, verbose_progress=self.verbose_progress, From f6e17ac2bdeff33cc08ba1d7f8bb603363a47993 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:56:53 +0100 Subject: [PATCH 33/53] adapt argo subclass to sensor refactoring, also separate the sampling kernels from the argo vertical movement kernel to enable easier scalability --- src/virtualship/instruments/argo_float.py | 110 ++++++++++++++-------- 1 file changed, 72 insertions(+), 38 deletions(-) diff --git a/src/virtualship/instruments/argo_float.py b/src/virtualship/instruments/argo_float.py index 1c697852..8252205e 100644 --- a/src/virtualship/instruments/argo_float.py +++ b/src/virtualship/instruments/argo_float.py @@ -4,18 +4,15 @@ from typing import ClassVar import numpy as np -from parcels import ( - AdvectionRK4, - JITParticle, - ParticleSet, - StatusCode, - Variable, -) +from parcels import AdvectionRK4, JITParticle, ParticleSet, StatusCode, Variable from virtualship.instruments.base import Instrument -from virtualship.instruments.types import InstrumentType +from virtualship.instruments.types import ( + InstrumentType, + SensorType, +) from virtualship.models.spacetime import Spacetime -from virtualship.utils import register_instrument +from virtualship.utils import build_particle_class_from_sensors, register_instrument # ===================================================== # SECTION: Dataclass @@ -37,25 +34,21 @@ class ArgoFloat: # ===================================================== -# SECTION: Particle Class +# SECTION: fixed/mechanical Particle Variables (non-sampling) # ===================================================== -_ArgoParticle = JITParticle.add_variables( - [ - Variable("cycle_phase", dtype=np.int32, initial=0.0), - Variable("cycle_age", dtype=np.float32, initial=0.0), - Variable("drift_age", dtype=np.float32, initial=0.0), - Variable("salinity", dtype=np.float32, initial=np.nan), - Variable("temperature", dtype=np.float32, initial=np.nan), - Variable("min_depth", dtype=np.float32), - Variable("max_depth", dtype=np.float32), - Variable("drift_depth", dtype=np.float32), - Variable("vertical_speed", dtype=np.float32), - Variable("cycle_days", dtype=np.int32), - Variable("drift_days", dtype=np.int32), - Variable("grounded", dtype=np.int32, initial=0), - ] -) +_ARGO_FIXED_VARIABLES = [ + Variable("cycle_phase", dtype=np.int32, initial=0.0), + Variable("cycle_age", dtype=np.float32, initial=0.0), + Variable("drift_age", dtype=np.float32, initial=0.0), + Variable("min_depth", dtype=np.float32), + Variable("max_depth", dtype=np.float32), + Variable("drift_depth", dtype=np.float32), + Variable("vertical_speed", dtype=np.float32), + Variable("cycle_days", dtype=np.int32), + Variable("drift_days", dtype=np.int32), + Variable("grounded", dtype=np.int32, initial=0), +] # ===================================================== # SECTION: Kernels @@ -118,18 +111,7 @@ def _argo_float_vertical_movement(particle, fieldset, time): particle.grounded = 0 if particle.depth + particle_ddepth >= particle.min_depth: particle_ddepth = particle.min_depth - particle.depth - particle.temperature = ( - math.nan - ) # reset temperature to NaN at end of sampling cycle - particle.salinity = math.nan # idem particle.cycle_phase = 4 - else: - particle.temperature = fieldset.T[ - time, particle.depth, particle.lat, particle.lon - ] - particle.salinity = fieldset.S[ - time, particle.depth, particle.lat, particle.lon - ] elif particle.cycle_phase == 4: # Phase 4: Transmitting at surface until cycletime is reached @@ -153,6 +135,35 @@ def _check_error(particle, fieldset, time): particle.delete() +# TODO: ensure the behaviour is still the same as previously now that the sampling is extracted from the main vertical movement kernels +def _argo_sample_temperature(particle, fieldset, time): + # Phase 3: ascending — sample temperature; reset to NaN when cycle ends + if particle.cycle_phase == 3: + if particle.depth > particle.min_depth: + particle.temperature = fieldset.T[ + time, particle.depth, particle.lat, particle.lon + ] + else: + particle.temperature = math.nan # reset at surface + + +def _argo_sample_salinity(particle, fieldset, time): + # Phase 3: ascending — sample salinity; reset to NaN when cycle ends + if particle.cycle_phase == 3: + if particle.depth > particle.min_depth: + particle.salinity = fieldset.S[ + time, particle.depth, particle.lat, particle.lon + ] + else: + particle.salinity = math.nan # reset at surface + + +_ARGO_SENSOR_KERNELS: dict[SensorType, callable] = { + SensorType.TEMPERATURE: _argo_sample_temperature, + SensorType.SALINITY: _argo_sample_salinity, +} + + # ===================================================== # SECTION: Instrument Class # ===================================================== @@ -164,7 +175,14 @@ class ArgoFloatInstrument(Instrument): def __init__(self, expedition, from_data): """Initialize ArgoFloatInstrument.""" - variables = {"U": "uo", "V": "vo", "S": "so", "T": "thetao"} + sensor_variables = ( + expedition.instruments_config.argo_float_config.active_variables() + ) + variables = { + "U": "uo", + "V": "vo", + **sensor_variables, + } # advection variables (U and V) are always required for argo float simulation; sensor variables come from config spacetime_buffer_size = { "latlon": 3.0, # [degrees] "time": expedition.instruments_config.argo_float_config.lifetime.total_seconds() @@ -215,6 +233,14 @@ def simulate(self, measurements, out_path) -> None: f"{self.__class__.__name__} cannot be deployed in waters shallower than 50m. The following waypoints are too shallow: {shallow_waypoints}." ) + # build dynamic particle class from the active sensors + argo_float_config = self.expedition.instruments_config.argo_float_config + _ArgoParticle = build_particle_class_from_sensors( + argo_float_config.sensors, + _ARGO_FIXED_VARIABLES, + JITParticle, + ) + # define parcel particles argo_float_particleset = ParticleSet( fieldset=fieldset, @@ -241,10 +267,18 @@ def simulate(self, measurements, out_path) -> None: # endtime endtime = fieldset.time_origin.fulltime(fieldset.U.grid.time_full[-1]) + # build kernel list from active sensors only + sampling_kernels = [ + _ARGO_SENSOR_KERNELS[sc.sensor_type] + for sc in argo_float_config.sensors + if sc.enabled and sc.sensor_type in _ARGO_SENSOR_KERNELS + ] + # execute simulation argo_float_particleset.execute( [ _argo_float_vertical_movement, + *sampling_kernels, AdvectionRK4, _keep_at_surface, _check_error, From 096226142cd628f8544cf3e64bdfc1f9c6d31abe Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:03:07 +0100 Subject: [PATCH 34/53] consistent particle variable naming --- src/virtualship/instruments/ship_underwater_st.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/virtualship/instruments/ship_underwater_st.py b/src/virtualship/instruments/ship_underwater_st.py index fe68e6b7..e505b896 100644 --- a/src/virtualship/instruments/ship_underwater_st.py +++ b/src/virtualship/instruments/ship_underwater_st.py @@ -39,12 +39,12 @@ class Underwater_ST: # define function sampling Salinity def _sample_salinity(particle, fieldset, time): - particle.S = fieldset.S[time, particle.depth, particle.lat, particle.lon] + particle.salinity = fieldset.S[time, particle.depth, particle.lat, particle.lon] # define function sampling Temperature def _sample_temperature(particle, fieldset, time): - particle.T = fieldset.T[time, particle.depth, particle.lat, particle.lon] + particle.temperature = fieldset.T[time, particle.depth, particle.lat, particle.lon] _ST_SENSOR_KERNELS: dict[SensorType, callable] = { From c9d0623e92792722c4661593d397e3ac49438e98 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:04:47 +0100 Subject: [PATCH 35/53] add back in ctd_bgc for now --- src/virtualship/instruments/types.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/virtualship/instruments/types.py b/src/virtualship/instruments/types.py index d365f4bd..fbaaa2e3 100644 --- a/src/virtualship/instruments/types.py +++ b/src/virtualship/instruments/types.py @@ -7,6 +7,7 @@ class InstrumentType(Enum): """Types of the instruments.""" CTD = "CTD" + CTD_BGC = "CTD_BGC" DRIFTER = "DRIFTER" ARGO_FLOAT = "ARGO_FLOAT" XBT = "XBT" From 8151a60d13e81e10c2814c64970bbfbd1d7a8c62 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:05:21 +0100 Subject: [PATCH 36/53] fix import --- src/virtualship/instruments/ctd.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/virtualship/instruments/ctd.py b/src/virtualship/instruments/ctd.py index eb19d491..7d5d5d29 100644 --- a/src/virtualship/instruments/ctd.py +++ b/src/virtualship/instruments/ctd.py @@ -9,9 +9,12 @@ from virtualship.instruments.types import ( InstrumentType, SensorType, +) +from virtualship.utils import ( + add_dummy_UV, build_particle_class_from_sensors, + register_instrument, ) -from virtualship.utils import add_dummy_UV, register_instrument if TYPE_CHECKING: from virtualship.models.spacetime import Spacetime From 892c75de7a7a8bfdb1b09f943e3be5bebb191265 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:14:45 +0100 Subject: [PATCH 37/53] move sensor information to new sensors.py file --- src/virtualship/instruments/sensors.py | 52 +++++++++++++++++++++++++ src/virtualship/instruments/types.py | 53 -------------------------- 2 files changed, 52 insertions(+), 53 deletions(-) create mode 100644 src/virtualship/instruments/sensors.py diff --git a/src/virtualship/instruments/sensors.py b/src/virtualship/instruments/sensors.py new file mode 100644 index 00000000..1edaed01 --- /dev/null +++ b/src/virtualship/instruments/sensors.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from enum import Enum + + +class SensorType(str, Enum): + """Sensors available. Different intstruments mix and match these sensors as needed.""" + + TEMPERATURE = "TEMPERATURE" + SALINITY = "SALINITY" + VELOCITY = "VELOCITY" + OXYGEN = "OXYGEN" + CHLOROPHYLL = "CHLOROPHYLL" + NITRATE = "NITRATE" + PHOSPHATE = "PHOSPHATE" + PH = "PH" + PHYTOPLANKTON = "PHYTOPLANKTON" + PRIMARY_PRODUCTION = "PRIMARY_PRODUCTION" + + +# per-instrument allowlists of supported sensors (source truth for validation for which sensors each instrument supports) + +ARGO_FLOAT_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset( + {SensorType.TEMPERATURE, SensorType.SALINITY} +) + +# TODO: CTD and CTD_BGC will be consoidated in future PR... +CTD_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset( + {SensorType.TEMPERATURE, SensorType.SALINITY} +) + +CTD_BGC_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset( + { + SensorType.OXYGEN, + SensorType.CHLOROPHYLL, + SensorType.NITRATE, + SensorType.PHOSPHATE, + SensorType.PH, + SensorType.PHYTOPLANKTON, + SensorType.PRIMARY_PRODUCTION, + } +) + +DRIFTER_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset({SensorType.TEMPERATURE}) + +ADCP_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset({SensorType.VELOCITY}) + +UNDERWATER_ST_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset( + {SensorType.TEMPERATURE, SensorType.SALINITY} +) + +XBT_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset({SensorType.TEMPERATURE}) diff --git a/src/virtualship/instruments/types.py b/src/virtualship/instruments/types.py index fbaaa2e3..489a331f 100644 --- a/src/virtualship/instruments/types.py +++ b/src/virtualship/instruments/types.py @@ -18,56 +18,3 @@ class InstrumentType(Enum): def is_underway(self) -> bool: """Return True if instrument is an underway instrument (ADCP, UNDERWATER_ST).""" return self in {InstrumentType.ADCP, InstrumentType.UNDERWATER_ST} - - -class SensorType(str, Enum): - """ - Sensors available. Different intstruments mix and match these sensors as needed. - - Each entry has a corresponding entry in `SENSOR_REGISTRY` which carries the centralised metadata (e.g. FieldSet key, Copernicus var name). - """ - - TEMPERATURE = "TEMPERATURE" - SALINITY = "SALINITY" - VELOCITY = "VELOCITY" - OXYGEN = "OXYGEN" - CHLOROPHYLL = "CHLOROPHYLL" - NITRATE = "NITRATE" - PHOSPHATE = "PHOSPHATE" - PH = "PH" - PHYTOPLANKTON = "PHYTOPLANKTON" - PRIMARY_PRODUCTION = "PRIMARY_PRODUCTION" - - -# per-instrument allowlists of supported sensors (source truth for validation for which sensors each instrument supports) - -ARGO_FLOAT_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset( - {SensorType.TEMPERATURE, SensorType.SALINITY} -) - -# TODO: CTD and CTD_BGC will be consoidated in future PR... -CTD_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset( - {SensorType.TEMPERATURE, SensorType.SALINITY} -) - -CTD_BGC_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset( - { - SensorType.OXYGEN, - SensorType.CHLOROPHYLL, - SensorType.NITRATE, - SensorType.PHOSPHATE, - SensorType.PH, - SensorType.PHYTOPLANKTON, - SensorType.PRIMARY_PRODUCTION, - } -) - -DRIFTER_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset({SensorType.TEMPERATURE}) - -ADCP_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset({SensorType.VELOCITY}) - -UNDERWATER_ST_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset( - {SensorType.TEMPERATURE, SensorType.SALINITY} -) - -XBT_SUPPORTED_SENSORS: frozenset[SensorType] = frozenset({SensorType.TEMPERATURE}) From 13ded3fc86af607ac0ac1bce4925bad82d981d46 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:15:10 +0100 Subject: [PATCH 38/53] update imports across codebase --- src/virtualship/instruments/adcp.py | 6 ++---- src/virtualship/instruments/argo_float.py | 6 ++---- src/virtualship/instruments/ctd.py | 6 ++---- src/virtualship/instruments/ctd_bgc.py | 6 ++---- src/virtualship/instruments/drifter.py | 3 ++- src/virtualship/instruments/ship_underwater_st.py | 3 ++- src/virtualship/instruments/xbt.py | 3 ++- src/virtualship/models/expedition.py | 4 ++-- src/virtualship/utils.py | 2 +- 9 files changed, 17 insertions(+), 22 deletions(-) diff --git a/src/virtualship/instruments/adcp.py b/src/virtualship/instruments/adcp.py index 1140604c..e1c404ef 100644 --- a/src/virtualship/instruments/adcp.py +++ b/src/virtualship/instruments/adcp.py @@ -5,10 +5,8 @@ from parcels import ParticleSet, ScipyParticle from virtualship.instruments.base import Instrument -from virtualship.instruments.types import ( - InstrumentType, - SensorType, -) +from virtualship.instruments.sensors import SensorType +from virtualship.instruments.types import InstrumentType from virtualship.utils import build_particle_class_from_sensors, register_instrument # ===================================================== diff --git a/src/virtualship/instruments/argo_float.py b/src/virtualship/instruments/argo_float.py index 8252205e..c8853f3e 100644 --- a/src/virtualship/instruments/argo_float.py +++ b/src/virtualship/instruments/argo_float.py @@ -7,10 +7,8 @@ from parcels import AdvectionRK4, JITParticle, ParticleSet, StatusCode, Variable from virtualship.instruments.base import Instrument -from virtualship.instruments.types import ( - InstrumentType, - SensorType, -) +from virtualship.instruments.sensors import SensorType +from virtualship.instruments.types import InstrumentType from virtualship.models.spacetime import Spacetime from virtualship.utils import build_particle_class_from_sensors, register_instrument diff --git a/src/virtualship/instruments/ctd.py b/src/virtualship/instruments/ctd.py index 7d5d5d29..dd397341 100644 --- a/src/virtualship/instruments/ctd.py +++ b/src/virtualship/instruments/ctd.py @@ -6,10 +6,8 @@ from parcels import ParticleSet, Variable from virtualship.instruments.base import Instrument -from virtualship.instruments.types import ( - InstrumentType, - SensorType, -) +from virtualship.instruments.sensors import SensorType +from virtualship.instruments.types import InstrumentType from virtualship.utils import ( add_dummy_UV, build_particle_class_from_sensors, diff --git a/src/virtualship/instruments/ctd_bgc.py b/src/virtualship/instruments/ctd_bgc.py index f0fac8c3..88968fb0 100644 --- a/src/virtualship/instruments/ctd_bgc.py +++ b/src/virtualship/instruments/ctd_bgc.py @@ -6,10 +6,8 @@ from parcels import JITParticle, ParticleSet, Variable from virtualship.instruments.base import Instrument -from virtualship.instruments.types import ( - InstrumentType, - SensorType, -) +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, diff --git a/src/virtualship/instruments/drifter.py b/src/virtualship/instruments/drifter.py index 87b81853..7ad2de4e 100644 --- a/src/virtualship/instruments/drifter.py +++ b/src/virtualship/instruments/drifter.py @@ -6,7 +6,8 @@ from parcels import AdvectionRK4, ParticleSet, Variable from virtualship.instruments.base import Instrument -from virtualship.instruments.types import InstrumentType, SensorType +from virtualship.instruments.sensors import SensorType +from virtualship.instruments.types import InstrumentType from virtualship.models.spacetime import Spacetime from virtualship.utils import ( _random_noise, diff --git a/src/virtualship/instruments/ship_underwater_st.py b/src/virtualship/instruments/ship_underwater_st.py index e505b896..73456ff3 100644 --- a/src/virtualship/instruments/ship_underwater_st.py +++ b/src/virtualship/instruments/ship_underwater_st.py @@ -5,7 +5,8 @@ from parcels import ParticleSet, ScipyParticle from virtualship.instruments.base import Instrument -from virtualship.instruments.types import InstrumentType, SensorType +from virtualship.instruments.sensors import SensorType +from virtualship.instruments.types import InstrumentType from virtualship.utils import ( add_dummy_UV, build_particle_class_from_sensors, diff --git a/src/virtualship/instruments/xbt.py b/src/virtualship/instruments/xbt.py index 9d01fcbd..94d2e291 100644 --- a/src/virtualship/instruments/xbt.py +++ b/src/virtualship/instruments/xbt.py @@ -6,7 +6,8 @@ from parcels import ParticleSet, Variable from virtualship.instruments.base import Instrument -from virtualship.instruments.types import InstrumentType, SensorType +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, diff --git a/src/virtualship/models/expedition.py b/src/virtualship/models/expedition.py index 0a351191..e5c397ef 100644 --- a/src/virtualship/models/expedition.py +++ b/src/virtualship/models/expedition.py @@ -11,7 +11,7 @@ import yaml from virtualship.errors import InstrumentsConfigError, ScheduleError -from virtualship.instruments.types import ( +from virtualship.instruments.sensors import ( ADCP_SUPPORTED_SENSORS, ARGO_FLOAT_SUPPORTED_SENSORS, CTD_BGC_SUPPORTED_SENSORS, @@ -19,9 +19,9 @@ DRIFTER_SUPPORTED_SENSORS, UNDERWATER_ST_SUPPORTED_SENSORS, XBT_SUPPORTED_SENSORS, - InstrumentType, SensorType, ) +from virtualship.instruments.types import InstrumentType from virtualship.utils import ( SENSOR_REGISTRY, _calc_sail_time, diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 363e9e6a..60563d26 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -19,7 +19,7 @@ from parcels import FieldSet, Variable from virtualship.errors import CopernicusCatalogueError -from virtualship.instruments.types import SensorType +from virtualship.instruments.sensors import SensorType if TYPE_CHECKING: from virtualship.expedition.simulate_schedule import ( From a30f72b7615880becd4b232eec6d3baa6d719844 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:46:06 +0100 Subject: [PATCH 39/53] add validator/serialiser for reading from YAML, remove unnecessary property shorthands --- src/virtualship/models/expedition.py | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/src/virtualship/models/expedition.py b/src/virtualship/models/expedition.py index e5c397ef..80692af3 100644 --- a/src/virtualship/models/expedition.py +++ b/src/virtualship/models/expedition.py @@ -3,7 +3,6 @@ import itertools from datetime import datetime, timedelta from pathlib import Path -from typing import Literal import numpy as np import pydantic @@ -253,7 +252,7 @@ def _check_sensor_compatibility( def build_variables_from_sensors(sensors: list[SensorConfig]) -> dict[str, str]: """Build variables dict (FieldSet key → Copernicus-variable).""" - return {sc.fs_key: sc.copernicus_var for sc in sensors if sc.enabled} + return {sc.meta.fs_key: sc.meta.copernicus_var for sc in sensors if sc.enabled} @register_instrument_config(InstrumentType.ARGO_FLOAT) @@ -695,6 +694,15 @@ class SensorConfig(pydantic.BaseModel): sensor_type: SensorType enabled: bool = True + # validator/serialiser for allowing the compact, single-string notation for sensors in YAML (e.g. "TEMPERATURE" instead of sensor_type: TEMPERATURE in each instance + @pydantic.model_validator(mode="before") + @classmethod + def _from_string(cls, value): + """Allow a bare sensor-type string (e.g. "TEMPERATURE") as shorthand for {"sensor_type": "TEMPERATURE"}.""" + if isinstance(value, str): + return {"sensor_type": value} + return value + @pydantic.field_validator("sensor_type", mode="before") @classmethod def _take_sensor_type(cls, value: str | SensorType) -> SensorType: @@ -707,18 +715,3 @@ def _take_sensor_type(cls, value: str | SensorType) -> SensorType: def meta(self) -> _SensorMeta: """Metadata for this sensor.""" return SENSOR_REGISTRY[self.sensor_type] - - @property - def fs_key(self) -> str: - """FieldSet key (e.g. T, o2).""" - return self.meta.fs_key - - @property - def copernicus_var(self) -> str: - """Copernicus Marine variable name (e.g. thetao, o2).""" - return self.meta.copernicus_var - - @property - def category(self) -> Literal["phys", "bgc"]: - """Physical (phys) or biogeochemical (bgc).""" - return self.meta.category From f0f8a19acc4aa5065063ddb9ab9b544097a90d31 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:46:40 +0100 Subject: [PATCH 40/53] re-add JITParticle to particle class when creating instruments --- src/virtualship/instruments/ctd.py | 4 ++-- src/virtualship/instruments/drifter.py | 4 ++-- src/virtualship/instruments/xbt.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/virtualship/instruments/ctd.py b/src/virtualship/instruments/ctd.py index dd397341..ef1f6969 100644 --- a/src/virtualship/instruments/ctd.py +++ b/src/virtualship/instruments/ctd.py @@ -4,7 +4,7 @@ import numpy as np -from parcels import 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 @@ -165,7 +165,7 @@ def simulate(self, measurements, out_path) -> None: # 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_FIXED_VARIABLES + ctd_config.sensors, _CTD_FIXED_VARIABLES, JITParticle ) # define parcel particles diff --git a/src/virtualship/instruments/drifter.py b/src/virtualship/instruments/drifter.py index 7ad2de4e..88d9779a 100644 --- a/src/virtualship/instruments/drifter.py +++ b/src/virtualship/instruments/drifter.py @@ -4,7 +4,7 @@ import numpy as np -from parcels import AdvectionRK4, ParticleSet, Variable +from parcels import AdvectionRK4, JITParticle, ParticleSet, Variable from virtualship.instruments.base import Instrument from virtualship.instruments.sensors import SensorType from virtualship.instruments.types import InstrumentType @@ -123,7 +123,7 @@ def simulate(self, measurements, out_path) -> None: # build dynamic particle class from the active sensors drifter_config = self.expedition.instruments_config.drifter_config _DrifterParticle = build_particle_class_from_sensors( - drifter_config.sensors, _DRIFTER_FIXED_VARIABLES + drifter_config.sensors, _DRIFTER_FIXED_VARIABLES, JITParticle ) # define parcel particles diff --git a/src/virtualship/instruments/xbt.py b/src/virtualship/instruments/xbt.py index 94d2e291..b53b5824 100644 --- a/src/virtualship/instruments/xbt.py +++ b/src/virtualship/instruments/xbt.py @@ -4,7 +4,7 @@ import numpy as np -from parcels import 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 @@ -166,7 +166,7 @@ def simulate(self, measurements, out_path) -> None: # build dynamic particle class from the active sensors xbt_config = self.expedition.instruments_config.xbt_config _XBTParticle = build_particle_class_from_sensors( - xbt_config.sensors, _XBT_FIXED_VARIABLES + xbt_config.sensors, _XBT_FIXED_VARIABLES, JITParticle ) # define xbt particles From 92fe4a15b557f67c7e9e5e3bac46008327bf85a5 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:47:58 +0100 Subject: [PATCH 41/53] update with new analysis environment --- pixi.toml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pixi.toml b/pixi.toml index d73f8c85..e658e7d0 100644 --- a/pixi.toml +++ b/pixi.toml @@ -58,6 +58,16 @@ ipykernel = "*" [feature.notebooks.tasks] tests-notebooks = "pytest --nbval-lax docs/" +[feature.analysis.dependencies] +jupyterlab = ">=4,<5" +ipykernel = ">=7,<8" +plotly = ">=6,<7" +nbformat = "*" +ipywidgets = "*" + +[feature.analysis.tasks] +lab = "jupyter lab" + [feature.docs.dependencies] sphinx = ">=7.0" myst-parser = ">=0.13" @@ -92,6 +102,7 @@ test-py310 = { features = ["test", "py310"] } test-py311 = { features = ["test", "py311"] } test-py312 = { features = ["test", "py312"] } test-notebooks = { features = ["test", "notebooks"], solve-group = "test" } +analysis = { features = ["analysis"], solve-group = "analysis" } docs = { features = ["docs"], solve-group = "docs" } typing = { features = ["typing"], solve-group = "typing" } pre-commit = { features = ["pre-commit"], no-default-feature = true } From 089d2b6a2df84df7fefc2d248661adc72cc39589 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:25:01 +0100 Subject: [PATCH 42/53] fix erroneous sampling during descent and drift --- src/virtualship/instruments/argo_float.py | 29 ++++++++++------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/src/virtualship/instruments/argo_float.py b/src/virtualship/instruments/argo_float.py index c8853f3e..4d5b538b 100644 --- a/src/virtualship/instruments/argo_float.py +++ b/src/virtualship/instruments/argo_float.py @@ -133,27 +133,22 @@ def _check_error(particle, fieldset, time): particle.delete() -# TODO: ensure the behaviour is still the same as previously now that the sampling is extracted from the main vertical movement kernels def _argo_sample_temperature(particle, fieldset, time): - # Phase 3: ascending — sample temperature; reset to NaN when cycle ends - if particle.cycle_phase == 3: - if particle.depth > particle.min_depth: - particle.temperature = fieldset.T[ - time, particle.depth, particle.lat, particle.lon - ] - else: - particle.temperature = math.nan # reset at surface + # Phase 3: ascending — sample temperature; NaN otherwise + if particle.cycle_phase == 3 and particle.depth < particle.min_depth: + particle.temperature = fieldset.T[ + time, particle.depth, particle.lat, particle.lon + ] + else: + particle.temperature = math.nan def _argo_sample_salinity(particle, fieldset, time): - # Phase 3: ascending — sample salinity; reset to NaN when cycle ends - if particle.cycle_phase == 3: - if particle.depth > particle.min_depth: - particle.salinity = fieldset.S[ - time, particle.depth, particle.lat, particle.lon - ] - else: - particle.salinity = math.nan # reset at surface + # Phase 3: ascending — sample salinity; NaN otherwise + if particle.cycle_phase == 3 and particle.depth < particle.min_depth: + particle.salinity = fieldset.S[time, particle.depth, particle.lat, particle.lon] + else: + particle.salinity = math.nan _ARGO_SENSOR_KERNELS: dict[SensorType, callable] = { From 96ff22d48fbc8f74ee7af1f51399fee762f3b4c3 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:25:09 +0100 Subject: [PATCH 43/53] update docstring --- src/virtualship/models/expedition.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/virtualship/models/expedition.py b/src/virtualship/models/expedition.py index 80692af3..9163e116 100644 --- a/src/virtualship/models/expedition.py +++ b/src/virtualship/models/expedition.py @@ -233,7 +233,7 @@ def _check_sensor_compatibility( supported: frozenset[SensorType], instrument_name: str, ) -> list[SensorConfig]: - """Raise ``ValueError`` if any sensor in `sensors` is not in `supported`, or if no sensors are enabled. Used as a Pydantic field_validator for each instrument config class.""" + """Raise ValueError if any sensor in `sensors` is not in `supported`, or if no sensors are enabled. Used as a Pydantic field_validator for each instrument config class.""" unsupported = {sc.sensor_type for sc in sensors} - supported if unsupported: names = ", ".join(sorted(s.value for s in unsupported)) From 73474e9ee9f667e1e3d351dd2e14754041025a57 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:00:43 +0100 Subject: [PATCH 44/53] Add sensor configuration tests for various instruments and update configurations --- tests/instruments/test_adcp.py | 51 +++++++- tests/instruments/test_argo_float.py | 93 ++++++++++++- tests/instruments/test_ctd.py | 130 ++++++++++++++++++- tests/instruments/test_ctd_bgc.py | 79 ++++++++++- tests/instruments/test_drifter.py | 34 ++++- tests/instruments/test_ship_underwater_st.py | 61 ++++++++- tests/instruments/test_xbt.py | 57 +++++++- 7 files changed, 486 insertions(+), 19 deletions(-) diff --git a/tests/instruments/test_adcp.py b/tests/instruments/test_adcp.py index 0a88b206..cc059dd9 100644 --- a/tests/instruments/test_adcp.py +++ b/tests/instruments/test_adcp.py @@ -3,12 +3,15 @@ import datetime import numpy as np +import pydantic +import pytest import xarray as xr from parcels import FieldSet from virtualship.instruments.adcp import ADCPInstrument -from virtualship.instruments.types import InstrumentType +from virtualship.instruments.types import InstrumentType, SensorType from virtualship.models import Location, Spacetime, Waypoint +from virtualship.models.expedition import ADCPConfig, InstrumentsConfig, SensorConfig def test_simulate_adcp(tmpdir) -> None: @@ -90,10 +93,14 @@ class schedule: ), ] - class instruments_config: - class adcp_config: - max_depth_meter = MAX_DEPTH - num_bins = NUM_BINS + instruments_config = InstrumentsConfig( + adcp_config=ADCPConfig( + max_depth_meter=MAX_DEPTH, + num_bins=NUM_BINS, + period_minutes=5.0, + sensors=[SensorConfig(sensor_type=SensorType.VELOCITY)], + ) + ) expedition = DummyExpedition() from_data = None @@ -131,3 +138,37 @@ class adcp_config: assert np.isclose(obs_value, exp_value), ( f"Observation incorrect {vert_loc=} {i=} {var=} {obs_value=} {exp_value=}." ) + + +def test_adcp_sensor_config_active_variables() -> None: + """active_variables() returns both U and V when VELOCITY is enabled.""" + config_with = ADCPConfig( + max_depth_meter=-1000.0, + num_bins=40, + period_minutes=5.0, + sensors=[SensorConfig(sensor_type=SensorType.VELOCITY)], + ) + assert config_with.active_variables() == {"U": "uo", "V": "vo"} + + with pytest.raises(pydantic.ValidationError, match="no enabled sensors"): + ADCPConfig( + max_depth_meter=-1000.0, + num_bins=40, + period_minutes=5.0, + sensors=[], # all disabled → invalid + ) + + +def test_adcp_sensor_config_yaml() -> None: + """ADCPConfig sensors survive YAML serialisation.""" + config = ADCPConfig( + max_depth_meter=-1000.0, + num_bins=40, + period_minutes=5.0, + sensors=[SensorConfig(sensor_type=SensorType.VELOCITY)], + ) + dumped = config.model_dump(by_alias=True) + loaded = ADCPConfig.model_validate(dumped) + assert len(loaded.sensors) == 1 + assert loaded.sensors[0].sensor_type == SensorType.VELOCITY + assert loaded.sensors[0].enabled is True diff --git a/tests/instruments/test_argo_float.py b/tests/instruments/test_argo_float.py index 66331d64..eb8a9237 100644 --- a/tests/instruments/test_argo_float.py +++ b/tests/instruments/test_argo_float.py @@ -7,8 +7,14 @@ from parcels import FieldSet from virtualship.instruments.argo_float import ArgoFloat, ArgoFloatInstrument +from virtualship.instruments.types import SensorType from virtualship.models import Location, Spacetime -from virtualship.models.expedition import Waypoint +from virtualship.models.expedition import ( + ArgoFloatConfig, + InstrumentsConfig, + SensorConfig, + Waypoint, +) def test_simulate_argo_floats(tmpdir) -> None: @@ -76,9 +82,15 @@ class schedule: ), ] - class instruments_config: - class argo_float_config: - lifetime = LIFETIME + instruments_config = InstrumentsConfig( + argo_float_config=ArgoFloatConfig( + lifetime=LIFETIME, + sensors=[ + SensorConfig(sensor_type=SensorType.TEMPERATURE), + SensorConfig(sensor_type=SensorType.SALINITY), + ], + ) + ) expedition = DummyExpedition() from_data = None @@ -96,3 +108,76 @@ class argo_float_config: assert len(results.trajectory) == len(argo_floats) for var in ["lon", "lat", "z", "temperature", "salinity"]: assert var in results, f"Results don't contain {var}" + + +def test_argo_float_disabled_sensor(tmpdir) -> None: + """Variables for disabled sensors must not appear in the zarr output.""" + base_time = datetime.strptime("1950-01-01", "%Y-%m-%d") + + DRIFT_DEPTH = -1000 + MAX_DEPTH = -2000 + VERTICAL_SPEED = -0.10 + CYCLE_DAYS = 10 + DRIFT_DAYS = 9 + LIFETIME = timedelta(days=1) + + v = np.full((2, 2, 2), 1.0) + u = np.full((2, 2, 2), 1.0) + t = np.full((2, 2, 2), 1.0) + bathy = np.full((2, 2), -5000.0) + + # only temperature fieldset, no salinity + fieldset = FieldSet.from_data( + {"V": v, "U": u, "T": t}, + { + "lon": np.array([0.0, 10.0]), + "lat": np.array([0.0, 10.0]), + "time": [ + np.datetime64(base_time + timedelta(seconds=0)), + np.datetime64(base_time + timedelta(hours=4)), + ], + }, + ) + fieldset.add_field( + FieldSet.from_data( + {"bathymetry": bathy}, + {"lon": np.array([0.0, 10.0]), "lat": np.array([0.0, 10.0])}, + ).bathymetry + ) + + argo_floats = [ + ArgoFloat( + spacetime=Spacetime(location=Location(latitude=0, longitude=0), time=0), + min_depth=0.0, + max_depth=MAX_DEPTH, + drift_depth=DRIFT_DEPTH, + vertical_speed=VERTICAL_SPEED, + cycle_days=CYCLE_DAYS, + drift_days=DRIFT_DAYS, + ) + ] + + class DummyExpedition: + class schedule: + waypoints = [Waypoint(location=Location(1, 2), time=base_time)] + + instruments_config = InstrumentsConfig( + argo_float_config=ArgoFloatConfig( + lifetime=LIFETIME, + sensors=[ + SensorConfig(sensor_type=SensorType.TEMPERATURE) + ], # SALINITY omitted = disabled + ) + ) + + expedition = DummyExpedition() + argo_instrument = ArgoFloatInstrument(expedition, None) + out_path = tmpdir.join("out_disabled.zarr") + argo_instrument.load_input_data = lambda: fieldset + argo_instrument.simulate(argo_floats, out_path) + + results = xr.open_zarr(out_path) + assert "temperature" in results, "Enabled sensor variable must be present" + assert "salinity" not in results, ( + "Disabled sensor variable must be absent from output" + ) diff --git a/tests/instruments/test_ctd.py b/tests/instruments/test_ctd.py index 954d0b78..d972a4c3 100644 --- a/tests/instruments/test_ctd.py +++ b/tests/instruments/test_ctd.py @@ -7,12 +7,20 @@ import datetime import numpy as np +import pydantic +import pytest import xarray as xr from parcels import Field, FieldSet from virtualship.instruments.ctd import CTD, CTDInstrument +from virtualship.instruments.types import SensorType from virtualship.models import Location, Spacetime -from virtualship.models.expedition import Waypoint +from virtualship.models.expedition import ( + CTDConfig, + InstrumentsConfig, + SensorConfig, + Waypoint, +) def test_simulate_ctds(tmpdir) -> None: @@ -113,6 +121,18 @@ class schedule: ), ] + 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.TEMPERATURE), + SensorConfig(sensor_type=SensorType.SALINITY), + ], + ) + ) + expedition = DummyExpedition() from_data = None @@ -146,3 +166,111 @@ class schedule: assert np.isclose(obs_value, exp_value), ( f"Observation incorrect {ctd_i=} {loc=} {var=} {obs_value=} {exp_value=}." ) + + +def test_ctd_sensor_config_active_variables() -> None: + """active_variables() only returns variables for enabled sensors.""" + config_both = CTDConfig( + stationkeeping_time_minutes=50, + min_depth_meter=-11.0, + max_depth_meter=-2000.0, + sensors=[ + SensorConfig(sensor_type=SensorType.TEMPERATURE), + SensorConfig(sensor_type=SensorType.SALINITY), + ], + ) + assert config_both.active_variables() == {"T": "thetao", "S": "so"} + + config_temp_only = CTDConfig( + stationkeeping_time_minutes=50, + min_depth_meter=-11.0, + max_depth_meter=-2000.0, + sensors=[ + SensorConfig(sensor_type=SensorType.TEMPERATURE) + ], # SALINITY absent = disabled + ) + assert config_temp_only.active_variables() == {"T": "thetao"} + + with pytest.raises(pydantic.ValidationError, match="no enabled sensors"): + CTDConfig( + stationkeeping_time_minutes=50, + min_depth_meter=-11.0, + max_depth_meter=-2000.0, + sensors=[], # all absent = all disabled → invalid + ) + + +def test_ctd_sensor_config_yaml() -> None: + """CTDConfig sensors survive YAML serialisation.""" + config = CTDConfig( + stationkeeping_time_minutes=50, + min_depth_meter=-11.0, + max_depth_meter=-2000.0, + sensors=[ + SensorConfig(sensor_type=SensorType.TEMPERATURE) + ], # SALINITY omitted = disabled + ) + dumped = config.model_dump(by_alias=True) + loaded = CTDConfig.model_validate(dumped) + + assert len(loaded.sensors) == 1 + assert loaded.sensors[0].sensor_type == SensorType.TEMPERATURE + assert loaded.sensors[0].enabled is True + + +def test_ctd_disabled_sensor_absent(tmpdir) -> None: + """Variables for disabled sensors 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, + ), + ] + + # Only temperature field, no salinty + t = np.full((2, 2, 2), 5.0) + fieldset = FieldSet.from_data( + {"T": t}, + { + "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.TEMPERATURE) + ], # SALINITY omitted = disabled + ) + ) + + expedition = DummyExpedition() + ctd_instrument = CTDInstrument(expedition, None) + out_path = tmpdir.join("out_disabled.zarr") + ctd_instrument.load_input_data = lambda: fieldset + ctd_instrument.simulate(ctds, out_path) + + results = xr.open_zarr(out_path) + assert "temperature" in results, "Enabled sensor variable must be present" + assert "salinity" not in results, ( + "Disabled sensor variable must be absent from output" + ) diff --git a/tests/instruments/test_ctd_bgc.py b/tests/instruments/test_ctd_bgc.py index 39fa6c1f..a97a7343 100644 --- a/tests/instruments/test_ctd_bgc.py +++ b/tests/instruments/test_ctd_bgc.py @@ -11,8 +11,14 @@ from parcels import Field, FieldSet from virtualship.instruments.ctd_bgc import CTD_BGC, CTD_BGCInstrument +from virtualship.instruments.types import SensorType from virtualship.models import Location, Spacetime -from virtualship.models.expedition import Waypoint +from virtualship.models.expedition import ( + CTD_BGCConfig, + InstrumentsConfig, + SensorConfig, + Waypoint, +) def test_simulate_ctd_bgcs(tmpdir) -> None: @@ -174,6 +180,23 @@ class schedule: ), ] + 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 @@ -216,3 +239,57 @@ class schedule: 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/instruments/test_drifter.py b/tests/instruments/test_drifter.py index 0dc72597..71fbbdc5 100644 --- a/tests/instruments/test_drifter.py +++ b/tests/instruments/test_drifter.py @@ -4,12 +4,20 @@ from typing import ClassVar import numpy as np +import pydantic +import pytest import xarray as xr -from parcels import FieldSet +from parcels import FieldSet from virtualship.instruments.drifter import Drifter, DrifterInstrument +from virtualship.instruments.types import SensorType from virtualship.models import Location, Spacetime -from virtualship.models.expedition import Waypoint +from virtualship.models.expedition import ( + DrifterConfig, + InstrumentsConfig, + SensorConfig, + Waypoint, +) BASE_TIME = datetime.datetime.strptime("1950-01-01", "%Y-%m-%d") LIFETIME = datetime.timedelta(days=1) @@ -31,10 +39,13 @@ class schedule: ), ] - class instruments_config: - class drifter_config: - lifetime = LIFETIME - depth_meter = DEPLOY_DEPTH + instruments_config = InstrumentsConfig( + drifter_config=DrifterConfig( + lifetime=LIFETIME, + depth_meter=DEPLOY_DEPTH, + sensors=[SensorConfig(sensor_type=SensorType.TEMPERATURE)], + ) + ) return DummyExpedition() @@ -188,3 +199,14 @@ def test_drifter_depths(tmpdir) -> None: assert drifter_surface.temperature[0] != drifter_depth.temperature[0], ( "Surface and deeper drifter should have different temperature measurements" ) + + +def test_drifter_disabled_sensor_absent_from_output(tmpdir) -> None: + """A DrifterConfig with no enabled sensors should be rejected at construction time.""" + with pytest.raises(pydantic.ValidationError, match="no enabled sensors"): + DrifterConfig( + lifetime=LIFETIME, + depth_meter=DEPLOY_DEPTH, + stationkeeping_time_minutes=10, + sensors=[], + ) diff --git a/tests/instruments/test_ship_underwater_st.py b/tests/instruments/test_ship_underwater_st.py index 3f1aae65..7ef166ba 100644 --- a/tests/instruments/test_ship_underwater_st.py +++ b/tests/instruments/test_ship_underwater_st.py @@ -3,12 +3,20 @@ import datetime import numpy as np +import pydantic +import pytest import xarray as xr from parcels import FieldSet from virtualship.instruments.ship_underwater_st import Underwater_STInstrument +from virtualship.instruments.types import SensorType from virtualship.models import Location, Spacetime -from virtualship.models.expedition import Waypoint +from virtualship.models.expedition import ( + InstrumentsConfig, + SensorConfig, + ShipUnderwaterSTConfig, + Waypoint, +) def test_simulate_ship_underwater_st(tmpdir) -> None: @@ -79,6 +87,16 @@ class schedule: ), ] + instruments_config = InstrumentsConfig( + ship_underwater_st_config=ShipUnderwaterSTConfig( + period_minutes=5.0, + sensors=[ + SensorConfig(sensor_type=SensorType.TEMPERATURE), + SensorConfig(sensor_type=SensorType.SALINITY), + ], + ) + ) + expedition = DummyExpedition() from_data = None @@ -109,3 +127,44 @@ class schedule: assert np.isclose(obs_value, exp_value), ( f"Observation incorrect {i=} {var=} {obs_value=} {exp_value=}." ) + + +def test_ship_underwater_st_sensor_config_active_variables() -> None: + """active_variables() only returns variables for enabled sensors.""" + config_both = ShipUnderwaterSTConfig( + period_minutes=5.0, + sensors=[ + SensorConfig(sensor_type=SensorType.TEMPERATURE), + SensorConfig(sensor_type=SensorType.SALINITY), + ], + ) + assert config_both.active_variables() == {"T": "thetao", "S": "so"} + + config_temp_only = ShipUnderwaterSTConfig( + period_minutes=5.0, + sensors=[ + SensorConfig(sensor_type=SensorType.TEMPERATURE) + ], # SALINITY omitted = disabled + ) + assert config_temp_only.active_variables() == {"T": "thetao"} + + with pytest.raises(pydantic.ValidationError, match="no enabled sensors"): + ShipUnderwaterSTConfig( + period_minutes=5.0, + sensors=[], # all disabled → invalid + ) + + +def test_ship_underwater_st_sensor_config_yaml() -> None: + """ShipUnderwaterSTConfig sensors survive YAML serialisation.""" + config = ShipUnderwaterSTConfig( + period_minutes=5.0, + sensors=[ + SensorConfig(sensor_type=SensorType.TEMPERATURE) + ], # SALINITY omitted = disabled + ) + dumped = config.model_dump(by_alias=True) + loaded = ShipUnderwaterSTConfig.model_validate(dumped) + assert len(loaded.sensors) == 1 + assert loaded.sensors[0].sensor_type == SensorType.TEMPERATURE + assert loaded.sensors[0].enabled is True diff --git a/tests/instruments/test_xbt.py b/tests/instruments/test_xbt.py index c6a36631..37b3b1b8 100644 --- a/tests/instruments/test_xbt.py +++ b/tests/instruments/test_xbt.py @@ -7,12 +7,20 @@ import datetime import numpy as np +import pydantic +import pytest import xarray as xr from parcels import Field, FieldSet from virtualship.instruments.xbt import XBT, XBTInstrument +from virtualship.instruments.types import SensorType from virtualship.models import Location, Spacetime -from virtualship.models.expedition import Waypoint +from virtualship.models.expedition import ( + InstrumentsConfig, + SensorConfig, + Waypoint, + XBTConfig, +) def test_simulate_xbts(tmpdir) -> None: @@ -107,6 +115,16 @@ class schedule: ), ] + instruments_config = InstrumentsConfig( + xbt_config=XBTConfig( + min_depth_meter=-2.0, + max_depth_meter=-285.0, + fall_speed_meter_per_second=6.7, + deceleration_coefficient=0.00225, + sensors=[SensorConfig(sensor_type=SensorType.TEMPERATURE)], + ) + ) + expedition = DummyExpedition() from_data = None @@ -139,3 +157,40 @@ class schedule: assert np.isclose(obs_value, exp_value), ( f"Observation incorrect {xbt_i=} {loc=} {var=} {obs_value=} {exp_value=}." ) + + +def test_xbt_sensor_config_active_variables() -> None: + """active_variables() only returns variables for enabled sensors.""" + config_with_temp = XBTConfig( + min_depth_meter=-2.0, + max_depth_meter=-285.0, + fall_speed_meter_per_second=6.7, + deceleration_coefficient=0.00225, + sensors=[SensorConfig(sensor_type=SensorType.TEMPERATURE)], + ) + assert config_with_temp.active_variables() == {"T": "thetao"} + + with pytest.raises(pydantic.ValidationError, match="no enabled sensors"): + XBTConfig( + min_depth_meter=-2.0, + max_depth_meter=-285.0, + fall_speed_meter_per_second=6.7, + deceleration_coefficient=0.00225, + sensors=[], # all disabled → invalid + ) + + +def test_xbt_sensor_config_yaml() -> None: + """XBTConfig sensors survive YAML serialisation.""" + config = XBTConfig( + min_depth_meter=-2.0, + max_depth_meter=-285.0, + fall_speed_meter_per_second=6.7, + deceleration_coefficient=0.00225, + sensors=[SensorConfig(sensor_type=SensorType.TEMPERATURE)], + ) + dumped = config.model_dump(by_alias=True) + loaded = XBTConfig.model_validate(dumped) + assert len(loaded.sensors) == 1 + assert loaded.sensors[0].sensor_type == SensorType.TEMPERATURE + assert loaded.sensors[0].enabled is True From c5ec69b29fe13284934200962b515c0a61728ea3 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:25:34 +0100 Subject: [PATCH 45/53] new tests for instruments, focused on new sensor logic --- tests/instruments/test_adcp.py | 36 ++++++++++--- tests/instruments/test_argo_float.py | 57 +++++++++++++++++++- tests/instruments/test_ctd.py | 32 ++++++++++- tests/instruments/test_ctd_bgc.py | 20 ++++++- tests/instruments/test_drifter.py | 29 +++++++++- tests/instruments/test_ship_underwater_st.py | 45 ++++++++++++---- tests/instruments/test_xbt.py | 30 ++++++++++- 7 files changed, 228 insertions(+), 21 deletions(-) diff --git a/tests/instruments/test_adcp.py b/tests/instruments/test_adcp.py index cc059dd9..8dbd7a10 100644 --- a/tests/instruments/test_adcp.py +++ b/tests/instruments/test_adcp.py @@ -9,17 +9,15 @@ from parcels import FieldSet from virtualship.instruments.adcp import ADCPInstrument -from virtualship.instruments.types import InstrumentType, SensorType +from virtualship.instruments.sensors import ADCP_SUPPORTED_SENSORS, SensorType +from virtualship.instruments.types import InstrumentType from virtualship.models import Location, Spacetime, Waypoint from virtualship.models.expedition import ADCPConfig, InstrumentsConfig, SensorConfig def test_simulate_adcp(tmpdir) -> None: - # maximum depth the ADCP can measure - MAX_DEPTH = -1000 # -1000 - # minimum depth the ADCP can measure - MIN_DEPTH = -5 # -5 - # How many samples to take in the complete range between max_depth and min_depth. + MAX_DEPTH = -1000 + MIN_DEPTH = -5 NUM_BINS = 40 # arbitrary time offset for the dummy fieldset @@ -172,3 +170,29 @@ def test_adcp_sensor_config_yaml() -> None: assert len(loaded.sensors) == 1 assert loaded.sensors[0].sensor_type == SensorType.VELOCITY assert loaded.sensors[0].enabled is True + + +def test_adcp_supported_sensors(): + """ADCP supports only VELOCITY.""" + assert ADCP_SUPPORTED_SENSORS == frozenset({SensorType.VELOCITY}) + + +def test_adcp_config_default_sensors(): + """ADCPConfig defaults to VELOCITY.""" + config = ADCPConfig( + max_depth_meter=-500.0, + num_bins=30, + period_minutes=30.0, + ) + assert config.sensors[0].sensor_type is SensorType.VELOCITY + + +def test_adcp_config_unsupported_sensor_rejected(): + """Unsupported sensor on ADCP is rejected.""" + with pytest.raises(pydantic.ValidationError, match="does not support"): + ADCPConfig( + max_depth_meter=-500.0, + num_bins=30, + period_minutes=30.0, + sensors=[SensorConfig(sensor_type=SensorType.TEMPERATURE)], + ) diff --git a/tests/instruments/test_argo_float.py b/tests/instruments/test_argo_float.py index eb8a9237..0eec56d7 100644 --- a/tests/instruments/test_argo_float.py +++ b/tests/instruments/test_argo_float.py @@ -3,11 +3,13 @@ from datetime import datetime, timedelta import numpy as np +import pydantic +import pytest import xarray as xr from parcels import FieldSet from virtualship.instruments.argo_float import ArgoFloat, ArgoFloatInstrument -from virtualship.instruments.types import SensorType +from virtualship.instruments.sensors import ARGO_FLOAT_SUPPORTED_SENSORS, SensorType from virtualship.models import Location, Spacetime from virtualship.models.expedition import ( ArgoFloatConfig, @@ -84,7 +86,14 @@ class schedule: instruments_config = InstrumentsConfig( argo_float_config=ArgoFloatConfig( + min_depth_meter=0.0, + max_depth_meter=MAX_DEPTH, + drift_depth_meter=DRIFT_DEPTH, + vertical_speed_meter_per_second=VERTICAL_SPEED, + cycle_days=CYCLE_DAYS, + drift_days=DRIFT_DAYS, lifetime=LIFETIME, + stationkeeping_time_minutes=10, sensors=[ SensorConfig(sensor_type=SensorType.TEMPERATURE), SensorConfig(sensor_type=SensorType.SALINITY), @@ -163,7 +172,14 @@ class schedule: instruments_config = InstrumentsConfig( argo_float_config=ArgoFloatConfig( + min_depth_meter=0.0, + max_depth_meter=MAX_DEPTH, + drift_depth_meter=DRIFT_DEPTH, + vertical_speed_meter_per_second=VERTICAL_SPEED, + cycle_days=CYCLE_DAYS, + drift_days=DRIFT_DAYS, lifetime=LIFETIME, + stationkeeping_time_minutes=10, sensors=[ SensorConfig(sensor_type=SensorType.TEMPERATURE) ], # SALINITY omitted = disabled @@ -181,3 +197,42 @@ class schedule: assert "salinity" not in results, ( "Disabled sensor variable must be absent from output" ) + + +def test_argo_float_supported_sensors(): + """ArgoFloat supports TEMPERATURE and SALINITY.""" + assert ARGO_FLOAT_SUPPORTED_SENSORS == frozenset( + {SensorType.TEMPERATURE, SensorType.SALINITY} + ) + + +def test_argo_config_default_sensors(): + """ArgoFloatConfig defaults to TEMPERATURE + SALINITY.""" + config = ArgoFloatConfig( + min_depth_meter=0.0, + max_depth_meter=-2000, + drift_depth_meter=-1000, + vertical_speed_meter_per_second=-0.10, + cycle_days=10, + drift_days=9, + lifetime=timedelta(days=30), + stationkeeping_time_minutes=10, + ) + types = {sc.sensor_type for sc in config.sensors} + assert types == {SensorType.TEMPERATURE, SensorType.SALINITY} + + +def test_argo_config_unsupported_sensor_rejected(): + """Unsupported sensor on ArgoFloat is rejected.""" + with pytest.raises(pydantic.ValidationError, match="does not support"): + ArgoFloatConfig( + min_depth_meter=0.0, + max_depth_meter=-2000, + drift_depth_meter=-1000, + vertical_speed_meter_per_second=-0.10, + cycle_days=10, + drift_days=9, + lifetime=timedelta(days=30), + stationkeeping_time_minutes=10, + sensors=[SensorConfig(sensor_type=SensorType.OXYGEN)], + ) diff --git a/tests/instruments/test_ctd.py b/tests/instruments/test_ctd.py index d972a4c3..c8a72da3 100644 --- a/tests/instruments/test_ctd.py +++ b/tests/instruments/test_ctd.py @@ -13,7 +13,7 @@ from parcels import Field, FieldSet from virtualship.instruments.ctd import CTD, CTDInstrument -from virtualship.instruments.types import SensorType +from virtualship.instruments.sensors import CTD_SUPPORTED_SENSORS, SensorType from virtualship.models import Location, Spacetime from virtualship.models.expedition import ( CTDConfig, @@ -274,3 +274,33 @@ class schedule: assert "salinity" not in results, ( "Disabled sensor variable must be absent from output" ) + + +def test_ctd_supported_sensors(): + """CTD supports TEMPERATURE and SALINITY.""" + assert CTD_SUPPORTED_SENSORS == frozenset( + {SensorType.TEMPERATURE, SensorType.SALINITY} + ) + + +def test_ctd_config_default_sensors(): + """CTDConfig defaults to TEMPERATURE + SALINITY.""" + 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} + + +# TODO: may need to be removed if add ADCP to CTDs in future PR... +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, + min_depth_meter=-11.0, + max_depth_meter=-2000.0, + sensors=[SensorConfig(sensor_type=SensorType.VELOCITY)], + ) diff --git a/tests/instruments/test_ctd_bgc.py b/tests/instruments/test_ctd_bgc.py index a97a7343..612429a7 100644 --- a/tests/instruments/test_ctd_bgc.py +++ b/tests/instruments/test_ctd_bgc.py @@ -7,11 +7,13 @@ 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.types import SensorType +from virtualship.instruments.sensors import CTD_BGC_SUPPORTED_SENSORS, SensorType from virtualship.models import Location, Spacetime from virtualship.models.expedition import ( CTD_BGCConfig, @@ -293,3 +295,19 @@ def test_ctd_bgc_sensor_config_yaml() -> None: assert len(loaded.sensors) == 1 assert loaded.sensors[0].sensor_type == SensorType.OXYGEN assert loaded.sensors[0].enabled is True + + +def test_ctd_bgc_supported_sensors(): + """CTD_BGC supports all BGC sensors.""" + expected = frozenset( + { + SensorType.OXYGEN, + SensorType.CHLOROPHYLL, + SensorType.NITRATE, + SensorType.PHOSPHATE, + SensorType.PH, + SensorType.PHYTOPLANKTON, + SensorType.PRIMARY_PRODUCTION, + } + ) + assert CTD_BGC_SUPPORTED_SENSORS == expected diff --git a/tests/instruments/test_drifter.py b/tests/instruments/test_drifter.py index 71fbbdc5..b701aa16 100644 --- a/tests/instruments/test_drifter.py +++ b/tests/instruments/test_drifter.py @@ -10,7 +10,7 @@ from parcels import FieldSet from virtualship.instruments.drifter import Drifter, DrifterInstrument -from virtualship.instruments.types import SensorType +from virtualship.instruments.sensors import DRIFTER_SUPPORTED_SENSORS, SensorType from virtualship.models import Location, Spacetime from virtualship.models.expedition import ( DrifterConfig, @@ -43,6 +43,7 @@ class schedule: drifter_config=DrifterConfig( lifetime=LIFETIME, depth_meter=DEPLOY_DEPTH, + stationkeeping_time_minutes=10, sensors=[SensorConfig(sensor_type=SensorType.TEMPERATURE)], ) ) @@ -210,3 +211,29 @@ def test_drifter_disabled_sensor_absent_from_output(tmpdir) -> None: stationkeeping_time_minutes=10, sensors=[], ) + + +def test_drifter_supported_sensors(): + """Drifter supports only TEMPERATURE.""" + assert DRIFTER_SUPPORTED_SENSORS == frozenset({SensorType.TEMPERATURE}) + + +def test_drifter_config_default_sensors(): + """DrifterConfig defaults to TEMPERATURE.""" + config = DrifterConfig( + lifetime=LIFETIME, + depth_meter=DEPLOY_DEPTH, + stationkeeping_time_minutes=10, + ) + assert config.sensors[0].sensor_type is SensorType.TEMPERATURE + + +def test_drifter_config_unsupported_sensor_rejected(): + """Unsupported sensor on Drifter is rejected.""" + with pytest.raises(pydantic.ValidationError, match="does not support"): + DrifterConfig( + lifetime=LIFETIME, + depth_meter=DEPLOY_DEPTH, + stationkeeping_time_minutes=10, + sensors=[SensorConfig(sensor_type=SensorType.VELOCITY)], + ) diff --git a/tests/instruments/test_ship_underwater_st.py b/tests/instruments/test_ship_underwater_st.py index 7ef166ba..45722204 100644 --- a/tests/instruments/test_ship_underwater_st.py +++ b/tests/instruments/test_ship_underwater_st.py @@ -9,7 +9,7 @@ from parcels import FieldSet from virtualship.instruments.ship_underwater_st import Underwater_STInstrument -from virtualship.instruments.types import SensorType +from virtualship.instruments.sensors import UNDERWATER_ST_SUPPORTED_SENSORS, SensorType from virtualship.models import Location, Spacetime from virtualship.models.expedition import ( InstrumentsConfig, @@ -32,15 +32,15 @@ def test_simulate_ship_underwater_st(tmpdir) -> None: # expected observations at sample points expected_obs = [ { - "S": 5, - "T": 6, + "salinity": 5, + "temperature": 6, "lat": sample_points[0].location.lat, "lon": sample_points[0].location.lon, "time": base_time + datetime.timedelta(seconds=0), }, { - "S": 7, - "T": 8, + "salinity": 7, + "temperature": 8, "lat": sample_points[1].location.lat, "lon": sample_points[1].location.lon, "time": base_time + datetime.timedelta(seconds=1), @@ -50,12 +50,12 @@ def test_simulate_ship_underwater_st(tmpdir) -> None: # create fieldset based on the expected observations # indices are time, latitude, longitude salinity = np.zeros((2, 2, 2)) - salinity[0, 0, 0] = expected_obs[0]["S"] - salinity[1, 1, 1] = expected_obs[1]["S"] + salinity[0, 0, 0] = expected_obs[0]["salinity"] + salinity[1, 1, 1] = expected_obs[1]["salinity"] temperature = np.zeros((2, 2, 2)) - temperature[0, 0, 0] = expected_obs[0]["T"] - temperature[1, 1, 1] = expected_obs[1]["T"] + temperature[0, 0, 0] = expected_obs[0]["temperature"] + temperature[1, 1, 1] = expected_obs[1]["temperature"] fieldset = FieldSet.from_data( { @@ -121,7 +121,7 @@ class schedule: zip(results.sel(trajectory=traj).obs, expected_obs, strict=True) ): obs = results.sel(trajectory=traj, obs=obs_i) - for var in ["S", "T", "lat", "lon"]: + for var in ["salinity", "temperature", "lat", "lon"]: obs_value = obs[var].values.item() exp_value = exp[var] assert np.isclose(obs_value, exp_value), ( @@ -168,3 +168,28 @@ def test_ship_underwater_st_sensor_config_yaml() -> None: assert len(loaded.sensors) == 1 assert loaded.sensors[0].sensor_type == SensorType.TEMPERATURE assert loaded.sensors[0].enabled is True + + +def test_underwater_st_supported_sensors(): + """Underwater ST supports TEMPERATURE and SALINITY.""" + assert UNDERWATER_ST_SUPPORTED_SENSORS == frozenset( + {SensorType.TEMPERATURE, SensorType.SALINITY} + ) + + +def test_underwater_st_config_default_sensors(): + """ShipUnderwaterSTConfig defaults to TEMPERATURE + SALINITY.""" + config = ShipUnderwaterSTConfig( + period_minutes=5.0, + ) + types = {sc.sensor_type for sc in config.sensors} + assert types == {SensorType.TEMPERATURE, SensorType.SALINITY} + + +def test_underwater_st_config_unsupported_sensor_rejected(): + """Unsupported sensor on Underwater ST is rejected.""" + with pytest.raises(pydantic.ValidationError, match="does not support"): + ShipUnderwaterSTConfig( + period_minutes=5.0, + sensors=[SensorConfig(sensor_type=SensorType.OXYGEN)], + ) diff --git a/tests/instruments/test_xbt.py b/tests/instruments/test_xbt.py index 37b3b1b8..52ea0fc7 100644 --- a/tests/instruments/test_xbt.py +++ b/tests/instruments/test_xbt.py @@ -13,7 +13,7 @@ from parcels import Field, FieldSet from virtualship.instruments.xbt import XBT, XBTInstrument -from virtualship.instruments.types import SensorType +from virtualship.instruments.sensors import XBT_SUPPORTED_SENSORS, SensorType from virtualship.models import Location, Spacetime from virtualship.models.expedition import ( InstrumentsConfig, @@ -194,3 +194,31 @@ def test_xbt_sensor_config_yaml() -> None: assert len(loaded.sensors) == 1 assert loaded.sensors[0].sensor_type == SensorType.TEMPERATURE assert loaded.sensors[0].enabled is True + + +def test_xbt_supported_sensors(): + """XBT supports only TEMPERATURE.""" + assert XBT_SUPPORTED_SENSORS == frozenset({SensorType.TEMPERATURE}) + + +def test_xbt_config_default_sensors(): + """XBTConfig defaults to TEMPERATURE.""" + config = XBTConfig( + min_depth_meter=-2.0, + max_depth_meter=-285.0, + fall_speed_meter_per_second=6.7, + deceleration_coefficient=0.00225, + ) + assert config.sensors[0].sensor_type is SensorType.TEMPERATURE + + +def test_xbt_config_unsupported_sensor_rejected(): + """Unsupported sensor on XBT is rejected.""" + with pytest.raises(pydantic.ValidationError, match="does not support"): + XBTConfig( + min_depth_meter=-2.0, + max_depth_meter=-285.0, + fall_speed_meter_per_second=6.7, + deceleration_coefficient=0.00225, + sensors=[SensorConfig(sensor_type=SensorType.SALINITY)], + ) From 7e9c5e91ad6849bb66808d774f54b6034a5b8021 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:25:46 +0100 Subject: [PATCH 46/53] new test_sensors suite --- tests/test_sensors.py | 127 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 tests/test_sensors.py diff --git a/tests/test_sensors.py b/tests/test_sensors.py new file mode 100644 index 00000000..397c4325 --- /dev/null +++ b/tests/test_sensors.py @@ -0,0 +1,127 @@ +import pydantic +import pytest + +from virtualship.instruments.sensors import ( + ADCP_SUPPORTED_SENSORS, + ARGO_FLOAT_SUPPORTED_SENSORS, + CTD_BGC_SUPPORTED_SENSORS, + CTD_SUPPORTED_SENSORS, + DRIFTER_SUPPORTED_SENSORS, + UNDERWATER_ST_SUPPORTED_SENSORS, + XBT_SUPPORTED_SENSORS, + SensorType, +) +from virtualship.models.expedition import ( + SensorConfig, + _check_sensor_compatibility, + _serialize_sensor_list, +) + +EXPECTED_SENSOR_MEMBERS = { + "TEMPERATURE", + "SALINITY", + "VELOCITY", + "OXYGEN", + "CHLOROPHYLL", + "NITRATE", + "PHOSPHATE", + "PH", + "PHYTOPLANKTON", + "PRIMARY_PRODUCTION", +} + + +def test_sensor_type_all_members_exist(): + """All expected SensorType members are present.""" + actual = {m.name for m in SensorType} + assert actual == EXPECTED_SENSOR_MEMBERS + + +def test_sensor_type_lookup_by_value(): + """Can construct a SensorType from its string value.""" + assert SensorType("SALINITY") is SensorType.SALINITY + + +def test_sensor_type_invalid_value_error(): + """Invalid string raises ValueError.""" + with pytest.raises(ValueError): + SensorType("NOT_A_SENSOR") + + +def test_all_allowlists_are_frozenset(): + """All per-instrument allowlists must be frozensets (immutable).""" + for allowlist in ( + ARGO_FLOAT_SUPPORTED_SENSORS, + CTD_SUPPORTED_SENSORS, + CTD_BGC_SUPPORTED_SENSORS, + DRIFTER_SUPPORTED_SENSORS, + ADCP_SUPPORTED_SENSORS, + UNDERWATER_ST_SUPPORTED_SENSORS, + XBT_SUPPORTED_SENSORS, + ): + assert isinstance(allowlist, frozenset) + + +def test_sensor_config_basic_construction(): + """Standard construction with SensorType enum.""" + sc = SensorConfig(sensor_type=SensorType.TEMPERATURE) + assert sc.sensor_type is SensorType.TEMPERATURE + assert sc.enabled is True + + +def test_sensor_config_disabled(): + """Can explicitly set enabled=False.""" + sc = SensorConfig(sensor_type=SensorType.SALINITY, enabled=False) + assert sc.enabled is False + + +def test_sensor_config_from_string_shorthand(): + """A bare string should be accepted as shorthand.""" + sc = SensorConfig.model_validate("TEMPERATURE") + assert sc.sensor_type is SensorType.TEMPERATURE + assert sc.enabled is True + + +def test_sensor_config_invalid_string_error(): + """An unknown sensor name should raise error.""" + with pytest.raises(pydantic.ValidationError): + SensorConfig.model_validate("NOT_REAL") + + +def test_serialize_sensor_list_disabled_excluded(): + """Disabled sensors are excluded from serialisation.""" + sensors = [ + SensorConfig(sensor_type=SensorType.TEMPERATURE, enabled=True), + SensorConfig(sensor_type=SensorType.SALINITY, enabled=False), + ] + assert _serialize_sensor_list(sensors) == ["TEMPERATURE"] + + +def test_check_sensor_compatibility_unsupported_error(): + """Unsupported sensor raises ValueError.""" + sensors = [SensorConfig(sensor_type=SensorType.OXYGEN)] + with pytest.raises(ValueError, match="does not support sensor"): + _check_sensor_compatibility(sensors, DRIFTER_SUPPORTED_SENSORS, "Drifter") + + +def test_check_sensor_compatibility_all_disabled_error(): + """All sensors disabled raises ValueError.""" + sensors = [SensorConfig(sensor_type=SensorType.TEMPERATURE, enabled=False)] + with pytest.raises(ValueError, match="no enabled sensors"): + _check_sensor_compatibility(sensors, DRIFTER_SUPPORTED_SENSORS, "Drifter") + + +def test_check_sensor_compatibility_empty_error(): + """Empty sensor list raises ValueError.""" + with pytest.raises(ValueError, match="no enabled sensors"): + _check_sensor_compatibility([], DRIFTER_SUPPORTED_SENSORS, "Drifter") + + +def test_check_sensor_compatibility_mixed_error(): + """Mix of valid and invalid sensors raises ValueError.""" + sensors = [ + SensorConfig(sensor_type=SensorType.TEMPERATURE), + SensorConfig(sensor_type=SensorType.OXYGEN), + ] + with pytest.raises(ValueError, match="does not support"): + _check_sensor_compatibility(sensors, DRIFTER_SUPPORTED_SENSORS, "Drifter") From 18c1a2d3971814cffde6cb686d6d775361c795cd Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:26:08 +0100 Subject: [PATCH 47/53] new tests for new sensor logic utils --- tests/test_utils.py | 100 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 2 deletions(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index 8f9ec016..efc89b9d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,14 +4,16 @@ import numpy as np import pytest import xarray as xr -from parcels import FieldSet 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 +from virtualship.models.expedition import Expedition, SensorConfig from virtualship.models.location import Location from virtualship.utils import ( PROJECTION, + SENSOR_REGISTRY, _calc_sail_time, _calc_wp_stationkeeping_time, _find_nc_file_with_variable, @@ -19,6 +21,7 @@ _select_product_id, _start_end_in_product_timerange, add_dummy_UV, + build_particle_class_from_sensors, get_example_expedition, ) @@ -360,3 +363,96 @@ def test_calc_wp_stationkeeping_time_no_instruments(expedition): assert stationkeeping_null == stationkeeping_emptylist # are equivalent assert stationkeeping_null == datetime.timedelta(0) # at least one is 0 time + + +def test_sensor_registry_every_sensor_type_has_entry(): + """Every SensorType must be present as a key in SENSOR_REGISTRY.""" + for sensor in SensorType: + assert sensor in SENSOR_REGISTRY, f"{sensor} missing from SENSOR_REGISTRY" + + +def test_sensor_registry_no_extra_keys(): + """SENSOR_REGISTRY should not contain keys outside SensorType.""" + for key in SENSOR_REGISTRY: + assert isinstance(key, SensorType) + + +@pytest.mark.parametrize( + "sensor_type", + [ + SensorType.OXYGEN, + SensorType.CHLOROPHYLL, + SensorType.NITRATE, + SensorType.PHOSPHATE, + SensorType.PH, + SensorType.PHYTOPLANKTON, + SensorType.PRIMARY_PRODUCTION, + ], +) +def test_sensor_registry_bgc_entries_category(sensor_type): + """All BGC sensors must have category 'bgc'.""" + assert SENSOR_REGISTRY[sensor_type].category == "bgc" + + +def test_sensor_registry_unique_fs_keys(): + """No two sensors should share an fs_key.""" + fs_keys = [meta.fs_key for meta in SENSOR_REGISTRY.values()] + assert len(fs_keys) == len(set(fs_keys)), ( + "Duplicate fs_key found in SENSOR_REGISTRY" + ) + + +# helper +def _make_sensors(*sensor_types, enabled=True): + """Helper to build a list of SensorConfig from SensorType values.""" + return [SensorConfig(sensor_type=st, enabled=enabled) for st in sensor_types] + + +def test_build_basic_particle_class(): + """Build basic particle class with T+S sensors and fixed variables.""" + fixed = [Variable("cycle_phase", dtype=np.int32, initial=0)] + sensors = _make_sensors(SensorType.TEMPERATURE, SensorType.SALINITY) + + ParticleClass = build_particle_class_from_sensors(sensors, fixed, JITParticle) + assert issubclass(ParticleClass, JITParticle) + + +def test_build_particle_class_disabled_sensors_excluded(): + """Disabled sensors should not contribute variables.""" + fixed = [] + sensors = [ + SensorConfig(sensor_type=SensorType.TEMPERATURE, enabled=True), + SensorConfig(sensor_type=SensorType.SALINITY, enabled=False), + ] + + ParticleClass = build_particle_class_from_sensors(sensors, fixed, JITParticle) + assert hasattr(ParticleClass, "temperature") + assert not hasattr(ParticleClass, "salinity") + + +def test_build_particle_class_empty_sensors(): + """With no sensors, build_particle_class_from_sensors returns a class with only fixed variables.""" + fixed = [Variable("raising", dtype=np.int8, initial=0)] + sensors = [] + + ParticleClass = build_particle_class_from_sensors(sensors, fixed, JITParticle) + assert hasattr(ParticleClass, "raising") + + +def test_build_particle_class_velocity_adds_U_V(): + """VELOCITY sensor should add both U and V particle variables.""" + fixed = [] + sensors = _make_sensors(SensorType.VELOCITY) + + ParticleClass = build_particle_class_from_sensors(sensors, fixed, JITParticle) + assert hasattr(ParticleClass, "U") + assert hasattr(ParticleClass, "V") + + +def test_build_particle_class_scipy_base(): + """Should also work with ScipyParticle as the base class.""" + fixed = [] + sensors = _make_sensors(SensorType.TEMPERATURE) + + ParticleClass = build_particle_class_from_sensors(sensors, fixed, ScipyParticle) + assert issubclass(ParticleClass, ScipyParticle) From 89e184aaebf2b0128da9e06833d34442d783e72f Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:04:42 +0100 Subject: [PATCH 48/53] remove some overlap/duplication of tests --- tests/instruments/test_adcp.py | 8 -------- tests/instruments/test_ctd.py | 8 -------- tests/instruments/test_ship_underwater_st.py | 6 ------ tests/instruments/test_xbt.py | 9 --------- tests/test_utils.py | 13 +++---------- 5 files changed, 3 insertions(+), 41 deletions(-) diff --git a/tests/instruments/test_adcp.py b/tests/instruments/test_adcp.py index 8dbd7a10..3036e8ef 100644 --- a/tests/instruments/test_adcp.py +++ b/tests/instruments/test_adcp.py @@ -148,14 +148,6 @@ def test_adcp_sensor_config_active_variables() -> None: ) assert config_with.active_variables() == {"U": "uo", "V": "vo"} - with pytest.raises(pydantic.ValidationError, match="no enabled sensors"): - ADCPConfig( - max_depth_meter=-1000.0, - num_bins=40, - period_minutes=5.0, - sensors=[], # all disabled → invalid - ) - def test_adcp_sensor_config_yaml() -> None: """ADCPConfig sensors survive YAML serialisation.""" diff --git a/tests/instruments/test_ctd.py b/tests/instruments/test_ctd.py index c8a72da3..d9e3d4e7 100644 --- a/tests/instruments/test_ctd.py +++ b/tests/instruments/test_ctd.py @@ -191,14 +191,6 @@ def test_ctd_sensor_config_active_variables() -> None: ) assert config_temp_only.active_variables() == {"T": "thetao"} - with pytest.raises(pydantic.ValidationError, match="no enabled sensors"): - CTDConfig( - stationkeeping_time_minutes=50, - min_depth_meter=-11.0, - max_depth_meter=-2000.0, - sensors=[], # all absent = all disabled → invalid - ) - def test_ctd_sensor_config_yaml() -> None: """CTDConfig sensors survive YAML serialisation.""" diff --git a/tests/instruments/test_ship_underwater_st.py b/tests/instruments/test_ship_underwater_st.py index 45722204..d2d01c88 100644 --- a/tests/instruments/test_ship_underwater_st.py +++ b/tests/instruments/test_ship_underwater_st.py @@ -148,12 +148,6 @@ def test_ship_underwater_st_sensor_config_active_variables() -> None: ) assert config_temp_only.active_variables() == {"T": "thetao"} - with pytest.raises(pydantic.ValidationError, match="no enabled sensors"): - ShipUnderwaterSTConfig( - period_minutes=5.0, - sensors=[], # all disabled → invalid - ) - def test_ship_underwater_st_sensor_config_yaml() -> None: """ShipUnderwaterSTConfig sensors survive YAML serialisation.""" diff --git a/tests/instruments/test_xbt.py b/tests/instruments/test_xbt.py index 52ea0fc7..7e892eec 100644 --- a/tests/instruments/test_xbt.py +++ b/tests/instruments/test_xbt.py @@ -170,15 +170,6 @@ def test_xbt_sensor_config_active_variables() -> None: ) assert config_with_temp.active_variables() == {"T": "thetao"} - with pytest.raises(pydantic.ValidationError, match="no enabled sensors"): - XBTConfig( - min_depth_meter=-2.0, - max_depth_meter=-285.0, - fall_speed_meter_per_second=6.7, - deceleration_coefficient=0.00225, - sensors=[], # all disabled → invalid - ) - def test_xbt_sensor_config_yaml() -> None: """XBTConfig sensors survive YAML serialisation.""" diff --git a/tests/test_utils.py b/tests/test_utils.py index efc89b9d..b15e789b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -365,16 +365,9 @@ def test_calc_wp_stationkeeping_time_no_instruments(expedition): assert stationkeeping_null == datetime.timedelta(0) # at least one is 0 time -def test_sensor_registry_every_sensor_type_has_entry(): - """Every SensorType must be present as a key in SENSOR_REGISTRY.""" - for sensor in SensorType: - assert sensor in SENSOR_REGISTRY, f"{sensor} missing from SENSOR_REGISTRY" - - -def test_sensor_registry_no_extra_keys(): - """SENSOR_REGISTRY should not contain keys outside SensorType.""" - for key in SENSOR_REGISTRY: - assert isinstance(key, SensorType) +def test_sensor_registry_keys_match_sensor_type(): + """SENSOR_REGISTRY keys must be exactly the set of SensorType members.""" + assert set(SENSOR_REGISTRY.keys()) == set(SensorType) @pytest.mark.parametrize( From bf61220d835305d451fa76818026dd50af283778 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:39:52 +0100 Subject: [PATCH 49/53] deal with circular import issues --- src/virtualship/models/expedition.py | 2 +- src/virtualship/utils.py | 139 +++++++++++++++------------ tests/test_utils.py | 6 +- 3 files changed, 80 insertions(+), 67 deletions(-) diff --git a/src/virtualship/models/expedition.py b/src/virtualship/models/expedition.py index 9163e116..e1827a7e 100644 --- a/src/virtualship/models/expedition.py +++ b/src/virtualship/models/expedition.py @@ -714,4 +714,4 @@ def _take_sensor_type(cls, value: str | SensorType) -> SensorType: @property def meta(self) -> _SensorMeta: """Metadata for this sensor.""" - return SENSOR_REGISTRY[self.sensor_type] + return SENSOR_REGISTRY()[self.sensor_type] diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index 60563d26..c0e3266d 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -19,12 +19,12 @@ from parcels import FieldSet, Variable from virtualship.errors import CopernicusCatalogueError -from virtualship.instruments.sensors import SensorType if TYPE_CHECKING: from virtualship.expedition.simulate_schedule import ( ScheduleOk, ) + from virtualship.instruments.sensors import SensorType from virtualship.models import Expedition, InstrumentsConfig, Location from virtualship.models.checkpoint import Checkpoint from virtualship.models.expedition import SensorConfig @@ -71,68 +71,81 @@ class _SensorMeta: # the copernicus_var field below is the bridge between this registry the Copernicus product-ID selection logic (PRODUCT_IDS, BGC_ANALYSIS_IDS, MONTHLY_BGC_REANALYSIS_IDS, etc.) -SENSOR_REGISTRY: dict[SensorType, _SensorMeta] = { - SensorType.TEMPERATURE: _SensorMeta( - fs_key="T", - copernicus_var="thetao", - category="phys", - particle_vars=["temperature"], - ), - SensorType.SALINITY: _SensorMeta( - fs_key="S", - copernicus_var="so", - category="phys", - particle_vars=["salinity"], - ), - SensorType.VELOCITY: _SensorMeta( - fs_key="UV", - copernicus_var="uo", # primary var... active_variables() in ADCPConfig expands to both uo and vo - category="phys", - particle_vars=["U", "V"], # two particle variables associated with one sensor - ), - SensorType.OXYGEN: _SensorMeta( - fs_key="o2", - copernicus_var="o2", - category="bgc", - particle_vars=["o2"], - ), - SensorType.CHLOROPHYLL: _SensorMeta( - fs_key="chl", - copernicus_var="chl", - category="bgc", - particle_vars=["chl"], - ), - SensorType.NITRATE: _SensorMeta( - fs_key="no3", - copernicus_var="no3", - category="bgc", - particle_vars=["no3"], - ), - SensorType.PHOSPHATE: _SensorMeta( - fs_key="po4", - copernicus_var="po4", - category="bgc", - particle_vars=["po4"], - ), - SensorType.PH: _SensorMeta( - fs_key="ph", - copernicus_var="ph", - category="bgc", - particle_vars=["ph"], - ), - SensorType.PHYTOPLANKTON: _SensorMeta( - fs_key="phyc", - copernicus_var="phyc", - category="bgc", - particle_vars=["phyc"], - ), - SensorType.PRIMARY_PRODUCTION: _SensorMeta( - fs_key="nppv", - copernicus_var="nppv", - category="bgc", - particle_vars=["nppv"], - ), -} +def _build_sensor_registry() -> dict[SensorType, _SensorMeta]: + """Build the sensor registry lazily to avoid circular import issues.""" + from virtualship.instruments.sensors import SensorType + + return { + SensorType.TEMPERATURE: _SensorMeta( + fs_key="T", + copernicus_var="thetao", + category="phys", + particle_vars=["temperature"], + ), + SensorType.SALINITY: _SensorMeta( + fs_key="S", + copernicus_var="so", + category="phys", + particle_vars=["salinity"], + ), + SensorType.VELOCITY: _SensorMeta( + fs_key="UV", + copernicus_var="uo", # primary var... active_variables() in ADCPConfig expands to both uo and vo + category="phys", + particle_vars=[ + "U", + "V", + ], # two particle variables associated with one sensor + ), + SensorType.OXYGEN: _SensorMeta( + fs_key="o2", + copernicus_var="o2", + category="bgc", + particle_vars=["o2"], + ), + SensorType.CHLOROPHYLL: _SensorMeta( + fs_key="chl", + copernicus_var="chl", + category="bgc", + particle_vars=["chl"], + ), + SensorType.NITRATE: _SensorMeta( + fs_key="no3", + copernicus_var="no3", + category="bgc", + particle_vars=["no3"], + ), + SensorType.PHOSPHATE: _SensorMeta( + fs_key="po4", + copernicus_var="po4", + category="bgc", + particle_vars=["po4"], + ), + SensorType.PH: _SensorMeta( + fs_key="ph", + copernicus_var="ph", + category="bgc", + particle_vars=["ph"], + ), + SensorType.PHYTOPLANKTON: _SensorMeta( + fs_key="phyc", + copernicus_var="phyc", + category="bgc", + particle_vars=["phyc"], + ), + SensorType.PRIMARY_PRODUCTION: _SensorMeta( + fs_key="nppv", + copernicus_var="nppv", + category="bgc", + particle_vars=["nppv"], + ), + } + + +@lru_cache(maxsize=1) # cache here so same dict is not rebuilt on every access +def SENSOR_REGISTRY() -> dict[SensorType, _SensorMeta]: + """Cached accessor for the sensor registry (lazy, avoids circular import errors).""" + return _build_sensor_registry() # ===================================================== diff --git a/tests/test_utils.py b/tests/test_utils.py index b15e789b..3af10906 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -367,7 +367,7 @@ def test_calc_wp_stationkeeping_time_no_instruments(expedition): def test_sensor_registry_keys_match_sensor_type(): """SENSOR_REGISTRY keys must be exactly the set of SensorType members.""" - assert set(SENSOR_REGISTRY.keys()) == set(SensorType) + assert set(SENSOR_REGISTRY().keys()) == set(SensorType) @pytest.mark.parametrize( @@ -384,12 +384,12 @@ def test_sensor_registry_keys_match_sensor_type(): ) def test_sensor_registry_bgc_entries_category(sensor_type): """All BGC sensors must have category 'bgc'.""" - assert SENSOR_REGISTRY[sensor_type].category == "bgc" + assert SENSOR_REGISTRY()[sensor_type].category == "bgc" def test_sensor_registry_unique_fs_keys(): """No two sensors should share an fs_key.""" - fs_keys = [meta.fs_key for meta in SENSOR_REGISTRY.values()] + fs_keys = [meta.fs_key for meta in SENSOR_REGISTRY().values()] assert len(fs_keys) == len(set(fs_keys)), ( "Duplicate fs_key found in SENSOR_REGISTRY" ) From 51f44571843b6c804dc4cde70866e4bfa46816a8 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:40:20 +0100 Subject: [PATCH 50/53] add ctd_bgc stationkeeping logic back in (accidentally removed earlier) --- src/virtualship/utils.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index c0e3266d..a238ecb0 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -70,9 +70,14 @@ class _SensorMeta: particle_vars: list[str] # particle variable name(s) produced by this sensor +@lru_cache(maxsize=1) # cache here so same dict is not rebuilt on every access +def SENSOR_REGISTRY() -> dict[SensorType, _SensorMeta]: + """Cached accessor for the sensor registry (lazily via _build_sensor_registry, avoids circular import errors).""" + return _build_sensor_registry() + + # the copernicus_var field below is the bridge between this registry the Copernicus product-ID selection logic (PRODUCT_IDS, BGC_ANALYSIS_IDS, MONTHLY_BGC_REANALYSIS_IDS, etc.) def _build_sensor_registry() -> dict[SensorType, _SensorMeta]: - """Build the sensor registry lazily to avoid circular import issues.""" from virtualship.instruments.sensors import SensorType return { @@ -90,7 +95,7 @@ def _build_sensor_registry() -> dict[SensorType, _SensorMeta]: ), SensorType.VELOCITY: _SensorMeta( fs_key="UV", - copernicus_var="uo", # primary var... active_variables() in ADCPConfig expands to both uo and vo + copernicus_var="uo", # uo is primary var here... active_variables() in ADCPConfig expands to both uo and vo category="phys", particle_vars=[ "U", @@ -142,12 +147,6 @@ def _build_sensor_registry() -> dict[SensorType, _SensorMeta]: } -@lru_cache(maxsize=1) # cache here so same dict is not rebuilt on every access -def SENSOR_REGISTRY() -> dict[SensorType, _SensorMeta]: - """Cached accessor for the sensor registry (lazy, avoids circular import errors).""" - return _build_sensor_registry() - - # ===================================================== # SECTION: Copernicus Marine Service constants # ===================================================== @@ -717,6 +716,14 @@ 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 @@ -737,6 +744,10 @@ 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 From f9f17f2e9f79ebfa819568f2248dfee419a9240f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:44:51 +0000 Subject: [PATCH 51/53] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/virtualship/instruments/adcp.py | 2 +- src/virtualship/instruments/argo_float.py | 2 +- src/virtualship/instruments/ctd.py | 2 +- src/virtualship/instruments/ctd_bgc.py | 2 +- src/virtualship/instruments/drifter.py | 2 +- src/virtualship/instruments/ship_underwater_st.py | 2 +- src/virtualship/instruments/xbt.py | 2 +- src/virtualship/utils.py | 2 +- tests/instruments/test_drifter.py | 2 +- tests/test_utils.py | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/virtualship/instruments/adcp.py b/src/virtualship/instruments/adcp.py index e1c404ef..ad63cdfa 100644 --- a/src/virtualship/instruments/adcp.py +++ b/src/virtualship/instruments/adcp.py @@ -2,8 +2,8 @@ from typing import ClassVar import numpy as np - from parcels import ParticleSet, ScipyParticle + from virtualship.instruments.base import Instrument from virtualship.instruments.sensors import SensorType from virtualship.instruments.types import InstrumentType diff --git a/src/virtualship/instruments/argo_float.py b/src/virtualship/instruments/argo_float.py index 4d5b538b..591b4a88 100644 --- a/src/virtualship/instruments/argo_float.py +++ b/src/virtualship/instruments/argo_float.py @@ -4,8 +4,8 @@ from typing import ClassVar import numpy as np - from parcels import AdvectionRK4, JITParticle, ParticleSet, StatusCode, Variable + from virtualship.instruments.base import Instrument from virtualship.instruments.sensors import SensorType from virtualship.instruments.types import InstrumentType diff --git a/src/virtualship/instruments/ctd.py b/src/virtualship/instruments/ctd.py index ef1f6969..06a05f39 100644 --- a/src/virtualship/instruments/ctd.py +++ b/src/virtualship/instruments/ctd.py @@ -3,8 +3,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/instruments/ctd_bgc.py b/src/virtualship/instruments/ctd_bgc.py index 88968fb0..0f6df618 100644 --- a/src/virtualship/instruments/ctd_bgc.py +++ b/src/virtualship/instruments/ctd_bgc.py @@ -3,8 +3,8 @@ 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 diff --git a/src/virtualship/instruments/drifter.py b/src/virtualship/instruments/drifter.py index 88d9779a..bb9a358f 100644 --- a/src/virtualship/instruments/drifter.py +++ b/src/virtualship/instruments/drifter.py @@ -3,8 +3,8 @@ from typing import ClassVar import numpy as np - from parcels import AdvectionRK4, 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/instruments/ship_underwater_st.py b/src/virtualship/instruments/ship_underwater_st.py index 73456ff3..0e812e31 100644 --- a/src/virtualship/instruments/ship_underwater_st.py +++ b/src/virtualship/instruments/ship_underwater_st.py @@ -2,8 +2,8 @@ from typing import ClassVar import numpy as np - from parcels import ParticleSet, ScipyParticle + from virtualship.instruments.base import Instrument from virtualship.instruments.sensors import SensorType from virtualship.instruments.types import InstrumentType diff --git a/src/virtualship/instruments/xbt.py b/src/virtualship/instruments/xbt.py index b53b5824..6cb41163 100644 --- a/src/virtualship/instruments/xbt.py +++ b/src/virtualship/instruments/xbt.py @@ -3,8 +3,8 @@ 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 diff --git a/src/virtualship/utils.py b/src/virtualship/utils.py index a238ecb0..dc426e80 100644 --- a/src/virtualship/utils.py +++ b/src/virtualship/utils.py @@ -16,8 +16,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/instruments/test_drifter.py b/tests/instruments/test_drifter.py index b701aa16..478e5c66 100644 --- a/tests/instruments/test_drifter.py +++ b/tests/instruments/test_drifter.py @@ -7,8 +7,8 @@ import pydantic import pytest import xarray as xr - from parcels import FieldSet + from virtualship.instruments.drifter import Drifter, DrifterInstrument from virtualship.instruments.sensors import DRIFTER_SUPPORTED_SENSORS, SensorType from virtualship.models import Location, Spacetime diff --git a/tests/test_utils.py b/tests/test_utils.py index 3af10906..43d360d0 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 From 548297b53702f167502a19793c081f289685b982 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:52:49 +0100 Subject: [PATCH 52/53] correct comment --- src/virtualship/instruments/drifter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/virtualship/instruments/drifter.py b/src/virtualship/instruments/drifter.py index 88d9779a..e447e1e2 100644 --- a/src/virtualship/instruments/drifter.py +++ b/src/virtualship/instruments/drifter.py @@ -79,7 +79,7 @@ def __init__(self, expedition, from_data): "U": "uo", "V": "vo", **sensor_variables, - } # advection variables (U and V) are always required for argo float simulation; sensor variables come from config + } # advection variables (U and V) are always required for drifter simulation; sensor variables come from config spacetime_buffer_size = { "latlon": None, "time": expedition.instruments_config.drifter_config.lifetime.total_seconds() From 7a163f76504b6ce17a3fc9f7ce9300d6b145ef78 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:10:01 +0100 Subject: [PATCH 53/53] update docstrings, rethink some testing --- src/virtualship/models/expedition.py | 4 ++-- tests/instruments/test_adcp.py | 5 ----- tests/instruments/test_argo_float.py | 7 ------- tests/instruments/test_ctd_bgc.py | 16 ---------------- tests/instruments/test_drifter.py | 7 +------ tests/{ => instruments}/test_sensors.py | 8 ++++---- tests/instruments/test_ship_underwater_st.py | 7 ------- tests/instruments/test_xbt.py | 5 ----- tests/test_utils.py | 9 --------- 9 files changed, 7 insertions(+), 61 deletions(-) rename tests/{ => instruments}/test_sensors.py (94%) diff --git a/src/virtualship/models/expedition.py b/src/virtualship/models/expedition.py index e1827a7e..6e917630 100644 --- a/src/virtualship/models/expedition.py +++ b/src/virtualship/models/expedition.py @@ -224,7 +224,7 @@ def serialize_instrument(self, instrument): def _serialize_sensor_list(sensors: list[SensorConfig]) -> list[str]: - """Serialise enabled sensors to a compact list of sensor-type name strings.""" + """Serialise enabled sensors to a list of sensor-type strings.""" return [sc.sensor_type.value for sc in sensors if sc.enabled] @@ -233,7 +233,7 @@ def _check_sensor_compatibility( supported: frozenset[SensorType], instrument_name: str, ) -> list[SensorConfig]: - """Raise ValueError if any sensor in `sensors` is not in `supported`, or if no sensors are enabled. Used as a Pydantic field_validator for each instrument config class.""" + """Errors if any sensor in `sensors` is not in `supported`, or if no sensors are enabled.""" unsupported = {sc.sensor_type for sc in sensors} - supported if unsupported: names = ", ".join(sorted(s.value for s in unsupported)) diff --git a/tests/instruments/test_adcp.py b/tests/instruments/test_adcp.py index 3036e8ef..3dd4d6b0 100644 --- a/tests/instruments/test_adcp.py +++ b/tests/instruments/test_adcp.py @@ -164,11 +164,6 @@ def test_adcp_sensor_config_yaml() -> None: assert loaded.sensors[0].enabled is True -def test_adcp_supported_sensors(): - """ADCP supports only VELOCITY.""" - assert ADCP_SUPPORTED_SENSORS == frozenset({SensorType.VELOCITY}) - - def test_adcp_config_default_sensors(): """ADCPConfig defaults to VELOCITY.""" config = ADCPConfig( diff --git a/tests/instruments/test_argo_float.py b/tests/instruments/test_argo_float.py index 0eec56d7..76290e90 100644 --- a/tests/instruments/test_argo_float.py +++ b/tests/instruments/test_argo_float.py @@ -199,13 +199,6 @@ class schedule: ) -def test_argo_float_supported_sensors(): - """ArgoFloat supports TEMPERATURE and SALINITY.""" - assert ARGO_FLOAT_SUPPORTED_SENSORS == frozenset( - {SensorType.TEMPERATURE, SensorType.SALINITY} - ) - - def test_argo_config_default_sensors(): """ArgoFloatConfig defaults to TEMPERATURE + SALINITY.""" config = ArgoFloatConfig( diff --git a/tests/instruments/test_ctd_bgc.py b/tests/instruments/test_ctd_bgc.py index 612429a7..b72c3468 100644 --- a/tests/instruments/test_ctd_bgc.py +++ b/tests/instruments/test_ctd_bgc.py @@ -295,19 +295,3 @@ def test_ctd_bgc_sensor_config_yaml() -> None: assert len(loaded.sensors) == 1 assert loaded.sensors[0].sensor_type == SensorType.OXYGEN assert loaded.sensors[0].enabled is True - - -def test_ctd_bgc_supported_sensors(): - """CTD_BGC supports all BGC sensors.""" - expected = frozenset( - { - SensorType.OXYGEN, - SensorType.CHLOROPHYLL, - SensorType.NITRATE, - SensorType.PHOSPHATE, - SensorType.PH, - SensorType.PHYTOPLANKTON, - SensorType.PRIMARY_PRODUCTION, - } - ) - assert CTD_BGC_SUPPORTED_SENSORS == expected diff --git a/tests/instruments/test_drifter.py b/tests/instruments/test_drifter.py index b701aa16..bf35450b 100644 --- a/tests/instruments/test_drifter.py +++ b/tests/instruments/test_drifter.py @@ -10,7 +10,7 @@ from parcels import FieldSet from virtualship.instruments.drifter import Drifter, DrifterInstrument -from virtualship.instruments.sensors import DRIFTER_SUPPORTED_SENSORS, SensorType +from virtualship.instruments.sensors import SensorType from virtualship.models import Location, Spacetime from virtualship.models.expedition import ( DrifterConfig, @@ -213,11 +213,6 @@ def test_drifter_disabled_sensor_absent_from_output(tmpdir) -> None: ) -def test_drifter_supported_sensors(): - """Drifter supports only TEMPERATURE.""" - assert DRIFTER_SUPPORTED_SENSORS == frozenset({SensorType.TEMPERATURE}) - - def test_drifter_config_default_sensors(): """DrifterConfig defaults to TEMPERATURE.""" config = DrifterConfig( diff --git a/tests/test_sensors.py b/tests/instruments/test_sensors.py similarity index 94% rename from tests/test_sensors.py rename to tests/instruments/test_sensors.py index 397c4325..86fa4a77 100644 --- a/tests/test_sensors.py +++ b/tests/instruments/test_sensors.py @@ -98,27 +98,27 @@ def test_serialize_sensor_list_disabled_excluded(): def test_check_sensor_compatibility_unsupported_error(): - """Unsupported sensor raises ValueError.""" + """Unsupported sensor fails.""" sensors = [SensorConfig(sensor_type=SensorType.OXYGEN)] with pytest.raises(ValueError, match="does not support sensor"): _check_sensor_compatibility(sensors, DRIFTER_SUPPORTED_SENSORS, "Drifter") def test_check_sensor_compatibility_all_disabled_error(): - """All sensors disabled raises ValueError.""" + """All sensors disabled fails.""" sensors = [SensorConfig(sensor_type=SensorType.TEMPERATURE, enabled=False)] with pytest.raises(ValueError, match="no enabled sensors"): _check_sensor_compatibility(sensors, DRIFTER_SUPPORTED_SENSORS, "Drifter") def test_check_sensor_compatibility_empty_error(): - """Empty sensor list raises ValueError.""" + """Empty sensor list fails.""" with pytest.raises(ValueError, match="no enabled sensors"): _check_sensor_compatibility([], DRIFTER_SUPPORTED_SENSORS, "Drifter") def test_check_sensor_compatibility_mixed_error(): - """Mix of valid and invalid sensors raises ValueError.""" + """Mix of valid and invalid sensors fails.""" sensors = [ SensorConfig(sensor_type=SensorType.TEMPERATURE), SensorConfig(sensor_type=SensorType.OXYGEN), diff --git a/tests/instruments/test_ship_underwater_st.py b/tests/instruments/test_ship_underwater_st.py index d2d01c88..ba824a2b 100644 --- a/tests/instruments/test_ship_underwater_st.py +++ b/tests/instruments/test_ship_underwater_st.py @@ -164,13 +164,6 @@ def test_ship_underwater_st_sensor_config_yaml() -> None: assert loaded.sensors[0].enabled is True -def test_underwater_st_supported_sensors(): - """Underwater ST supports TEMPERATURE and SALINITY.""" - assert UNDERWATER_ST_SUPPORTED_SENSORS == frozenset( - {SensorType.TEMPERATURE, SensorType.SALINITY} - ) - - def test_underwater_st_config_default_sensors(): """ShipUnderwaterSTConfig defaults to TEMPERATURE + SALINITY.""" config = ShipUnderwaterSTConfig( diff --git a/tests/instruments/test_xbt.py b/tests/instruments/test_xbt.py index 7e892eec..e5c19b28 100644 --- a/tests/instruments/test_xbt.py +++ b/tests/instruments/test_xbt.py @@ -187,11 +187,6 @@ def test_xbt_sensor_config_yaml() -> None: assert loaded.sensors[0].enabled is True -def test_xbt_supported_sensors(): - """XBT supports only TEMPERATURE.""" - assert XBT_SUPPORTED_SENSORS == frozenset({SensorType.TEMPERATURE}) - - def test_xbt_config_default_sensors(): """XBTConfig defaults to TEMPERATURE.""" config = XBTConfig( diff --git a/tests/test_utils.py b/tests/test_utils.py index 3af10906..d23991c7 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -423,15 +423,6 @@ def test_build_particle_class_disabled_sensors_excluded(): assert not hasattr(ParticleClass, "salinity") -def test_build_particle_class_empty_sensors(): - """With no sensors, build_particle_class_from_sensors returns a class with only fixed variables.""" - fixed = [Variable("raising", dtype=np.int8, initial=0)] - sensors = [] - - ParticleClass = build_particle_class_from_sensors(sensors, fixed, JITParticle) - assert hasattr(ParticleClass, "raising") - - def test_build_particle_class_velocity_adds_U_V(): """VELOCITY sensor should add both U and V particle variables.""" fixed = []