Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/user-guide/assignments/Research_proposal_intro.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@
"\n",
"The floats periodically descend to a predetermined depth, typically around 1000 meters, and then ascend to the surface, measuring temperature and salinity profiles along the way.\n",
"\n",
"In VirtualShip, Argo floats can optionally also be fitted with biogeochemical (BGC) sensors for oxygen, chlorophyll, nitrate and pH, as on real [Biogeochemical-Argo](https://biogeochemical-argo.org/) floats. These are off by default (see the [full sensor list](../documentation/full_sensor_list.md)).\n",
"\n",
"For deployment, the ship needs to slow down to a stop, which takes 15 minutes (default in VirtualShip). You can assume the actual release takes no time but then after the deployment the ship needs to speed back up again and set course to the next waypoint, which takes another 15 minutes (default in VirtualShip).\n",
" \n",
"<iframe width=\"560\" height=\"315\" src=\"https://www.youtube.com/embed/IgcYQML5se4?si=Y7m1kLqUl14QZfcd\" title=\"YouTube video player\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\" referrerpolicy=\"strict-origin-when-cross-origin\" allowfullscreen></iframe>"
Expand Down
10 changes: 10 additions & 0 deletions docs/user-guide/documentation/full_sensor_list.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,15 @@ Trying to add a sensor to an instrument that does not support it will result in
| | PRIMARY_PRODUCTION | Net Primary Production of Biomass | mmol m<sup>-3</sup> day<sup>-1</sup> | Biogeochemical |
| **ARGO_FLOAT** | TEMPERATURE | Sea Water Potential Temperature | °C | Physical |
| | SALINITY | Sea Water Salinity | psu | Physical |
| | OXYGEN | Mole Concentration of Dissolved Molecular Oxygen in Sea Water (off by default) | mmol m<sup>-3</sup> | Biogeochemical |
| | CHLOROPHYLL | Mass Concentration of Chlorophyll a in Sea Water (off by default) | mmol m<sup>-3</sup> | Biogeochemical |
| | NITRATE | Mole Concentration of Nitrate in Sea Water (off by default) | mmol m<sup>-3</sup> | Biogeochemical |
| | PH | Sea Water pH (off by default) | - | Biogeochemical |
| **DRIFTER** | TEMPERATURE | Sea Water Potential Temperature | °C | Physical |
| **XBT** | TEMPERATURE | Sea Water Potential Temperature | °C | Physical |

```{note}
The `ARGO_FLOAT` BGC sensors (oxygen, chlorophyll, nitrate, pH) are off by default. Each extra sensor adds another variable for the simulation to load and sample, which can make the already-computationally intensive Argo float simulations even more intensive. When needed though, they can be turned on in the `virtualship plan` tool or by adding them to the `sensors` list of `argo_float_config` in `expedition.yaml`.

Only BGC variables that real-life [BGC-Argo](https://biogeochemical-argo.org/) floats measure are available for the Argo floats in VirtualShip.
```
2 changes: 1 addition & 1 deletion docs/user-guide/documentation/pre_download_data.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ The following assumptions are also made about the data:
1. All pre-downloaded data files must be in NetCDF format (`.nc`).
2. Physical data files must contain the following variables: `uo`, `vo`, `so`, `thetao`
- Or these strings must appear as substrings within the variable names (e.g. `uo_glor` is acceptable for `uo`).
3. If using BGC-enabled instruments (e.g. BGC variables on the `CTD`), the relevant biogeochemical data files must contain the following variables: `o2`, `chl`, `no3`, `po4`, `nppv`, `ph`, `phyc`.
3. If using BGC-enabled instruments (e.g. BGC variables on the `CTD` or `ARGO_FLOAT`), the relevant biogeochemical data files must contain the following variables: `o2`, `chl`, `no3`, `po4`, `nppv`, `ph`, `phyc`.
- Or these strings must appear as substrings within the variable names (e.g. `o2_glor` is acceptable for `o2`).
4. Bathymetry data files must contain a variable named `deptho`.
5. Pre-downloaded data files must have a `"positive"` attribute for the depth dimension (e.g. `"positive": "down"` or `"positive": "up"`) in order to ensure that the depth dimension is correctly interpreted under-the-hood.
Expand Down
39 changes: 25 additions & 14 deletions src/virtualship/cli/_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
type_to_textual,
)
from virtualship.errors import UnexpectedError, UserError
from virtualship.instruments.sensors import SensorType
from virtualship.instruments.types import InstrumentType
from virtualship.models import (
ADCPConfig,
Expand All @@ -48,7 +49,7 @@
XBTConfig,
)
from virtualship.models.expedition import Port
from virtualship.utils import EXPEDITION, INCOMPLETE_PORT_MSG
from virtualship.utils import EXPEDITION, INCOMPLETE_PORT_MSG, get_supported_sensors

UNEXPECTED_MSG_ONSAVE = (
"Please ensure that:\n"
Expand Down Expand Up @@ -91,6 +92,19 @@ def _default_sensors(config_class) -> list:
return sensors_field.default_factory()


def _sensor_options(config_class) -> list:
"""SensorConfig for every sensor the config class's instrument supports (in SensorType order), enabled if on by default."""
on_by_default = {sc.sensor_type for sc in _default_sensors(config_class)}
if not on_by_default:
return []
supported = get_supported_sensors(config_class._instrument_type)
return [
SensorConfig(sensor_type=s, enabled=s in on_by_default)
for s in SensorType
if s in supported
]


WAYPOINT_TIME_FORMAT = "%Y-%m-%d %H:%M"
WAYPOINT_TIME_TEMPLATE = "9999-99-99 99:99"
WAYPOINT_TIME_INVALID_MSG = (
Expand Down Expand Up @@ -467,21 +481,21 @@ def _compose_instrument_config(self, instrument_name: str, info: dict):
value=_config_display_value(config_instance, attr_meta),
)
yield _failure_label(f"{instrument_name}_{attr}")
# sensor toggles, derived from the config class's sensors default_factory
default_sensor_configs = _default_sensors(config_class)
if default_sensor_configs:
# sensor toggles, one per sensor supported by the instrument
sensor_options = _sensor_options(config_class)
if sensor_options:
yield Label("[b]Sensors:[/b]", markup=True)
# which sensors are currently active
if config_instance and hasattr(config_instance, "sensors"):
active_sensor_types = {
sc.sensor_type for sc in config_instance.sensors if sc.enabled
}
else:
# if no config loaded yet, default all sensors on
# if no config loaded yet, only the default sensors are on
active_sensor_types = {
sc.sensor_type for sc in default_sensor_configs
sc.sensor_type for sc in sensor_options if sc.enabled
}
for sc in default_sensor_configs:
for sc in sensor_options:
with Horizontal(classes="sensor-toggle-row"):
yield Label(
f" {sc.sensor_type.value.replace('_', ' ').title()}:"
Expand Down Expand Up @@ -613,11 +627,11 @@ def _update_instrument_configs(self):
kwargs["max_depth_meter"] = -1000.0 if is_deep else -150.0

# collect sensor toggles
default_sensor_configs = _default_sensors(config_class)
if default_sensor_configs:
sensor_options = _sensor_options(config_class)
if sensor_options:
sensors = [
SensorConfig(sensor_type=sc.sensor_type)
for sc in default_sensor_configs
for sc in sensor_options
if self.query_one(
f"#{instrument_name}_sensor_{sc.sensor_type.value}", Switch
).value
Expand All @@ -629,10 +643,7 @@ def _update_instrument_configs(self):
f"At least one sensor must be enabled for each active instrument."
)
# fall back to the default sensors so pydantic validation passes
sensors = [
SensorConfig(sensor_type=sc.sensor_type)
for sc in default_sensor_configs
]
sensors = _default_sensors(config_class)
kwargs["sensors"] = sensors

try:
Expand Down
52 changes: 38 additions & 14 deletions src/virtualship/instruments/argo_float.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,31 +160,51 @@ def _check_error(particles, fieldset):
particles.state[errors] = StatusCode.Delete


## physical variables


def _argo_sample_temperature(particles, fieldset):
# Phase 3: ascending — sample temperature
phase_mask = particles.cycle_phase == 3
depth_mask = particles.z < particles.min_depth # still ascending
mask = np.logical_and(phase_mask, depth_mask)
particles.temperature[~mask] = np.nan # no measurement outside the ascent
sampling_particles = particles[mask]
sampling_particles.temperature = fieldset.T[sampling_particles]
_sample_on_ascent(particles, fieldset.T, "temperature")


def _argo_sample_salinity(particles, fieldset):
# Phase 3: ascending — sample salinity
phase_mask = particles.cycle_phase == 3
depth_mask = particles.z < particles.min_depth # still ascending
mask = np.logical_and(phase_mask, depth_mask)
particles.salinity[~mask] = np.nan # no measurement outside the ascent
sampling_particles = particles[mask]
sampling_particles.salinity = fieldset.S[sampling_particles]
_sample_on_ascent(particles, fieldset.S, "salinity")


## bgc variables


def _argo_sample_o2(particles, fieldset):
_sample_on_ascent(particles, fieldset.o2, "o2")


def _argo_sample_chlorophyll(particles, fieldset):
_sample_on_ascent(particles, fieldset.chl, "chl")


def _argo_sample_nitrate(particles, fieldset):
_sample_on_ascent(particles, fieldset.no3, "no3")


def _argo_sample_ph(particles, fieldset):
_sample_on_ascent(particles, fieldset.ph, "ph")


# =====================================================
# SECTION: Helper Functions
# =====================================================


def _sample_on_ascent(particles, field, var):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice improvement to make this its own function! Shows the flexibility of Parcels v4 ;-)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes this is a really nice feature of v4!

"""Sample `field` into particle variable `var` during the ascent (phase 3) only."""
phase_mask = particles.cycle_phase == 3
depth_mask = particles.z < particles.min_depth # still ascending
mask = np.logical_and(phase_mask, depth_mask)
getattr(particles, var)[~mask] = np.nan # no measurement outside the ascent
sampling_particles = particles[mask]
setattr(sampling_particles, var, field[sampling_particles])


def _handle_grounding(
ptcls_subset, bathysafe_mask, loc_bathy, fieldset, phase_name, target_phase
):
Expand Down Expand Up @@ -237,6 +257,10 @@ class ArgoFloatInstrument(Instrument):
sensor_kernels: ClassVar[dict[SensorType, Callable]] = {
SensorType.TEMPERATURE: _argo_sample_temperature,
SensorType.SALINITY: _argo_sample_salinity,
SensorType.OXYGEN: _argo_sample_o2,
SensorType.CHLOROPHYLL: _argo_sample_chlorophyll,
SensorType.NITRATE: _argo_sample_nitrate,
SensorType.PH: _argo_sample_ph,
}

def __init__(self, expedition, from_data):
Expand Down
4 changes: 3 additions & 1 deletion src/virtualship/models/expedition.py
Original file line number Diff line number Diff line change
Expand Up @@ -508,9 +508,11 @@ class ArgoFloatConfig(_InstrumentConfigMixin, pydantic.BaseModel):
default_factory=lambda: [
SensorConfig(sensor_type=SensorType.TEMPERATURE),
SensorConfig(sensor_type=SensorType.SALINITY),
# BGC sensors are supported but not on by default
],
description=(
"Sensors fitted to the Argo float. Supported: TEMPERATURE, SALINITY. "
"Sensors fitted to the Argo float. Supported: TEMPERATURE, SALINITY, OXYGEN, CHLOROPHYLL, NITRATE, PH. "
"BGC sensors (OXYGEN, CHLOROPHYLL, NITRATE, PH) are off by default. "
),
)

Expand Down
67 changes: 67 additions & 0 deletions tests/cli/test_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
QuitConfirmScreen,
WaypointWidget,
_default_sensors,
_sensor_options,
parse_waypoint_time,
)
from virtualship.instruments.sensors import SensorType
from virtualship.instruments.types import InstrumentType
from virtualship.models import (
ArgoFloatConfig,
CTDConfig,
Expedition,
InstrumentsConfig,
Expand Down Expand Up @@ -331,6 +333,28 @@ def test_default_sensors_returns_declared_defaults():
assert SensorType.SALINITY in types


def test_sensor_options_include_supported_sensors_off_by_default():
"""_sensor_options() lists every supported sensor, with only the default sensors enabled."""
argo_options = _sensor_options(ArgoFloatConfig)
assert [sc.sensor_type for sc in argo_options if sc.enabled] == [
SensorType.TEMPERATURE,
SensorType.SALINITY,
]
assert [sc.sensor_type for sc in argo_options if not sc.enabled] == [
SensorType.OXYGEN,
SensorType.CHLOROPHYLL,
SensorType.NITRATE,
SensorType.PH,
]

# instruments where all supported sensors are on by default are unchanged
ctd_options = _sensor_options(CTDConfig)
assert all(sc.enabled for sc in ctd_options)
assert [sc.sensor_type for sc in ctd_options] == [
sc.sensor_type for sc in _default_sensors(CTDConfig)
]


@pytest.mark.asyncio
async def test_sensor_toggle_saved_to_yaml(tmp_path):
"""Toggling a sensor switch off and saving should persist only the enabled sensors."""
Expand Down Expand Up @@ -519,6 +543,49 @@ async def test_sensor_initial_state_reflects_config(tmp_path):
assert sal_switch.value is False, "SALINITY should be OFF (not in saved config)"


@pytest.mark.asyncio
async def test_sensor_initial_state_reflects_defaults_without_config(tmp_path):
"""Without a stored config, sensor switches should follow the default on/off state (e.g. Argo BGC sensors listed but OFF)."""
instruments_config = InstrumentsConfig.model_validate(
yaml.safe_load(_get_example_expedition()).get("instruments_config")
)
instruments_config.argo_float_config = None
_make_expedition(
tmp_path,
[
Port(location=None, time=None),
Waypoint(
location=Location(0, 0),
time=datetime(2022, 1, 1, 0, 0, 0),
instrument=["CTD"],
),
Waypoint(
location=Location(0.01, 0.01),
time=datetime(2022, 1, 1, 1, 0, 0),
instrument=None,
),
Port(location=None, time=None),
],
instruments_config,
)

app = PlanApp(path=tmp_path)
async with app.run_test(size=(160, 120)) as pilot:
await pilot.pause(0.5)
expedition_editor = pilot.app.screen.query_one(ExpeditionEditor)

for sensor in ["TEMPERATURE", "SALINITY"]:
switch = expedition_editor.query_one(
f"#argo_float_config_sensor_{sensor}", Switch
)
assert switch.value is True, f"{sensor} should be ON by default"
for sensor in ["OXYGEN", "CHLOROPHYLL", "NITRATE", "PH"]:
switch = expedition_editor.query_one(
f"#argo_float_config_sensor_{sensor}", Switch
)
assert switch.value is False, f"{sensor} should be OFF by default"


@pytest.mark.asyncio
async def test_adcp_type_always_exactly_one_selected(tmp_path):
"""Switching one ADCP type off selects the other, so both can never be off/on together."""
Expand Down
Loading
Loading