Skip to content
Open
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: 1 addition & 1 deletion docs/user-guide/assignments/Research_proposal_intro.ipynb

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.

But not all drifters record salinity, right? This is only for a small subset of the drifters. So make clear it's optional?

Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@
"source": [
"### Drifters\n",
"\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",
"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, sea surface temperature and sea surface salinity.\n",
"\n",
"You should assume it takes a combined 20 minutes to both slow down and speed the ship back up, in order to safely deploy drifters from the ship. Make sure to account for this time in your planning.\n",
"\n",
Expand Down
1 change: 1 addition & 0 deletions docs/user-guide/documentation/full_sensor_list.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ Trying to add a sensor to an instrument that does not support it will result in
| **ARGO_FLOAT** | TEMPERATURE | Sea Water Potential Temperature | °C | Physical |
| | SALINITY | Sea Water Salinity | psu | Physical |
| **DRIFTER** | TEMPERATURE | Sea Water Potential Temperature | °C | Physical |
| | SALINITY | Sea Water Salinity | psu | Physical |

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.

Hmm, we might need to consider the units here. psu is old and deprecated - the "correct" uint is g/kg (or unites) now. But what does copernicusmarine provide?

| **XBT** | TEMPERATURE | Sea Water Potential Temperature | °C | Physical |
56 changes: 42 additions & 14 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 @@ -71,6 +75,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
9 changes: 7 additions & 2 deletions src/virtualship/models/expedition.py
Original file line number Diff line number Diff line change
Expand Up @@ -579,8 +579,13 @@ class DrifterConfig(_InstrumentConfigMixin, pydantic.BaseModel):
)

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),
SensorConfig(sensor_type=SensorType.SALINITY),

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.

So salinity sampling is default always on for drifters? Do we really want that? is that authentic?

],
description=(
"Sensors fitted to the drifter. Supported: TEMPERATURE, SALINITY. "
),
)

model_config = pydantic.ConfigDict(populate_by_name=True)
Expand Down
1 change: 1 addition & 0 deletions src/virtualship/static/expedition.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ instruments_config:
stationkeeping_time_minutes: 20.0
sensors:
- TEMPERATURE
- SALINITY
xbt_config:
max_depth_meter: -285.0
min_depth_meter: -2.0
Expand Down
90 changes: 82 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 @@ -103,12 +103,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 @@ -129,7 +131,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 @@ -160,6 +167,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 @@ -269,18 +282,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 @@ -303,7 +319,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 @@ -336,9 +357,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 @@ -350,13 +423,14 @@ def test_drifter_disabled_sensor_absent_from_output(tmpdir) -> None:


def test_drifter_config_default_sensors():
"""DrifterConfig defaults to TEMPERATURE."""
"""DrifterConfig defaults to TEMPERATURE + SALINITY."""
config = DrifterConfig(
lifetime=LIFETIME,
depth_meter=DEPLOY_DEPTH,
stationkeeping_time_minutes=10,
)
assert config.sensors[0].sensor_type is SensorType.TEMPERATURE
types = {sc.sensor_type for sc in config.sensors}
assert types == {SensorType.TEMPERATURE, SensorType.SALINITY}


def test_drifter_config_unsupported_sensor_rejected():
Expand Down
Loading