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
Comment thread
erikvansebille marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@
"\n",
"A surface drifter is an oceanographic instrument used to study surface currents and ocean circulation patterns. These devices are designed to drift passively with the surface currents while transmitting data on their position and sea surface temperature.\n",
"\n",
"VirtualShip drifters can also (optionally) record sea surface salinity, but this measurement is off by default.\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/3m_CxEE8Bhs?si=XdyMFAVq9RVxQdcE&amp;start=15\" 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
19 changes: 15 additions & 4 deletions docs/user-guide/documentation/full_sensor_list.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,21 @@ Trying to add a sensor to an instrument that does not support it will result in
| | 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 |
| | SALINITY | Sea Water Salinity (off by default) | psu | 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`.
### Optional sensors

Only BGC variables that real-life [BGC-Argo](https://biogeochemical-argo.org/) floats measure are available for the Argo floats in VirtualShip.
```
Some sensors are optional and are off by default (see the table above), because they are not standard on most deployments and/or they make simulations more computationally intensive. These optional sensors can be turned on in the `virtualship plan` tool or by adding them to the `sensors` list of the respective instrument configuration in `expedition.yaml`.

The optional sensors for different instruments are:

#### Argo floats

- BGC sensors (oxygen, chlorophyll, nitrate, pH) are avaialble for the `ARGO_FLOAT` instrument but off by default.
- Note, 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.
- Only BGC variables that real-life [BGC-Argo](https://biogeochemical-argo.org/) floats measure are available for the Argo floats in VirtualShip.

#### Drifters

- A `salinity` sensor is available but off by default for the `DRIFTER` instrument.
56 changes: 43 additions & 13 deletions docs/user-guide/tutorials/Drifter_data_tutorial.ipynb

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions src/virtualship/instruments/drifter.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ def _sample_temperature(particles, fieldset):
particles.temperature = fieldset.T[particles]


def _sample_salinity(particles, fieldset):
particles.salinity = fieldset.S[particles]


def _check_lifetime(particles, fieldset):
particles_wlifetime = particles[particles.has_lifetime == 1]

Expand All @@ -74,6 +78,7 @@ class DrifterInstrument(Instrument):

sensor_kernels: ClassVar[dict[SensorType, Callable]] = {
SensorType.TEMPERATURE: _sample_temperature,
SensorType.SALINITY: _sample_salinity,
}

def __init__(self, expedition, from_data):
Expand Down
10 changes: 8 additions & 2 deletions src/virtualship/models/expedition.py
Original file line number Diff line number Diff line change
Expand Up @@ -634,8 +634,14 @@ class DrifterConfig(_InstrumentConfigMixin, 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. "),
default_factory=lambda: [
SensorConfig(sensor_type=SensorType.TEMPERATURE),
# SALINITY is supported but not on by default
],
description=(
"Sensors fitted to the drifter. Supported: TEMPERATURE, SALINITY. "
"SALINITY is off by default. "
),
)

model_config = pydantic.ConfigDict(populate_by_name=True, extra="forbid")
Expand Down
8 changes: 8 additions & 0 deletions tests/cli/test_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from virtualship.models import (
ArgoFloatConfig,
CTDConfig,
DrifterConfig,
Expedition,
InstrumentsConfig,
Location,
Expand Down Expand Up @@ -355,6 +356,13 @@ def test_sensor_options_include_supported_sensors_off_by_default():
]


def test_drifter_salinity_option_off_by_default():
"""The drifter lists SALINITY as a sensor option, but only TEMPERATURE is on by default."""
options = _sensor_options(DrifterConfig)
assert [sc.sensor_type for sc in options if sc.enabled] == [SensorType.TEMPERATURE]
assert [sc.sensor_type for sc in options if not sc.enabled] == [SensorType.SALINITY]


@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
107 changes: 99 additions & 8 deletions tests/instruments/test_drifter.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import xarray as xr

from virtualship.instruments.drifter import Drifter, DrifterInstrument
from virtualship.instruments.sensors import SensorType
from virtualship.instruments.sensors import SENSOR_REGISTRY, SensorType
from virtualship.instruments.types import InstrumentType
from virtualship.models import Location, Spacetime
from virtualship.models.expedition import (
Expand Down Expand Up @@ -102,12 +102,14 @@ def create_fieldset(

def test_simulate_drifters(tmpdir) -> None:
CONST_TEMPERATURE = 1.0 # constant temperature in fieldset
CONST_SALINITY = 35.0 # constant salinity in fieldset

v = np.full((2, 2, 2), 1.0)
u = np.full((2, 2, 2), 1.0)
t = np.full((2, 2, 2), CONST_TEMPERATURE)
s = np.full((2, 2, 2), CONST_SALINITY)

fieldset = create_fieldset({"V": v, "U": u, "T": t})
fieldset = create_fieldset({"V": v, "U": u, "T": t, "S": s})

drifters = [
Drifter(
Expand All @@ -128,7 +130,12 @@ def test_simulate_drifters(tmpdir) -> None:
),
]

expedition = create_dummy_expedition()
expedition = create_dummy_expedition(
sensors=[
SensorConfig(sensor_type=SensorType.TEMPERATURE),
SensorConfig(sensor_type=SensorType.SALINITY),
]
)
from_data = None

drifter_instrument = DrifterInstrument(expedition, from_data)
Expand Down Expand Up @@ -159,6 +166,12 @@ def test_simulate_drifters(tmpdir) -> None:
f"measured temperature does not match {drifter_i=}"
)

sal = traj_df["salinity"].to_numpy()
assert np.isfinite(sal).any(), f"no salinity measured {drifter_i=}"
assert np.all(sal[np.isfinite(sal)] == CONST_SALINITY), (
f"measured salinity does not match {drifter_i=}"
)


def test_simulate_drifters_multiple_waypoints(tmpdir) -> None:
"""Should handle multiple drifters deployed at different waypoints and times."""
Expand Down Expand Up @@ -268,18 +281,21 @@ def test_simulate_drifters_at_same_waypoint(tmpdir) -> None:

def test_drifter_depths(tmpdir) -> None:
CONST_TEMPERATURE = 1.0 # constant temperature in fieldset
CONST_SALINITY = 35.0 # constant salinity in fieldset
DEPTH_FACTOR = 3.0 # factor to multiply surface values by at depth for test

v = np.full((2, 2, 2, 2), 1.0)
u = np.full((2, 2, 2, 2), 1.0)
t = np.full((2, 2, 2, 2), CONST_TEMPERATURE)
s = np.full((2, 2, 2, 2), CONST_SALINITY)

v[:, -1, :, :] = 1.0 * DEPTH_FACTOR
u[:, -1, :, :] = 1.0 * DEPTH_FACTOR
t[:, -1, :, :] = CONST_TEMPERATURE * DEPTH_FACTOR
s[:, -1, :, :] = CONST_SALINITY * DEPTH_FACTOR

fieldset = create_fieldset(
{"V": v, "U": u, "T": t},
{"V": v, "U": u, "T": t, "S": s},
depth_range=(-10, 0),
)

Expand All @@ -302,7 +318,12 @@ def test_drifter_depths(tmpdir) -> None:
),
]

expedition = create_dummy_expedition()
expedition = create_dummy_expedition(
sensors=[
SensorConfig(sensor_type=SensorType.TEMPERATURE),
SensorConfig(sensor_type=SensorType.SALINITY),
]
)
from_data = None

drifter_instrument = DrifterInstrument(expedition, from_data)
Expand Down Expand Up @@ -335,9 +356,61 @@ def test_drifter_depths(tmpdir) -> None:
assert drifter_surface["temperature"][0] != drifter_depth["temperature"][0], (
"Surface and deeper drifter should have different temperature measurements"
)
assert drifter_surface["salinity"][0] != drifter_depth["salinity"][0], (
"Surface and deeper drifter should have different salinity measurements"
)


@pytest.mark.parametrize(
"enabled_sensor, absent_var",
[
(SensorType.TEMPERATURE, "salinity"),
(SensorType.SALINITY, "temperature"),
],
)
def test_drifter_single_sensor(tmpdir, enabled_sensor, absent_var) -> None:
"""A drifter with only one sensor simulates without the other's field, and only the enabled sensor's variable is in the output."""
present_var = SENSOR_REGISTRY()[enabled_sensor].particle_vars[0].name
fs_key = SENSOR_REGISTRY()[enabled_sensor].fs_key

# fieldset deliberately only contains the field for the enabled sensor
fieldset = create_fieldset(
{
"V": np.full((2, 2, 2), 1.0),
"U": np.full((2, 2, 2), 1.0),
fs_key: np.full((2, 2, 2), 1.0),
}
)

expedition = create_dummy_expedition(
sensors=[SensorConfig(sensor_type=enabled_sensor)]
)
drifters = [
Drifter(
spacetime=Spacetime(
location=Location(latitude=1.0, longitude=1.0), time=BASE_TIME
),
depth=DEPLOY_DEPTH,
lifetime=expedition.instruments_config.drifter_config.lifetime,
)
]

drifter_instrument = DrifterInstrument(expedition, None)
out_path = tmpdir.join(f"out_{present_var}_only.parquet")
drifter_instrument.load_input_data = lambda: fieldset
drifter_instrument.simulate(drifters, out_path)

results = parcels.read_particlefile(out_path)
assert present_var in results, "Enabled sensor variable must be present"
assert np.isfinite(results[present_var].to_numpy()).any(), (
"Enabled sensor should record measurements"
)
assert absent_var not in results, (
"Disabled sensor variable must be absent from output"
)


def test_drifter_disabled_sensor_absent_from_output(tmpdir) -> None:
def test_drifter_config_no_enabled_sensors_rejected() -> None:
"""A DrifterConfig with no enabled sensors should be rejected at construction time."""
with pytest.raises(pydantic.ValidationError, match="no enabled sensors"):
DrifterConfig(
Expand All @@ -348,12 +421,30 @@ def test_drifter_disabled_sensor_absent_from_output(tmpdir) -> None:


def test_drifter_config_default_sensors():
"""DrifterConfig defaults to TEMPERATURE."""
"""DrifterConfig defaults to TEMPERATURE only, SALINITY is supported but off by default."""
config = DrifterConfig(
lifetime=LIFETIME,
depth_meter=DEPLOY_DEPTH,
)
assert config.sensors[0].sensor_type is SensorType.TEMPERATURE
types = {sc.sensor_type for sc in config.sensors}
assert types == {SensorType.TEMPERATURE}
assert SensorType.SALINITY not in types


def test_drifter_config_salinity_added():
"""SALINITY can be added to a DrifterConfig."""
config = DrifterConfig(
lifetime=LIFETIME,
depth_meter=DEPLOY_DEPTH,
sensors=[
SensorConfig(sensor_type=SensorType.TEMPERATURE),
SensorConfig(sensor_type=SensorType.SALINITY),
],
)
assert {sc.sensor_type for sc in config.sensors} == {
SensorType.TEMPERATURE,
SensorType.SALINITY,
}


def test_drifter_config_unsupported_sensor_rejected():
Expand Down
Loading