Skip to content

Commit bc85b4d

Browse files
committed
Merge branch 'main' into problems-at-waypoints
2 parents 10fc1d9 + 91d0b38 commit bc85b4d

File tree

4 files changed

+162
-61
lines changed

4 files changed

+162
-61
lines changed

src/virtualship/expedition/simulate_schedule.py

Lines changed: 86 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ def __init__(self, projection: pyproj.Geod, expedition: Expedition) -> None:
116116
self._next_ship_underwater_st_time = self._time
117117

118118
def simulate(self) -> ScheduleOk | ScheduleProblem:
119-
# TODO: instrument config mapping (as introduced in #269) should be helpful for refactoring here...
119+
# TODO: instrument config mapping (as introduced in #269) should be helpful for refactoring here (i.e. #236)...
120120

121121
for wp_i, waypoint in enumerate(self._expedition.schedule.waypoints):
122122
# sail towards waypoint
@@ -140,100 +140,116 @@ def simulate(self) -> ScheduleOk | ScheduleProblem:
140140

141141
# wait while measurements are being done
142142
self._progress_time_stationary(time_passed)
143+
143144
return ScheduleOk(self._time, self._measurements_to_simulate)
144145

145146
def _progress_time_traveling_towards(self, location: Location) -> None:
147+
"""Travel from current location/waypoint to next waypoint, also mark locations and times for underway instrument measurements."""
146148
time_to_reach, azimuth1, ship_speed_meter_per_second = _calc_sail_time(
147149
self._location,
148150
location,
149151
self._expedition.ship_config.ship_speed_knots,
150152
self._projection,
151153
)
152154
end_time = self._time + time_to_reach
155+
distance_to_move = ship_speed_meter_per_second * time_to_reach.total_seconds()
156+
153157
# note all ADCP measurements
154158
if self._expedition.instruments_config.adcp_config is not None:
155-
location = self._location
156-
time = self._time
157-
while self._next_adcp_time <= end_time:
158-
time_to_sail = self._next_adcp_time - time
159-
distance_to_move = (
160-
ship_speed_meter_per_second * time_to_sail.total_seconds()
161-
)
162-
geodfwd: tuple[float, float, float] = self._projection.fwd(
163-
lons=location.lon,
164-
lats=location.lat,
165-
az=azimuth1,
166-
dist=distance_to_move,
167-
)
168-
location = Location(latitude=geodfwd[1], longitude=geodfwd[0])
169-
time = time + time_to_sail
170-
159+
adcp_times, adcp_lons, adcp_lats = self._get_underway_measurements(
160+
self._expedition.instruments_config.adcp_config,
161+
azimuth1,
162+
distance_to_move,
163+
time_to_reach,
164+
)
165+
166+
for time, lon, lat in zip(adcp_times, adcp_lons, adcp_lats, strict=False):
167+
location = Location(latitude=lat, longitude=lon)
171168
self._measurements_to_simulate.adcps.append(
172169
Spacetime(location=location, time=time)
173170
)
174171

175-
self._next_adcp_time = (
176-
self._next_adcp_time
177-
+ self._expedition.instruments_config.adcp_config.period
178-
)
179-
180172
# note all ship underwater ST measurements
181173
if self._expedition.instruments_config.ship_underwater_st_config is not None:
182-
location = self._location
183-
time = self._time
184-
while self._next_ship_underwater_st_time <= end_time:
185-
time_to_sail = self._next_ship_underwater_st_time - time
186-
distance_to_move = (
187-
ship_speed_meter_per_second * time_to_sail.total_seconds()
188-
)
189-
geodfwd: tuple[float, float, float] = self._projection.fwd(
190-
lons=location.lon,
191-
lats=location.lat,
192-
az=azimuth1,
193-
dist=distance_to_move,
194-
)
195-
location = Location(latitude=geodfwd[1], longitude=geodfwd[0])
196-
time = time + time_to_sail
197-
174+
st_times, st_lons, st_lats = self._get_underway_measurements(
175+
self._expedition.instruments_config.ship_underwater_st_config,
176+
azimuth1,
177+
distance_to_move,
178+
time_to_reach,
179+
)
180+
181+
for time, lon, lat in zip(st_times, st_lons, st_lats, strict=False):
182+
location = Location(latitude=lat, longitude=lon)
198183
self._measurements_to_simulate.ship_underwater_sts.append(
199184
Spacetime(location=location, time=time)
200185
)
201186

202-
self._next_ship_underwater_st_time = (
203-
self._next_ship_underwater_st_time
204-
+ self._expedition.instruments_config.ship_underwater_st_config.period
205-
)
206-
207187
self._time = end_time
208188
self._location = location
209189

190+
def _get_underway_measurements(
191+
self,
192+
underway_instrument_config,
193+
azimuth: float,
194+
distance_to_move: float,
195+
time_to_reach: timedelta,
196+
):
197+
"""Get the times and locations of measurements between current location/waypoint and the next waypoint, for underway instruments."""
198+
period = underway_instrument_config.period
199+
npts = (time_to_reach.total_seconds() / period.total_seconds()) + 1
200+
times = [self._time + i * period for i in range(1, int(npts) + 1)]
201+
202+
geodfwd = self._projection.fwd_intermediate(
203+
lon1=self._location.lon,
204+
lat1=self._location.lat,
205+
azi1=azimuth,
206+
npts=npts,
207+
del_s=distance_to_move / npts,
208+
return_back_azimuth=False,
209+
)
210+
211+
return times, geodfwd.lons, geodfwd.lats
212+
210213
def _progress_time_stationary(self, time_passed: timedelta) -> None:
214+
"""Make ship stay at waypoint whilst instruments are deployed, also set the underway instrument measurements that are taken during this time whilst stationary."""
211215
end_time = self._time + time_passed
212216

213-
# note all ADCP measurements
217+
# note all ADCP measurements (stationary at wp)
214218
if self._expedition.instruments_config.adcp_config is not None:
215-
while self._next_adcp_time <= end_time:
219+
adcp_times = self._get_underway_stationary_times(
220+
self._expedition.instruments_config.adcp_config, time_passed
221+
)
222+
223+
for time in adcp_times:
216224
self._measurements_to_simulate.adcps.append(
217-
Spacetime(self._location, self._next_adcp_time)
218-
)
219-
self._next_adcp_time = (
220-
self._next_adcp_time
221-
+ self._expedition.instruments_config.adcp_config.period
225+
Spacetime(location=self._location, time=time)
222226
)
223227

224-
# note all ship underwater ST measurements
228+
# note all underwater ST measurements (stationary at wp)
225229
if self._expedition.instruments_config.ship_underwater_st_config is not None:
226-
while self._next_ship_underwater_st_time <= end_time:
230+
st_times = self._get_underway_stationary_times(
231+
self._expedition.instruments_config.ship_underwater_st_config,
232+
time_passed,
233+
)
234+
for time in st_times:
227235
self._measurements_to_simulate.ship_underwater_sts.append(
228-
Spacetime(self._location, self._next_ship_underwater_st_time)
229-
)
230-
self._next_ship_underwater_st_time = (
231-
self._next_ship_underwater_st_time
232-
+ self._expedition.instruments_config.ship_underwater_st_config.period
236+
Spacetime(location=self._location, time=time)
233237
)
234238

235239
self._time = end_time
236240

241+
def _get_underway_stationary_times(
242+
self, underway_instrument_config, time_passed: timedelta
243+
):
244+
npts = (
245+
time_passed.total_seconds()
246+
/ underway_instrument_config.period.total_seconds()
247+
) + 1
248+
return [
249+
self._time + i * underway_instrument_config.period
250+
for i in range(1, int(npts) + 1)
251+
]
252+
237253
def _make_measurements(self, waypoint: Waypoint) -> timedelta:
238254
# if there are no instruments, there is no time cost
239255
if waypoint.instrument is None:
@@ -268,6 +284,12 @@ def _make_measurements(self, waypoint: Waypoint) -> timedelta:
268284
drift_days=self._expedition.instruments_config.argo_float_config.drift_days,
269285
)
270286
)
287+
# TODO: would be good to avoid having to twice make sure that stationkeeping time is factored in; i.e. in schedule validity checks and here (and for CTDs and Drifters)
288+
# TODO: makes it easy to forget to update both...
289+
# TODO: this is likely to fall under refactoring simulate_schedule.py (i.e. #236)
290+
time_costs.append(
291+
self._expedition.instruments_config.argo_float_config.stationkeeping_time
292+
)
271293

272294
elif instrument is InstrumentType.CTD:
273295
self._measurements_to_simulate.ctds.append(
@@ -302,6 +324,10 @@ def _make_measurements(self, waypoint: Waypoint) -> timedelta:
302324
lifetime=self._expedition.instruments_config.drifter_config.lifetime,
303325
)
304326
)
327+
time_costs.append(
328+
self._expedition.instruments_config.drifter_config.stationkeeping_time
329+
)
330+
305331
elif instrument is InstrumentType.XBT:
306332
self._measurements_to_simulate.xbts.append(
307333
XBT(
@@ -315,5 +341,7 @@ def _make_measurements(self, waypoint: Waypoint) -> timedelta:
315341
else:
316342
raise NotImplementedError("Instrument type not supported.")
317343

318-
# measurements are done in parallel, so return time of longest one
344+
# measurements are done simultaneously onboard, so return time of longest one
345+
# TODO: docs suggest that add individual instrument stationkeeping times are cumulative, which is at odds with measurements being done simultaneously onboard here
346+
# TODO: update one or the other?
319347
return max(time_costs)

src/virtualship/utils.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -619,9 +619,9 @@ def _calc_wp_stationkeeping_time(
619619
"""For a given waypoint (and the instruments present at this waypoint), calculate how much time is required to carry out all instrument deployments."""
620620
from virtualship.instruments.types import InstrumentType # avoid circular imports
621621

622-
assert isinstance(wp_instrument_types, list), (
623-
"waypoint instruments must be provided as a list, even if empty."
624-
)
622+
# to empty list if wp instruments set to 'null'
623+
if not wp_instrument_types:
624+
wp_instrument_types = []
625625

626626
# TODO: this can be removed if/when CTD and CTD_BGC are merged to a single instrument
627627
both_ctd_and_bgc = (

tests/expedition/test_simulate_schedule.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from datetime import datetime, timedelta
22

3+
import numpy as np
34
import pyproj
45

56
from virtualship.expedition.simulate_schedule import (
@@ -65,3 +66,62 @@ def test_time_in_minutes_in_ship_schedule() -> None:
6566
minutes=20
6667
)
6768
assert instruments_config.ship_underwater_st_config.period == timedelta(minutes=5)
69+
70+
71+
def test_ship_path_inside_domain() -> None:
72+
"""Test that the ship path (here represented by underway ADCP measurement sites) is inside the domain defined by the waypoints (which determines the fieldset bounds)."""
73+
base_time = datetime.strptime("2022-01-01T00:00:00", "%Y-%m-%dT%H:%M:%S")
74+
75+
projection = pyproj.Geod(ellps="WGS84")
76+
expedition = Expedition.from_yaml("expedition_dir/expedition.yaml")
77+
expedition.ship_config.ship_speed_knots = 10.0
78+
79+
wp1 = Location(-63.0, -57.7) # most southern
80+
wp2 = Location(-55.4, -66.2) # most northern
81+
wp3 = Location(-61.8, -73.2) # most western
82+
wp4 = Location(-57.3, -51.8) # most eastern
83+
84+
# waypoints with enough distance where curvature is clear
85+
expedition.schedule = Schedule(
86+
waypoints=[
87+
Waypoint(location=wp1, time=base_time),
88+
Waypoint(location=wp2, time=base_time + timedelta(days=5)),
89+
Waypoint(location=wp3, time=base_time + timedelta(days=10)),
90+
Waypoint(location=wp4, time=base_time + timedelta(days=15)),
91+
]
92+
)
93+
94+
# get waypoint domain bounds
95+
wp_max_lat, wp_min_lat, wp_max_lon, wp_min_lon = (
96+
max(wp.location.lat for wp in expedition.schedule.waypoints),
97+
min(wp.location.lat for wp in expedition.schedule.waypoints),
98+
max(wp.location.lon for wp in expedition.schedule.waypoints),
99+
min(wp.location.lon for wp in expedition.schedule.waypoints),
100+
)
101+
102+
result = simulate_schedule(projection, expedition)
103+
assert isinstance(result, ScheduleOk)
104+
105+
# adcp measurements path
106+
adcp_measurements = result.measurements_to_simulate.adcps
107+
adcp_lats = [m.location.lat for m in adcp_measurements]
108+
adcp_lons = [m.location.lon for m in adcp_measurements]
109+
110+
adcp_max_lat, adcp_min_lat, adcp_max_lon, adcp_min_lon = (
111+
max(adcp_lats),
112+
min(adcp_lats),
113+
max(adcp_lons),
114+
min(adcp_lons),
115+
)
116+
117+
# check adcp route is within wp bounds
118+
assert adcp_max_lat <= wp_max_lat
119+
assert adcp_min_lat >= wp_min_lat
120+
assert adcp_max_lon <= wp_max_lon
121+
assert adcp_min_lon >= wp_min_lon
122+
123+
# the adcp route extremes should also approximately match waypoints defined in this test
124+
assert np.isclose(adcp_max_lat, wp2.lat, atol=0.1)
125+
assert np.isclose(adcp_min_lat, wp1.lat, atol=0.1)
126+
assert np.isclose(adcp_max_lon, wp4.lon, atol=0.1)
127+
assert np.isclose(adcp_min_lon, wp3.lon, atol=0.1)

tests/test_utils.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,3 +347,16 @@ class DrifterConfig:
347347
assert stationkeeping_time_xbt == datetime.timedelta(0), (
348348
"XBT should have zero stationkeeping time"
349349
)
350+
351+
352+
def test_calc_wp_stationkeeping_time_no_instruments(expedition):
353+
"""Test calc_wp_stationkeeping_time handles no instruments, either marked as 'null' or empty list."""
354+
stationkeeping_emptylist = _calc_wp_stationkeeping_time(
355+
[], expedition.instruments_config
356+
)
357+
stationkeeping_null = _calc_wp_stationkeeping_time(
358+
None, expedition.instruments_config
359+
) # "null" in YAML translates to None in Python
360+
361+
assert stationkeeping_null == stationkeeping_emptylist # are equivalent
362+
assert stationkeeping_null == datetime.timedelta(0) # at least one is 0 time

0 commit comments

Comments
 (0)