From eab13f37dbb1aa7cb707c6017f72d98cc2e0817a Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Mon, 19 Aug 2024 15:24:33 +0200 Subject: [PATCH 01/35] wip --- tests/test_loop.py | 5 +++++ virtual_ship/__init__.py | 2 ++ virtual_ship/loop.py | 32 ++++++++++++++++++++++++++++++++ virtual_ship/schedule.py | 5 +++-- 4 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 tests/test_loop.py create mode 100644 virtual_ship/loop.py diff --git a/tests/test_loop.py b/tests/test_loop.py new file mode 100644 index 00000000..65827335 --- /dev/null +++ b/tests/test_loop.py @@ -0,0 +1,5 @@ +from virtual_ship import loop + + +def test_loop() -> None: + loop("test_exp") diff --git a/virtual_ship/__init__.py b/virtual_ship/__init__.py index 717f5c38..6a746aeb 100644 --- a/virtual_ship/__init__.py +++ b/virtual_ship/__init__.py @@ -7,6 +7,7 @@ from .schedule import Schedule from .spacetime import Spacetime from .waypoint import Waypoint +from .loop import loop __all__ = [ "InstrumentType", @@ -17,4 +18,5 @@ "Waypoint", "instruments", "sailship", + "loop", ] diff --git a/virtual_ship/loop.py b/virtual_ship/loop.py new file mode 100644 index 00000000..139bb6d4 --- /dev/null +++ b/virtual_ship/loop.py @@ -0,0 +1,32 @@ +from datetime import datetime +from .schedule import Schedule +from pathlib import Path + + +def loop(expedition_dir: str | Path) -> None: + if isinstance(expedition_dir, str): + expedition_dir = Path(expedition_dir) + schedule_dir = expedition_dir.joinpath("schedules") + + start_schedule = None + while start_schedule is None: + start_schedule = get_schedule(schedule_dir=schedule_dir, datetime=None) + if start_schedule is None: + print_and_wait_for_user( + f"No schedule found. Save it to \"{schedule_dir.joinpath('start.yaml')}\"" + ) + + +def print_and_wait_for_user(message: str) -> None: + print(message) + input() + + +def get_schedule(schedule_dir: Path, datetime: datetime | None) -> Schedule | None: + if datetime is not None: + raise NotImplementedError() + + try: + return Schedule.from_yaml(schedule_dir.joinpath("start.yaml")) + except FileNotFoundError: + return None diff --git a/virtual_ship/schedule.py b/virtual_ship/schedule.py index 5418d2e4..69243627 100644 --- a/virtual_ship/schedule.py +++ b/virtual_ship/schedule.py @@ -8,6 +8,7 @@ from .location import Location from .waypoint import Waypoint +from pathlib import Path @dataclass @@ -17,7 +18,7 @@ class Schedule: waypoints: list[Waypoint] @classmethod - def from_yaml(cls, path: str) -> Schedule: + def from_yaml(cls, path: str | Path) -> Schedule: """ Load schedule from YAML file. @@ -36,7 +37,7 @@ def from_yaml(cls, path: str) -> Schedule: ] return Schedule(waypoints) - def to_yaml(self, path: str) -> None: + def to_yaml(self, path: str | Path) -> None: """ Save schedule to YAML file. From 1e48185e694c8d4d9ceb7e40db9266639a1b392b Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Wed, 21 Aug 2024 19:07:31 +0200 Subject: [PATCH 02/35] wip --- pyproject.toml | 1 + tests/expedition/schedule.yaml | 8 ++ tests/expedition/test.yaml | 21 +++ tests/expedition/test_expedition.py | 55 +++++++ tests/test_loop.py | 2 +- virtual_ship/__init__.py | 14 ++ virtual_ship/loop.py | 213 +++++++++++++++++++++++++--- virtual_ship/schedule.py | 20 +-- virtual_ship/ship_config.py | 146 +++++++++++++++++++ 9 files changed, 449 insertions(+), 31 deletions(-) create mode 100644 tests/expedition/schedule.yaml create mode 100644 tests/expedition/test.yaml create mode 100644 tests/expedition/test_expedition.py create mode 100644 virtual_ship/ship_config.py diff --git a/pyproject.toml b/pyproject.toml index d87416a4..154a0b7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "sortedcontainers == 2.4.0", "opensimplex == 0.4.5", "numpy >=1, < 2", + "pydantic >=2, <3", ] [project.urls] diff --git a/tests/expedition/schedule.yaml b/tests/expedition/schedule.yaml new file mode 100644 index 00000000..7e7cad0c --- /dev/null +++ b/tests/expedition/schedule.yaml @@ -0,0 +1,8 @@ +- lat: 0 + lon: 0 + time: None + instrument: None +- lat: 1 + lon: 1 + time: None + instrument: None \ No newline at end of file diff --git a/tests/expedition/test.yaml b/tests/expedition/test.yaml new file mode 100644 index 00000000..5029b55c --- /dev/null +++ b/tests/expedition/test.yaml @@ -0,0 +1,21 @@ +adcp_config: + bin_size_m: 24 + max_depth: -1000.0 + period_minutes: 5.0 +argo_float_config: + cycle_days: 10.0 + drift_days: 9.0 + drift_depth: -1000.0 + max_depth: -2000.0 + min_depth: 0.0 + vertical_speed: -0.1 +ctd_config: + max_depth: 2000.0 + min_depth: 0.0 + stationkeeping_time_minutes: 20.0 +drifter_config: + depth: 0.0 + lifetime_minutes: 40320.0 +ship_speed: 5.14 +ship_underwater_st_config: + period_minutes: 5.0 diff --git a/tests/expedition/test_expedition.py b/tests/expedition/test_expedition.py new file mode 100644 index 00000000..e322d82f --- /dev/null +++ b/tests/expedition/test_expedition.py @@ -0,0 +1,55 @@ +from virtual_ship import ( + ShipConfig, + ArgoFloatConfig, + ADCPConfig, + CTDConfig, + DrifterConfig, + ShipUnderwaterSTConfig, +) +from datetime import timedelta + + +def test_expedition() -> None: + argo_float_config = ArgoFloatConfig( + min_depth=0, + max_depth=-2000, + drift_depth=-1000, + vertical_speed=-0.10, + cycle_days=10, + drift_days=9, + ) + + adcp_config = ADCPConfig( + max_depth=-1000, + bin_size_m=24, + period=timedelta(minutes=5), + ) + + ship_underwater_st_config = ShipUnderwaterSTConfig( + period=timedelta(minutes=5), + ) + + ctd_config = CTDConfig( + stationkeeping_time=timedelta(minutes=20), + min_depth=0, + max_depth=2000, + ) + + drifter_config = DrifterConfig( + depth=0, + lifetime=timedelta(weeks=4), + ) + + ShipConfig( + ship_speed=5.14, + argo_float_config=argo_float_config, + adcp_config=adcp_config, + ctd_config=ctd_config, + ship_underwater_st_config=ship_underwater_st_config, + drifter_config=drifter_config, + ).save_to_yaml("test.yaml") + ShipConfig.load_from_yaml("test.yaml") + # with open("ship_config.yaml", "r") as file: + # data = yaml.load(file) + # x = ShipConfig() + # x = ShipConfig(ship_speed=3.0) diff --git a/tests/test_loop.py b/tests/test_loop.py index 65827335..730bc56c 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -2,4 +2,4 @@ def test_loop() -> None: - loop("test_exp") + loop("test_expedition") diff --git a/virtual_ship/__init__.py b/virtual_ship/__init__.py index 6a746aeb..0034dd24 100644 --- a/virtual_ship/__init__.py +++ b/virtual_ship/__init__.py @@ -8,6 +8,14 @@ from .spacetime import Spacetime from .waypoint import Waypoint from .loop import loop +from .ship_config import ( + ShipConfig, + ArgoFloatConfig, + ADCPConfig, + ShipUnderwaterSTConfig, + CTDConfig, + DrifterConfig, +) __all__ = [ "InstrumentType", @@ -19,4 +27,10 @@ "instruments", "sailship", "loop", + "ShipConfig", + "ArgoFloatConfig", + "ADCPConfig", + "ShipUnderwaterSTConfig", + "CTDConfig", + "DrifterConfig", ] diff --git a/virtual_ship/loop.py b/virtual_ship/loop.py index 139bb6d4..57c96769 100644 --- a/virtual_ship/loop.py +++ b/virtual_ship/loop.py @@ -1,32 +1,201 @@ -from datetime import datetime -from .schedule import Schedule +# from datetime import datetime +# from .schedule import Schedule from pathlib import Path +# import pyproj +# from virtual_ship import ShipConfig + def loop(expedition_dir: str | Path) -> None: - if isinstance(expedition_dir, str): - expedition_dir = Path(expedition_dir) - schedule_dir = expedition_dir.joinpath("schedules") + raise NotImplementedError() + + +# if isinstance(expedition_dir, str): +# expedition_dir = Path(expedition_dir) + +# ship_config = get_ship_config(expedition_dir) +# if ship_config is None: +# return + +# schedule = get_schedule(expedition_dir) +# if schedule is None: +# return + +# # projection used to sail between waypoints +# projection = pyproj.Geod(ellps="WGS84") + +# ship_config = ShipConfig.from_yaml() + +# simulate_schedule(projection=projection, config=config) + + +# def print_and_wait_for_user(message: str) -> None: +# print(message) +# input() + + +# def get_ship_config(expedition_dir: Path) -> Schedule | None: +# schedule_path = expedition_dir.joinpath("ship_config.yaml") +# try: +# return Schedule.from_yaml(schedule_path) +# except FileNotFoundError: +# print(f'Schedule not found. Save it to "{schedule_path}".') +# return None + + +# def get_schedule(expedition_dir: Path) -> Schedule | None: +# schedule_path = expedition_dir.joinpath("schedule.yaml") +# try: +# return Schedule.from_yaml(schedule_path) +# except FileNotFoundError: +# print(f'Schedule not found. Save it to "{schedule_path}".') +# return None + + +# def simulate_schedule( +# projection: pyproj.Geod, +# config: VirtualShipConfig, +# ) -> _ScheduleResults: +# """ +# Simulate the sailing and aggregate the virtual measurements that should be taken. + +# :param projection: Projection used to sail between waypoints. +# :param config: The cruise configuration. +# :returns: Results from the simulation. +# :raises NotImplementedError: When unsupported instruments are encountered. +# :raises RuntimeError: When schedule appears infeasible. This should not happen in this version of virtual ship as the schedule is verified beforehand. +# """ +# cruise = _Cruise( +# Spacetime( +# config.schedule.waypoints[0].location, config.schedule.waypoints[0].time +# ) +# ) +# measurements = _MeasurementsToSimulate() + +# # add recurring tasks to task list +# waiting_tasks = SortedList[_WaitingTask]() +# if config.ship_underwater_st_config is not None: +# waiting_tasks.add( +# _WaitingTask( +# task=_ship_underwater_st_loop( +# config.ship_underwater_st_config.period, cruise, measurements +# ), +# wait_until=cruise.spacetime.time, +# ) +# ) +# if config.adcp_config is not None: +# waiting_tasks.add( +# _WaitingTask( +# task=_adcp_loop(config.adcp_config.period, cruise, measurements), +# wait_until=cruise.spacetime.time, +# ) +# ) + +# # sail to each waypoint while executing tasks +# for waypoint in config.schedule.waypoints: +# if waypoint.time is not None and cruise.spacetime.time > waypoint.time: +# raise RuntimeError( +# "Could not reach waypoint in time. This should not happen in this version of virtual ship as the schedule is verified beforehand." +# ) + +# # add task to the task queue for the instrument at the current waypoint +# if waypoint.instrument is InstrumentType.ARGO_FLOAT: +# _argo_float_task(cruise, measurements, config=config) +# elif waypoint.instrument is InstrumentType.DRIFTER: +# _drifter_task(cruise, measurements, config=config) +# elif waypoint.instrument is InstrumentType.CTD: +# waiting_tasks.add( +# _WaitingTask( +# _ctd_task( +# config.ctd_config.stationkeeping_time, +# config.ctd_config.min_depth, +# config.ctd_config.max_depth, +# cruise, +# measurements, +# ), +# cruise.spacetime.time, +# ) +# ) +# elif waypoint.instrument is None: +# pass +# else: +# raise NotImplementedError() - start_schedule = None - while start_schedule is None: - start_schedule = get_schedule(schedule_dir=schedule_dir, datetime=None) - if start_schedule is None: - print_and_wait_for_user( - f"No schedule found. Save it to \"{schedule_dir.joinpath('start.yaml')}\"" - ) +# # sail to the next waypoint +# waypoint_reached = False +# while not waypoint_reached: +# # execute all tasks planned for current time +# while ( +# len(waiting_tasks) > 0 +# and waiting_tasks[0].wait_until <= cruise.spacetime.time +# ): +# task = waiting_tasks.pop(0) +# try: +# wait_for = next(task.task) +# waiting_tasks.add( +# _WaitingTask(task.task, cruise.spacetime.time + wait_for.time) +# ) +# except StopIteration: +# pass +# # if sailing is prevented by a current task, just let time pass until the next task +# if cruise.sail_is_locked: +# cruise.spacetime = Spacetime( +# cruise.spacetime.location, waiting_tasks[0].wait_until +# ) +# # else, let time pass while sailing +# else: +# # calculate time at which waypoint would be reached if simply sailing +# geodinv: tuple[float, float, float] = projection.inv( +# lons1=cruise.spacetime.location.lon, +# lats1=cruise.spacetime.location.lat, +# lons2=waypoint.location.lon, +# lats2=waypoint.location.lat, +# ) +# azimuth1 = geodinv[0] +# distance_to_next_waypoint = geodinv[2] +# time_to_reach = timedelta( +# seconds=distance_to_next_waypoint / config.ship_speed +# ) +# arrival_time = cruise.spacetime.time + time_to_reach -def print_and_wait_for_user(message: str) -> None: - print(message) - input() +# # if waypoint is reached before next task, sail to the waypoint +# if ( +# len(waiting_tasks) == 0 +# or arrival_time <= waiting_tasks[0].wait_until +# ): +# cruise.spacetime = Spacetime(waypoint.location, arrival_time) +# waypoint_reached = True +# # else, sail until task starts +# else: +# time_to_sail = waiting_tasks[0].wait_until - cruise.spacetime.time +# distance_to_move = config.ship_speed * time_to_sail.total_seconds() +# geodfwd: tuple[float, float, float] = projection.fwd( +# lons=cruise.spacetime.location.lon, +# lats=cruise.spacetime.location.lat, +# az=azimuth1, +# dist=distance_to_move, +# ) +# lon = geodfwd[0] +# lat = geodfwd[1] +# cruise.spacetime = Spacetime( +# Location(latitude=lat, longitude=lon), +# cruise.spacetime.time + time_to_sail, +# ) +# cruise.finish() -def get_schedule(schedule_dir: Path, datetime: datetime | None) -> Schedule | None: - if datetime is not None: - raise NotImplementedError() +# # don't sail anymore, but let tasks finish +# while len(waiting_tasks) > 0: +# task = waiting_tasks.pop(0) +# try: +# wait_for = next(task.task) +# waiting_tasks.add( +# _WaitingTask(task.task, cruise.spacetime.time + wait_for.time) +# ) +# except StopIteration: +# pass - try: - return Schedule.from_yaml(schedule_dir.joinpath("start.yaml")) - except FileNotFoundError: - return None +# return _ScheduleResults( +# measurements_to_simulate=measurements, end_spacetime=cruise.spacetime +# ) diff --git a/virtual_ship/schedule.py b/virtual_ship/schedule.py index 69243627..1cc4f3e0 100644 --- a/virtual_ship/schedule.py +++ b/virtual_ship/schedule.py @@ -27,14 +27,18 @@ def from_yaml(cls, path: str | Path) -> Schedule: """ with open(path, "r") as in_file: data = yaml.safe_load(in_file) - waypoints = [ - Waypoint( - location=Location(waypoint["lat"], waypoint["lon"]), - time=waypoint["time"], - instrument=waypoint["instrument"], - ) - for waypoint in data - ] + try: + waypoints = [ + Waypoint( + location=Location(waypoint["lat"], waypoint["lon"]), + time=waypoint["time"], + instrument=waypoint["instrument"], + ) + for waypoint in data + ] + except Exception as err: + raise ValueError("Schedule not in correct format.") from err + return Schedule(waypoints) def to_yaml(self, path: str | Path) -> None: diff --git a/virtual_ship/ship_config.py b/virtual_ship/ship_config.py new file mode 100644 index 00000000..23c25a17 --- /dev/null +++ b/virtual_ship/ship_config.py @@ -0,0 +1,146 @@ +"""VirtualShipConfig class.""" + +from __future__ import annotations +from datetime import timedelta + +from parcels import FieldSet +from pathlib import Path +from pydantic import BaseModel, ConfigDict, field_serializer, field_validator, Field +import yaml +from typing import Any + + +class ArgoFloatConfig(BaseModel): + """Configuration for argos floats.""" + + min_depth: float = Field(le=0.0) + max_depth: float = Field(le=0.0) + drift_depth: float = Field(le=0.0) + vertical_speed: float = Field(lt=0.0) + cycle_days: float = Field(gt=0.0) + drift_days: float = Field(gt=0.0) + + +class ADCPConfig(BaseModel): + """Configuration for ADCP instrument.""" + + max_depth: float = Field(le=0.0) + bin_size_m: int = Field(gt=0.0) + period: timedelta = Field( + serialization_alias="period_minutes", + validation_alias="period_minutes", + gt=timedelta(), + ) + + model_config = ConfigDict(populate_by_name=True) + + @field_serializer("period") + def serialize_period(self, value: timedelta, _info): + return value.total_seconds() / 60.0 + + +class CTDConfig(BaseModel): + """Configuration for CTD instrument.""" + + stationkeeping_time: timedelta = Field( + serialization_alias="stationkeeping_time_minutes", + validation_alias="stationkeeping_time_minutes", + gt=timedelta(), + ) + min_depth: float = Field(ge=0.0) + max_depth: float = Field(ge=0.0) + + model_config = ConfigDict(populate_by_name=True) + + @field_serializer("stationkeeping_time") + def serialize_stationkeeping_time(self, value: timedelta, _info): + return value.total_seconds() / 60.0 + + +class ShipUnderwaterSTConfig(BaseModel): + """Configuration for underwater ST.""" + + period: timedelta = Field( + serialization_alias="period_minutes", + validation_alias="period_minutes", + gt=timedelta(), + ) + + model_config = ConfigDict(populate_by_name=True) + + @field_serializer("period") + def serialize_period(self, value: timedelta, _info): + return value.total_seconds() / 60.0 + + +class DrifterConfig(BaseModel): + """Configuration for drifters.""" + + depth: float = Field(le=0.0) + lifetime: timedelta = Field( + serialization_alias="lifetime_minutes", + validation_alias="lifetime_minutes", + gt=timedelta(), + ) + + model_config = ConfigDict(populate_by_name=True) + + @field_serializer("lifetime") + def serialize_lifetime(self, value: timedelta, _info): + return value.total_seconds() / 60.0 + + +class ShipConfig(BaseModel): + """Configuration of the virtual ship.""" + + ship_speed: float = Field(gt=0.0) + """ + Velocity of the ship in meters per second. + """ + + argo_float_config: ArgoFloatConfig | None = None + """ + Argo float configuration. + + If None, no argo floats can be deployed. + """ + + adcp_config: ADCPConfig | None + """ + ADCP configuration. + + If None, no ADCP measurements will be performed. + """ + + ctd_config: CTDConfig | None + """ + CTD configuration. + + If None, no CTDs can be cast. + """ + + ship_underwater_st_config: ShipUnderwaterSTConfig | None + """ + Ship underwater salinity temperature measurementconfiguration. + + If None, no ST measurements will be performed. + """ + + drifter_config: DrifterConfig + """ + Drifter configuration. + + If None, no drifters can be deployed. + """ + + model_config = ConfigDict(extra="forbid") + + def save_to_yaml(self, file_path: str | Path) -> None: + with open(file_path, "w") as file: + yaml.dump(self.model_dump(by_alias=True), file) + + @classmethod + def load_from_yaml(cls, file_path: str | Path) -> ShipConfig: + with open(file_path, "r") as file: + data = yaml.safe_load(file) + return ShipConfig(**data) From 86bc46abfa98121083c347e9fbf6febd951b75ec Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Thu, 22 Aug 2024 01:16:01 +0200 Subject: [PATCH 03/35] wip --- .../{test.yaml => ship_config.yaml} | 0 tests/expedition/test_expedition.py | 84 +++++++++---------- virtual_ship/loop.py | 67 +++++++-------- virtual_ship/ship_config.py | 6 +- 4 files changed, 75 insertions(+), 82 deletions(-) rename tests/expedition/{test.yaml => ship_config.yaml} (100%) diff --git a/tests/expedition/test.yaml b/tests/expedition/ship_config.yaml similarity index 100% rename from tests/expedition/test.yaml rename to tests/expedition/ship_config.yaml diff --git a/tests/expedition/test_expedition.py b/tests/expedition/test_expedition.py index e322d82f..4820604f 100644 --- a/tests/expedition/test_expedition.py +++ b/tests/expedition/test_expedition.py @@ -5,51 +5,49 @@ CTDConfig, DrifterConfig, ShipUnderwaterSTConfig, + loop, ) from datetime import timedelta def test_expedition() -> None: - argo_float_config = ArgoFloatConfig( - min_depth=0, - max_depth=-2000, - drift_depth=-1000, - vertical_speed=-0.10, - cycle_days=10, - drift_days=9, - ) - - adcp_config = ADCPConfig( - max_depth=-1000, - bin_size_m=24, - period=timedelta(minutes=5), - ) - - ship_underwater_st_config = ShipUnderwaterSTConfig( - period=timedelta(minutes=5), - ) - - ctd_config = CTDConfig( - stationkeeping_time=timedelta(minutes=20), - min_depth=0, - max_depth=2000, - ) - - drifter_config = DrifterConfig( - depth=0, - lifetime=timedelta(weeks=4), - ) - - ShipConfig( - ship_speed=5.14, - argo_float_config=argo_float_config, - adcp_config=adcp_config, - ctd_config=ctd_config, - ship_underwater_st_config=ship_underwater_st_config, - drifter_config=drifter_config, - ).save_to_yaml("test.yaml") - ShipConfig.load_from_yaml("test.yaml") - # with open("ship_config.yaml", "r") as file: - # data = yaml.load(file) - # x = ShipConfig() - # x = ShipConfig(ship_speed=3.0) + # argo_float_config = ArgoFloatConfig( + # min_depth=0, + # max_depth=-2000, + # drift_depth=-1000, + # vertical_speed=-0.10, + # cycle_days=10, + # drift_days=9, + # ) + + # adcp_config = ADCPConfig( + # max_depth=-1000, + # bin_size_m=24, + # period=timedelta(minutes=5), + # ) + + # ship_underwater_st_config = ShipUnderwaterSTConfig( + # period=timedelta(minutes=5), + # ) + + # ctd_config = CTDConfig( + # stationkeeping_time=timedelta(minutes=20), + # min_depth=0, + # max_depth=2000, + # ) + + # drifter_config = DrifterConfig( + # depth=0, + # lifetime=timedelta(weeks=4), + # ) + + # ShipConfig( + # ship_speed=5.14, + # argo_float_config=argo_float_config, + # adcp_config=adcp_config, + # ctd_config=ctd_config, + # ship_underwater_st_config=ship_underwater_st_config, + # drifter_config=drifter_config, + # ).save_to_yaml("test.yaml") + + loop(".") diff --git a/virtual_ship/loop.py b/virtual_ship/loop.py index 57c96769..a55cc4bc 100644 --- a/virtual_ship/loop.py +++ b/virtual_ship/loop.py @@ -1,55 +1,50 @@ # from datetime import datetime -# from .schedule import Schedule +from .schedule import Schedule from pathlib import Path -# import pyproj -# from virtual_ship import ShipConfig +import pyproj +from .ship_config import ShipConfig def loop(expedition_dir: str | Path) -> None: - raise NotImplementedError() + if isinstance(expedition_dir, str): + expedition_dir = Path(expedition_dir) + ship_config = _get_ship_config(expedition_dir) + if ship_config is None: + return -# if isinstance(expedition_dir, str): -# expedition_dir = Path(expedition_dir) + schedule = _get_schedule(expedition_dir) + if schedule is None: + return -# ship_config = get_ship_config(expedition_dir) -# if ship_config is None: -# return + # projection used to sail between waypoints + projection = pyproj.Geod(ellps="WGS84") -# schedule = get_schedule(expedition_dir) -# if schedule is None: -# return + # simulate_schedule(projection=projection, config=config) -# # projection used to sail between waypoints -# projection = pyproj.Geod(ellps="WGS84") -# ship_config = ShipConfig.from_yaml() +def print_and_wait_for_user(message: str) -> None: + print(message) + input() -# simulate_schedule(projection=projection, config=config) +def _get_ship_config(expedition_dir: Path) -> ShipConfig | None: + schedule_path = expedition_dir.joinpath("ship_config.yaml") + try: + return ShipConfig.from_yaml(schedule_path) + except FileNotFoundError: + print(f'Schedule not found. Save it to "{schedule_path}".') + return None -# def print_and_wait_for_user(message: str) -> None: -# print(message) -# input() - -# def get_ship_config(expedition_dir: Path) -> Schedule | None: -# schedule_path = expedition_dir.joinpath("ship_config.yaml") -# try: -# return Schedule.from_yaml(schedule_path) -# except FileNotFoundError: -# print(f'Schedule not found. Save it to "{schedule_path}".') -# return None - - -# def get_schedule(expedition_dir: Path) -> Schedule | None: -# schedule_path = expedition_dir.joinpath("schedule.yaml") -# try: -# return Schedule.from_yaml(schedule_path) -# except FileNotFoundError: -# print(f'Schedule not found. Save it to "{schedule_path}".') -# return None +def _get_schedule(expedition_dir: Path) -> Schedule | None: + schedule_path = expedition_dir.joinpath("schedule.yaml") + try: + return Schedule.from_yaml(schedule_path) + except FileNotFoundError: + print(f'Schedule not found. Save it to "{schedule_path}".') + return None # def simulate_schedule( diff --git a/virtual_ship/ship_config.py b/virtual_ship/ship_config.py index 23c25a17..7a3314fb 100644 --- a/virtual_ship/ship_config.py +++ b/virtual_ship/ship_config.py @@ -126,7 +126,7 @@ class ShipConfig(BaseModel): If None, no ST measurements will be performed. """ - drifter_config: DrifterConfig + drifter_config: DrifterConfig | None """ Drifter configuration. @@ -135,12 +135,12 @@ class ShipConfig(BaseModel): model_config = ConfigDict(extra="forbid") - def save_to_yaml(self, file_path: str | Path) -> None: + def to_yaml(self, file_path: str | Path) -> None: with open(file_path, "w") as file: yaml.dump(self.model_dump(by_alias=True), file) @classmethod - def load_from_yaml(cls, file_path: str | Path) -> ShipConfig: + def from_yaml(cls, file_path: str | Path) -> ShipConfig: with open(file_path, "r") as file: data = yaml.safe_load(file) return ShipConfig(**data) From f8eeebefa41fbcaac3d7f43a5fd9d4b811dd84c4 Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Thu, 22 Aug 2024 03:08:16 +0200 Subject: [PATCH 04/35] wip --- virtual_ship/checkpoint.py | 20 ++ virtual_ship/loop.py | 186 +++--------------- virtual_ship/ship_config.py | 3 +- virtual_ship/simulate_schedule.py | 308 ++++++++++++++++++++++++++++++ 4 files changed, 357 insertions(+), 160 deletions(-) create mode 100644 virtual_ship/checkpoint.py create mode 100644 virtual_ship/simulate_schedule.py diff --git a/virtual_ship/checkpoint.py b/virtual_ship/checkpoint.py new file mode 100644 index 00000000..03c5ae3c --- /dev/null +++ b/virtual_ship/checkpoint.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, field_serializer, Field +from pathlib import Path +import yaml +from .schedule import Schedule + + +class Checkpoint(BaseModel): + past_schedule: Schedule + + def to_yaml(self, file_path: str | Path) -> None: + with open(file_path, "w") as file: + yaml.dump(self.model_dump(by_alias=True), file) + + @classmethod + def from_yaml(cls, file_path: str | Path) -> Checkpoint: + with open(file_path, "r") as file: + data = yaml.safe_load(file) + return Checkpoint(**data) diff --git a/virtual_ship/loop.py b/virtual_ship/loop.py index a55cc4bc..fd0a2380 100644 --- a/virtual_ship/loop.py +++ b/virtual_ship/loop.py @@ -4,6 +4,9 @@ import pyproj from .ship_config import ShipConfig +from .checkpoint import Checkpoint +from datetime import datetime +from .simulate_schedule import simulate_schedule def loop(expedition_dir: str | Path) -> None: @@ -18,179 +21,46 @@ def loop(expedition_dir: str | Path) -> None: if schedule is None: return + checkpoint = _load_checkpoint(expedition_dir) + if checkpoint is None: + checkpoint = Checkpoint() + # projection used to sail between waypoints projection = pyproj.Geod(ellps="WGS84") - # simulate_schedule(projection=projection, config=config) - + # simulate the schedule from the checkpoint + simulate_schedule(projection=projection, ship_config=ship_config) + # TODO this should return whether the complete schedule is done + # or the part of the schedule that's done + # store as checkpoint + # ask user to update schedule + # reload and check if matching checkpoint + # then simulate whole schedule again (it's fast anyway) -def print_and_wait_for_user(message: str) -> None: - print(message) - input() + # finally, simulate measurements def _get_ship_config(expedition_dir: Path) -> ShipConfig | None: - schedule_path = expedition_dir.joinpath("ship_config.yaml") + file_path = expedition_dir.joinpath("ship_config.yaml") try: - return ShipConfig.from_yaml(schedule_path) + return ShipConfig.from_yaml(file_path) except FileNotFoundError: - print(f'Schedule not found. Save it to "{schedule_path}".') + print(f'Schedule not found. Save it to "{file_path}".') return None def _get_schedule(expedition_dir: Path) -> Schedule | None: - schedule_path = expedition_dir.joinpath("schedule.yaml") + file_path = expedition_dir.joinpath("schedule.yaml") try: - return Schedule.from_yaml(schedule_path) + return Schedule.from_yaml(file_path) except FileNotFoundError: - print(f'Schedule not found. Save it to "{schedule_path}".') + print(f'Schedule not found. Save it to "{file_path}".') return None -# def simulate_schedule( -# projection: pyproj.Geod, -# config: VirtualShipConfig, -# ) -> _ScheduleResults: -# """ -# Simulate the sailing and aggregate the virtual measurements that should be taken. - -# :param projection: Projection used to sail between waypoints. -# :param config: The cruise configuration. -# :returns: Results from the simulation. -# :raises NotImplementedError: When unsupported instruments are encountered. -# :raises RuntimeError: When schedule appears infeasible. This should not happen in this version of virtual ship as the schedule is verified beforehand. -# """ -# cruise = _Cruise( -# Spacetime( -# config.schedule.waypoints[0].location, config.schedule.waypoints[0].time -# ) -# ) -# measurements = _MeasurementsToSimulate() - -# # add recurring tasks to task list -# waiting_tasks = SortedList[_WaitingTask]() -# if config.ship_underwater_st_config is not None: -# waiting_tasks.add( -# _WaitingTask( -# task=_ship_underwater_st_loop( -# config.ship_underwater_st_config.period, cruise, measurements -# ), -# wait_until=cruise.spacetime.time, -# ) -# ) -# if config.adcp_config is not None: -# waiting_tasks.add( -# _WaitingTask( -# task=_adcp_loop(config.adcp_config.period, cruise, measurements), -# wait_until=cruise.spacetime.time, -# ) -# ) - -# # sail to each waypoint while executing tasks -# for waypoint in config.schedule.waypoints: -# if waypoint.time is not None and cruise.spacetime.time > waypoint.time: -# raise RuntimeError( -# "Could not reach waypoint in time. This should not happen in this version of virtual ship as the schedule is verified beforehand." -# ) - -# # add task to the task queue for the instrument at the current waypoint -# if waypoint.instrument is InstrumentType.ARGO_FLOAT: -# _argo_float_task(cruise, measurements, config=config) -# elif waypoint.instrument is InstrumentType.DRIFTER: -# _drifter_task(cruise, measurements, config=config) -# elif waypoint.instrument is InstrumentType.CTD: -# waiting_tasks.add( -# _WaitingTask( -# _ctd_task( -# config.ctd_config.stationkeeping_time, -# config.ctd_config.min_depth, -# config.ctd_config.max_depth, -# cruise, -# measurements, -# ), -# cruise.spacetime.time, -# ) -# ) -# elif waypoint.instrument is None: -# pass -# else: -# raise NotImplementedError() - -# # sail to the next waypoint -# waypoint_reached = False -# while not waypoint_reached: -# # execute all tasks planned for current time -# while ( -# len(waiting_tasks) > 0 -# and waiting_tasks[0].wait_until <= cruise.spacetime.time -# ): -# task = waiting_tasks.pop(0) -# try: -# wait_for = next(task.task) -# waiting_tasks.add( -# _WaitingTask(task.task, cruise.spacetime.time + wait_for.time) -# ) -# except StopIteration: -# pass - -# # if sailing is prevented by a current task, just let time pass until the next task -# if cruise.sail_is_locked: -# cruise.spacetime = Spacetime( -# cruise.spacetime.location, waiting_tasks[0].wait_until -# ) -# # else, let time pass while sailing -# else: -# # calculate time at which waypoint would be reached if simply sailing -# geodinv: tuple[float, float, float] = projection.inv( -# lons1=cruise.spacetime.location.lon, -# lats1=cruise.spacetime.location.lat, -# lons2=waypoint.location.lon, -# lats2=waypoint.location.lat, -# ) -# azimuth1 = geodinv[0] -# distance_to_next_waypoint = geodinv[2] -# time_to_reach = timedelta( -# seconds=distance_to_next_waypoint / config.ship_speed -# ) -# arrival_time = cruise.spacetime.time + time_to_reach - -# # if waypoint is reached before next task, sail to the waypoint -# if ( -# len(waiting_tasks) == 0 -# or arrival_time <= waiting_tasks[0].wait_until -# ): -# cruise.spacetime = Spacetime(waypoint.location, arrival_time) -# waypoint_reached = True -# # else, sail until task starts -# else: -# time_to_sail = waiting_tasks[0].wait_until - cruise.spacetime.time -# distance_to_move = config.ship_speed * time_to_sail.total_seconds() -# geodfwd: tuple[float, float, float] = projection.fwd( -# lons=cruise.spacetime.location.lon, -# lats=cruise.spacetime.location.lat, -# az=azimuth1, -# dist=distance_to_move, -# ) -# lon = geodfwd[0] -# lat = geodfwd[1] -# cruise.spacetime = Spacetime( -# Location(latitude=lat, longitude=lon), -# cruise.spacetime.time + time_to_sail, -# ) - -# cruise.finish() - -# # don't sail anymore, but let tasks finish -# while len(waiting_tasks) > 0: -# task = waiting_tasks.pop(0) -# try: -# wait_for = next(task.task) -# waiting_tasks.add( -# _WaitingTask(task.task, cruise.spacetime.time + wait_for.time) -# ) -# except StopIteration: -# pass - -# return _ScheduleResults( -# measurements_to_simulate=measurements, end_spacetime=cruise.spacetime -# ) +def _load_checkpoint(expedition_dir: Path) -> Schedule | None: + file_path = expedition_dir.joinpath("checkpoint.yaml") + try: + return Checkpoint.from_yaml(file_path) + except FileNotFoundError: + return None diff --git a/virtual_ship/ship_config.py b/virtual_ship/ship_config.py index 7a3314fb..1808a24c 100644 --- a/virtual_ship/ship_config.py +++ b/virtual_ship/ship_config.py @@ -3,9 +3,8 @@ from __future__ import annotations from datetime import timedelta -from parcels import FieldSet from pathlib import Path -from pydantic import BaseModel, ConfigDict, field_serializer, field_validator, Field +from pydantic import BaseModel, ConfigDict, field_serializer, Field import yaml from typing import Any diff --git a/virtual_ship/simulate_schedule.py b/virtual_ship/simulate_schedule.py new file mode 100644 index 00000000..23ca55c4 --- /dev/null +++ b/virtual_ship/simulate_schedule.py @@ -0,0 +1,308 @@ +from __future__ import annotations +import pyproj +from .ship_config import ShipConfig +from datetime import datetime, timedelta +from dataclasses import dataclass, field +from .instruments.argo_float import ArgoFloat +from .instruments.ctd import CTD +from .instruments.drifter import Drifter +from .spacetime import Spacetime +from sortedcontainers import SortedList +from .instrument_type import InstrumentType +from .location import Location +from contextlib import contextmanager +from typing import Generator +from .schedule import Schedule + + +def simulate_schedule( + projection: pyproj.Geod, ship_config: ShipConfig, schedule: Schedule +) -> _ScheduleResults: + """ + Simulate the expedition schedule and aggregate the virtual measurements that should be taken. + + :param projection: Projection used to sail between waypoints. + :param ship_config: The ship configuration. + :returns: Results from the simulation. + :raises NotImplementedError: When unsupported instruments are encountered. + :raises RuntimeError: When schedule appears infeasible. This should not happen in this version of virtual ship as the schedule is verified beforehand. + """ + cruise = _SimulationState( + Spacetime( + schedule.waypoints[0].location, + schedule.waypoints[0].time, + ) + ) + measurements = _MeasurementsToSimulate() + + # add recurring tasks to task list + waiting_tasks = SortedList[_WaitingTask]() + if ship_config.ship_underwater_st_config is not None: + waiting_tasks.add( + _WaitingTask( + task=_ship_underwater_st_loop( + ship_config.ship_underwater_st_config.period, cruise, measurements + ), + wait_until=cruise.spacetime.time, + ) + ) + if ship_config.adcp_config is not None: + waiting_tasks.add( + _WaitingTask( + task=_adcp_loop(ship_config.adcp_config.period, cruise, measurements), + wait_until=cruise.spacetime.time, + ) + ) + + # sail to each waypoint while executing tasks + for waypoint in schedule.waypoints: + if waypoint.time is not None and cruise.spacetime.time > waypoint.time: + raise RuntimeError( + "Could not reach waypoint in time. This should not happen in this version of virtual ship as the schedule is verified beforehand." + ) + + # add task to the task queue for the instrument at the current waypoint + if waypoint.instrument is InstrumentType.ARGO_FLOAT: + _argo_float_task(cruise, measurements, config=ship_config) + elif waypoint.instrument is InstrumentType.DRIFTER: + _drifter_task(cruise, measurements, config=ship_config) + elif waypoint.instrument is InstrumentType.CTD: + waiting_tasks.add( + _WaitingTask( + _ctd_task( + ship_config.ctd_config.stationkeeping_time, + ship_config.ctd_config.min_depth, + ship_config.ctd_config.max_depth, + cruise, + measurements, + ), + cruise.spacetime.time, + ) + ) + elif waypoint.instrument is None: + pass + else: + raise NotImplementedError() + + # sail to the next waypoint + waypoint_reached = False + while not waypoint_reached: + # execute all tasks planned for current time + while ( + len(waiting_tasks) > 0 + and waiting_tasks[0].wait_until <= cruise.spacetime.time + ): + task = waiting_tasks.pop(0) + try: + wait_for = next(task.task) + waiting_tasks.add( + _WaitingTask(task.task, cruise.spacetime.time + wait_for.time) + ) + except StopIteration: + pass + + # if sailing is prevented by a current task, just let time pass until the next task + if cruise.sail_is_locked: + cruise.spacetime = Spacetime( + cruise.spacetime.location, waiting_tasks[0].wait_until + ) + # else, let time pass while sailing + else: + # calculate time at which waypoint would be reached if simply sailing + geodinv: tuple[float, float, float] = projection.inv( + lons1=cruise.spacetime.location.lon, + lats1=cruise.spacetime.location.lat, + lons2=waypoint.location.lon, + lats2=waypoint.location.lat, + ) + azimuth1 = geodinv[0] + distance_to_next_waypoint = geodinv[2] + time_to_reach = timedelta( + seconds=distance_to_next_waypoint / ship_config.ship_speed + ) + arrival_time = cruise.spacetime.time + time_to_reach + + # if waypoint is reached before next task, sail to the waypoint + if ( + len(waiting_tasks) == 0 + or arrival_time <= waiting_tasks[0].wait_until + ): + cruise.spacetime = Spacetime(waypoint.location, arrival_time) + waypoint_reached = True + # else, sail until task starts + else: + time_to_sail = waiting_tasks[0].wait_until - cruise.spacetime.time + distance_to_move = ( + ship_config.ship_speed * time_to_sail.total_seconds() + ) + geodfwd: tuple[float, float, float] = projection.fwd( + lons=cruise.spacetime.location.lon, + lats=cruise.spacetime.location.lat, + az=azimuth1, + dist=distance_to_move, + ) + lon = geodfwd[0] + lat = geodfwd[1] + cruise.spacetime = Spacetime( + Location(latitude=lat, longitude=lon), + cruise.spacetime.time + time_to_sail, + ) + + cruise.finish() + + # don't sail anymore, but let tasks finish + while len(waiting_tasks) > 0: + task = waiting_tasks.pop(0) + try: + wait_for = next(task.task) + waiting_tasks.add( + _WaitingTask(task.task, cruise.spacetime.time + wait_for.time) + ) + except StopIteration: + pass + + return _ScheduleResults( + measurements_to_simulate=measurements, end_spacetime=cruise.spacetime + ) + + +class _SimulationState: + _finished: bool # if last waypoint has been reached + _sail_lock_count: int # if sailing should be paused because of tasks; number of tasks that requested a pause; 0 means good to go sail + spacetime: Spacetime # current location and time + + def __init__(self, spacetime: Spacetime) -> None: + self._finished = False + self._sail_lock_count = 0 + self.spacetime = spacetime + + @property + def finished(self) -> bool: + return self._finished + + @contextmanager + def do_not_sail(self) -> Generator[None, None, None]: + try: + self._sail_lock_count += 1 + yield + finally: + self._sail_lock_count -= 1 + + def finish(self) -> None: + self._finished = True + + @property + def sail_is_locked(self) -> bool: + return self._sail_lock_count > 0 + + +@dataclass +class _MeasurementsToSimulate: + adcps: list[Spacetime] = field(default_factory=list, init=False) + ship_underwater_sts: list[Spacetime] = field(default_factory=list, init=False) + argo_floats: list[ArgoFloat] = field(default_factory=list, init=False) + drifters: list[Drifter] = field(default_factory=list, init=False) + ctds: list[CTD] = field(default_factory=list, init=False) + + +@dataclass +class _ScheduleResults: + measurements_to_simulate: _MeasurementsToSimulate + end_spacetime: Spacetime + + +@dataclass +class _WaitFor: + time: timedelta + + +class _WaitingTask: + _task: Generator[_WaitFor, None, None] + _wait_until: datetime + + def __init__( + self, task: Generator[_WaitFor, None, None], wait_until: datetime + ) -> None: + self._task = task + self._wait_until = wait_until + + def __lt__(self, other: _WaitingTask): + return self._wait_until < other._wait_until + + @property + def task(self) -> Generator[_WaitFor, None, None]: + return self._task + + @property + def wait_until(self) -> datetime: + return self._wait_until + + +def _ship_underwater_st_loop( + sample_period: timedelta, + cruise: _SimulationState, + measurements: _MeasurementsToSimulate, +) -> Generator[_WaitFor, None, None]: + while not cruise.finished: + measurements.ship_underwater_sts.append(cruise.spacetime) + yield _WaitFor(sample_period) + + +def _adcp_loop( + sample_period: timedelta, + cruise: _SimulationState, + measurements: _MeasurementsToSimulate, +) -> Generator[_WaitFor, None, None]: + while not cruise.finished: + measurements.adcps.append(cruise.spacetime) + yield _WaitFor(sample_period) + + +def _ctd_task( + stationkeeping_time: timedelta, + min_depth: float, + max_depth: float, + cruise: _SimulationState, + measurements: _MeasurementsToSimulate, +) -> Generator[_WaitFor, None, None]: + with cruise.do_not_sail(): + measurements.ctds.append( + CTD( + spacetime=cruise.spacetime, + min_depth=min_depth, + max_depth=max_depth, + ) + ) + yield _WaitFor(stationkeeping_time) + + +def _drifter_task( + cruise: _SimulationState, + measurements: _MeasurementsToSimulate, + config: ShipConfig, +) -> None: + measurements.drifters.append( + Drifter( + cruise.spacetime, + depth=config.drifter_config.depth, + lifetime=config.drifter_config.lifetime, + ) + ) + + +def _argo_float_task( + cruise: _SimulationState, + measurements: _MeasurementsToSimulate, + config: ShipConfig, +) -> None: + measurements.argo_floats.append( + ArgoFloat( + spacetime=cruise.spacetime, + min_depth=config.argo_float_config.min_depth, + max_depth=config.argo_float_config.max_depth, + drift_depth=config.argo_float_config.drift_depth, + vertical_speed=config.argo_float_config.vertical_speed, + cycle_days=config.argo_float_config.cycle_days, + drift_days=config.argo_float_config.drift_days, + ) + ) From 37780f108b59e89980ff5a9f8aacc414357f66b1 Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Thu, 22 Aug 2024 23:01:17 +0200 Subject: [PATCH 05/35] wip --- download_data.py | 102 ++++++++++++ tests/expedition/schedule.yaml | 19 ++- tests/expedition/ship_config.yaml | 2 +- tests/instruments/test_ship_underwater_st.py | 6 +- tests/test.yaml | 11 ++ tests/test_schedule.py | 12 +- virtual_ship/input_data.py | 152 ++++++++++++++++++ .../instruments/ship_underwater_st.py | 12 +- virtual_ship/loop.py | 74 +++++++-- virtual_ship/schedule.py | 63 ++------ virtual_ship/ship_config.py | 5 +- virtual_ship/simulate_measurements.py | 90 +++++++++++ virtual_ship/simulate_schedule.py | 39 +++-- virtual_ship/verify_schedule.py | 119 ++++++++++++++ 14 files changed, 603 insertions(+), 103 deletions(-) create mode 100644 download_data.py create mode 100644 tests/test.yaml create mode 100644 virtual_ship/input_data.py create mode 100644 virtual_ship/simulate_measurements.py create mode 100644 virtual_ship/verify_schedule.py diff --git a/download_data.py b/download_data.py new file mode 100644 index 00000000..fb9a9ef1 --- /dev/null +++ b/download_data.py @@ -0,0 +1,102 @@ +import copernicusmarine +import datetime + +if __name__ == "__main__": + datadir = "data_groupF" + + download_dict = { + "UVdata": { + "dataset_id": "cmems_mod_glo_phy-cur_anfc_0.083deg_PT6H-i", + "variables": ["uo", "vo"], + "output_filename": "default_uv.nc", + }, + "Sdata": { + "dataset_id": "cmems_mod_glo_phy-so_anfc_0.083deg_PT6H-i", + "variables": ["so"], + "output_filename": "default_s.nc", + }, + "Tdata": { + "dataset_id": "cmems_mod_glo_phy-thetao_anfc_0.083deg_PT6H-i", + "variables": ["thetao"], + "output_filename": "default_t.nc", + }, + } + + for dataset in download_dict: + copernicusmarine.subset( + dataset_id=download_dict[dataset]["dataset_id"], + variables=download_dict[dataset]["variables"], + minimum_longitude=-1, + maximum_longitude=1, + minimum_latitude=-1, + maximum_latitude=1, + start_datetime=datetime.datetime.strptime("2023-01-01", "%Y-%m-%d"), + end_datetime=datetime.datetime.strptime("2023-01-02", "%Y-%m-%d"), + minimum_depth=0.49402499198913574, + maximum_depth=5727.9169921875, + output_filename=download_dict[dataset]["output_filename"], + output_directory=datadir, + ) + + download_dict = { + "UVdata": { + "dataset_id": "cmems_mod_glo_phy-cur_anfc_0.083deg_PT6H-i", + "variables": ["uo", "vo"], + "output_filename": "drifter_uv.nc", + }, + "Tdata": { + "dataset_id": "cmems_mod_glo_phy-thetao_anfc_0.083deg_PT6H-i", + "variables": ["thetao"], + "output_filename": "drifter_t.nc", + }, + } + + for dataset in download_dict: + copernicusmarine.subset( + dataset_id=download_dict[dataset]["dataset_id"], + variables=download_dict[dataset]["variables"], + minimum_longitude=-1, + maximum_longitude=1, + minimum_latitude=-1, + maximum_latitude=1, + start_datetime=datetime.datetime.strptime("2023-01-01", "%Y-%m-%d"), + end_datetime=datetime.datetime.strptime("2023-01-02", "%Y-%m-%d"), + minimum_depth=0.49402499198913574, + maximum_depth=0.49402499198913574, + output_filename=download_dict[dataset]["output_filename"], + output_directory=datadir, + ) + + download_dict = { + "UVdata": { + "dataset_id": "cmems_mod_glo_phy-cur_anfc_0.083deg_PT6H-i", + "variables": ["uo", "vo"], + "output_filename": "argo_float_uv.nc", + }, + "Sdata": { + "dataset_id": "cmems_mod_glo_phy-so_anfc_0.083deg_PT6H-i", + "variables": ["so"], + "output_filename": "argo_float_s.nc", + }, + "Tdata": { + "dataset_id": "cmems_mod_glo_phy-thetao_anfc_0.083deg_PT6H-i", + "variables": ["thetao"], + "output_filename": "argo_float_t.nc", + }, + } + + for dataset in download_dict: + copernicusmarine.subset( + dataset_id=download_dict[dataset]["dataset_id"], + variables=download_dict[dataset]["variables"], + minimum_longitude=-1, + maximum_longitude=1, + minimum_latitude=-1, + maximum_latitude=1, + start_datetime=datetime.datetime.strptime("2023-01-01", "%Y-%m-%d"), + end_datetime=datetime.datetime.strptime("2023-01-02", "%Y-%m-%d"), + minimum_depth=0.49402499198913574, + maximum_depth=5727.9169921875, + output_filename=download_dict[dataset]["output_filename"], + output_directory=datadir, + ) diff --git a/tests/expedition/schedule.yaml b/tests/expedition/schedule.yaml index 7e7cad0c..58a0e309 100644 --- a/tests/expedition/schedule.yaml +++ b/tests/expedition/schedule.yaml @@ -1,8 +1,11 @@ -- lat: 0 - lon: 0 - time: None - instrument: None -- lat: 1 - lon: 1 - time: None - instrument: None \ No newline at end of file +waypoints: +- instrument: null + location: + latitude: 0 + longitude: 0 + time: 2023-01-01 00:00:00 +- instrument: null + location: + latitude: 0.01 + longitude: 0.01 + time: 2023-01-01 01:00:00 diff --git a/tests/expedition/ship_config.yaml b/tests/expedition/ship_config.yaml index 5029b55c..486c3fd4 100644 --- a/tests/expedition/ship_config.yaml +++ b/tests/expedition/ship_config.yaml @@ -1,5 +1,5 @@ adcp_config: - bin_size_m: 24 + num_bins: 40 max_depth: -1000.0 period_minutes: 5.0 argo_float_config: diff --git a/tests/instruments/test_ship_underwater_st.py b/tests/instruments/test_ship_underwater_st.py index a0535c8d..58df1860 100644 --- a/tests/instruments/test_ship_underwater_st.py +++ b/tests/instruments/test_ship_underwater_st.py @@ -56,8 +56,8 @@ def test_simulate_ship_underwater_st(tmpdir: py.path.LocalPath) -> None: { "V": np.zeros((2, 2, 2)), "U": np.zeros((2, 2, 2)), - "salinity": salinity, - "temperature": temperature, + "S": salinity, + "T": temperature, }, { "lat": np.array([expected_obs[0]["lat"], expected_obs[1]["lat"]]), @@ -95,7 +95,7 @@ def test_simulate_ship_underwater_st(tmpdir: py.path.LocalPath) -> None: zip(results.sel(trajectory=traj).obs, expected_obs, strict=True) ): obs = results.sel(trajectory=traj, obs=obs_i) - for var in ["salinity", "temperature", "lat", "lon"]: + for var in ["S", "T", "lat", "lon"]: obs_value = obs[var].values.item() exp_value = exp[var] assert np.isclose( diff --git a/tests/test.yaml b/tests/test.yaml new file mode 100644 index 00000000..ad9c3a89 --- /dev/null +++ b/tests/test.yaml @@ -0,0 +1,11 @@ +waypoints: +- instrument: null + location: + latitude: 0 + longitude: 0 + time: 1950-01-01 00:00:00 +- instrument: null + location: + latitude: 1 + longitude: 1 + time: 1950-01-01 01:00:00 diff --git a/tests/test_schedule.py b/tests/test_schedule.py index 371c786f..56ee0d4f 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -1,15 +1,21 @@ import py from virtual_ship import Location, Schedule, Waypoint +from datetime import datetime, timedelta def test_schedule(tmpdir: py.path.LocalPath) -> None: out_path = tmpdir.join("schedule.yaml") + # arbitrary time for testing + base_time = datetime.strptime("1950-01-01", "%Y-%m-%d") + schedule = Schedule( - [ - Waypoint(Location(0, 0), time=0, instrument=None), - Waypoint(Location(1, 1), time=1, instrument=None), + waypoints=[ + Waypoint(Location(0, 0), time=base_time, instrument=None), + Waypoint( + Location(1, 1), time=base_time + timedelta(hours=1), instrument=None + ), ] ) schedule.to_yaml(out_path) diff --git a/virtual_ship/input_data.py b/virtual_ship/input_data.py new file mode 100644 index 00000000..e1e30c6c --- /dev/null +++ b/virtual_ship/input_data.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from dataclasses import dataclass +from parcels import FieldSet, Field +from pathlib import Path + + +@dataclass +class InputData: + adcp_fieldset: FieldSet | None + argo_float_fieldset: FieldSet | None + ctd_fieldset: FieldSet | None + drifter_fieldset: FieldSet | None + ship_underwater_st_fieldset: FieldSet | None + + @classmethod + def load( + cls, + directory: str | Path, + load_adcp: bool, + load_argo_float: bool, + load_ctd: bool, + load_drifter: bool, + load_ship_underwater_st: bool, + ) -> InputData: + if load_drifter: + drifter_fieldset = cls._load_drifter_fieldset(directory) + else: + drifter_fieldset = None + if load_argo_float: + argo_float_fieldset = cls._load_argo_float_fieldset(directory) + else: + argo_float_fieldset = None + if load_adcp or load_ctd or load_ship_underwater_st: + default_fieldset = cls._load_default_fieldset(directory) + if load_adcp: + adcp_fieldset = default_fieldset + else: + adcp_fieldset = None + if load_ctd: + ctd_fieldset = default_fieldset + else: + ctd_fieldset = None + if load_ship_underwater_st: + ship_underwater_st_fieldset = default_fieldset + else: + ship_underwater_st_fieldset = None + + return InputData( + adcp_fieldset=adcp_fieldset, + argo_float_fieldset=argo_float_fieldset, + ctd_fieldset=ctd_fieldset, + drifter_fieldset=drifter_fieldset, + ship_underwater_st_fieldset=ship_underwater_st_fieldset, + ) + + @classmethod + def _load_default_fieldset(cls, directory: str | Path) -> FieldSet: + filenames = { + "U": directory.joinpath("default_uv.nc"), + "V": directory.joinpath("default_uv.nc"), + "S": directory.joinpath("default_s.nc"), + "T": directory.joinpath("default_t.nc"), + } + variables = {"U": "uo", "V": "vo", "S": "so", "T": "thetao"} + dimensions = { + "lon": "longitude", + "lat": "latitude", + "time": "time", + "depth": "depth", + } + + # create the fieldset and set interpolation methods + fieldset = FieldSet.from_netcdf( + filenames, variables, dimensions, allow_time_extrapolation=True + ) + fieldset.T.interp_method = "linear_invdist_land_tracer" + fieldset.S.interp_method = "linear_invdist_land_tracer" + + # make depth negative + for g in fieldset.gridset.grids: + if max(g.depth) > 0: + g.depth = -g.depth + + # add bathymetry data + bathymetry_file = directory.joinpath("GLO-MFC_001_024_mask_bathy.nc") + bathymetry_variables = ("bathymetry", "deptho") + bathymetry_dimensions = {"lon": "longitude", "lat": "latitude"} + bathymetry_field = Field.from_netcdf( + bathymetry_file, bathymetry_variables, bathymetry_dimensions + ) + fieldset.add_field(bathymetry_field) + + # read in data already + fieldset.computeTimeChunk(0, 1) + + return fieldset + + @classmethod + def _load_drifter_fieldset(cls, directory: str | Path) -> FieldSet: + filenames = { + "U": directory.joinpath("drifter_uv.nc"), + "V": directory.joinpath("drifter_uv.nc"), + "T": directory.joinpath("drifter_t.nc"), + } + variables = {"U": "uo", "V": "vo", "T": "thetao"} + dimensions = { + "lon": "longitude", + "lat": "latitude", + "time": "time", + "depth": "depth", + } + + fieldset = FieldSet.from_netcdf( + filenames, variables, dimensions, allow_time_extrapolation=False + ) + fieldset.T.interp_method = "linear_invdist_land_tracer" + + # make depth negative + for g in fieldset.gridset.grids: + if max(g.depth) > 0: + g.depth = -g.depth + + return fieldset + + @classmethod + def _load_argo_float_fieldset(cls, directory: str | Path) -> FieldSet: + filenames = { + "U": directory.joinpath("argo_float_uv.nc"), + "V": directory.joinpath("argo_float_uv.nc"), + "S": directory.joinpath("argo_float_s.nc"), + "T": directory.joinpath("argo_float_t.nc"), + } + variables = {"U": "uo", "V": "vo", "S": "so", "T": "thetao"} + dimensions = { + "lon": "longitude", + "lat": "latitude", + "time": "time", + "depth": "depth", + } + + fieldset = FieldSet.from_netcdf( + filenames, variables, dimensions, allow_time_extrapolation=False + ) + fieldset.T.interp_method = "linear_invdist_land_tracer" + + # make depth negative + for g in fieldset.gridset.grids: + if max(g.depth) > 0: + g.depth = -g.depth + + return fieldset diff --git a/virtual_ship/instruments/ship_underwater_st.py b/virtual_ship/instruments/ship_underwater_st.py index 3b4ac59e..3d2050d8 100644 --- a/virtual_ship/instruments/ship_underwater_st.py +++ b/virtual_ship/instruments/ship_underwater_st.py @@ -10,24 +10,20 @@ # there is some overhead with JITParticle and this ends up being significantly faster _ShipSTParticle = ScipyParticle.add_variables( [ - Variable("salinity", dtype=np.float32, initial=np.nan), - Variable("temperature", dtype=np.float32, initial=np.nan), + Variable("S", dtype=np.float32, initial=np.nan), + Variable("T", dtype=np.float32, initial=np.nan), ] ) # define function sampling Salinity def _sample_salinity(particle, fieldset, time): - particle.salinity = fieldset.salinity[ - time, particle.depth, particle.lat, particle.lon - ] + particle.S = fieldset.S[time, particle.depth, particle.lat, particle.lon] # define function sampling Temperature def _sample_temperature(particle, fieldset, time): - particle.temperature = fieldset.temperature[ - time, particle.depth, particle.lat, particle.lon - ] + particle.T = fieldset.T[time, particle.depth, particle.lat, particle.lon] def simulate_ship_underwater_st( diff --git a/virtual_ship/loop.py b/virtual_ship/loop.py index fd0a2380..873cc5b8 100644 --- a/virtual_ship/loop.py +++ b/virtual_ship/loop.py @@ -7,6 +7,9 @@ from .checkpoint import Checkpoint from datetime import datetime from .simulate_schedule import simulate_schedule +from .verify_schedule import verify_schedule +from .input_data import InputData +from .simulate_measurements import simulate_measurements def loop(expedition_dir: str | Path) -> None: @@ -23,21 +26,54 @@ def loop(expedition_dir: str | Path) -> None: checkpoint = _load_checkpoint(expedition_dir) if checkpoint is None: - checkpoint = Checkpoint() + checkpoint = Checkpoint(past_schedule=Schedule(waypoints=[])) + + # verify schedule and checkpoint match + if ( + not schedule.waypoints[: len(checkpoint.past_schedule.waypoints)] + == checkpoint.past_schedule.waypoints + ): + print( + "Past waypoints in schedule have been changed! Restore past schedule and only change future waypoints." + ) + return # projection used to sail between waypoints projection = pyproj.Geod(ellps="WGS84") - # simulate the schedule from the checkpoint - simulate_schedule(projection=projection, ship_config=ship_config) - # TODO this should return whether the complete schedule is done - # or the part of the schedule that's done - # store as checkpoint - # ask user to update schedule - # reload and check if matching checkpoint - # then simulate whole schedule again (it's fast anyway) + # load fieldsets + input_data = _load_input_data( + expedition_dir=expedition_dir, ship_config=ship_config + ) + + # verify schedule makes sense + verify_schedule(projection, ship_config, schedule, input_data) + + # simulate the schedule + schedule_results = simulate_schedule( + projection=projection, ship_config=ship_config, schedule=schedule + ) + if not schedule_results.success: + print( + f"It is currently {schedule_results.end_spacetime} and waypoint {schedule_results.failed_waypoint_i} could not be reached in time. Update your schedule and continue the expedition." + ) + _save_checkpoint( + Checkpoint( + past_schedule=Schedule( + waypoints=schedule.waypoints[: schedule_results.failed_waypoint_i] + ) + ) + ) + return - # finally, simulate measurements + print("Simulating measurements. This may take a while..") + simulate_measurements( + expedition_dir, + ship_config, + input_data, + schedule_results.measurements_to_simulate, + ) + print("Done simulating measurements.") def _get_ship_config(expedition_dir: Path) -> ShipConfig | None: @@ -49,6 +85,17 @@ def _get_ship_config(expedition_dir: Path) -> ShipConfig | None: return None +def _load_input_data(expedition_dir: Path, ship_config: ShipConfig) -> InputData: + return InputData.load( + directory=expedition_dir.joinpath("input_data"), + load_adcp=ship_config.adcp_config is not None, + load_argo_float=ship_config.argo_float_config is not None, + load_ctd=ship_config.ctd_config is not None, + load_drifter=ship_config.drifter_config is not None, + load_ship_underwater_st=ship_config.ship_underwater_st_config is not None, + ) + + def _get_schedule(expedition_dir: Path) -> Schedule | None: file_path = expedition_dir.joinpath("schedule.yaml") try: @@ -58,9 +105,14 @@ def _get_schedule(expedition_dir: Path) -> Schedule | None: return None -def _load_checkpoint(expedition_dir: Path) -> Schedule | None: +def _load_checkpoint(expedition_dir: Path) -> Checkpoint | None: file_path = expedition_dir.joinpath("checkpoint.yaml") try: return Checkpoint.from_yaml(file_path) except FileNotFoundError: return None + + +def _save_checkpoint(checkpoint: Checkpoint, expedition_dir: Path) -> None: + file_path = expedition_dir.joinpath("checkpoint.yaml") + checkpoint.to_yaml(file_path) diff --git a/virtual_ship/schedule.py b/virtual_ship/schedule.py index 1cc4f3e0..99add03f 100644 --- a/virtual_ship/schedule.py +++ b/virtual_ship/schedule.py @@ -2,64 +2,25 @@ from __future__ import annotations -from dataclasses import dataclass - +from pathlib import Path +from pydantic import BaseModel, ConfigDict import yaml - -from .location import Location from .waypoint import Waypoint -from pathlib import Path -@dataclass -class Schedule: +class Schedule(BaseModel): """Schedule of the virtual ship.""" waypoints: list[Waypoint] - @classmethod - def from_yaml(cls, path: str | Path) -> Schedule: - """ - Load schedule from YAML file. - - :param path: The file to read from. - :returns: Schedule of waypoints from the YAML file. - """ - with open(path, "r") as in_file: - data = yaml.safe_load(in_file) - try: - waypoints = [ - Waypoint( - location=Location(waypoint["lat"], waypoint["lon"]), - time=waypoint["time"], - instrument=waypoint["instrument"], - ) - for waypoint in data - ] - except Exception as err: - raise ValueError("Schedule not in correct format.") from err + model_config = ConfigDict(extra="forbid") - return Schedule(waypoints) + def to_yaml(self, file_path: str | Path) -> None: + with open(file_path, "w") as file: + yaml.dump(self.model_dump(by_alias=True), file) - def to_yaml(self, path: str | Path) -> None: - """ - Save schedule to YAML file. - - :param path: The file to write to. - """ - with open(path, "w") as out_file: - print( - yaml.dump( - [ - { - "lat": waypoint.location.lat, - "lon": waypoint.location.lon, - "time": waypoint.time, - "instrument": waypoint.instrument, - } - for waypoint in self.waypoints - ], - out_file, - ) - ) - pass + @classmethod + def from_yaml(cls, file_path: str | Path) -> Schedule: + with open(file_path, "r") as file: + data = yaml.safe_load(file) + return Schedule(**data) diff --git a/virtual_ship/ship_config.py b/virtual_ship/ship_config.py index 1808a24c..54094064 100644 --- a/virtual_ship/ship_config.py +++ b/virtual_ship/ship_config.py @@ -1,4 +1,4 @@ -"""VirtualShipConfig class.""" +"""ShipConfig and supporting classes.""" from __future__ import annotations from datetime import timedelta @@ -6,7 +6,6 @@ from pathlib import Path from pydantic import BaseModel, ConfigDict, field_serializer, Field import yaml -from typing import Any class ArgoFloatConfig(BaseModel): @@ -24,7 +23,7 @@ class ADCPConfig(BaseModel): """Configuration for ADCP instrument.""" max_depth: float = Field(le=0.0) - bin_size_m: int = Field(gt=0.0) + num_bins: int = Field(gt=0.0) period: timedelta = Field( serialization_alias="period_minutes", validation_alias="period_minutes", diff --git a/virtual_ship/simulate_measurements.py b/virtual_ship/simulate_measurements.py new file mode 100644 index 00000000..a7d9b5ba --- /dev/null +++ b/virtual_ship/simulate_measurements.py @@ -0,0 +1,90 @@ +from .ship_config import ShipConfig +from .instruments.adcp import simulate_adcp +from .instruments.argo_float import simulate_argo_floats +from .instruments.ctd import simulate_ctd +from .instruments.drifter import simulate_drifters +from .instruments.ship_underwater_st import simulate_ship_underwater_st +from .simulate_schedule import MeasurementsToSimulate +from pathlib import Path +from datetime import timedelta +from .input_data import InputData + + +def simulate_measurements( + expedition_dir: str | Path, + ship_config: ShipConfig, + input_data: InputData, + measurements: MeasurementsToSimulate, +) -> None: + if isinstance(expedition_dir, str): + expedition_dir = Path(expedition_dir) + + if len(measurements.ship_underwater_sts) > 0: + print("Simulating onboard salinity and temperature measurements.") + if input_data.ship_underwater_st_fieldset is None: + raise RuntimeError("No fieldset for ship underwater ST provided.") + if ship_config.ship_underwater_st_config is None: + raise RuntimeError("No configuration for ship underwater ST provided.") + simulate_ship_underwater_st( + fieldset=input_data.ship_underwater_st_fieldset, + out_path=expedition_dir.joinpath("results", "ship_underwater_st.zarr"), + depth=-2, + sample_points=measurements.ship_underwater_sts, + ) + + if len(measurements.adcps) > 0: + print("Simulating onboard ADCP.") + if input_data.adcp_fieldset is None: + raise RuntimeError("No fieldset for ADCP provided.") + if ship_config.adcp_config is None: + raise RuntimeError("No configuration for ADCP provided.") + simulate_adcp( + fieldset=input_data.adcp_fieldset, + out_path=expedition_dir.joinpath("results", "adcp.zarr"), + max_depth=ship_config.adcp_config.max_depth, + min_depth=-5, + num_bins=ship_config.adcp_config.num_bins, + sample_points=measurements.adcps, + ) + + if len(measurements.ctds) > 0: + print("Simulating CTD casts.") + if input_data.ctd_fieldset is None: + raise RuntimeError("No fieldset for CTD provided.") + if ship_config.ctd_config is None: + raise RuntimeError("No configuration for CTD provided.") + simulate_ctd( + out_path=expedition_dir.joinpath("results", "ctd.zarr"), + fieldset=input_data.ctd_fieldset, + ctds=measurements.ctds, + outputdt=timedelta(seconds=10), + ) + + if len(measurements.drifters) > 0: + print("Simulating drifters") + if input_data.drifter_fieldset is None: + raise RuntimeError("No fieldset for drifters provided.") + if ship_config.drifter_config is None: + raise RuntimeError("No configuration for drifters provided.") + simulate_drifters( + out_path=expedition_dir.joinpath("results", "drifters.zarr"), + fieldset=input_data.drifter_fieldset, + drifters=measurements.drifters, + outputdt=timedelta(hours=5), + dt=timedelta(minutes=5), + endtime=None, + ) + + if len(measurements.argo_floats) > 0: + print("Simulating argo floats") + if input_data.argo_float_fieldset is None: + raise RuntimeError("No fieldset for argo floats provided.") + if ship_config.argo_float_config is None: + raise RuntimeError("No configuration for argo floats provided.") + simulate_argo_floats( + out_path=expedition_dir.joinpath("results", "argo_floats.zarr"), + argo_floats=measurements.argo_floats, + fieldset=input_data.argo_float_fieldset, + outputdt=timedelta(minutes=5), + endtime=None, + ) diff --git a/virtual_ship/simulate_schedule.py b/virtual_ship/simulate_schedule.py index 23ca55c4..69d6a395 100644 --- a/virtual_ship/simulate_schedule.py +++ b/virtual_ship/simulate_schedule.py @@ -17,7 +17,7 @@ def simulate_schedule( projection: pyproj.Geod, ship_config: ShipConfig, schedule: Schedule -) -> _ScheduleResults: +) -> ScheduleResults: """ Simulate the expedition schedule and aggregate the virtual measurements that should be taken. @@ -25,7 +25,6 @@ def simulate_schedule( :param ship_config: The ship configuration. :returns: Results from the simulation. :raises NotImplementedError: When unsupported instruments are encountered. - :raises RuntimeError: When schedule appears infeasible. This should not happen in this version of virtual ship as the schedule is verified beforehand. """ cruise = _SimulationState( Spacetime( @@ -33,7 +32,7 @@ def simulate_schedule( schedule.waypoints[0].time, ) ) - measurements = _MeasurementsToSimulate() + measurements = MeasurementsToSimulate() # add recurring tasks to task list waiting_tasks = SortedList[_WaitingTask]() @@ -55,11 +54,17 @@ def simulate_schedule( ) # sail to each waypoint while executing tasks - for waypoint in schedule.waypoints: + for waypoint_i, waypoint in enumerate(schedule.waypoints): if waypoint.time is not None and cruise.spacetime.time > waypoint.time: - raise RuntimeError( + print( "Could not reach waypoint in time. This should not happen in this version of virtual ship as the schedule is verified beforehand." ) + return ScheduleResults( + success=False, + measurements_to_simulate=measurements, + end_spacetime=cruise.spacetime.time, + failed_waypoint_i=waypoint_i, + ) # add task to the task queue for the instrument at the current waypoint if waypoint.instrument is InstrumentType.ARGO_FLOAT: @@ -161,8 +166,10 @@ def simulate_schedule( except StopIteration: pass - return _ScheduleResults( - measurements_to_simulate=measurements, end_spacetime=cruise.spacetime + return ScheduleResults( + success=True, + measurements_to_simulate=measurements, + end_spacetime=cruise.spacetime, ) @@ -197,7 +204,7 @@ def sail_is_locked(self) -> bool: @dataclass -class _MeasurementsToSimulate: +class MeasurementsToSimulate: adcps: list[Spacetime] = field(default_factory=list, init=False) ship_underwater_sts: list[Spacetime] = field(default_factory=list, init=False) argo_floats: list[ArgoFloat] = field(default_factory=list, init=False) @@ -206,9 +213,11 @@ class _MeasurementsToSimulate: @dataclass -class _ScheduleResults: - measurements_to_simulate: _MeasurementsToSimulate +class ScheduleResults: + success: bool + measurements_to_simulate: MeasurementsToSimulate end_spacetime: Spacetime + failed_waypoint_i: int | None = None @dataclass @@ -241,7 +250,7 @@ def wait_until(self) -> datetime: def _ship_underwater_st_loop( sample_period: timedelta, cruise: _SimulationState, - measurements: _MeasurementsToSimulate, + measurements: MeasurementsToSimulate, ) -> Generator[_WaitFor, None, None]: while not cruise.finished: measurements.ship_underwater_sts.append(cruise.spacetime) @@ -251,7 +260,7 @@ def _ship_underwater_st_loop( def _adcp_loop( sample_period: timedelta, cruise: _SimulationState, - measurements: _MeasurementsToSimulate, + measurements: MeasurementsToSimulate, ) -> Generator[_WaitFor, None, None]: while not cruise.finished: measurements.adcps.append(cruise.spacetime) @@ -263,7 +272,7 @@ def _ctd_task( min_depth: float, max_depth: float, cruise: _SimulationState, - measurements: _MeasurementsToSimulate, + measurements: MeasurementsToSimulate, ) -> Generator[_WaitFor, None, None]: with cruise.do_not_sail(): measurements.ctds.append( @@ -278,7 +287,7 @@ def _ctd_task( def _drifter_task( cruise: _SimulationState, - measurements: _MeasurementsToSimulate, + measurements: MeasurementsToSimulate, config: ShipConfig, ) -> None: measurements.drifters.append( @@ -292,7 +301,7 @@ def _drifter_task( def _argo_float_task( cruise: _SimulationState, - measurements: _MeasurementsToSimulate, + measurements: MeasurementsToSimulate, config: ShipConfig, ) -> None: measurements.argo_floats.append( diff --git a/virtual_ship/verify_schedule.py b/virtual_ship/verify_schedule.py new file mode 100644 index 00000000..c65ebb5b --- /dev/null +++ b/virtual_ship/verify_schedule.py @@ -0,0 +1,119 @@ +import pyproj +from .ship_config import ShipConfig +from .planning_error import PlanningError +from .schedule import Schedule +from parcels import FieldSet +from .instrument_type import InstrumentType +from datetime import timedelta +from .waypoint import Waypoint +from .input_data import InputData + + +def verify_schedule( + projection: pyproj.Geod, + ship_config: ShipConfig, + schedule: Schedule, + input_data: InputData, +) -> None: + """ + Verify waypoints are ordered by time, first waypoint has a start time, and that schedule is feasible in terms of time if no unexpected events happen. + + :param projection: projection used to sail between waypoints. + :param ship_config: The cruise ship_configuration. + :raises PlanningError: If waypoints are not feasible or incorrect. + :raises ValueError: If there are no fieldsets in the ship_config, which are needed to verify all waypoints are on water. + """ + if len(schedule.waypoints) == 0: + raise PlanningError("At least one waypoint must be provided.") + + # check first waypoint has a time + if schedule.waypoints[0].time is None: + raise PlanningError("First waypoint must have a specified time.") + + # check waypoint times are in ascending order + timed_waypoints = [wp for wp in schedule.waypoints if wp.time is not None] + if not all( + [ + next.time >= cur.time + for cur, next in zip(timed_waypoints, timed_waypoints[1:]) + ] + ): + raise PlanningError( + "Each waypoint should be timed after all previous waypoints" + ) + + # check if all waypoints are in water + # this is done by picking an arbitrary provided fieldset and checking if UV is not zero + + # get all available fieldsets + available_fieldsets = [ + fs + for fs in [ + input_data.adcp_fieldset, + input_data.argo_float_fieldset, + input_data.ctd_fieldset, + input_data.drifter_fieldset, + input_data.ship_underwater_st_fieldset, + ] + if fs is not None + ] + # check if there are any fieldsets, else its an error + if len(available_fieldsets) == 0: + raise ValueError( + "No fieldsets provided to check if waypoints are on land. Assuming no provided fieldsets is an error." + ) + # pick any + fieldset = available_fieldsets[0] + # get waypoints with 0 UV + land_waypoints = [ + (wp_i, wp) + for wp_i, wp in enumerate(schedule.waypoints) + if _is_on_land_zero_uv(fieldset, wp) + ] + # raise an error if there are any + if len(land_waypoints) > 0: + raise PlanningError( + f"The following waypoints are on land: {['#' + str(wp_i) + ' ' + str(wp) for (wp_i, wp) in land_waypoints]}" + ) + + # check that ship will arrive on time at each waypoint (in case no unexpected event happen) + time = schedule.waypoints[0].time + for wp_i, (wp, wp_next) in enumerate( + zip(schedule.waypoints, schedule.waypoints[1:]) + ): + if wp.instrument is InstrumentType.CTD: + time += timedelta(minutes=20) + + geodinv: tuple[float, float, float] = projection.inv( + wp.location.lon, wp.location.lat, wp_next.location.lon, wp_next.location.lat + ) + distance = geodinv[2] + + time_to_reach = timedelta(seconds=distance / ship_config.ship_speed) + arrival_time = time + time_to_reach + + if wp_next.time is None: + time = arrival_time + elif arrival_time > wp_next.time: + raise PlanningError( + f"Waypoint planning is not valid: would arrive too late at a waypoint number {wp_i + 1}. location: {wp_next.location} time: {wp_next.time} instrument: {wp_next.instrument}" + ) + else: + time = wp_next.time + + +def _is_on_land_zero_uv(fieldset: FieldSet, waypoint: Waypoint) -> bool: + """ + Check if waypoint is on land by assuming zero velocity means land. + + :param fieldset: The fieldset to sample the velocity from. + :param waypoint: The waypoint to check. + :returns: If the waypoint is on land. + """ + return fieldset.UV.eval( + 0, + -fieldset.U.depth[0], + waypoint.location.lat, + waypoint.location.lon, + applyConversion=False, + ) == (0.0, 0.0) From e5a5768a8ec6d0d86d35776c43b6bcd5b4b6af2d Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Thu, 22 Aug 2024 23:01:56 +0200 Subject: [PATCH 06/35] clean --- tests/expedition/test_expedition.py | 50 +---------------------------- 1 file changed, 1 insertion(+), 49 deletions(-) diff --git a/tests/expedition/test_expedition.py b/tests/expedition/test_expedition.py index 4820604f..6e37fc6e 100644 --- a/tests/expedition/test_expedition.py +++ b/tests/expedition/test_expedition.py @@ -1,53 +1,5 @@ -from virtual_ship import ( - ShipConfig, - ArgoFloatConfig, - ADCPConfig, - CTDConfig, - DrifterConfig, - ShipUnderwaterSTConfig, - loop, -) -from datetime import timedelta +from virtual_ship import loop def test_expedition() -> None: - # argo_float_config = ArgoFloatConfig( - # min_depth=0, - # max_depth=-2000, - # drift_depth=-1000, - # vertical_speed=-0.10, - # cycle_days=10, - # drift_days=9, - # ) - - # adcp_config = ADCPConfig( - # max_depth=-1000, - # bin_size_m=24, - # period=timedelta(minutes=5), - # ) - - # ship_underwater_st_config = ShipUnderwaterSTConfig( - # period=timedelta(minutes=5), - # ) - - # ctd_config = CTDConfig( - # stationkeeping_time=timedelta(minutes=20), - # min_depth=0, - # max_depth=2000, - # ) - - # drifter_config = DrifterConfig( - # depth=0, - # lifetime=timedelta(weeks=4), - # ) - - # ShipConfig( - # ship_speed=5.14, - # argo_float_config=argo_float_config, - # adcp_config=adcp_config, - # ctd_config=ctd_config, - # ship_underwater_st_config=ship_underwater_st_config, - # drifter_config=drifter_config, - # ).save_to_yaml("test.yaml") - loop(".") From 9528cd84e666abe21e2e53fc1a0a0dbeb04c9324 Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Thu, 22 Aug 2024 23:20:01 +0200 Subject: [PATCH 07/35] costs --- tests/expedition/results/cost.txt | 1 + tests/expedition/test_expedition.py | 4 ++-- virtual_ship/__init__.py | 4 ++-- virtual_ship/{loop.py => do_expedition.py} | 21 +++++++++++++++---- virtual_ship/expedition_cost.py | 24 ++++++++++++++++++++++ 5 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 tests/expedition/results/cost.txt rename virtual_ship/{loop.py => do_expedition.py} (82%) create mode 100644 virtual_ship/expedition_cost.py diff --git a/tests/expedition/results/cost.txt b/tests/expedition/results/cost.txt new file mode 100644 index 00000000..3f21b28d --- /dev/null +++ b/tests/expedition/results/cost.txt @@ -0,0 +1 @@ +cost: 105.0 US$ \ No newline at end of file diff --git a/tests/expedition/test_expedition.py b/tests/expedition/test_expedition.py index 6e37fc6e..5b7b180b 100644 --- a/tests/expedition/test_expedition.py +++ b/tests/expedition/test_expedition.py @@ -1,5 +1,5 @@ -from virtual_ship import loop +from virtual_ship import do_expedition def test_expedition() -> None: - loop(".") + do_expedition(".") diff --git a/virtual_ship/__init__.py b/virtual_ship/__init__.py index 0034dd24..2b3690cb 100644 --- a/virtual_ship/__init__.py +++ b/virtual_ship/__init__.py @@ -7,7 +7,7 @@ from .schedule import Schedule from .spacetime import Spacetime from .waypoint import Waypoint -from .loop import loop +from .do_expedition import do_expedition from .ship_config import ( ShipConfig, ArgoFloatConfig, @@ -26,7 +26,7 @@ "Waypoint", "instruments", "sailship", - "loop", + "do_expedition", "ShipConfig", "ArgoFloatConfig", "ADCPConfig", diff --git a/virtual_ship/loop.py b/virtual_ship/do_expedition.py similarity index 82% rename from virtual_ship/loop.py rename to virtual_ship/do_expedition.py index 873cc5b8..efd2cfbc 100644 --- a/virtual_ship/loop.py +++ b/virtual_ship/do_expedition.py @@ -1,34 +1,36 @@ -# from datetime import datetime from .schedule import Schedule from pathlib import Path import pyproj from .ship_config import ShipConfig from .checkpoint import Checkpoint -from datetime import datetime from .simulate_schedule import simulate_schedule from .verify_schedule import verify_schedule from .input_data import InputData from .simulate_measurements import simulate_measurements +from .expedition_cost import expedition_cost -def loop(expedition_dir: str | Path) -> None: +def do_expedition(expedition_dir: str | Path) -> None: if isinstance(expedition_dir, str): expedition_dir = Path(expedition_dir) + # load ship configuration ship_config = _get_ship_config(expedition_dir) if ship_config is None: return + # load schedule schedule = _get_schedule(expedition_dir) if schedule is None: return + # load last checkpoint checkpoint = _load_checkpoint(expedition_dir) if checkpoint is None: checkpoint = Checkpoint(past_schedule=Schedule(waypoints=[])) - # verify schedule and checkpoint match + # verify that schedule and checkpoint match if ( not schedule.waypoints[: len(checkpoint.past_schedule.waypoints)] == checkpoint.past_schedule.waypoints @@ -66,6 +68,7 @@ def loop(expedition_dir: str | Path) -> None: ) return + # simulate measurements print("Simulating measurements. This may take a while..") simulate_measurements( expedition_dir, @@ -75,6 +78,16 @@ def loop(expedition_dir: str | Path) -> None: ) print("Done simulating measurements.") + # calculate expedition cost in US$ + assert ( + schedule.waypoints[0].time is not None + ), "First waypoint has no time. This should not be possible as it should have been verified before." + time_past = schedule_results.end_spacetime.time - schedule.waypoints[0].time + cost = expedition_cost(schedule_results, time_past) + with open(expedition_dir.joinpath("results", "cost.txt"), "w") as file: + file.writelines(f"cost: {cost} US$") + print(f"This expedition took {time_past} and would have cost {cost:,.0f} US$.") + def _get_ship_config(expedition_dir: Path) -> ShipConfig | None: file_path = expedition_dir.joinpath("ship_config.yaml") diff --git a/virtual_ship/expedition_cost.py b/virtual_ship/expedition_cost.py new file mode 100644 index 00000000..34466aaa --- /dev/null +++ b/virtual_ship/expedition_cost.py @@ -0,0 +1,24 @@ +from .simulate_schedule import ScheduleResults +from datetime import timedelta + + +def expedition_cost(schedule_results: ScheduleResults, time_past: timedelta) -> float: + """ + Calculate the cost of the expedition in US$. + + :schedule_results: Results from schedule simulation. + :time_past: Time the expedition took. + :returns: The calculated cost of the expedition in US$. + """ + SHIP_COST_PER_DAY = 30000 + DRIFTER_DEPLOY_COST = 2500 + ARGO_DEPLOY_COST = 15000 + + ship_cost = SHIP_COST_PER_DAY / 24 * time_past.total_seconds() // 3600 + num_argos = len(schedule_results.measurements_to_simulate.argo_floats) + argo_cost = num_argos * ARGO_DEPLOY_COST + num_drifters = len(schedule_results.measurements_to_simulate.drifters) + drifter_cost = num_drifters * DRIFTER_DEPLOY_COST + + cost = ship_cost + argo_cost + drifter_cost + return cost From 2232a63831dc193bfe19cc6fbe5e9131bc1bd1dc Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Thu, 22 Aug 2024 23:40:59 +0200 Subject: [PATCH 08/35] cleanup --- tests/test_schedule.py | 3 +- virtual_ship/__init__.py | 28 +- virtual_ship/checkpoint.py | 23 +- virtual_ship/costs.py | 40 --- virtual_ship/do_expedition.py | 20 +- virtual_ship/expedition_cost.py | 9 +- virtual_ship/input_data.py | 20 +- virtual_ship/planning_error.py | 7 - virtual_ship/postprocess.py | 71 ---- virtual_ship/sailship.py | 495 -------------------------- virtual_ship/schedule.py | 15 +- virtual_ship/ship_config.py | 30 +- virtual_ship/simulate_measurements.py | 22 +- virtual_ship/simulate_schedule.py | 26 +- virtual_ship/verify_schedule.py | 21 +- virtual_ship/virtual_ship_config.py | 102 ------ 16 files changed, 164 insertions(+), 768 deletions(-) delete mode 100644 virtual_ship/costs.py delete mode 100644 virtual_ship/planning_error.py delete mode 100644 virtual_ship/postprocess.py delete mode 100644 virtual_ship/sailship.py delete mode 100644 virtual_ship/virtual_ship_config.py diff --git a/tests/test_schedule.py b/tests/test_schedule.py index 56ee0d4f..bd5bafe4 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -1,7 +1,8 @@ +from datetime import datetime, timedelta + import py from virtual_ship import Location, Schedule, Waypoint -from datetime import datetime, timedelta def test_schedule(tmpdir: py.path.LocalPath) -> None: diff --git a/virtual_ship/__init__.py b/virtual_ship/__init__.py index 2b3690cb..080803be 100644 --- a/virtual_ship/__init__.py +++ b/virtual_ship/__init__.py @@ -1,36 +1,34 @@ """Code for the Virtual Ship Classroom, where Marine Scientists can combine Copernicus Marine Data with an OceanParcels ship to go on a virtual expedition.""" from . import instruments, sailship +from .do_expedition import do_expedition from .instrument_type import InstrumentType from .location import Location -from .planning_error import PlanningError from .schedule import Schedule -from .spacetime import Spacetime -from .waypoint import Waypoint -from .do_expedition import do_expedition from .ship_config import ( - ShipConfig, - ArgoFloatConfig, ADCPConfig, - ShipUnderwaterSTConfig, + ArgoFloatConfig, CTDConfig, DrifterConfig, + ShipConfig, + ShipUnderwaterSTConfig, ) +from .spacetime import Spacetime +from .waypoint import Waypoint __all__ = [ + "ADCPConfig", + "ArgoFloatConfig", + "CTDConfig", + "DrifterConfig", "InstrumentType", "Location", - "PlanningError", "Schedule", + "ShipConfig", + "ShipUnderwaterSTConfig", "Spacetime", "Waypoint", + "do_expedition", "instruments", "sailship", - "do_expedition", - "ShipConfig", - "ArgoFloatConfig", - "ADCPConfig", - "ShipUnderwaterSTConfig", - "CTDConfig", - "DrifterConfig", ] diff --git a/virtual_ship/checkpoint.py b/virtual_ship/checkpoint.py index 03c5ae3c..606b49c9 100644 --- a/virtual_ship/checkpoint.py +++ b/virtual_ship/checkpoint.py @@ -1,20 +1,41 @@ +"""Checkpoint class.""" + from __future__ import annotations -from pydantic import BaseModel, ConfigDict, field_serializer, Field from pathlib import Path + import yaml +from pydantic import BaseModel + from .schedule import Schedule class Checkpoint(BaseModel): + """ + Checkpoint is schedule simulation. + + Until where the schedule execution proceeded without troubles. + """ + past_schedule: Schedule def to_yaml(self, file_path: str | Path) -> None: + """ + Write checkpoint to yaml file. + + :param file_path: Path to the file to write to. + """ with open(file_path, "w") as file: yaml.dump(self.model_dump(by_alias=True), file) @classmethod def from_yaml(cls, file_path: str | Path) -> Checkpoint: + """ + Load checkpoint from yaml file. + + :param file_path: Path to the file to load from. + :returns: The checkpoint. + """ with open(file_path, "r") as file: data = yaml.safe_load(file) return Checkpoint(**data) diff --git a/virtual_ship/costs.py b/virtual_ship/costs.py deleted file mode 100644 index 9e4c20e6..00000000 --- a/virtual_ship/costs.py +++ /dev/null @@ -1,40 +0,0 @@ -"""costs function.""" - -from datetime import timedelta - -from .instrument_type import InstrumentType -from .virtual_ship_config import VirtualShipConfig - - -def costs(config: VirtualShipConfig, total_time: timedelta): - """ - Calculate the cost of the virtual ship (in US$). - - :param config: The cruise configuration. - :param total_time: Time cruised. - :returns: The calculated cost of the cruise. - """ - ship_cost_per_day = 30000 - drifter_deploy_cost = 2500 - argo_deploy_cost = 15000 - - ship_cost = ship_cost_per_day / 24 * total_time.total_seconds() // 3600 - num_argos = len( - [ - waypoint - for waypoint in config.schedule.waypoints - if waypoint.instrument is InstrumentType.ARGO_FLOAT - ] - ) - argo_cost = num_argos * argo_deploy_cost - num_drifters = len( - [ - waypoint - for waypoint in config.schedule.waypoints - if waypoint.instrument is InstrumentType.DRIFTER - ] - ) - drifter_cost = num_drifters * drifter_deploy_cost - - cost = ship_cost + argo_cost + drifter_cost - return cost diff --git a/virtual_ship/do_expedition.py b/virtual_ship/do_expedition.py index efd2cfbc..8f72dc3f 100644 --- a/virtual_ship/do_expedition.py +++ b/virtual_ship/do_expedition.py @@ -1,17 +1,27 @@ -from .schedule import Schedule +"""do_expedition function.""" + from pathlib import Path import pyproj -from .ship_config import ShipConfig + from .checkpoint import Checkpoint -from .simulate_schedule import simulate_schedule -from .verify_schedule import verify_schedule +from .expedition_cost import expedition_cost from .input_data import InputData +from .schedule import Schedule +from .ship_config import ShipConfig from .simulate_measurements import simulate_measurements -from .expedition_cost import expedition_cost +from .simulate_schedule import simulate_schedule +from .verify_schedule import verify_schedule def do_expedition(expedition_dir: str | Path) -> None: + """ + Perform an expedition, providing terminal feedback and file output. + + This function is written as the entry point for a command line script. + + :param expedition_dir: The base directory for the expedition. + """ if isinstance(expedition_dir, str): expedition_dir = Path(expedition_dir) diff --git a/virtual_ship/expedition_cost.py b/virtual_ship/expedition_cost.py index 34466aaa..c008ffc6 100644 --- a/virtual_ship/expedition_cost.py +++ b/virtual_ship/expedition_cost.py @@ -1,13 +1,16 @@ -from .simulate_schedule import ScheduleResults +"""expedition_cost function.""" + from datetime import timedelta +from .simulate_schedule import ScheduleResults + def expedition_cost(schedule_results: ScheduleResults, time_past: timedelta) -> float: """ Calculate the cost of the expedition in US$. - :schedule_results: Results from schedule simulation. - :time_past: Time the expedition took. + :param schedule_results: Results from schedule simulation. + :param time_past: Time the expedition took. :returns: The calculated cost of the expedition in US$. """ SHIP_COST_PER_DAY = 30000 diff --git a/virtual_ship/input_data.py b/virtual_ship/input_data.py index e1e30c6c..4cb01c4a 100644 --- a/virtual_ship/input_data.py +++ b/virtual_ship/input_data.py @@ -1,12 +1,17 @@ +"""InputData class.""" + from __future__ import annotations from dataclasses import dataclass -from parcels import FieldSet, Field from pathlib import Path +from parcels import Field, FieldSet + @dataclass class InputData: + """A collection of fieldsets that function as input data for simulation.""" + adcp_fieldset: FieldSet | None argo_float_fieldset: FieldSet | None ctd_fieldset: FieldSet | None @@ -23,6 +28,19 @@ def load( load_drifter: bool, load_ship_underwater_st: bool, ) -> InputData: + """ + Create an instance of this class from netCDF files. + + For now this function makes a lot of assumption about file location and contents. + + :param directory: Base directory of the expedition. + :param load_adcp: Whether to load the ADCP fieldset. + :param load_argo_float: Whether to load the argo float fieldset. + :param load_ctd: Whether to load the CTD fieldset. + :param load_drifter: Whether to load the drifter fieldset. + :param load_ship_underwater_st: Whether to load the ship underwater ST fieldset. + :returns: An instance of this class with loaded fieldsets. + """ if load_drifter: drifter_fieldset = cls._load_drifter_fieldset(directory) else: diff --git a/virtual_ship/planning_error.py b/virtual_ship/planning_error.py deleted file mode 100644 index 5d599dd2..00000000 --- a/virtual_ship/planning_error.py +++ /dev/null @@ -1,7 +0,0 @@ -"""PlanningError Exception.""" - - -class PlanningError(RuntimeError): - """An error when checking the schedule or during sailing.""" - - pass diff --git a/virtual_ship/postprocess.py b/virtual_ship/postprocess.py deleted file mode 100644 index a234dab9..00000000 --- a/virtual_ship/postprocess.py +++ /dev/null @@ -1,71 +0,0 @@ -"""postprocess function.""" - -import os -import shutil - -import numpy as np -import xarray as xr -from scipy.ndimage import uniform_filter1d - - -def postprocess(): - """Postprocesses CTD data and writes to csv files.""" - if os.path.isdir(os.path.join("results", "CTDs")): - i = 0 - filenames = os.listdir(os.path.join("results", "CTDs")) - for filename in sorted(filenames): - if filename.endswith(".zarr"): - try: # too many errors, just skip the faulty zarr files - i += 1 - # Open output and read to x, y, z - ds = xr.open_zarr(os.path.join("results", "CTDs", filename)) - x = ds["lon"][:].squeeze() - y = ds["lat"][:].squeeze() - z = ds["z"][:].squeeze() - time = ds["time"][:].squeeze() - T = ds["temperature"][:].squeeze() - S = ds["salinity"][:].squeeze() - ds.close() - - random_walk = np.random.random() / 10 - z_norm = (z - np.min(z)) / (np.max(z) - np.min(z)) - t_norm = np.linspace(0, 1, num=len(time)) - # add smoothed random noise scaled with depth - # and random (reversed) diversion from initial through time scaled with depth - S = S + uniform_filter1d( - np.random.random(S.shape) / 5 * (1 - z_norm) - + random_walk - * (np.max(S).values - np.min(S).values) - * (1 - z_norm) - * t_norm - / 10, - max(int(len(time) / 40), 1), - ) - T = T + uniform_filter1d( - np.random.random(T.shape) * 5 * (1 - z_norm) - - random_walk - / 2 - * (np.max(T).values - np.min(T).values) - * (1 - z_norm) - * t_norm - / 10, - max(int(len(time) / 20), 1), - ) - - # reshaping data to export to csv - header = "pressure [dbar],temperature [degC],salinity [g kg-1]" - data = np.column_stack([-z, T, S]) - new_line = "\n" - np.savetxt( - f"{os.path.join('results', 'CTDs', 'CTD_station_')}{i}.csv", - data, - fmt="%.4f", - header=header, - delimiter=",", - comments=f'longitude,{x[0].values},"{x.attrs}"{new_line}latitude,{y[0].values},"{y.attrs}"{new_line}start time,{time[0].values}{new_line}end time,{time[-1].values}{new_line}', - ) - shutil.rmtree(os.path.join("results", "CTDs", filename)) - except TypeError: - print(f"CTD file {filename} seems faulty, skipping.") - continue - print("CTD data postprocessed.") diff --git a/virtual_ship/sailship.py b/virtual_ship/sailship.py deleted file mode 100644 index 5da43dae..00000000 --- a/virtual_ship/sailship.py +++ /dev/null @@ -1,495 +0,0 @@ -"""sailship function.""" - -from __future__ import annotations - -import os -from contextlib import contextmanager -from dataclasses import dataclass, field -from datetime import datetime, timedelta -from typing import Generator - -import pyproj -from parcels import FieldSet -from sortedcontainers import SortedList - -from .costs import costs -from .instrument_type import InstrumentType -from .instruments.adcp import simulate_adcp -from .instruments.argo_float import ArgoFloat, simulate_argo_floats -from .instruments.ctd import CTD, simulate_ctd -from .instruments.drifter import Drifter, simulate_drifters -from .instruments.ship_underwater_st import simulate_ship_underwater_st -from .location import Location -from .planning_error import PlanningError -from .spacetime import Spacetime -from .virtual_ship_config import VirtualShipConfig -from .waypoint import Waypoint - - -def sailship(config: VirtualShipConfig): - """ - Use Parcels to simulate a virtual ship expedition. - - :param config: The expedition configuration. - """ - config.verify() - - # projection used to sail between waypoints - projection = pyproj.Geod(ellps="WGS84") - - _verify_waypoints(projection=projection, config=config) - - # simulate the sailing and aggregate what measurements should be simulated - schedule_results = _simulate_schedule( - projection=projection, - config=config, - ) - - # simulate the measurements - - if config.ship_underwater_st_config is not None: - print("Simulating onboard salinity and temperature measurements.") - simulate_ship_underwater_st( - fieldset=config.ship_underwater_st_config.fieldset, - out_path=os.path.join("results", "ship_underwater_st.zarr"), - depth=-2, - sample_points=schedule_results.measurements_to_simulate.ship_underwater_sts, - ) - - if config.adcp_config is not None: - print("Simulating onboard ADCP.") - simulate_adcp( - fieldset=config.adcp_config.fieldset, - out_path=os.path.join("results", "adcp.zarr"), - max_depth=config.adcp_config.max_depth, - min_depth=-5, - num_bins=(-5 - config.adcp_config.max_depth) - // config.adcp_config.bin_size_m, - sample_points=schedule_results.measurements_to_simulate.adcps, - ) - - print("Simulating CTD casts.") - simulate_ctd( - out_path=os.path.join("results", "ctd.zarr"), - fieldset=config.ctd_config.fieldset, - ctds=schedule_results.measurements_to_simulate.ctds, - outputdt=timedelta(seconds=10), - ) - - print("Simulating drifters") - simulate_drifters( - out_path=os.path.join("results", "drifters.zarr"), - fieldset=config.drifter_config.fieldset, - drifters=schedule_results.measurements_to_simulate.drifters, - outputdt=timedelta(hours=5), - dt=timedelta(minutes=5), - endtime=None, - ) - - print("Simulating argo floats") - simulate_argo_floats( - out_path=os.path.join("results", "argo_floats.zarr"), - argo_floats=schedule_results.measurements_to_simulate.argo_floats, - fieldset=config.argo_float_config.fieldset, - outputdt=timedelta(minutes=5), - endtime=None, - ) - - # calculate cruise cost - assert ( - config.schedule.waypoints[0].time is not None - ), "First waypoints cannot have None time as this has been verified before during config verification." - time_past = schedule_results.end_spacetime.time - config.schedule.waypoints[0].time - cost = costs(config, time_past) - print(f"This cruise took {time_past} and would have cost {cost:,.0f} euros.") - - -def _simulate_schedule( - projection: pyproj.Geod, - config: VirtualShipConfig, -) -> _ScheduleResults: - """ - Simulate the sailing and aggregate the virtual measurements that should be taken. - - :param projection: Projection used to sail between waypoints. - :param config: The cruise configuration. - :returns: Results from the simulation. - :raises NotImplementedError: When unsupported instruments are encountered. - :raises RuntimeError: When schedule appears infeasible. This should not happen in this version of virtual ship as the schedule is verified beforehand. - """ - cruise = _Cruise( - Spacetime( - config.schedule.waypoints[0].location, config.schedule.waypoints[0].time - ) - ) - measurements = _MeasurementsToSimulate() - - # add recurring tasks to task list - waiting_tasks = SortedList[_WaitingTask]() - if config.ship_underwater_st_config is not None: - waiting_tasks.add( - _WaitingTask( - task=_ship_underwater_st_loop( - config.ship_underwater_st_config.period, cruise, measurements - ), - wait_until=cruise.spacetime.time, - ) - ) - if config.adcp_config is not None: - waiting_tasks.add( - _WaitingTask( - task=_adcp_loop(config.adcp_config.period, cruise, measurements), - wait_until=cruise.spacetime.time, - ) - ) - - # sail to each waypoint while executing tasks - for waypoint in config.schedule.waypoints: - if waypoint.time is not None and cruise.spacetime.time > waypoint.time: - raise RuntimeError( - "Could not reach waypoint in time. This should not happen in this version of virtual ship as the schedule is verified beforehand." - ) - - # add task to the task queue for the instrument at the current waypoint - if waypoint.instrument is InstrumentType.ARGO_FLOAT: - _argo_float_task(cruise, measurements, config=config) - elif waypoint.instrument is InstrumentType.DRIFTER: - _drifter_task(cruise, measurements, config=config) - elif waypoint.instrument is InstrumentType.CTD: - waiting_tasks.add( - _WaitingTask( - _ctd_task( - config.ctd_config.stationkeeping_time, - config.ctd_config.min_depth, - config.ctd_config.max_depth, - cruise, - measurements, - ), - cruise.spacetime.time, - ) - ) - elif waypoint.instrument is None: - pass - else: - raise NotImplementedError() - - # sail to the next waypoint - waypoint_reached = False - while not waypoint_reached: - # execute all tasks planned for current time - while ( - len(waiting_tasks) > 0 - and waiting_tasks[0].wait_until <= cruise.spacetime.time - ): - task = waiting_tasks.pop(0) - try: - wait_for = next(task.task) - waiting_tasks.add( - _WaitingTask(task.task, cruise.spacetime.time + wait_for.time) - ) - except StopIteration: - pass - - # if sailing is prevented by a current task, just let time pass until the next task - if cruise.sail_is_locked: - cruise.spacetime = Spacetime( - cruise.spacetime.location, waiting_tasks[0].wait_until - ) - # else, let time pass while sailing - else: - # calculate time at which waypoint would be reached if simply sailing - geodinv: tuple[float, float, float] = projection.inv( - lons1=cruise.spacetime.location.lon, - lats1=cruise.spacetime.location.lat, - lons2=waypoint.location.lon, - lats2=waypoint.location.lat, - ) - azimuth1 = geodinv[0] - distance_to_next_waypoint = geodinv[2] - time_to_reach = timedelta( - seconds=distance_to_next_waypoint / config.ship_speed - ) - arrival_time = cruise.spacetime.time + time_to_reach - - # if waypoint is reached before next task, sail to the waypoint - if ( - len(waiting_tasks) == 0 - or arrival_time <= waiting_tasks[0].wait_until - ): - cruise.spacetime = Spacetime(waypoint.location, arrival_time) - waypoint_reached = True - # else, sail until task starts - else: - time_to_sail = waiting_tasks[0].wait_until - cruise.spacetime.time - distance_to_move = config.ship_speed * time_to_sail.total_seconds() - geodfwd: tuple[float, float, float] = projection.fwd( - lons=cruise.spacetime.location.lon, - lats=cruise.spacetime.location.lat, - az=azimuth1, - dist=distance_to_move, - ) - lon = geodfwd[0] - lat = geodfwd[1] - cruise.spacetime = Spacetime( - Location(latitude=lat, longitude=lon), - cruise.spacetime.time + time_to_sail, - ) - - cruise.finish() - - # don't sail anymore, but let tasks finish - while len(waiting_tasks) > 0: - task = waiting_tasks.pop(0) - try: - wait_for = next(task.task) - waiting_tasks.add( - _WaitingTask(task.task, cruise.spacetime.time + wait_for.time) - ) - except StopIteration: - pass - - return _ScheduleResults( - measurements_to_simulate=measurements, end_spacetime=cruise.spacetime - ) - - -class _Cruise: - _finished: bool # if last waypoint has been reached - _sail_lock_count: int # if sailing should be paused because of tasks; number of tasks that requested a pause; 0 means good to go sail - spacetime: Spacetime # current location and time - - def __init__(self, spacetime: Spacetime) -> None: - self._finished = False - self._sail_lock_count = 0 - self.spacetime = spacetime - - @property - def finished(self) -> bool: - return self._finished - - @contextmanager - def do_not_sail(self) -> Generator[None, None, None]: - try: - self._sail_lock_count += 1 - yield - finally: - self._sail_lock_count -= 1 - - def finish(self) -> None: - self._finished = True - - @property - def sail_is_locked(self) -> bool: - return self._sail_lock_count > 0 - - -@dataclass -class _MeasurementsToSimulate: - adcps: list[Spacetime] = field(default_factory=list, init=False) - ship_underwater_sts: list[Spacetime] = field(default_factory=list, init=False) - argo_floats: list[ArgoFloat] = field(default_factory=list, init=False) - drifters: list[Drifter] = field(default_factory=list, init=False) - ctds: list[CTD] = field(default_factory=list, init=False) - - -@dataclass -class _ScheduleResults: - measurements_to_simulate: _MeasurementsToSimulate - end_spacetime: Spacetime - - -@dataclass -class _WaitFor: - time: timedelta - - -class _WaitingTask: - _task: Generator[_WaitFor, None, None] - _wait_until: datetime - - def __init__( - self, task: Generator[_WaitFor, None, None], wait_until: datetime - ) -> None: - self._task = task - self._wait_until = wait_until - - def __lt__(self, other: _WaitingTask): - return self._wait_until < other._wait_until - - @property - def task(self) -> Generator[_WaitFor, None, None]: - return self._task - - @property - def wait_until(self) -> datetime: - return self._wait_until - - -def _ship_underwater_st_loop( - sample_period: timedelta, cruise: _Cruise, measurements: _MeasurementsToSimulate -) -> Generator[_WaitFor, None, None]: - while not cruise.finished: - measurements.ship_underwater_sts.append(cruise.spacetime) - yield _WaitFor(sample_period) - - -def _adcp_loop( - sample_period: timedelta, cruise: _Cruise, measurements: _MeasurementsToSimulate -) -> Generator[_WaitFor, None, None]: - while not cruise.finished: - measurements.adcps.append(cruise.spacetime) - yield _WaitFor(sample_period) - - -def _ctd_task( - stationkeeping_time: timedelta, - min_depth: float, - max_depth: float, - cruise: _Cruise, - measurements: _MeasurementsToSimulate, -) -> Generator[_WaitFor, None, None]: - with cruise.do_not_sail(): - measurements.ctds.append( - CTD( - spacetime=cruise.spacetime, - min_depth=min_depth, - max_depth=max_depth, - ) - ) - yield _WaitFor(stationkeeping_time) - - -def _drifter_task( - cruise: _Cruise, measurements: _MeasurementsToSimulate, config: VirtualShipConfig -) -> None: - measurements.drifters.append( - Drifter( - cruise.spacetime, - depth=config.drifter_config.depth, - lifetime=config.drifter_config.lifetime, - ) - ) - - -def _argo_float_task( - cruise: _Cruise, measurements: _MeasurementsToSimulate, config: VirtualShipConfig -) -> None: - measurements.argo_floats.append( - ArgoFloat( - spacetime=cruise.spacetime, - min_depth=config.argo_float_config.min_depth, - max_depth=config.argo_float_config.max_depth, - drift_depth=config.argo_float_config.drift_depth, - vertical_speed=config.argo_float_config.vertical_speed, - cycle_days=config.argo_float_config.cycle_days, - drift_days=config.argo_float_config.drift_days, - ) - ) - - -def _verify_waypoints( - projection: pyproj.Geod, - config: VirtualShipConfig, -) -> None: - """ - Verify waypoints are ordered by time, first waypoint has a start time, and that schedule is feasible in terms of time if no unexpected events happen. - - :param projection: projection used to sail between waypoints. - :param config: The cruise configuration. - :raises PlanningError: If waypoints are not feasible or incorrect. - :raises ValueError: If there are no fieldsets in the config, which are needed to verify all waypoints are on water. - """ - if len(config.schedule.waypoints) == 0: - raise PlanningError("At least one waypoint must be provided.") - - # check first waypoint has a time - if config.schedule.waypoints[0].time is None: - raise PlanningError("First waypoint must have a specified time.") - - # check waypoint times are in ascending order - timed_waypoints = [wp for wp in config.schedule.waypoints if wp.time is not None] - if not all( - [ - next.time >= cur.time - for cur, next in zip(timed_waypoints, timed_waypoints[1:]) - ] - ): - raise PlanningError( - "Each waypoint should be timed after all previous waypoints" - ) - - # check if all waypoints are in water - # this is done by picking an arbitrary provided fieldset and checking if UV is not zero - - # get all available fieldsets - available_fieldsets = [ - fs - for fs in [ - config.adcp_config.fieldset if config.adcp_config is not None else None, - config.argo_float_config.fieldset, - config.ctd_config.fieldset, - config.drifter_config.fieldset, - ( - config.ship_underwater_st_config.fieldset - if config.ship_underwater_st_config is not None - else None - ), - ] - if fs is not None - ] - # check if there are any fieldsets, else its an error - if len(available_fieldsets) == 0: - raise ValueError( - "No fieldsets provided to check if waypoints are on land. Assuming no provided fieldsets is an error." - ) - # pick any - fieldset = available_fieldsets[0] - # get waypoints with 0 UV - land_waypoints = [ - (wp_i, wp) - for wp_i, wp in enumerate(config.schedule.waypoints) - if _is_on_land_zero_uv(fieldset, wp) - ] - # raise an error if there are any - if len(land_waypoints) > 0: - raise PlanningError( - f"The following waypoints are on land: {['#' + str(wp_i) + ' ' + str(wp) for (wp_i, wp) in land_waypoints]}" - ) - - # check that ship will arrive on time at each waypoint (in case no unexpected event happen) - time = config.schedule.waypoints[0].time - for wp_i, (wp, wp_next) in enumerate( - zip(config.schedule.waypoints, config.schedule.waypoints[1:]) - ): - if wp.instrument is InstrumentType.CTD: - time += timedelta(minutes=20) - - geodinv: tuple[float, float, float] = projection.inv( - wp.location.lon, wp.location.lat, wp_next.location.lon, wp_next.location.lat - ) - distance = geodinv[2] - - time_to_reach = timedelta(seconds=distance / config.ship_speed) - arrival_time = time + time_to_reach - - if wp_next.time is None: - time = arrival_time - elif arrival_time > wp_next.time: - raise PlanningError( - f"Waypoint planning is not valid: would arrive too late at a waypoint number {wp_i}. location: {wp.location} time: {wp.time} instrument: {wp.instrument}" - ) - else: - time = wp_next.time - - -def _is_on_land_zero_uv(fieldset: FieldSet, waypoint: Waypoint) -> bool: - """ - Check if waypoint is on land by assuming zero velocity means land. - - :param fieldset: The fieldset to sample the velocity from. - :param waypoint: The waypoint to check. - :returns: If the waypoint is on land. - """ - return fieldset.UV.eval( - 0, 0, waypoint.location.lat, waypoint.location.lon, applyConversion=False - ) == (0.0, 0.0) diff --git a/virtual_ship/schedule.py b/virtual_ship/schedule.py index 99add03f..9e4415a7 100644 --- a/virtual_ship/schedule.py +++ b/virtual_ship/schedule.py @@ -3,8 +3,10 @@ from __future__ import annotations from pathlib import Path -from pydantic import BaseModel, ConfigDict + import yaml +from pydantic import BaseModel, ConfigDict + from .waypoint import Waypoint @@ -16,11 +18,22 @@ class Schedule(BaseModel): model_config = ConfigDict(extra="forbid") def to_yaml(self, file_path: str | Path) -> None: + """ + Write schedule to yaml file. + + :param file_path: Path to the file to write to. + """ with open(file_path, "w") as file: yaml.dump(self.model_dump(by_alias=True), file) @classmethod def from_yaml(cls, file_path: str | Path) -> Schedule: + """ + Load schedule from yaml file. + + :param file_path: Path to the file to load from. + :returns: The schedule. + """ with open(file_path, "r") as file: data = yaml.safe_load(file) return Schedule(**data) diff --git a/virtual_ship/ship_config.py b/virtual_ship/ship_config.py index 54094064..a50930e1 100644 --- a/virtual_ship/ship_config.py +++ b/virtual_ship/ship_config.py @@ -1,11 +1,12 @@ """ShipConfig and supporting classes.""" from __future__ import annotations -from datetime import timedelta +from datetime import timedelta from pathlib import Path -from pydantic import BaseModel, ConfigDict, field_serializer, Field + import yaml +from pydantic import BaseModel, ConfigDict, Field, field_serializer class ArgoFloatConfig(BaseModel): @@ -33,7 +34,7 @@ class ADCPConfig(BaseModel): model_config = ConfigDict(populate_by_name=True) @field_serializer("period") - def serialize_period(self, value: timedelta, _info): + def _serialize_period(self, value: timedelta, _info): return value.total_seconds() / 60.0 @@ -51,7 +52,7 @@ class CTDConfig(BaseModel): model_config = ConfigDict(populate_by_name=True) @field_serializer("stationkeeping_time") - def serialize_stationkeeping_time(self, value: timedelta, _info): + def _serialize_stationkeeping_time(self, value: timedelta, _info): return value.total_seconds() / 60.0 @@ -67,7 +68,7 @@ class ShipUnderwaterSTConfig(BaseModel): model_config = ConfigDict(populate_by_name=True) @field_serializer("period") - def serialize_period(self, value: timedelta, _info): + def _serialize_period(self, value: timedelta, _info): return value.total_seconds() / 60.0 @@ -84,7 +85,7 @@ class DrifterConfig(BaseModel): model_config = ConfigDict(populate_by_name=True) @field_serializer("lifetime") - def serialize_lifetime(self, value: timedelta, _info): + def _serialize_lifetime(self, value: timedelta, _info): return value.total_seconds() / 60.0 @@ -99,7 +100,7 @@ class ShipConfig(BaseModel): argo_float_config: ArgoFloatConfig | None = None """ Argo float configuration. - + If None, no argo floats can be deployed. """ @@ -120,25 +121,36 @@ class ShipConfig(BaseModel): ship_underwater_st_config: ShipUnderwaterSTConfig | None """ Ship underwater salinity temperature measurementconfiguration. - + If None, no ST measurements will be performed. """ drifter_config: DrifterConfig | None """ Drifter configuration. - + If None, no drifters can be deployed. """ model_config = ConfigDict(extra="forbid") def to_yaml(self, file_path: str | Path) -> None: + """ + Write config to yaml file. + + :param file_path: Path to the file to write to. + """ with open(file_path, "w") as file: yaml.dump(self.model_dump(by_alias=True), file) @classmethod def from_yaml(cls, file_path: str | Path) -> ShipConfig: + """ + Load config from yaml file. + + :param file_path: Path to the file to load from. + :returns: The config. + """ with open(file_path, "r") as file: data = yaml.safe_load(file) return ShipConfig(**data) diff --git a/virtual_ship/simulate_measurements.py b/virtual_ship/simulate_measurements.py index a7d9b5ba..891a54a6 100644 --- a/virtual_ship/simulate_measurements.py +++ b/virtual_ship/simulate_measurements.py @@ -1,13 +1,16 @@ -from .ship_config import ShipConfig +"""simulate_measurements function.""" + +from datetime import timedelta +from pathlib import Path + +from .input_data import InputData from .instruments.adcp import simulate_adcp from .instruments.argo_float import simulate_argo_floats from .instruments.ctd import simulate_ctd from .instruments.drifter import simulate_drifters from .instruments.ship_underwater_st import simulate_ship_underwater_st +from .ship_config import ShipConfig from .simulate_schedule import MeasurementsToSimulate -from pathlib import Path -from datetime import timedelta -from .input_data import InputData def simulate_measurements( @@ -16,6 +19,17 @@ def simulate_measurements( input_data: InputData, measurements: MeasurementsToSimulate, ) -> None: + """ + Simulate measurements using parcels. + + Saves everything the $expedition_dir/results. + + :param expedition_dir: Base directory of the expedition. + :param ship_config: Ship configuration. + :param input_data: Input data for simulation. + :param measurements: The measurements to simulate. + :raises RuntimeError: In case fieldsets of configuration is not provided. Make sure to check this before calling this function. + """ if isinstance(expedition_dir, str): expedition_dir = Path(expedition_dir) diff --git a/virtual_ship/simulate_schedule.py b/virtual_ship/simulate_schedule.py index 69d6a395..675e8cd9 100644 --- a/virtual_ship/simulate_schedule.py +++ b/virtual_ship/simulate_schedule.py @@ -1,18 +1,23 @@ +"""simulate_schedule function and supporting classes.""" + from __future__ import annotations -import pyproj -from .ship_config import ShipConfig -from datetime import datetime, timedelta + +from contextlib import contextmanager from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Generator + +import pyproj +from sortedcontainers import SortedList + +from .instrument_type import InstrumentType from .instruments.argo_float import ArgoFloat from .instruments.ctd import CTD from .instruments.drifter import Drifter -from .spacetime import Spacetime -from sortedcontainers import SortedList -from .instrument_type import InstrumentType from .location import Location -from contextlib import contextmanager -from typing import Generator from .schedule import Schedule +from .ship_config import ShipConfig +from .spacetime import Spacetime def simulate_schedule( @@ -23,6 +28,7 @@ def simulate_schedule( :param projection: Projection used to sail between waypoints. :param ship_config: The ship configuration. + :param schedule: The schedule to simulate. :returns: Results from the simulation. :raises NotImplementedError: When unsupported instruments are encountered. """ @@ -205,6 +211,8 @@ def sail_is_locked(self) -> bool: @dataclass class MeasurementsToSimulate: + """The measurements to simulate, as concluded from schedule simulation.""" + adcps: list[Spacetime] = field(default_factory=list, init=False) ship_underwater_sts: list[Spacetime] = field(default_factory=list, init=False) argo_floats: list[ArgoFloat] = field(default_factory=list, init=False) @@ -214,6 +222,8 @@ class MeasurementsToSimulate: @dataclass class ScheduleResults: + """Results from schedule simulation.""" + success: bool measurements_to_simulate: MeasurementsToSimulate end_spacetime: Spacetime diff --git a/virtual_ship/verify_schedule.py b/virtual_ship/verify_schedule.py index c65ebb5b..06076d0f 100644 --- a/virtual_ship/verify_schedule.py +++ b/virtual_ship/verify_schedule.py @@ -1,12 +1,15 @@ +"""verify_schedule function and supporting classes.""" + +from datetime import timedelta + import pyproj -from .ship_config import ShipConfig -from .planning_error import PlanningError -from .schedule import Schedule from parcels import FieldSet + +from .input_data import InputData from .instrument_type import InstrumentType -from datetime import timedelta +from .schedule import Schedule +from .ship_config import ShipConfig from .waypoint import Waypoint -from .input_data import InputData def verify_schedule( @@ -20,6 +23,8 @@ def verify_schedule( :param projection: projection used to sail between waypoints. :param ship_config: The cruise ship_configuration. + :param schedule: The schedule to verify. + :param input_data: Fieldsets that can be used to check for zero UV condition (is waypoint on land). :raises PlanningError: If waypoints are not feasible or incorrect. :raises ValueError: If there are no fieldsets in the ship_config, which are needed to verify all waypoints are on water. """ @@ -102,6 +107,12 @@ def verify_schedule( time = wp_next.time +class PlanningError(RuntimeError): + """An error in the schedule.""" + + pass + + def _is_on_land_zero_uv(fieldset: FieldSet, waypoint: Waypoint) -> bool: """ Check if waypoint is on land by assuming zero velocity means land. diff --git a/virtual_ship/virtual_ship_config.py b/virtual_ship/virtual_ship_config.py deleted file mode 100644 index 9dd20a20..00000000 --- a/virtual_ship/virtual_ship_config.py +++ /dev/null @@ -1,102 +0,0 @@ -"""VirtualShipConfig class.""" - -from dataclasses import dataclass -from datetime import timedelta - -from parcels import FieldSet - -from .schedule import Schedule - - -@dataclass -class ArgoFloatConfig: - """Configuration for argos floats.""" - - fieldset: FieldSet - min_depth: float - max_depth: float - drift_depth: float - vertical_speed: float - cycle_days: float - drift_days: float - - -@dataclass -class ADCPConfig: - """Configuration for ADCP instrument.""" - - max_depth: float - bin_size_m: int - period: timedelta - fieldset: FieldSet - - -@dataclass -class CTDConfig: - """Configuration for CTD instrument.""" - - stationkeeping_time: timedelta - fieldset: FieldSet - min_depth: float - max_depth: float - - -@dataclass -class ShipUnderwaterSTConfig: - """Configuration for underwater ST.""" - - period: timedelta - fieldset: FieldSet - - -@dataclass -class DrifterConfig: - """Configuration for drifters.""" - - fieldset: FieldSet - depth: float - lifetime: timedelta - - -@dataclass -class VirtualShipConfig: - """Configuration of the virtual ship.""" - - ship_speed: float # m/s - - schedule: Schedule - - argo_float_config: ArgoFloatConfig - adcp_config: ADCPConfig | None # if None, ADCP is disabled - ctd_config: CTDConfig - ship_underwater_st_config: ( - ShipUnderwaterSTConfig | None - ) # if None, ship underwater st is disabled - drifter_config: DrifterConfig - - def verify(self) -> None: - """ - Verify this configuration is valid. - - :raises ValueError: If not valid. - """ - if len(self.schedule.waypoints) < 2: - raise ValueError("Waypoints require at least a start and an end.") - - if self.argo_float_config.max_depth > 0: - raise ValueError("Argo float max depth must be negative or zero.") - - if self.argo_float_config.drift_depth > 0: - raise ValueError("Argo float drift depth must be negative or zero.") - - if self.argo_float_config.vertical_speed >= 0: - raise ValueError("Argo float vertical speed must be negative.") - - if self.argo_float_config.cycle_days <= 0: - raise ValueError("Argo float cycle days must be larger than zero.") - - if self.argo_float_config.drift_days <= 0: - raise ValueError("Argo drift cycle days must be larger than zero.") - - if self.adcp_config is not None and self.adcp_config.max_depth > 0: - raise ValueError("ADCP max depth must be negative.") From 944538238fdd9d400e73c610d4569687077bfd53 Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Fri, 23 Aug 2024 00:14:04 +0200 Subject: [PATCH 09/35] cleanup --- tests/instruments/test_ship_underwater_st.py | 16 +- tests/test_loop.py | 5 - tests/test_sailship.py | 236 ------------------- virtual_ship/__init__.py | 2 - 4 files changed, 8 insertions(+), 251 deletions(-) delete mode 100644 tests/test_loop.py delete mode 100644 tests/test_sailship.py diff --git a/tests/instruments/test_ship_underwater_st.py b/tests/instruments/test_ship_underwater_st.py index 58df1860..54b8bdf0 100644 --- a/tests/instruments/test_ship_underwater_st.py +++ b/tests/instruments/test_ship_underwater_st.py @@ -27,15 +27,15 @@ def test_simulate_ship_underwater_st(tmpdir: py.path.LocalPath) -> None: # expected observations at sample points expected_obs = [ { - "salinity": 5, - "temperature": 6, + "S": 5, + "T": 6, "lat": sample_points[0].location.lat, "lon": sample_points[0].location.lon, "time": base_time + datetime.timedelta(seconds=0), }, { - "salinity": 7, - "temperature": 8, + "S": 7, + "T": 8, "lat": sample_points[1].location.lat, "lon": sample_points[1].location.lon, "time": base_time + datetime.timedelta(seconds=1), @@ -45,12 +45,12 @@ def test_simulate_ship_underwater_st(tmpdir: py.path.LocalPath) -> 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]["salinity"] - salinity[1, 1, 1] = expected_obs[1]["salinity"] + salinity[0, 0, 0] = expected_obs[0]["S"] + salinity[1, 1, 1] = expected_obs[1]["S"] temperature = np.zeros((2, 2, 2)) - temperature[0, 0, 0] = expected_obs[0]["temperature"] - temperature[1, 1, 1] = expected_obs[1]["temperature"] + temperature[0, 0, 0] = expected_obs[0]["T"] + temperature[1, 1, 1] = expected_obs[1]["T"] fieldset = FieldSet.from_data( { diff --git a/tests/test_loop.py b/tests/test_loop.py deleted file mode 100644 index 730bc56c..00000000 --- a/tests/test_loop.py +++ /dev/null @@ -1,5 +0,0 @@ -from virtual_ship import loop - - -def test_loop() -> None: - loop("test_expedition") diff --git a/tests/test_sailship.py b/tests/test_sailship.py deleted file mode 100644 index 0cdcd521..00000000 --- a/tests/test_sailship.py +++ /dev/null @@ -1,236 +0,0 @@ -"""Performs a complete cruise with virtual ship.""" - -import datetime -from datetime import timedelta - -import numpy as np -import pyproj -import pytest -from parcels import Field, FieldSet - -from virtual_ship import InstrumentType, Location, Schedule, Waypoint -from virtual_ship.sailship import PlanningError, _verify_waypoints, sailship -from virtual_ship.virtual_ship_config import ( - ADCPConfig, - ArgoFloatConfig, - CTDConfig, - DrifterConfig, - ShipUnderwaterSTConfig, - VirtualShipConfig, -) - - -def _make_ctd_fieldset(base_time: datetime) -> FieldSet: - u = np.full((2, 2, 2, 2), 1.0) - v = np.full((2, 2, 2, 2), 1.0) - t = np.full((2, 2, 2, 2), 1.0) - s = np.full((2, 2, 2, 2), 1.0) - - fieldset = FieldSet.from_data( - {"V": v, "U": u, "T": t, "S": s}, - { - "time": [ - np.datetime64(base_time + datetime.timedelta(seconds=0)), - np.datetime64(base_time + datetime.timedelta(minutes=200)), - ], - "depth": [0, -1000], - "lat": [-40, 90], - "lon": [-90, 90], - }, - ) - fieldset.add_field(Field("bathymetry", [-1000], lon=0, lat=0)) - return fieldset - - -def _make_drifter_fieldset(base_time: datetime) -> FieldSet: - v = np.full((2, 2, 2), 1.0) - u = np.full((2, 2, 2), 1.0) - t = np.full((2, 2, 2), 1.0) - - fieldset = FieldSet.from_data( - {"V": v, "U": u, "T": t}, - { - "time": [ - np.datetime64(base_time + datetime.timedelta(seconds=0)), - np.datetime64(base_time + datetime.timedelta(weeks=10)), - ], - "lat": [-40, 90], - "lon": [-90, 90], - }, - ) - return fieldset - - -def test_sailship() -> None: - # arbitrary time offset for the dummy fieldsets - base_time = datetime.datetime.strptime("2022-01-01T00:00:00", "%Y-%m-%dT%H:%M:%S") - - adcp_fieldset = FieldSet.from_data( - {"U": 1, "V": 1}, - {"lon": 0, "lat": 0}, - ) - - ship_underwater_st_fieldset = FieldSet.from_data( - {"U": 1, "V": 1, "salinity": 0, "temperature": 0}, - {"lon": 0, "lat": 0}, - ) - - ctd_fieldset = _make_ctd_fieldset(base_time) - - drifter_fieldset = _make_drifter_fieldset(base_time) - - argo_float_fieldset = FieldSet.from_data( - {"U": 1, "V": 1, "T": 0, "S": 0}, - { - "lon": 0, - "lat": 0, - "time": [np.datetime64("1950-01-01") + np.timedelta64(632160, "h")], - }, - ) - - argo_float_config = ArgoFloatConfig( - fieldset=argo_float_fieldset, - min_depth=-argo_float_fieldset.U.depth[0], - max_depth=-2000, - drift_depth=-1000, - vertical_speed=-0.10, - cycle_days=10, - drift_days=9, - ) - - adcp_config = ADCPConfig( - max_depth=-1000, - bin_size_m=24, - period=timedelta(minutes=5), - fieldset=adcp_fieldset, - ) - - ship_underwater_st_config = ShipUnderwaterSTConfig( - period=timedelta(minutes=5), fieldset=ship_underwater_st_fieldset - ) - - ctd_config = CTDConfig( - stationkeeping_time=timedelta(minutes=20), - fieldset=ctd_fieldset, - min_depth=ctd_fieldset.U.depth[0], - max_depth=ctd_fieldset.U.depth[-1], - ) - - drifter_config = DrifterConfig( - fieldset=drifter_fieldset, - depth=-drifter_fieldset.U.depth[0], - lifetime=timedelta(weeks=4), - ) - - waypoints = [ - Waypoint( - location=Location(latitude=-23.071289, longitude=63.743631), - time=base_time, - ), - Waypoint( - location=Location(latitude=-23.081289, longitude=63.743631), - instrument=InstrumentType.CTD, - ), - Waypoint( - location=Location(latitude=-23.181289, longitude=63.743631), - time=base_time + datetime.timedelta(hours=1), - instrument=InstrumentType.CTD, - ), - Waypoint( - location=Location(latitude=-23.281289, longitude=63.743631), - instrument=InstrumentType.DRIFTER, - ), - Waypoint( - location=Location(latitude=-23.381289, longitude=63.743631), - instrument=InstrumentType.ARGO_FLOAT, - ), - ] - - schedule = Schedule(waypoints=waypoints) - - config = VirtualShipConfig( - ship_speed=5.14, - schedule=schedule, - argo_float_config=argo_float_config, - adcp_config=adcp_config, - ship_underwater_st_config=ship_underwater_st_config, - ctd_config=ctd_config, - drifter_config=drifter_config, - ) - - sailship(config) - - -def test_verify_waypoints() -> None: - # arbitrary cruise start time - BASE_TIME = datetime.datetime.strptime("2022-01-01T00:00:00", "%Y-%m-%dT%H:%M:%S") - PROJECTION = pyproj.Geod(ellps="WGS84") - - # the sets of waypoints to test - WAYPOINTS = [ - [], # require at least one waypoint - [Waypoint(Location(0.0, 0.0))], # first waypoint must have time - [ - Waypoint(Location(0.0, 0.0), BASE_TIME + datetime.timedelta(days=1)), - Waypoint(Location(0.0, 0.0), BASE_TIME), - ], # waypoint times must be in ascending order - [ - Waypoint(Location(0.0, 0.0), BASE_TIME), - ], # 0 uv points are on land - [ - Waypoint(Location(0.1, 0.1), BASE_TIME), - Waypoint(Location(1.0, 1.0), BASE_TIME + datetime.timedelta(seconds=1)), - ], # waypoints must be reachable in time - [ - Waypoint(Location(0.1, 0.1), BASE_TIME), - Waypoint(Location(1.0, 1.0), BASE_TIME + datetime.timedelta(days=1)), - ], # a valid schedule - ] - - # the expected errors for the schedules, or None if expected to be valid - EXPECT_MATCH = [ - "^At least one waypoint must be provided.$", - "^First waypoint must have a specified time.$", - "^Each waypoint should be timed after all previous waypoints$", - "^The following waypoints are on land: .*$", - "^Waypoint planning is not valid: would arrive too late at a waypoint number .*$", - None, - ] - - # create a fieldset matching the test waypoints - u = np.full((1, 1, 2, 2), 1.0) - v = np.full((1, 1, 2, 2), 1.0) - u[0, 0, 0, 0] = 0.0 - v[0, 0, 0, 0] = 0.0 - - fieldset = FieldSet.from_data( - {"V": v, "U": u}, - { - "time": [np.datetime64(BASE_TIME)], - "depth": [0], - "lat": [0, 1], - "lon": [0, 1], - }, - ) - - # dummy configs - ctd_config = CTDConfig(None, fieldset, None, None) - drifter_config = DrifterConfig(None, None, None) - argo_float_config = ArgoFloatConfig(None, None, None, None, None, None, None) - - # test each set of waypoints and verify the raised errors (or none if valid) - for waypoints, expect_match in zip(WAYPOINTS, EXPECT_MATCH, strict=True): - config = VirtualShipConfig( - ship_speed=5.14, - schedule=Schedule(waypoints), - argo_float_config=argo_float_config, - adcp_config=None, - ship_underwater_st_config=None, - ctd_config=ctd_config, - drifter_config=drifter_config, - ) - if expect_match is not None: - with pytest.raises(PlanningError, match=expect_match): - _verify_waypoints(PROJECTION, config) - else: - _verify_waypoints(PROJECTION, config) diff --git a/virtual_ship/__init__.py b/virtual_ship/__init__.py index 080803be..bcbb5449 100644 --- a/virtual_ship/__init__.py +++ b/virtual_ship/__init__.py @@ -1,6 +1,5 @@ """Code for the Virtual Ship Classroom, where Marine Scientists can combine Copernicus Marine Data with an OceanParcels ship to go on a virtual expedition.""" -from . import instruments, sailship from .do_expedition import do_expedition from .instrument_type import InstrumentType from .location import Location @@ -30,5 +29,4 @@ "Waypoint", "do_expedition", "instruments", - "sailship", ] From 8896b87952997a6633ac3fc6ebc91e5d85a835ae Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Fri, 23 Aug 2024 03:51:58 +0200 Subject: [PATCH 10/35] add cli --- pyproject.toml | 3 + sailship_todo.py | 236 ++++++++++++++++++ tests/expedition/expedition_dir/.gitignore | 1 + .../{ => expedition_dir}/schedule.yaml | 0 .../{ => expedition_dir}/ship_config.yaml | 0 tests/expedition/results/cost.txt | 1 - tests/expedition/test_expedition.py | 9 +- tests/{ => expedition}/test_schedule.py | 3 +- tests/test.yaml | 11 - virtual_ship/__init__.py | 23 -- virtual_ship/cli/__init__.py | 1 + virtual_ship/cli/do_expedition.py | 30 +++ virtual_ship/expedition/__init__.py | 29 +++ virtual_ship/{ => expedition}/checkpoint.py | 0 .../{ => expedition}/do_expedition.py | 2 - .../{ => expedition}/expedition_cost.py | 0 virtual_ship/{ => expedition}/input_data.py | 0 .../{ => expedition}/instrument_type.py | 0 virtual_ship/{ => expedition}/schedule.py | 0 virtual_ship/{ => expedition}/ship_config.py | 0 .../{ => expedition}/simulate_measurements.py | 10 +- .../{ => expedition}/simulate_schedule.py | 10 +- .../{ => expedition}/verify_schedule.py | 0 virtual_ship/{ => expedition}/waypoint.py | 2 +- 24 files changed, 319 insertions(+), 52 deletions(-) create mode 100644 sailship_todo.py create mode 100644 tests/expedition/expedition_dir/.gitignore rename tests/expedition/{ => expedition_dir}/schedule.yaml (100%) rename tests/expedition/{ => expedition_dir}/ship_config.yaml (100%) delete mode 100644 tests/expedition/results/cost.txt rename tests/{ => expedition}/test_schedule.py (87%) delete mode 100644 tests/test.yaml create mode 100644 virtual_ship/cli/__init__.py create mode 100644 virtual_ship/cli/do_expedition.py create mode 100644 virtual_ship/expedition/__init__.py rename virtual_ship/{ => expedition}/checkpoint.py (100%) rename virtual_ship/{ => expedition}/do_expedition.py (98%) rename virtual_ship/{ => expedition}/expedition_cost.py (100%) rename virtual_ship/{ => expedition}/input_data.py (100%) rename virtual_ship/{ => expedition}/instrument_type.py (100%) rename virtual_ship/{ => expedition}/schedule.py (100%) rename virtual_ship/{ => expedition}/ship_config.py (100%) rename virtual_ship/{ => expedition}/simulate_measurements.py (93%) rename virtual_ship/{ => expedition}/simulate_schedule.py (98%) rename virtual_ship/{ => expedition}/verify_schedule.py (100%) rename virtual_ship/{ => expedition}/waypoint.py (91%) diff --git a/pyproject.toml b/pyproject.toml index 154a0b7c..f585c3f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,9 @@ dev = [ "sortedcontainers-stubs == 2.4.2", ] +[project.scripts] +do_expedition = "virtual_ship.cli.do_expedition:main" + [tool.isort] profile = "black" skip_gitignore = true diff --git a/sailship_todo.py b/sailship_todo.py new file mode 100644 index 00000000..0cdcd521 --- /dev/null +++ b/sailship_todo.py @@ -0,0 +1,236 @@ +"""Performs a complete cruise with virtual ship.""" + +import datetime +from datetime import timedelta + +import numpy as np +import pyproj +import pytest +from parcels import Field, FieldSet + +from virtual_ship import InstrumentType, Location, Schedule, Waypoint +from virtual_ship.sailship import PlanningError, _verify_waypoints, sailship +from virtual_ship.virtual_ship_config import ( + ADCPConfig, + ArgoFloatConfig, + CTDConfig, + DrifterConfig, + ShipUnderwaterSTConfig, + VirtualShipConfig, +) + + +def _make_ctd_fieldset(base_time: datetime) -> FieldSet: + u = np.full((2, 2, 2, 2), 1.0) + v = np.full((2, 2, 2, 2), 1.0) + t = np.full((2, 2, 2, 2), 1.0) + s = np.full((2, 2, 2, 2), 1.0) + + fieldset = FieldSet.from_data( + {"V": v, "U": u, "T": t, "S": s}, + { + "time": [ + np.datetime64(base_time + datetime.timedelta(seconds=0)), + np.datetime64(base_time + datetime.timedelta(minutes=200)), + ], + "depth": [0, -1000], + "lat": [-40, 90], + "lon": [-90, 90], + }, + ) + fieldset.add_field(Field("bathymetry", [-1000], lon=0, lat=0)) + return fieldset + + +def _make_drifter_fieldset(base_time: datetime) -> FieldSet: + v = np.full((2, 2, 2), 1.0) + u = np.full((2, 2, 2), 1.0) + t = np.full((2, 2, 2), 1.0) + + fieldset = FieldSet.from_data( + {"V": v, "U": u, "T": t}, + { + "time": [ + np.datetime64(base_time + datetime.timedelta(seconds=0)), + np.datetime64(base_time + datetime.timedelta(weeks=10)), + ], + "lat": [-40, 90], + "lon": [-90, 90], + }, + ) + return fieldset + + +def test_sailship() -> None: + # arbitrary time offset for the dummy fieldsets + base_time = datetime.datetime.strptime("2022-01-01T00:00:00", "%Y-%m-%dT%H:%M:%S") + + adcp_fieldset = FieldSet.from_data( + {"U": 1, "V": 1}, + {"lon": 0, "lat": 0}, + ) + + ship_underwater_st_fieldset = FieldSet.from_data( + {"U": 1, "V": 1, "salinity": 0, "temperature": 0}, + {"lon": 0, "lat": 0}, + ) + + ctd_fieldset = _make_ctd_fieldset(base_time) + + drifter_fieldset = _make_drifter_fieldset(base_time) + + argo_float_fieldset = FieldSet.from_data( + {"U": 1, "V": 1, "T": 0, "S": 0}, + { + "lon": 0, + "lat": 0, + "time": [np.datetime64("1950-01-01") + np.timedelta64(632160, "h")], + }, + ) + + argo_float_config = ArgoFloatConfig( + fieldset=argo_float_fieldset, + min_depth=-argo_float_fieldset.U.depth[0], + max_depth=-2000, + drift_depth=-1000, + vertical_speed=-0.10, + cycle_days=10, + drift_days=9, + ) + + adcp_config = ADCPConfig( + max_depth=-1000, + bin_size_m=24, + period=timedelta(minutes=5), + fieldset=adcp_fieldset, + ) + + ship_underwater_st_config = ShipUnderwaterSTConfig( + period=timedelta(minutes=5), fieldset=ship_underwater_st_fieldset + ) + + ctd_config = CTDConfig( + stationkeeping_time=timedelta(minutes=20), + fieldset=ctd_fieldset, + min_depth=ctd_fieldset.U.depth[0], + max_depth=ctd_fieldset.U.depth[-1], + ) + + drifter_config = DrifterConfig( + fieldset=drifter_fieldset, + depth=-drifter_fieldset.U.depth[0], + lifetime=timedelta(weeks=4), + ) + + waypoints = [ + Waypoint( + location=Location(latitude=-23.071289, longitude=63.743631), + time=base_time, + ), + Waypoint( + location=Location(latitude=-23.081289, longitude=63.743631), + instrument=InstrumentType.CTD, + ), + Waypoint( + location=Location(latitude=-23.181289, longitude=63.743631), + time=base_time + datetime.timedelta(hours=1), + instrument=InstrumentType.CTD, + ), + Waypoint( + location=Location(latitude=-23.281289, longitude=63.743631), + instrument=InstrumentType.DRIFTER, + ), + Waypoint( + location=Location(latitude=-23.381289, longitude=63.743631), + instrument=InstrumentType.ARGO_FLOAT, + ), + ] + + schedule = Schedule(waypoints=waypoints) + + config = VirtualShipConfig( + ship_speed=5.14, + schedule=schedule, + argo_float_config=argo_float_config, + adcp_config=adcp_config, + ship_underwater_st_config=ship_underwater_st_config, + ctd_config=ctd_config, + drifter_config=drifter_config, + ) + + sailship(config) + + +def test_verify_waypoints() -> None: + # arbitrary cruise start time + BASE_TIME = datetime.datetime.strptime("2022-01-01T00:00:00", "%Y-%m-%dT%H:%M:%S") + PROJECTION = pyproj.Geod(ellps="WGS84") + + # the sets of waypoints to test + WAYPOINTS = [ + [], # require at least one waypoint + [Waypoint(Location(0.0, 0.0))], # first waypoint must have time + [ + Waypoint(Location(0.0, 0.0), BASE_TIME + datetime.timedelta(days=1)), + Waypoint(Location(0.0, 0.0), BASE_TIME), + ], # waypoint times must be in ascending order + [ + Waypoint(Location(0.0, 0.0), BASE_TIME), + ], # 0 uv points are on land + [ + Waypoint(Location(0.1, 0.1), BASE_TIME), + Waypoint(Location(1.0, 1.0), BASE_TIME + datetime.timedelta(seconds=1)), + ], # waypoints must be reachable in time + [ + Waypoint(Location(0.1, 0.1), BASE_TIME), + Waypoint(Location(1.0, 1.0), BASE_TIME + datetime.timedelta(days=1)), + ], # a valid schedule + ] + + # the expected errors for the schedules, or None if expected to be valid + EXPECT_MATCH = [ + "^At least one waypoint must be provided.$", + "^First waypoint must have a specified time.$", + "^Each waypoint should be timed after all previous waypoints$", + "^The following waypoints are on land: .*$", + "^Waypoint planning is not valid: would arrive too late at a waypoint number .*$", + None, + ] + + # create a fieldset matching the test waypoints + u = np.full((1, 1, 2, 2), 1.0) + v = np.full((1, 1, 2, 2), 1.0) + u[0, 0, 0, 0] = 0.0 + v[0, 0, 0, 0] = 0.0 + + fieldset = FieldSet.from_data( + {"V": v, "U": u}, + { + "time": [np.datetime64(BASE_TIME)], + "depth": [0], + "lat": [0, 1], + "lon": [0, 1], + }, + ) + + # dummy configs + ctd_config = CTDConfig(None, fieldset, None, None) + drifter_config = DrifterConfig(None, None, None) + argo_float_config = ArgoFloatConfig(None, None, None, None, None, None, None) + + # test each set of waypoints and verify the raised errors (or none if valid) + for waypoints, expect_match in zip(WAYPOINTS, EXPECT_MATCH, strict=True): + config = VirtualShipConfig( + ship_speed=5.14, + schedule=Schedule(waypoints), + argo_float_config=argo_float_config, + adcp_config=None, + ship_underwater_st_config=None, + ctd_config=ctd_config, + drifter_config=drifter_config, + ) + if expect_match is not None: + with pytest.raises(PlanningError, match=expect_match): + _verify_waypoints(PROJECTION, config) + else: + _verify_waypoints(PROJECTION, config) diff --git a/tests/expedition/expedition_dir/.gitignore b/tests/expedition/expedition_dir/.gitignore new file mode 100644 index 00000000..68bcbc96 --- /dev/null +++ b/tests/expedition/expedition_dir/.gitignore @@ -0,0 +1 @@ +results/ \ No newline at end of file diff --git a/tests/expedition/schedule.yaml b/tests/expedition/expedition_dir/schedule.yaml similarity index 100% rename from tests/expedition/schedule.yaml rename to tests/expedition/expedition_dir/schedule.yaml diff --git a/tests/expedition/ship_config.yaml b/tests/expedition/expedition_dir/ship_config.yaml similarity index 100% rename from tests/expedition/ship_config.yaml rename to tests/expedition/expedition_dir/ship_config.yaml diff --git a/tests/expedition/results/cost.txt b/tests/expedition/results/cost.txt deleted file mode 100644 index 3f21b28d..00000000 --- a/tests/expedition/results/cost.txt +++ /dev/null @@ -1 +0,0 @@ -cost: 105.0 US$ \ No newline at end of file diff --git a/tests/expedition/test_expedition.py b/tests/expedition/test_expedition.py index 5b7b180b..910bba71 100644 --- a/tests/expedition/test_expedition.py +++ b/tests/expedition/test_expedition.py @@ -1,5 +1,8 @@ -from virtual_ship import do_expedition +from virtual_ship.expedition import do_expedition +from pytest import CaptureFixture -def test_expedition() -> None: - do_expedition(".") +def test_expedition(capfd: CaptureFixture) -> None: + do_expedition("expedition_dir") + out, _ = capfd.readouterr() + assert "This expedition took" in out, "Expedition did not complete successfully." diff --git a/tests/test_schedule.py b/tests/expedition/test_schedule.py similarity index 87% rename from tests/test_schedule.py rename to tests/expedition/test_schedule.py index bd5bafe4..37ee4a47 100644 --- a/tests/test_schedule.py +++ b/tests/expedition/test_schedule.py @@ -2,7 +2,8 @@ import py -from virtual_ship import Location, Schedule, Waypoint +from virtual_ship import Location +from virtual_ship.expedition import Schedule, Waypoint def test_schedule(tmpdir: py.path.LocalPath) -> None: diff --git a/tests/test.yaml b/tests/test.yaml deleted file mode 100644 index ad9c3a89..00000000 --- a/tests/test.yaml +++ /dev/null @@ -1,11 +0,0 @@ -waypoints: -- instrument: null - location: - latitude: 0 - longitude: 0 - time: 1950-01-01 00:00:00 -- instrument: null - location: - latitude: 1 - longitude: 1 - time: 1950-01-01 01:00:00 diff --git a/virtual_ship/__init__.py b/virtual_ship/__init__.py index bcbb5449..6e74f1c8 100644 --- a/virtual_ship/__init__.py +++ b/virtual_ship/__init__.py @@ -1,32 +1,9 @@ """Code for the Virtual Ship Classroom, where Marine Scientists can combine Copernicus Marine Data with an OceanParcels ship to go on a virtual expedition.""" -from .do_expedition import do_expedition -from .instrument_type import InstrumentType from .location import Location -from .schedule import Schedule -from .ship_config import ( - ADCPConfig, - ArgoFloatConfig, - CTDConfig, - DrifterConfig, - ShipConfig, - ShipUnderwaterSTConfig, -) from .spacetime import Spacetime -from .waypoint import Waypoint __all__ = [ - "ADCPConfig", - "ArgoFloatConfig", - "CTDConfig", - "DrifterConfig", - "InstrumentType", "Location", - "Schedule", - "ShipConfig", - "ShipUnderwaterSTConfig", "Spacetime", - "Waypoint", - "do_expedition", - "instruments", ] diff --git a/virtual_ship/cli/__init__.py b/virtual_ship/cli/__init__.py new file mode 100644 index 00000000..d9f62d70 --- /dev/null +++ b/virtual_ship/cli/__init__.py @@ -0,0 +1 @@ +"""Command line interface tools.""" diff --git a/virtual_ship/cli/do_expedition.py b/virtual_ship/cli/do_expedition.py new file mode 100644 index 00000000..d224ca4f --- /dev/null +++ b/virtual_ship/cli/do_expedition.py @@ -0,0 +1,30 @@ +""" +Command line interface tool for virtualship.expedition.do_expedition:do_expedition function. + +See --help for usage. +""" + +import argparse +from pathlib import Path + +from ..expedition.do_expedition import do_expedition + + +def main() -> None: + """Entrypoint for the tool.""" + parser = argparse.ArgumentParser( + prog="do_expedition", + description="Perform an expedition based on a provided schedule.", + ) + parser.add_argument( + "dir", + type=str, + help="Directory for the expedition. This should contain all required configuration files, and the result will be saved here as well.", + ) + args = parser.parse_args() + + do_expedition(Path(args.dir)) + + +if __name__ == "__main__": + main() diff --git a/virtual_ship/expedition/__init__.py b/virtual_ship/expedition/__init__.py new file mode 100644 index 00000000..95a7fd8c --- /dev/null +++ b/virtual_ship/expedition/__init__.py @@ -0,0 +1,29 @@ +"""Everything for simulating an expedition.""" + +# from .do_expedition import do_expedition +from .do_expedition import do_expedition +from .instrument_type import InstrumentType +from .schedule import Schedule +from .ship_config import ( + ADCPConfig, + ArgoFloatConfig, + CTDConfig, + DrifterConfig, + ShipConfig, + ShipUnderwaterSTConfig, +) +from .waypoint import Waypoint + +__all__ = [ + "ADCPConfig", + "ArgoFloatConfig", + "CTDConfig", + "DrifterConfig", + "InstrumentType", + "Schedule", + "ShipConfig", + "ShipUnderwaterSTConfig", + "Waypoint", + "do_expedition", + "instruments", +] diff --git a/virtual_ship/checkpoint.py b/virtual_ship/expedition/checkpoint.py similarity index 100% rename from virtual_ship/checkpoint.py rename to virtual_ship/expedition/checkpoint.py diff --git a/virtual_ship/do_expedition.py b/virtual_ship/expedition/do_expedition.py similarity index 98% rename from virtual_ship/do_expedition.py rename to virtual_ship/expedition/do_expedition.py index 8f72dc3f..04d99a1d 100644 --- a/virtual_ship/do_expedition.py +++ b/virtual_ship/expedition/do_expedition.py @@ -18,8 +18,6 @@ def do_expedition(expedition_dir: str | Path) -> None: """ Perform an expedition, providing terminal feedback and file output. - This function is written as the entry point for a command line script. - :param expedition_dir: The base directory for the expedition. """ if isinstance(expedition_dir, str): diff --git a/virtual_ship/expedition_cost.py b/virtual_ship/expedition/expedition_cost.py similarity index 100% rename from virtual_ship/expedition_cost.py rename to virtual_ship/expedition/expedition_cost.py diff --git a/virtual_ship/input_data.py b/virtual_ship/expedition/input_data.py similarity index 100% rename from virtual_ship/input_data.py rename to virtual_ship/expedition/input_data.py diff --git a/virtual_ship/instrument_type.py b/virtual_ship/expedition/instrument_type.py similarity index 100% rename from virtual_ship/instrument_type.py rename to virtual_ship/expedition/instrument_type.py diff --git a/virtual_ship/schedule.py b/virtual_ship/expedition/schedule.py similarity index 100% rename from virtual_ship/schedule.py rename to virtual_ship/expedition/schedule.py diff --git a/virtual_ship/ship_config.py b/virtual_ship/expedition/ship_config.py similarity index 100% rename from virtual_ship/ship_config.py rename to virtual_ship/expedition/ship_config.py diff --git a/virtual_ship/simulate_measurements.py b/virtual_ship/expedition/simulate_measurements.py similarity index 93% rename from virtual_ship/simulate_measurements.py rename to virtual_ship/expedition/simulate_measurements.py index 891a54a6..cea4e9d7 100644 --- a/virtual_ship/simulate_measurements.py +++ b/virtual_ship/expedition/simulate_measurements.py @@ -4,11 +4,11 @@ from pathlib import Path from .input_data import InputData -from .instruments.adcp import simulate_adcp -from .instruments.argo_float import simulate_argo_floats -from .instruments.ctd import simulate_ctd -from .instruments.drifter import simulate_drifters -from .instruments.ship_underwater_st import simulate_ship_underwater_st +from ..instruments.adcp import simulate_adcp +from ..instruments.argo_float import simulate_argo_floats +from ..instruments.ctd import simulate_ctd +from ..instruments.drifter import simulate_drifters +from ..instruments.ship_underwater_st import simulate_ship_underwater_st from .ship_config import ShipConfig from .simulate_schedule import MeasurementsToSimulate diff --git a/virtual_ship/simulate_schedule.py b/virtual_ship/expedition/simulate_schedule.py similarity index 98% rename from virtual_ship/simulate_schedule.py rename to virtual_ship/expedition/simulate_schedule.py index 675e8cd9..3642fdf6 100644 --- a/virtual_ship/simulate_schedule.py +++ b/virtual_ship/expedition/simulate_schedule.py @@ -11,13 +11,13 @@ from sortedcontainers import SortedList from .instrument_type import InstrumentType -from .instruments.argo_float import ArgoFloat -from .instruments.ctd import CTD -from .instruments.drifter import Drifter -from .location import Location +from ..instruments.argo_float import ArgoFloat +from ..instruments.ctd import CTD +from ..instruments.drifter import Drifter +from ..location import Location from .schedule import Schedule from .ship_config import ShipConfig -from .spacetime import Spacetime +from ..spacetime import Spacetime def simulate_schedule( diff --git a/virtual_ship/verify_schedule.py b/virtual_ship/expedition/verify_schedule.py similarity index 100% rename from virtual_ship/verify_schedule.py rename to virtual_ship/expedition/verify_schedule.py diff --git a/virtual_ship/waypoint.py b/virtual_ship/expedition/waypoint.py similarity index 91% rename from virtual_ship/waypoint.py rename to virtual_ship/expedition/waypoint.py index bf3d33b4..284e763b 100644 --- a/virtual_ship/waypoint.py +++ b/virtual_ship/expedition/waypoint.py @@ -4,7 +4,7 @@ from datetime import datetime from .instrument_type import InstrumentType -from .location import Location +from ..location import Location @dataclass From 7c260e01d4aed639f83f95a17f22f68baff64a38 Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Fri, 23 Aug 2024 17:05:28 +0200 Subject: [PATCH 11/35] all done but _progress_time_traveling_towards --- tests/expedition/test_expedition.py | 4 +- virtual_ship/expedition/do_expedition.py | 8 +- virtual_ship/expedition/expedition_cost.py | 4 +- virtual_ship/expedition/simulate_schedule.py | 432 +++++++------------ virtual_ship/expedition/waypoint.py | 2 +- 5 files changed, 159 insertions(+), 291 deletions(-) diff --git a/tests/expedition/test_expedition.py b/tests/expedition/test_expedition.py index 910bba71..6bda614c 100644 --- a/tests/expedition/test_expedition.py +++ b/tests/expedition/test_expedition.py @@ -4,5 +4,5 @@ def test_expedition(capfd: CaptureFixture) -> None: do_expedition("expedition_dir") - out, _ = capfd.readouterr() - assert "This expedition took" in out, "Expedition did not complete successfully." + # out, _ = capfd.readouterr() + # assert "This expedition took" in out, "Expedition did not complete successfully." diff --git a/virtual_ship/expedition/do_expedition.py b/virtual_ship/expedition/do_expedition.py index 04d99a1d..06843805 100644 --- a/virtual_ship/expedition/do_expedition.py +++ b/virtual_ship/expedition/do_expedition.py @@ -10,7 +10,7 @@ from .schedule import Schedule from .ship_config import ShipConfig from .simulate_measurements import simulate_measurements -from .simulate_schedule import simulate_schedule +from .simulate_schedule import simulate_schedule, ScheduleOk, ScheduleProblem from .verify_schedule import verify_schedule @@ -63,9 +63,9 @@ def do_expedition(expedition_dir: str | Path) -> None: schedule_results = simulate_schedule( projection=projection, ship_config=ship_config, schedule=schedule ) - if not schedule_results.success: + if isinstance(schedule_results, ScheduleProblem): print( - f"It is currently {schedule_results.end_spacetime} and waypoint {schedule_results.failed_waypoint_i} could not be reached in time. Update your schedule and continue the expedition." + "Update your schedule and continue the expedition by running the tool again." ) _save_checkpoint( Checkpoint( @@ -90,7 +90,7 @@ def do_expedition(expedition_dir: str | Path) -> None: assert ( schedule.waypoints[0].time is not None ), "First waypoint has no time. This should not be possible as it should have been verified before." - time_past = schedule_results.end_spacetime.time - schedule.waypoints[0].time + time_past = schedule_results.time - schedule.waypoints[0].time cost = expedition_cost(schedule_results, time_past) with open(expedition_dir.joinpath("results", "cost.txt"), "w") as file: file.writelines(f"cost: {cost} US$") diff --git a/virtual_ship/expedition/expedition_cost.py b/virtual_ship/expedition/expedition_cost.py index c008ffc6..cab6ab7d 100644 --- a/virtual_ship/expedition/expedition_cost.py +++ b/virtual_ship/expedition/expedition_cost.py @@ -2,10 +2,10 @@ from datetime import timedelta -from .simulate_schedule import ScheduleResults +from .simulate_schedule import ScheduleOk -def expedition_cost(schedule_results: ScheduleResults, time_past: timedelta) -> float: +def expedition_cost(schedule_results: ScheduleOk, time_past: timedelta) -> float: """ Calculate the cost of the expedition in US$. diff --git a/virtual_ship/expedition/simulate_schedule.py b/virtual_ship/expedition/simulate_schedule.py index 3642fdf6..1ab7d7a1 100644 --- a/virtual_ship/expedition/simulate_schedule.py +++ b/virtual_ship/expedition/simulate_schedule.py @@ -2,13 +2,10 @@ from __future__ import annotations -from contextlib import contextmanager from dataclasses import dataclass, field from datetime import datetime, timedelta -from typing import Generator import pyproj -from sortedcontainers import SortedList from .instrument_type import InstrumentType from ..instruments.argo_float import ArgoFloat @@ -18,195 +15,19 @@ from .schedule import Schedule from .ship_config import ShipConfig from ..spacetime import Spacetime +from .waypoint import Waypoint -def simulate_schedule( - projection: pyproj.Geod, ship_config: ShipConfig, schedule: Schedule -) -> ScheduleResults: - """ - Simulate the expedition schedule and aggregate the virtual measurements that should be taken. - - :param projection: Projection used to sail between waypoints. - :param ship_config: The ship configuration. - :param schedule: The schedule to simulate. - :returns: Results from the simulation. - :raises NotImplementedError: When unsupported instruments are encountered. - """ - cruise = _SimulationState( - Spacetime( - schedule.waypoints[0].location, - schedule.waypoints[0].time, - ) - ) - measurements = MeasurementsToSimulate() - - # add recurring tasks to task list - waiting_tasks = SortedList[_WaitingTask]() - if ship_config.ship_underwater_st_config is not None: - waiting_tasks.add( - _WaitingTask( - task=_ship_underwater_st_loop( - ship_config.ship_underwater_st_config.period, cruise, measurements - ), - wait_until=cruise.spacetime.time, - ) - ) - if ship_config.adcp_config is not None: - waiting_tasks.add( - _WaitingTask( - task=_adcp_loop(ship_config.adcp_config.period, cruise, measurements), - wait_until=cruise.spacetime.time, - ) - ) - - # sail to each waypoint while executing tasks - for waypoint_i, waypoint in enumerate(schedule.waypoints): - if waypoint.time is not None and cruise.spacetime.time > waypoint.time: - print( - "Could not reach waypoint in time. This should not happen in this version of virtual ship as the schedule is verified beforehand." - ) - return ScheduleResults( - success=False, - measurements_to_simulate=measurements, - end_spacetime=cruise.spacetime.time, - failed_waypoint_i=waypoint_i, - ) - - # add task to the task queue for the instrument at the current waypoint - if waypoint.instrument is InstrumentType.ARGO_FLOAT: - _argo_float_task(cruise, measurements, config=ship_config) - elif waypoint.instrument is InstrumentType.DRIFTER: - _drifter_task(cruise, measurements, config=ship_config) - elif waypoint.instrument is InstrumentType.CTD: - waiting_tasks.add( - _WaitingTask( - _ctd_task( - ship_config.ctd_config.stationkeeping_time, - ship_config.ctd_config.min_depth, - ship_config.ctd_config.max_depth, - cruise, - measurements, - ), - cruise.spacetime.time, - ) - ) - elif waypoint.instrument is None: - pass - else: - raise NotImplementedError() - - # sail to the next waypoint - waypoint_reached = False - while not waypoint_reached: - # execute all tasks planned for current time - while ( - len(waiting_tasks) > 0 - and waiting_tasks[0].wait_until <= cruise.spacetime.time - ): - task = waiting_tasks.pop(0) - try: - wait_for = next(task.task) - waiting_tasks.add( - _WaitingTask(task.task, cruise.spacetime.time + wait_for.time) - ) - except StopIteration: - pass - - # if sailing is prevented by a current task, just let time pass until the next task - if cruise.sail_is_locked: - cruise.spacetime = Spacetime( - cruise.spacetime.location, waiting_tasks[0].wait_until - ) - # else, let time pass while sailing - else: - # calculate time at which waypoint would be reached if simply sailing - geodinv: tuple[float, float, float] = projection.inv( - lons1=cruise.spacetime.location.lon, - lats1=cruise.spacetime.location.lat, - lons2=waypoint.location.lon, - lats2=waypoint.location.lat, - ) - azimuth1 = geodinv[0] - distance_to_next_waypoint = geodinv[2] - time_to_reach = timedelta( - seconds=distance_to_next_waypoint / ship_config.ship_speed - ) - arrival_time = cruise.spacetime.time + time_to_reach - - # if waypoint is reached before next task, sail to the waypoint - if ( - len(waiting_tasks) == 0 - or arrival_time <= waiting_tasks[0].wait_until - ): - cruise.spacetime = Spacetime(waypoint.location, arrival_time) - waypoint_reached = True - # else, sail until task starts - else: - time_to_sail = waiting_tasks[0].wait_until - cruise.spacetime.time - distance_to_move = ( - ship_config.ship_speed * time_to_sail.total_seconds() - ) - geodfwd: tuple[float, float, float] = projection.fwd( - lons=cruise.spacetime.location.lon, - lats=cruise.spacetime.location.lat, - az=azimuth1, - dist=distance_to_move, - ) - lon = geodfwd[0] - lat = geodfwd[1] - cruise.spacetime = Spacetime( - Location(latitude=lat, longitude=lon), - cruise.spacetime.time + time_to_sail, - ) - - cruise.finish() - - # don't sail anymore, but let tasks finish - while len(waiting_tasks) > 0: - task = waiting_tasks.pop(0) - try: - wait_for = next(task.task) - waiting_tasks.add( - _WaitingTask(task.task, cruise.spacetime.time + wait_for.time) - ) - except StopIteration: - pass - - return ScheduleResults( - success=True, - measurements_to_simulate=measurements, - end_spacetime=cruise.spacetime, - ) - - -class _SimulationState: - _finished: bool # if last waypoint has been reached - _sail_lock_count: int # if sailing should be paused because of tasks; number of tasks that requested a pause; 0 means good to go sail - spacetime: Spacetime # current location and time - - def __init__(self, spacetime: Spacetime) -> None: - self._finished = False - self._sail_lock_count = 0 - self.spacetime = spacetime - - @property - def finished(self) -> bool: - return self._finished - - @contextmanager - def do_not_sail(self) -> Generator[None, None, None]: - try: - self._sail_lock_count += 1 - yield - finally: - self._sail_lock_count -= 1 +@dataclass +class ScheduleOk: + time: datetime + measurements_to_simulate: MeasurementsToSimulate - def finish(self) -> None: - self._finished = True - @property - def sail_is_locked(self) -> bool: - return self._sail_lock_count > 0 +@dataclass +class ScheduleProblem: + time: datetime + failed_waypoint_i: int @dataclass @@ -220,108 +41,155 @@ class MeasurementsToSimulate: ctds: list[CTD] = field(default_factory=list, init=False) -@dataclass -class ScheduleResults: - """Results from schedule simulation.""" +def simulate_schedule( + projection: pyproj.Geod, ship_config: ShipConfig, schedule: Schedule +) -> ScheduleOk | ScheduleProblem: + return _ScheduleSimulator(projection, ship_config, schedule).simulate() - success: bool - measurements_to_simulate: MeasurementsToSimulate - end_spacetime: Spacetime - failed_waypoint_i: int | None = None +class _ScheduleSimulator: + _projection: pyproj.Geod + _ship_config: ShipConfig + _schedule: Schedule -@dataclass -class _WaitFor: - time: timedelta + _time: datetime + """Current time.""" + _location: Location + """Current ship location.""" + _measurements_to_simulate: MeasurementsToSimulate -class _WaitingTask: - _task: Generator[_WaitFor, None, None] - _wait_until: datetime + _next_adcp_time: datetime + """Next moment ADCP measurement will be done.""" + _next_ship_underwater_st_time: datetime + """Next moment ship underwater ST measurement will be done.""" def __init__( - self, task: Generator[_WaitFor, None, None], wait_until: datetime + self, projection: pyproj.Geod, ship_config: ShipConfig, schedule: Schedule ) -> None: - self._task = task - self._wait_until = wait_until - - def __lt__(self, other: _WaitingTask): - return self._wait_until < other._wait_until - - @property - def task(self) -> Generator[_WaitFor, None, None]: - return self._task - - @property - def wait_until(self) -> datetime: - return self._wait_until - - -def _ship_underwater_st_loop( - sample_period: timedelta, - cruise: _SimulationState, - measurements: MeasurementsToSimulate, -) -> Generator[_WaitFor, None, None]: - while not cruise.finished: - measurements.ship_underwater_sts.append(cruise.spacetime) - yield _WaitFor(sample_period) - - -def _adcp_loop( - sample_period: timedelta, - cruise: _SimulationState, - measurements: MeasurementsToSimulate, -) -> Generator[_WaitFor, None, None]: - while not cruise.finished: - measurements.adcps.append(cruise.spacetime) - yield _WaitFor(sample_period) - - -def _ctd_task( - stationkeeping_time: timedelta, - min_depth: float, - max_depth: float, - cruise: _SimulationState, - measurements: MeasurementsToSimulate, -) -> Generator[_WaitFor, None, None]: - with cruise.do_not_sail(): - measurements.ctds.append( - CTD( - spacetime=cruise.spacetime, - min_depth=min_depth, - max_depth=max_depth, - ) + self._projection = projection + self._ship_config = ship_config + self._schedule = schedule + + assert ( + self._schedule.waypoints[0].time is not None + ), "First waypoint must have a time. This should have been verified before calling this function." + self._time = schedule.waypoints[0].time + self._location = schedule.waypoints[0].location + + self._measurements_to_simulate = MeasurementsToSimulate() + + self._next_adcp_time = self._time + self._next_ship_underwater_st_time = self._time + + def simulate(self) -> ScheduleOk | ScheduleProblem: + for wp_i, waypoint in enumerate(self._schedule.waypoints): + # sail towards waypoint + self._progress_time_traveling_towards(waypoint.location) + + # check if waypoint was reached in time + if waypoint.time is not None and self._time > waypoint.time: + print( + f"Waypoint {wp_i} could not be reached in time. Current time: {self._time}. Waypoint time: {waypoint.time}." + ) + return ScheduleProblem(self._time, wp_i) + + # note measurements made at waypoint + time_passed = self._make_measurements(waypoint) + + # wait while measurements are being done + self._progress_time_stationary(time_passed) + return ScheduleOk(self._time, self._measurements_to_simulate) + + def _progress_time_traveling_towards(self, location: Location) -> None: + geodinv: tuple[float, float, float] = self._projection.inv( + lons1=self._location.lon, + lats1=self._location.lat, + lons2=location.lon, + lats2=location.lat, ) - yield _WaitFor(stationkeeping_time) - - -def _drifter_task( - cruise: _SimulationState, - measurements: MeasurementsToSimulate, - config: ShipConfig, -) -> None: - measurements.drifters.append( - Drifter( - cruise.spacetime, - depth=config.drifter_config.depth, - lifetime=config.drifter_config.lifetime, + distance_to_next_waypoint = geodinv[2] + time_to_reach = timedelta( + seconds=distance_to_next_waypoint / self._ship_config.ship_speed ) - ) - - -def _argo_float_task( - cruise: _SimulationState, - measurements: MeasurementsToSimulate, - config: ShipConfig, -) -> None: - measurements.argo_floats.append( - ArgoFloat( - spacetime=cruise.spacetime, - min_depth=config.argo_float_config.min_depth, - max_depth=config.argo_float_config.max_depth, - drift_depth=config.argo_float_config.drift_depth, - vertical_speed=config.argo_float_config.vertical_speed, - cycle_days=config.argo_float_config.cycle_days, - drift_days=config.argo_float_config.drift_days, + self._time = self._time + time_to_reach + self._location = location + + # TODO ADCP and ship underwater ST + + def _progress_time_stationary(self, time_passed: timedelta) -> None: + end_time = self._time + time_passed + + # note all ADCP measurements + if self._ship_config.adcp_config is not None: + while self._next_adcp_time < end_time: + self._measurements_to_simulate.adcps.append( + Spacetime(self._location, self._next_adcp_time) + ) + self._next_adcp_time = ( + self._next_adcp_time + self._ship_config.adcp_config.period + ) + + # note all ship underwater ST measurements + if self._ship_config.ship_underwater_st_config is not None: + while self._next_ship_underwater_st_time < end_time: + self._measurements_to_simulate.ship_underwater_sts.append( + Spacetime(self._location, self._next_ship_underwater_st_time) + ) + self._next_ship_underwater_st_time = ( + self._next_ship_underwater_st_time + + self._ship_config.ship_underwater_st_config.period + ) + + self._time = end_time + + def _make_measurements(self, waypoint: Waypoint) -> timedelta: + # if there are no instruments, there is no time cost + if waypoint.instrument is None: + return timedelta() + + # make instruments a list even if it's only a single one + instruments = ( + waypoint.instrument + if isinstance(waypoint.instrument, list) + else [waypoint.instrument] ) - ) + + # time costs of each measurement + time_costs = [] + + for instrument in instruments: + if instrument is InstrumentType.ARGO_FLOAT: + self._measurements_to_simulate.argo_floats.append( + ArgoFloat( + spacetime=Spacetime(self._location, self._time), + min_depth=self._ship_config.argo_float_config.min_depth, + max_depth=self._ship_config.argo_float_config.max_depth, + drift_depth=self._ship_config.argo_float_config.drift_depth, + vertical_speed=self._ship_config.argo_float_config.vertical_speed, + cycle_days=self._ship_config.argo_float_config.cycle_days, + drift_days=self._ship_config.argo_float_config.drift_days, + ) + ) + elif instrument is InstrumentType.CTD: + self._measurements_to_simulate.ctds.append( + CTD( + spacetime=Spacetime(self._location, self._time), + min_depth=self._ship_config.ctd_config.min_depth, + max_depth=self._ship_config.ctd_config.max_depth, + ) + ) + time_costs.append(timedelta(minutes=20)) + elif instrument is InstrumentType.DRIFTER: + self._measurements_to_simulate.drifters.append( + Drifter( + spacetime=Spacetime(self._location, self._time), + depth=self._ship_config.drifter_config.depth, + lifetime=self._ship_config.drifter_config.lifetime, + ) + ) + else: + raise NotImplementedError("Instrument type not supported.") + + # measurements are done in parallel, so return time of longest one + return max(time_costs) diff --git a/virtual_ship/expedition/waypoint.py b/virtual_ship/expedition/waypoint.py index 284e763b..d13d7827 100644 --- a/virtual_ship/expedition/waypoint.py +++ b/virtual_ship/expedition/waypoint.py @@ -13,4 +13,4 @@ class Waypoint: location: Location time: datetime | None = None - instrument: InstrumentType | None = None + instrument: InstrumentType | list[InstrumentType] | None = None From ce2b7c8ec4f8d6a06b4ed7f436190707cdefa9d7 Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Fri, 23 Aug 2024 19:45:39 +0200 Subject: [PATCH 12/35] it workss --- virtual_ship/expedition/do_expedition.py | 22 ++++--- virtual_ship/expedition/simulate_schedule.py | 64 ++++++++++++++++++-- 2 files changed, 71 insertions(+), 15 deletions(-) diff --git a/virtual_ship/expedition/do_expedition.py b/virtual_ship/expedition/do_expedition.py index 06843805..ee27e743 100644 --- a/virtual_ship/expedition/do_expedition.py +++ b/virtual_ship/expedition/do_expedition.py @@ -10,7 +10,7 @@ from .schedule import Schedule from .ship_config import ShipConfig from .simulate_measurements import simulate_measurements -from .simulate_schedule import simulate_schedule, ScheduleOk, ScheduleProblem +from .simulate_schedule import simulate_schedule, ScheduleProblem from .verify_schedule import verify_schedule @@ -76,6 +76,16 @@ def do_expedition(expedition_dir: str | Path) -> None: ) return + # calculate expedition cost in US$ + assert ( + schedule.waypoints[0].time is not None + ), "First waypoint has no time. This should not be possible as it should have been verified before." + time_past = schedule_results.time - schedule.waypoints[0].time + cost = expedition_cost(schedule_results, time_past) + with open(expedition_dir.joinpath("results", "cost.txt"), "w") as file: + file.writelines(f"cost: {cost} US$") + print(f"This expedition took {time_past} and would have cost {cost:,.0f} US$.") + # simulate measurements print("Simulating measurements. This may take a while..") simulate_measurements( @@ -86,15 +96,7 @@ def do_expedition(expedition_dir: str | Path) -> None: ) print("Done simulating measurements.") - # calculate expedition cost in US$ - assert ( - schedule.waypoints[0].time is not None - ), "First waypoint has no time. This should not be possible as it should have been verified before." - time_past = schedule_results.time - schedule.waypoints[0].time - cost = expedition_cost(schedule_results, time_past) - with open(expedition_dir.joinpath("results", "cost.txt"), "w") as file: - file.writelines(f"cost: {cost} US$") - print(f"This expedition took {time_past} and would have cost {cost:,.0f} US$.") + print("Your expedition has concluded successfully!") def _get_ship_config(expedition_dir: Path) -> ShipConfig | None: diff --git a/virtual_ship/expedition/simulate_schedule.py b/virtual_ship/expedition/simulate_schedule.py index 1ab7d7a1..f272f42a 100644 --- a/virtual_ship/expedition/simulate_schedule.py +++ b/virtual_ship/expedition/simulate_schedule.py @@ -108,21 +108,75 @@ def _progress_time_traveling_towards(self, location: Location) -> None: lons2=location.lon, lats2=location.lat, ) + azimuth1 = geodinv[0] distance_to_next_waypoint = geodinv[2] time_to_reach = timedelta( seconds=distance_to_next_waypoint / self._ship_config.ship_speed ) - self._time = self._time + time_to_reach - self._location = location + end_time = self._time + time_to_reach + + # note all ADCP measurements + if self._ship_config.adcp_config is not None: + location = self._location + time = self._time + while self._next_adcp_time <= end_time: + time_to_sail = self._next_adcp_time - time + distance_to_move = ( + self._ship_config.ship_speed * time_to_sail.total_seconds() + ) + geodfwd: tuple[float, float, float] = self._projection.fwd( + lons=location.lon, + lats=location.lat, + az=azimuth1, + dist=distance_to_move, + ) + location = Location(latitude=geodfwd[1], longitude=geodfwd[0]) + time = time + time_to_sail + + self._measurements_to_simulate.adcps.append( + Spacetime(location=location, time=time) + ) + + self._next_adcp_time = ( + self._next_adcp_time + self._ship_config.adcp_config.period + ) + + # note all ship underwater ST measurements + if self._ship_config.ship_underwater_st_config is not None: + location = self._location + time = self._time + while self._next_ship_underwater_st_time <= end_time: + time_to_sail = self._next_ship_underwater_st_time - time + distance_to_move = ( + self._ship_config.ship_speed * time_to_sail.total_seconds() + ) + geodfwd: tuple[float, float, float] = self._projection.fwd( + lons=location.lon, + lats=location.lat, + az=azimuth1, + dist=distance_to_move, + ) + location = Location(latitude=geodfwd[1], longitude=geodfwd[0]) + time = time + time_to_sail + + self._measurements_to_simulate.ship_underwater_sts.append( + Spacetime(location=location, time=time) + ) - # TODO ADCP and ship underwater ST + self._next_ship_underwater_st_time = ( + self._next_ship_underwater_st_time + + self._ship_config.ship_underwater_st_config.period + ) + + self._time = end_time + self._location = location def _progress_time_stationary(self, time_passed: timedelta) -> None: end_time = self._time + time_passed # note all ADCP measurements if self._ship_config.adcp_config is not None: - while self._next_adcp_time < end_time: + while self._next_adcp_time <= end_time: self._measurements_to_simulate.adcps.append( Spacetime(self._location, self._next_adcp_time) ) @@ -132,7 +186,7 @@ def _progress_time_stationary(self, time_passed: timedelta) -> None: # note all ship underwater ST measurements if self._ship_config.ship_underwater_st_config is not None: - while self._next_ship_underwater_st_time < end_time: + while self._next_ship_underwater_st_time <= end_time: self._measurements_to_simulate.ship_underwater_sts.append( Spacetime(self._location, self._next_ship_underwater_st_time) ) From 3b576b0146ebff95a104df9e7359e7f4ee84a4a5 Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Fri, 23 Aug 2024 21:10:21 +0200 Subject: [PATCH 13/35] codetools --- tests/expedition/test_expedition.py | 3 ++- virtual_ship/expedition/do_expedition.py | 2 +- virtual_ship/expedition/simulate_measurements.py | 2 +- virtual_ship/expedition/simulate_schedule.py | 16 ++++++++++++++-- virtual_ship/expedition/waypoint.py | 2 +- 5 files changed, 19 insertions(+), 6 deletions(-) diff --git a/tests/expedition/test_expedition.py b/tests/expedition/test_expedition.py index 6bda614c..b67ea276 100644 --- a/tests/expedition/test_expedition.py +++ b/tests/expedition/test_expedition.py @@ -1,6 +1,7 @@ -from virtual_ship.expedition import do_expedition from pytest import CaptureFixture +from virtual_ship.expedition import do_expedition + def test_expedition(capfd: CaptureFixture) -> None: do_expedition("expedition_dir") diff --git a/virtual_ship/expedition/do_expedition.py b/virtual_ship/expedition/do_expedition.py index ee27e743..3b9e0968 100644 --- a/virtual_ship/expedition/do_expedition.py +++ b/virtual_ship/expedition/do_expedition.py @@ -10,7 +10,7 @@ from .schedule import Schedule from .ship_config import ShipConfig from .simulate_measurements import simulate_measurements -from .simulate_schedule import simulate_schedule, ScheduleProblem +from .simulate_schedule import ScheduleProblem, simulate_schedule from .verify_schedule import verify_schedule diff --git a/virtual_ship/expedition/simulate_measurements.py b/virtual_ship/expedition/simulate_measurements.py index cea4e9d7..d21dd477 100644 --- a/virtual_ship/expedition/simulate_measurements.py +++ b/virtual_ship/expedition/simulate_measurements.py @@ -3,12 +3,12 @@ from datetime import timedelta from pathlib import Path -from .input_data import InputData from ..instruments.adcp import simulate_adcp from ..instruments.argo_float import simulate_argo_floats from ..instruments.ctd import simulate_ctd from ..instruments.drifter import simulate_drifters from ..instruments.ship_underwater_st import simulate_ship_underwater_st +from .input_data import InputData from .ship_config import ShipConfig from .simulate_schedule import MeasurementsToSimulate diff --git a/virtual_ship/expedition/simulate_schedule.py b/virtual_ship/expedition/simulate_schedule.py index f272f42a..672fda1e 100644 --- a/virtual_ship/expedition/simulate_schedule.py +++ b/virtual_ship/expedition/simulate_schedule.py @@ -7,25 +7,29 @@ import pyproj -from .instrument_type import InstrumentType from ..instruments.argo_float import ArgoFloat from ..instruments.ctd import CTD from ..instruments.drifter import Drifter from ..location import Location +from ..spacetime import Spacetime +from .instrument_type import InstrumentType from .schedule import Schedule from .ship_config import ShipConfig -from ..spacetime import Spacetime from .waypoint import Waypoint @dataclass class ScheduleOk: + """Result of schedule that could be completed.""" + time: datetime measurements_to_simulate: MeasurementsToSimulate @dataclass class ScheduleProblem: + """Result of schedule that could not be fully completed.""" + time: datetime failed_waypoint_i: int @@ -44,6 +48,14 @@ class MeasurementsToSimulate: def simulate_schedule( projection: pyproj.Geod, ship_config: ShipConfig, schedule: Schedule ) -> ScheduleOk | ScheduleProblem: + """ + Simulate a schedule. + + :param projection: The projection to use for sailing. + :param ship_config: Ship configuration. + :param schedule: The schedule to simulate. + :returns: Either the results of a successfully simulated schedule, or information on where the schedule became infeasible. + """ return _ScheduleSimulator(projection, ship_config, schedule).simulate() diff --git a/virtual_ship/expedition/waypoint.py b/virtual_ship/expedition/waypoint.py index d13d7827..85e99181 100644 --- a/virtual_ship/expedition/waypoint.py +++ b/virtual_ship/expedition/waypoint.py @@ -3,8 +3,8 @@ from dataclasses import dataclass from datetime import datetime -from .instrument_type import InstrumentType from ..location import Location +from .instrument_type import InstrumentType @dataclass From 8c6c2b23008e289e8dd8039a1057c6ced5723203 Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Sat, 24 Aug 2024 01:51:25 +0200 Subject: [PATCH 14/35] done --- meta.yaml | 4 ++ download_data.py => scripts/download_data.py | 60 +++++++++++++++---- tests/expedition/expedition_dir/schedule.yaml | 9 ++- .../expedition_dir/ship_config.yaml | 4 +- ...st_expedition.py => test_do_expedition.py} | 2 +- tests/expedition/test_simulate_schedule.py | 46 ++++++++++++++ virtual_ship/expedition/input_data.py | 5 +- virtual_ship/expedition/instrument_type.py | 6 +- virtual_ship/expedition/ship_config.py | 4 +- virtual_ship/expedition/simulate_schedule.py | 2 +- virtual_ship/instruments/ctd.py | 4 +- 11 files changed, 120 insertions(+), 26 deletions(-) rename download_data.py => scripts/download_data.py (69%) rename tests/expedition/{test_expedition.py => test_do_expedition.py} (81%) create mode 100644 tests/expedition/test_simulate_schedule.py diff --git a/meta.yaml b/meta.yaml index f38a2c79..6073e7c7 100644 --- a/meta.yaml +++ b/meta.yaml @@ -5,6 +5,10 @@ package: source: path: virtual_ship +build: + entry_points: + - do_expedition = virtual_ship.cli.do_expedition:main + requirements: run: - python >=3.8 diff --git a/download_data.py b/scripts/download_data.py similarity index 69% rename from download_data.py rename to scripts/download_data.py index fb9a9ef1..e388b9f2 100644 --- a/download_data.py +++ b/scripts/download_data.py @@ -1,8 +1,33 @@ +""" +Download data required to run expeditions. + +This is a very crude script, here just as long as we do not properly incorporate it into the library. +""" + import copernicusmarine import datetime if __name__ == "__main__": - datadir = "data_groupF" + datadir = "download" + username = input("username: ") + password = input("password: ") + + copernicusmarine.subset( + dataset_id="cmems_mod_glo_phy_my_0.083deg_static", + force_dataset_part="bathy", + variables=["deptho"], + minimum_longitude=-0.01, + maximum_longitude=0.01, + minimum_latitude=-0.01, + maximum_latitude=0.01, + minimum_depth=0.49402499198913574, + maximum_depth=5727.9169921875, + output_filename="bathymetry.nc", + output_directory=datadir, + username=username, + password=password, + force_download=True, + ) download_dict = { "UVdata": { @@ -26,16 +51,19 @@ copernicusmarine.subset( dataset_id=download_dict[dataset]["dataset_id"], variables=download_dict[dataset]["variables"], - minimum_longitude=-1, - maximum_longitude=1, - minimum_latitude=-1, - maximum_latitude=1, + minimum_longitude=-0.01, + maximum_longitude=0.01, + minimum_latitude=-0.01, + maximum_latitude=0.01, start_datetime=datetime.datetime.strptime("2023-01-01", "%Y-%m-%d"), end_datetime=datetime.datetime.strptime("2023-01-02", "%Y-%m-%d"), minimum_depth=0.49402499198913574, maximum_depth=5727.9169921875, output_filename=download_dict[dataset]["output_filename"], output_directory=datadir, + username=username, + password=password, + force_download=True, ) download_dict = { @@ -55,16 +83,19 @@ copernicusmarine.subset( dataset_id=download_dict[dataset]["dataset_id"], variables=download_dict[dataset]["variables"], - minimum_longitude=-1, - maximum_longitude=1, - minimum_latitude=-1, - maximum_latitude=1, + minimum_longitude=-0.01, + maximum_longitude=0.01, + minimum_latitude=-0.01, + maximum_latitude=0.01, start_datetime=datetime.datetime.strptime("2023-01-01", "%Y-%m-%d"), end_datetime=datetime.datetime.strptime("2023-01-02", "%Y-%m-%d"), minimum_depth=0.49402499198913574, maximum_depth=0.49402499198913574, output_filename=download_dict[dataset]["output_filename"], output_directory=datadir, + username=username, + password=password, + force_download=True, ) download_dict = { @@ -89,14 +120,17 @@ copernicusmarine.subset( dataset_id=download_dict[dataset]["dataset_id"], variables=download_dict[dataset]["variables"], - minimum_longitude=-1, - maximum_longitude=1, - minimum_latitude=-1, - maximum_latitude=1, + minimum_longitude=-0.01, + maximum_longitude=0.01, + minimum_latitude=-0.01, + maximum_latitude=0.01, start_datetime=datetime.datetime.strptime("2023-01-01", "%Y-%m-%d"), end_datetime=datetime.datetime.strptime("2023-01-02", "%Y-%m-%d"), minimum_depth=0.49402499198913574, maximum_depth=5727.9169921875, output_filename=download_dict[dataset]["output_filename"], output_directory=datadir, + username=username, + password=password, + force_download=True, ) diff --git a/tests/expedition/expedition_dir/schedule.yaml b/tests/expedition/expedition_dir/schedule.yaml index 58a0e309..6d55f6c5 100644 --- a/tests/expedition/expedition_dir/schedule.yaml +++ b/tests/expedition/expedition_dir/schedule.yaml @@ -1,11 +1,16 @@ waypoints: -- instrument: null +- instrument: CTD location: latitude: 0 longitude: 0 time: 2023-01-01 00:00:00 -- instrument: null +- instrument: DRIFTER location: latitude: 0.01 longitude: 0.01 time: 2023-01-01 01:00:00 +- instrument: ARGO_FLOAT + location: + latitude: 0.02 + longitude: 0.02 + time: 2023-01-01 02:00:00 diff --git a/tests/expedition/expedition_dir/ship_config.yaml b/tests/expedition/expedition_dir/ship_config.yaml index 486c3fd4..0d8d6914 100644 --- a/tests/expedition/expedition_dir/ship_config.yaml +++ b/tests/expedition/expedition_dir/ship_config.yaml @@ -10,8 +10,8 @@ argo_float_config: min_depth: 0.0 vertical_speed: -0.1 ctd_config: - max_depth: 2000.0 - min_depth: 0.0 + max_depth: -2000.0 + min_depth: -11.0 stationkeeping_time_minutes: 20.0 drifter_config: depth: 0.0 diff --git a/tests/expedition/test_expedition.py b/tests/expedition/test_do_expedition.py similarity index 81% rename from tests/expedition/test_expedition.py rename to tests/expedition/test_do_expedition.py index b67ea276..dddf12b8 100644 --- a/tests/expedition/test_expedition.py +++ b/tests/expedition/test_do_expedition.py @@ -3,7 +3,7 @@ from virtual_ship.expedition import do_expedition -def test_expedition(capfd: CaptureFixture) -> None: +def test_do_expedition(capfd: CaptureFixture) -> None: do_expedition("expedition_dir") # out, _ = capfd.readouterr() # assert "This expedition took" in out, "Expedition did not complete successfully." diff --git a/tests/expedition/test_simulate_schedule.py b/tests/expedition/test_simulate_schedule.py new file mode 100644 index 00000000..d2fbb499 --- /dev/null +++ b/tests/expedition/test_simulate_schedule.py @@ -0,0 +1,46 @@ +import pyproj +from virtual_ship.expedition import ShipConfig, Schedule, Waypoint +from virtual_ship import Location +from virtual_ship.expedition.simulate_schedule import ( + simulate_schedule, + ScheduleOk, + ScheduleProblem, +) +from datetime import datetime, timedelta + + +def test_simulate_schedule_feasible() -> None: + """Test schedule with two waypoints that can be reached within time is OK.""" + base_time = datetime.strptime("2022-01-01T00:00:00", "%Y-%m-%dT%H:%M:%S") + + projection = pyproj.Geod(ellps="WGS84") + ship_config = ShipConfig.from_yaml("expedition_dir/ship_config.yaml") + ship_config.ship_speed = 5.14 + schedule = Schedule( + waypoints=[ + Waypoint(Location(0, 0), base_time), + Waypoint(Location(0.01, 0), base_time + timedelta(days=1)), + ] + ) + + result = simulate_schedule(projection, ship_config, schedule) + + assert isinstance(result, ScheduleOk) + + +def test_simulate_schedule_too_far() -> None: + """Test schedule with two waypoints that are very far away and cannot be reached in time is not OK.""" + base_time = datetime.strptime("2022-01-01T00:00:00", "%Y-%m-%dT%H:%M:%S") + + projection = pyproj.Geod(ellps="WGS84") + ship_config = ShipConfig.from_yaml("expedition_dir/ship_config.yaml") + schedule = Schedule( + waypoints=[ + Waypoint(Location(0, 0), base_time), + Waypoint(Location(1.0, 0), base_time + timedelta(minutes=1)), + ] + ) + + result = simulate_schedule(projection, ship_config, schedule) + + assert isinstance(result, ScheduleProblem) diff --git a/virtual_ship/expedition/input_data.py b/virtual_ship/expedition/input_data.py index 4cb01c4a..e039bbcb 100644 --- a/virtual_ship/expedition/input_data.py +++ b/virtual_ship/expedition/input_data.py @@ -101,12 +101,15 @@ def _load_default_fieldset(cls, directory: str | Path) -> FieldSet: g.depth = -g.depth # add bathymetry data - bathymetry_file = directory.joinpath("GLO-MFC_001_024_mask_bathy.nc") + bathymetry_file = directory.joinpath("bathymetry.nc") bathymetry_variables = ("bathymetry", "deptho") bathymetry_dimensions = {"lon": "longitude", "lat": "latitude"} bathymetry_field = Field.from_netcdf( bathymetry_file, bathymetry_variables, bathymetry_dimensions ) + # make depth negative + if max(bathymetry_field.data > 0): + bathymetry_field.data = -bathymetry_field.data fieldset.add_field(bathymetry_field) # read in data already diff --git a/virtual_ship/expedition/instrument_type.py b/virtual_ship/expedition/instrument_type.py index 9a19b814..1f69d04f 100644 --- a/virtual_ship/expedition/instrument_type.py +++ b/virtual_ship/expedition/instrument_type.py @@ -6,6 +6,6 @@ class InstrumentType(Enum): """Types of instruments.""" - CTD = auto() - DRIFTER = auto() - ARGO_FLOAT = auto() + CTD = "CTD" + DRIFTER = "DRIFTER" + ARGO_FLOAT = "ARGO_FLOAT" diff --git a/virtual_ship/expedition/ship_config.py b/virtual_ship/expedition/ship_config.py index a50930e1..d228d21c 100644 --- a/virtual_ship/expedition/ship_config.py +++ b/virtual_ship/expedition/ship_config.py @@ -46,8 +46,8 @@ class CTDConfig(BaseModel): validation_alias="stationkeeping_time_minutes", gt=timedelta(), ) - min_depth: float = Field(ge=0.0) - max_depth: float = Field(ge=0.0) + min_depth: float = Field(le=0.0) + max_depth: float = Field(le=0.0) model_config = ConfigDict(populate_by_name=True) diff --git a/virtual_ship/expedition/simulate_schedule.py b/virtual_ship/expedition/simulate_schedule.py index 672fda1e..aea73776 100644 --- a/virtual_ship/expedition/simulate_schedule.py +++ b/virtual_ship/expedition/simulate_schedule.py @@ -222,7 +222,7 @@ def _make_measurements(self, waypoint: Waypoint) -> timedelta: ) # time costs of each measurement - time_costs = [] + time_costs = [timedelta()] for instrument in instruments: if instrument is InstrumentType.ARGO_FLOAT: diff --git a/virtual_ship/instruments/ctd.py b/virtual_ship/instruments/ctd.py index 50a397af..0d017fa7 100644 --- a/virtual_ship/instruments/ctd.py +++ b/virtual_ship/instruments/ctd.py @@ -82,7 +82,9 @@ def simulate_ctd( fieldset_endtime = fieldset.time_origin.fulltime(fieldset.U.grid.time_full[-1]) # deploy time for all ctds should be later than fieldset start time - if not all([ctd.spacetime.time >= fieldset_starttime for ctd in ctds]): + if not all( + [np.datetime64(ctd.spacetime.time) >= fieldset_starttime for ctd in ctds] + ): raise ValueError("CTD deployed before fieldset starts.") # depth the ctd will go to. shallowest between ctd max depth and bathymetry. From 9a7c8d65761e1b1eef3b304d0d4d48c8287ec80b Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Sat, 24 Aug 2024 01:58:31 +0200 Subject: [PATCH 15/35] cleanup --- sailship_todo.py | 236 --------------------- tests/expedition/expedition_dir/.gitignore | 2 +- tests/expedition/test_do_expedition.py | 4 +- tests/expedition/test_simulate_schedule.py | 8 +- virtual_ship/expedition/__init__.py | 1 - virtual_ship/expedition/instrument_type.py | 2 +- 6 files changed, 9 insertions(+), 244 deletions(-) delete mode 100644 sailship_todo.py diff --git a/sailship_todo.py b/sailship_todo.py deleted file mode 100644 index 0cdcd521..00000000 --- a/sailship_todo.py +++ /dev/null @@ -1,236 +0,0 @@ -"""Performs a complete cruise with virtual ship.""" - -import datetime -from datetime import timedelta - -import numpy as np -import pyproj -import pytest -from parcels import Field, FieldSet - -from virtual_ship import InstrumentType, Location, Schedule, Waypoint -from virtual_ship.sailship import PlanningError, _verify_waypoints, sailship -from virtual_ship.virtual_ship_config import ( - ADCPConfig, - ArgoFloatConfig, - CTDConfig, - DrifterConfig, - ShipUnderwaterSTConfig, - VirtualShipConfig, -) - - -def _make_ctd_fieldset(base_time: datetime) -> FieldSet: - u = np.full((2, 2, 2, 2), 1.0) - v = np.full((2, 2, 2, 2), 1.0) - t = np.full((2, 2, 2, 2), 1.0) - s = np.full((2, 2, 2, 2), 1.0) - - fieldset = FieldSet.from_data( - {"V": v, "U": u, "T": t, "S": s}, - { - "time": [ - np.datetime64(base_time + datetime.timedelta(seconds=0)), - np.datetime64(base_time + datetime.timedelta(minutes=200)), - ], - "depth": [0, -1000], - "lat": [-40, 90], - "lon": [-90, 90], - }, - ) - fieldset.add_field(Field("bathymetry", [-1000], lon=0, lat=0)) - return fieldset - - -def _make_drifter_fieldset(base_time: datetime) -> FieldSet: - v = np.full((2, 2, 2), 1.0) - u = np.full((2, 2, 2), 1.0) - t = np.full((2, 2, 2), 1.0) - - fieldset = FieldSet.from_data( - {"V": v, "U": u, "T": t}, - { - "time": [ - np.datetime64(base_time + datetime.timedelta(seconds=0)), - np.datetime64(base_time + datetime.timedelta(weeks=10)), - ], - "lat": [-40, 90], - "lon": [-90, 90], - }, - ) - return fieldset - - -def test_sailship() -> None: - # arbitrary time offset for the dummy fieldsets - base_time = datetime.datetime.strptime("2022-01-01T00:00:00", "%Y-%m-%dT%H:%M:%S") - - adcp_fieldset = FieldSet.from_data( - {"U": 1, "V": 1}, - {"lon": 0, "lat": 0}, - ) - - ship_underwater_st_fieldset = FieldSet.from_data( - {"U": 1, "V": 1, "salinity": 0, "temperature": 0}, - {"lon": 0, "lat": 0}, - ) - - ctd_fieldset = _make_ctd_fieldset(base_time) - - drifter_fieldset = _make_drifter_fieldset(base_time) - - argo_float_fieldset = FieldSet.from_data( - {"U": 1, "V": 1, "T": 0, "S": 0}, - { - "lon": 0, - "lat": 0, - "time": [np.datetime64("1950-01-01") + np.timedelta64(632160, "h")], - }, - ) - - argo_float_config = ArgoFloatConfig( - fieldset=argo_float_fieldset, - min_depth=-argo_float_fieldset.U.depth[0], - max_depth=-2000, - drift_depth=-1000, - vertical_speed=-0.10, - cycle_days=10, - drift_days=9, - ) - - adcp_config = ADCPConfig( - max_depth=-1000, - bin_size_m=24, - period=timedelta(minutes=5), - fieldset=adcp_fieldset, - ) - - ship_underwater_st_config = ShipUnderwaterSTConfig( - period=timedelta(minutes=5), fieldset=ship_underwater_st_fieldset - ) - - ctd_config = CTDConfig( - stationkeeping_time=timedelta(minutes=20), - fieldset=ctd_fieldset, - min_depth=ctd_fieldset.U.depth[0], - max_depth=ctd_fieldset.U.depth[-1], - ) - - drifter_config = DrifterConfig( - fieldset=drifter_fieldset, - depth=-drifter_fieldset.U.depth[0], - lifetime=timedelta(weeks=4), - ) - - waypoints = [ - Waypoint( - location=Location(latitude=-23.071289, longitude=63.743631), - time=base_time, - ), - Waypoint( - location=Location(latitude=-23.081289, longitude=63.743631), - instrument=InstrumentType.CTD, - ), - Waypoint( - location=Location(latitude=-23.181289, longitude=63.743631), - time=base_time + datetime.timedelta(hours=1), - instrument=InstrumentType.CTD, - ), - Waypoint( - location=Location(latitude=-23.281289, longitude=63.743631), - instrument=InstrumentType.DRIFTER, - ), - Waypoint( - location=Location(latitude=-23.381289, longitude=63.743631), - instrument=InstrumentType.ARGO_FLOAT, - ), - ] - - schedule = Schedule(waypoints=waypoints) - - config = VirtualShipConfig( - ship_speed=5.14, - schedule=schedule, - argo_float_config=argo_float_config, - adcp_config=adcp_config, - ship_underwater_st_config=ship_underwater_st_config, - ctd_config=ctd_config, - drifter_config=drifter_config, - ) - - sailship(config) - - -def test_verify_waypoints() -> None: - # arbitrary cruise start time - BASE_TIME = datetime.datetime.strptime("2022-01-01T00:00:00", "%Y-%m-%dT%H:%M:%S") - PROJECTION = pyproj.Geod(ellps="WGS84") - - # the sets of waypoints to test - WAYPOINTS = [ - [], # require at least one waypoint - [Waypoint(Location(0.0, 0.0))], # first waypoint must have time - [ - Waypoint(Location(0.0, 0.0), BASE_TIME + datetime.timedelta(days=1)), - Waypoint(Location(0.0, 0.0), BASE_TIME), - ], # waypoint times must be in ascending order - [ - Waypoint(Location(0.0, 0.0), BASE_TIME), - ], # 0 uv points are on land - [ - Waypoint(Location(0.1, 0.1), BASE_TIME), - Waypoint(Location(1.0, 1.0), BASE_TIME + datetime.timedelta(seconds=1)), - ], # waypoints must be reachable in time - [ - Waypoint(Location(0.1, 0.1), BASE_TIME), - Waypoint(Location(1.0, 1.0), BASE_TIME + datetime.timedelta(days=1)), - ], # a valid schedule - ] - - # the expected errors for the schedules, or None if expected to be valid - EXPECT_MATCH = [ - "^At least one waypoint must be provided.$", - "^First waypoint must have a specified time.$", - "^Each waypoint should be timed after all previous waypoints$", - "^The following waypoints are on land: .*$", - "^Waypoint planning is not valid: would arrive too late at a waypoint number .*$", - None, - ] - - # create a fieldset matching the test waypoints - u = np.full((1, 1, 2, 2), 1.0) - v = np.full((1, 1, 2, 2), 1.0) - u[0, 0, 0, 0] = 0.0 - v[0, 0, 0, 0] = 0.0 - - fieldset = FieldSet.from_data( - {"V": v, "U": u}, - { - "time": [np.datetime64(BASE_TIME)], - "depth": [0], - "lat": [0, 1], - "lon": [0, 1], - }, - ) - - # dummy configs - ctd_config = CTDConfig(None, fieldset, None, None) - drifter_config = DrifterConfig(None, None, None) - argo_float_config = ArgoFloatConfig(None, None, None, None, None, None, None) - - # test each set of waypoints and verify the raised errors (or none if valid) - for waypoints, expect_match in zip(WAYPOINTS, EXPECT_MATCH, strict=True): - config = VirtualShipConfig( - ship_speed=5.14, - schedule=Schedule(waypoints), - argo_float_config=argo_float_config, - adcp_config=None, - ship_underwater_st_config=None, - ctd_config=ctd_config, - drifter_config=drifter_config, - ) - if expect_match is not None: - with pytest.raises(PlanningError, match=expect_match): - _verify_waypoints(PROJECTION, config) - else: - _verify_waypoints(PROJECTION, config) diff --git a/tests/expedition/expedition_dir/.gitignore b/tests/expedition/expedition_dir/.gitignore index 68bcbc96..fbca2253 100644 --- a/tests/expedition/expedition_dir/.gitignore +++ b/tests/expedition/expedition_dir/.gitignore @@ -1 +1 @@ -results/ \ No newline at end of file +results/ diff --git a/tests/expedition/test_do_expedition.py b/tests/expedition/test_do_expedition.py index dddf12b8..11e6b862 100644 --- a/tests/expedition/test_do_expedition.py +++ b/tests/expedition/test_do_expedition.py @@ -5,5 +5,5 @@ def test_do_expedition(capfd: CaptureFixture) -> None: do_expedition("expedition_dir") - # out, _ = capfd.readouterr() - # assert "This expedition took" in out, "Expedition did not complete successfully." + out, _ = capfd.readouterr() + assert "This expedition took" in out, "Expedition did not complete successfully." diff --git a/tests/expedition/test_simulate_schedule.py b/tests/expedition/test_simulate_schedule.py index d2fbb499..ed2cf324 100644 --- a/tests/expedition/test_simulate_schedule.py +++ b/tests/expedition/test_simulate_schedule.py @@ -1,12 +1,14 @@ +from datetime import datetime, timedelta + import pyproj -from virtual_ship.expedition import ShipConfig, Schedule, Waypoint + from virtual_ship import Location +from virtual_ship.expedition import Schedule, ShipConfig, Waypoint from virtual_ship.expedition.simulate_schedule import ( - simulate_schedule, ScheduleOk, ScheduleProblem, + simulate_schedule, ) -from datetime import datetime, timedelta def test_simulate_schedule_feasible() -> None: diff --git a/virtual_ship/expedition/__init__.py b/virtual_ship/expedition/__init__.py index 95a7fd8c..c755d33b 100644 --- a/virtual_ship/expedition/__init__.py +++ b/virtual_ship/expedition/__init__.py @@ -1,6 +1,5 @@ """Everything for simulating an expedition.""" -# from .do_expedition import do_expedition from .do_expedition import do_expedition from .instrument_type import InstrumentType from .schedule import Schedule diff --git a/virtual_ship/expedition/instrument_type.py b/virtual_ship/expedition/instrument_type.py index 1f69d04f..556d8464 100644 --- a/virtual_ship/expedition/instrument_type.py +++ b/virtual_ship/expedition/instrument_type.py @@ -1,6 +1,6 @@ """InstrumentType Enum.""" -from enum import Enum, auto +from enum import Enum class InstrumentType(Enum): From 0e3da787b841e90c27ece9801637406e44fc2bee Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Sat, 24 Aug 2024 02:03:14 +0200 Subject: [PATCH 16/35] add missing test files --- .../expedition_dir/input_data/.gitignore | 9 +++++++++ .../expedition_dir/input_data/argo_float_s.nc | Bin 0 -> 24666 bytes .../expedition_dir/input_data/argo_float_t.nc | Bin 0 -> 24666 bytes .../expedition_dir/input_data/argo_float_uv.nc | Bin 0 -> 28674 bytes .../expedition_dir/input_data/bathymetry.nc | Bin 0 -> 15787 bytes .../expedition_dir/input_data/default_s.nc | Bin 0 -> 24666 bytes .../expedition_dir/input_data/default_t.nc | Bin 0 -> 24666 bytes .../expedition_dir/input_data/default_uv.nc | Bin 0 -> 28674 bytes .../expedition_dir/input_data/drifter_t.nc | Bin 0 -> 24666 bytes .../expedition_dir/input_data/drifter_uv.nc | Bin 0 -> 27674 bytes 10 files changed, 9 insertions(+) create mode 100644 tests/expedition/expedition_dir/input_data/.gitignore create mode 100644 tests/expedition/expedition_dir/input_data/argo_float_s.nc create mode 100644 tests/expedition/expedition_dir/input_data/argo_float_t.nc create mode 100644 tests/expedition/expedition_dir/input_data/argo_float_uv.nc create mode 100644 tests/expedition/expedition_dir/input_data/bathymetry.nc create mode 100644 tests/expedition/expedition_dir/input_data/default_s.nc create mode 100644 tests/expedition/expedition_dir/input_data/default_t.nc create mode 100644 tests/expedition/expedition_dir/input_data/default_uv.nc create mode 100644 tests/expedition/expedition_dir/input_data/drifter_t.nc create mode 100644 tests/expedition/expedition_dir/input_data/drifter_uv.nc diff --git a/tests/expedition/expedition_dir/input_data/.gitignore b/tests/expedition/expedition_dir/input_data/.gitignore new file mode 100644 index 00000000..8d6d7cf4 --- /dev/null +++ b/tests/expedition/expedition_dir/input_data/.gitignore @@ -0,0 +1,9 @@ +!argo_float_s.nc +!argo_float_t.nc +!argo_float_uv.nc +!bathymetry.nc +!default_s.nc +!default_t.nc +!default_uv.nc +!drifter_t.nc +!drifter_uv.nc \ No newline at end of file diff --git a/tests/expedition/expedition_dir/input_data/argo_float_s.nc b/tests/expedition/expedition_dir/input_data/argo_float_s.nc new file mode 100644 index 0000000000000000000000000000000000000000..7743124b86356bd4e3b05329593e0b5c289fc5ba GIT binary patch literal 24666 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 literal 0 HcmV?d00001 diff --git a/tests/expedition/expedition_dir/input_data/argo_float_uv.nc b/tests/expedition/expedition_dir/input_data/argo_float_uv.nc new file mode 100644 index 0000000000000000000000000000000000000000..6012603aaabe5c56ef803ab6544273db8a79f36a GIT binary patch literal 28674 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 literal 0 HcmV?d00001 diff --git a/tests/expedition/expedition_dir/input_data/bathymetry.nc b/tests/expedition/expedition_dir/input_data/bathymetry.nc new file mode 100644 index 0000000000000000000000000000000000000000..002eb3392e53cbfea6dd4974fdb9e74a63dd4703 GIT binary patch literal 15787 zcmeHOeQX@X6@TYTY{$OD4meIBxLpNGoRT}+u}K8PiS6@U&gFb|d~t%+pkujR+biyN zoxMHRmQo>7M4^dLsv=rG5$)lk5Xrh5fJlQ4@M;wwG92Xfw5Y4EqvwER2-TWC%gkvG`Ws-b6pH2yJ{V zHfyaH#M})aXE|m~Y-Cich-x*bTFH5os2=~`nHS?}9herTp&x8ugd>R`+7?uP$R!_5HoSiSSNmmYu?n&mEl z5LSB*pLr09)X>R6X#4t~j=*tzh7`I9tiE$J{(Wes1_AKd_V65SVMal^z4Wg?z5p-N zEO!9ZVRhkP^7p`;mcr5a!Eerd00(FYN?VOp;*m8UK65^CBmTS^3)BB952_x#JX-Z= z!(#v*^YGy7(T~R@JVxiyn#WK)M&dCLkC7g@_{8q3QGuUOX8!j6c_45TF{-Kx1h~(T zzT@&b*hFq+t?~uo`V?4S3+wO4(JSkyC-^1q5-%QVWFENh8otECymH_BHLh2d-dWb6 z7?B2{3H?G7KqjR-S#7*4YFV~sn5v@}pb>ovpGE;%Gi`Y)vZmi8NT`SEAbOl8#wu>) za{JU=QJ+@;;#4Ux|$i!p;#GqvdP@5=d7=0CUnctLU?vrfjp`?kD_Tf1vVY6 zkH+Q!j=glM!1dQu6+ob{V;%t(uCH3AsbJvfEyPO#Tso=M!yDwevD`xr8_PqYhGP;o z-tPau91z&_tGCZx`8@QbP}@(W9{HXssa}X7)c&zT;c&@;Z9l+M2%d-Bm+MbYYyhUmS=U65 zpBLw$Q^aEzKM$?j^IS8$Nz9Ot#QVl8ecNF_HQ+`e!qaCT-@#vp*gyxQ*Jj#A;4o3( zRJ2<&M;`bl{E!-uB8Zh9+WBqROM~&8Mn@MgPP_>3Q$vpXl;(e)KLv-xI_L&%-N2_g4n3=R^Rf6pp?||4TzW zosPkn=n6a$fC(00*#6Y7Pp~eTb`ZdT@Bb(Y4Bwrd+RVCS1U`Vl>~S{gjSa7i#uNR0 z?xhP`PTpnKiw3;)tOx`dTac4*vdxxttiyYnn?Gz&$ ziN#|hu~an1V`STKoun&Gb<~3Hl!9fNk=M7dg2W}-VFvg2qx+!5JU+HAJ2vaKuocPEGXdXqt9!q{Xj z(^0byBcT8_u_);Fw2{>{y>MS7o7eM&p1f{nRmZZ!R#sQdh>oc`HDbK2YX%>_RSb_s zMk7kpn$m64$QBFAplTbYuB16vWxyP_>^!FHS*Fq&9gGd8H@mBmmQ8o-Uli;na@M_S zE{t-RYHqe*6v9)Jv*Gskj&OTtdl)a+9V1(CVKK}CB?1z6CKFv9l1zg_0%eFKa3{>6 zoMx6^<_`(dNynMGy{#?JmL18uf}q%K0vcQcx`}ZsXIQc+oVD_K!@-T{6vBAv9~P|J zf|yvvpq{cVt(etqwyt(D83RBGE4!JL%Akdp{TAXip&$<>ZrOTPMTzh^ikxlTT{-8* zk#7;qxNb8evEEc~ax6WN&I~8UG6+=$p`tfO**cs|5rxU*RkNt(*pi|PkYBU1MJ8g~ z@aSM0TYMO24datkNn}bJr=$xum#vTMHX+Q7R3<-M0b@34tr8G-A|+6&Z$cb86h&8s1;OK*(^IJCd`e zl*YN+$mKZ<_pFdgg??kbsGFkB^exzl|ikRKwO;+4Tvur9$R{Ve(%99npCe}gm zn)#CzKSva0$%@B!q#nZ-zbp-bg(WKrtwOm*q1;5E+!~-IX3_R literal 0 HcmV?d00001 diff --git a/tests/expedition/expedition_dir/input_data/default_s.nc b/tests/expedition/expedition_dir/input_data/default_s.nc new file mode 100644 index 0000000000000000000000000000000000000000..7743124b86356bd4e3b05329593e0b5c289fc5ba GIT binary patch literal 24666 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 literal 0 HcmV?d00001 diff --git a/tests/expedition/expedition_dir/input_data/default_uv.nc b/tests/expedition/expedition_dir/input_data/default_uv.nc new file mode 100644 index 0000000000000000000000000000000000000000..908cebafc6dcd848d73def8a768b42e1ae2fd45d GIT binary patch literal 28674 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 F{vT@t2Aco? literal 0 HcmV?d00001 diff --git a/tests/expedition/expedition_dir/input_data/drifter_t.nc b/tests/expedition/expedition_dir/input_data/drifter_t.nc new file mode 100644 index 0000000000000000000000000000000000000000..83cad2cf07f429b86a40bc046a68cf5815294183 GIT binary patch literal 24666 zcmeGk3vg7`^=y_;62jLIKLpt#j!4-}0+Gs}&1RQG^CKG&D9-x2`+#S6_l4aTh?S9_ zZAYycqgm<{k`x$YC|1&dqZ}^$LRy6=AU=9co6M0etR}J9AZWj7w!bp`BxzHaD+`%tu zo>V@r(l8eD(W7`37r1Jk*jv$5Rf!a#hLc*w#vT`K4+h!F1N}DbVo-SmHIPFIoy|ZM zK3g3Lg%KA#ATWqQLzQf)4>Uk4Qi3Cgl^-6)UtcbuWIp4ulD3Mbnh;V~NI2raI5%mi zYXGdKf)xOHm_7Q&u;EZd9i0O(hyW+P=7nSU2b6FI7)6(a;Cd>UMVtqyJkoX@9Hi#4 zfCkC3k^8TQ2}Jlf0FuJ1?>w>w7EwV2pa8QuYoERq_E5n@&V-V6udIi=@fqrHlE7@! z9ijW6hzhQtAAlpJ-Ur}&)Cxk-5dQ3gmmh+UhyWa;`IwzpR`X|=j(>oMb7T9yr$2^E zs0#L$cwBYcpiieg^~2}z&#z))^3Stz)y6NIRBh6*DS(ZoHj&u0z@}$59kWTzrYJTg zu_=g6C+}SL%+ar+0MD||+;*RExe5tUjti5(D06oqO(R1+qr_C_feUlt!c_>>H=o|P z$MGrfmjkyg^PlR(QVM}Q zB_$+!{%C+#o%5M^(^Zlcn6-TG9w> z3Bto|X=MGH+u>d+z$#=1)q)ut;by9W{w*6(+O~&bIGH^(YGg}`-v8$w*d*v63C>vg z;0t_`_u~_$=nDtlh5~{Z%}wXpuMPdEb^bHbCt*O&kA(wPODULW6QoUMHhI|;z@{%Y z3D~p%ta@e_wpFe+WwWWkktq*fX`$Zv^K3c5E#;_lVkt8gF^;32(q|$cBZMYx>7p!K z3R^h*dHcI---1vE0X5Cm^PGTOi9H~$Ha&>TWd*bMueKQb*xr3aP~BLL_l_O6NI3U1h8C*Yq3Z| zOST)gXfMif=;S9eva2ht-K;(sN~!h6U?|uatP2DqHPzuJn1p;FF~$@_Nvj3`EqYNK zAclJlNiqkJOYfQC0Z+TyA@>|eGEVx!IbgT{M9#*a?!wVhz(+zrPcBDOj*{gHDu-qu zR?i#&g6wyWip{y0{d+MoullVANnT+zupk9D3HH_vd zB_#>gs4r@gfkc;xj-|cLddgU=$2DCRd82wFp&8^HrM)P0V=YlN#po&( zjWO~)UeD~f{w*c$snv15(6P5mJcz>+(o3A4L8P>_EK)k5oMwMM5Aynv7VJ_ap(I7S_>{ zhPkyRtP5Uj+lx!w-c)5O4n5>hYnM#?~TT((TObrS)2K-I+jUMc28SW7YBZ%4-=lWAA@K>V> zDmW&Rs*bw5SX1LM%=DB;h>Q^60O~|c7mLwhsvp~fXAZ9ab>&E`>BgF&y2y5+e~y3HcAm_)0km;{Kqe0)a5K9&ounZMR!jk&tw~Dl*{AST{TI(VG+kZE%>~^ z3XMYIrag5Szrvun2x4TS)DkYn;UY;6XOCESBjikEWk;;L5%A{Kdp}}L ztZ~vg<3Pv$Qiz5WCh1A`j=$74iMIlS1k{iT-( zqCmby(j(>9mpjl=GSOlb(na|XL6Y4n+}TIVUq4y zjr{nn1CL3X;lM+^G*H=kVm~1p$31-(fBgYLOyyBc z7JrQr#4JA6-@kp-82B$0RB(>4aO-_kFrNDcd7C$^1C4N?Z%PXcpw+@&fs~dpEST!1 zc?sj`H19+={^)J~jZZB;Q2~DAb705RkNK1HdoDO&T~7!$gH^I%6(U%*!X|I9Dv({y zR>iRi9IV2=d}+g;bI~6MNB208Jv%KFPC_1E(jzkEm6>cAnZY|dP+vm>mz@H|j16bZ zX%thE_f60S2qjrA@41lAGMCp|(4eOz%jKmOycA%A^D&=fg_AoRa5&&_z~O+y0fz$) z2OJJK9B??`aKPb!!+~DsK*upw++AQvZa0__QZrJ@-^Vi>Il(Mf9}42xC(8l*LBVRKT`X z0CsOb<*{9?!){1gOW1G^gW{d+XvNQ{zXn1G zs~ljbG2~SnWRjRjHT07cT4KKD8TfqwVd>3y?-9eWf|T#=ksp zz&KI8`CwhQ#=rE6WKKho5*d3M1#bM(gq`t@gtT~ko)T|SyL^iWz{!WVz9X-I9WLt+V|xc{vK5#FsLbImx&oKm>3%&-zT8XVz!h R6tg}{$Cm0k=?mh({{VB*b6)@e literal 0 HcmV?d00001 diff --git a/tests/expedition/expedition_dir/input_data/drifter_uv.nc b/tests/expedition/expedition_dir/input_data/drifter_uv.nc new file mode 100644 index 0000000000000000000000000000000000000000..5a14b9d4631ddddc7829e95b6100b60e5c225815 GIT binary patch literal 27674 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 Date: Sat, 24 Aug 2024 02:08:37 +0200 Subject: [PATCH 17/35] recreate results dir --- virtual_ship/expedition/do_expedition.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/virtual_ship/expedition/do_expedition.py b/virtual_ship/expedition/do_expedition.py index 3b9e0968..8f14b1fe 100644 --- a/virtual_ship/expedition/do_expedition.py +++ b/virtual_ship/expedition/do_expedition.py @@ -12,6 +12,8 @@ from .simulate_measurements import simulate_measurements from .simulate_schedule import ScheduleProblem, simulate_schedule from .verify_schedule import verify_schedule +import os +import shutil def do_expedition(expedition_dir: str | Path) -> None: @@ -76,6 +78,11 @@ def do_expedition(expedition_dir: str | Path) -> None: ) return + # delete and create results directory + if os.path.exists(expedition_dir.joinpath("results")): + shutil.rmtree(expedition_dir.joinpath("results")) + os.makedirs(expedition_dir.joinpath("results")) + # calculate expedition cost in US$ assert ( schedule.waypoints[0].time is not None From e81df714831adcccf0bda8d5af94feaaaccf0d46 Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Sat, 24 Aug 2024 02:27:47 +0200 Subject: [PATCH 18/35] fix checkpoint --- virtual_ship/expedition/checkpoint.py | 12 +++++++++++- virtual_ship/expedition/do_expedition.py | 3 ++- virtual_ship/expedition/schedule.py | 7 ++++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/virtual_ship/expedition/checkpoint.py b/virtual_ship/expedition/checkpoint.py index 606b49c9..ea261b7f 100644 --- a/virtual_ship/expedition/checkpoint.py +++ b/virtual_ship/expedition/checkpoint.py @@ -8,6 +8,16 @@ from pydantic import BaseModel from .schedule import Schedule +from .instrument_type import InstrumentType + + +class _YamlDumper(yaml.SafeDumper): + pass + + +_YamlDumper.add_representer( + InstrumentType, lambda dumper, data: dumper.represent_data(data.value) +) class Checkpoint(BaseModel): @@ -26,7 +36,7 @@ def to_yaml(self, file_path: str | Path) -> None: :param file_path: Path to the file to write to. """ with open(file_path, "w") as file: - yaml.dump(self.model_dump(by_alias=True), file) + yaml.dump(self.model_dump(by_alias=True), file, Dumper=_YamlDumper) @classmethod def from_yaml(cls, file_path: str | Path) -> Checkpoint: diff --git a/virtual_ship/expedition/do_expedition.py b/virtual_ship/expedition/do_expedition.py index 8f14b1fe..6b0ffd82 100644 --- a/virtual_ship/expedition/do_expedition.py +++ b/virtual_ship/expedition/do_expedition.py @@ -74,7 +74,8 @@ def do_expedition(expedition_dir: str | Path) -> None: past_schedule=Schedule( waypoints=schedule.waypoints[: schedule_results.failed_waypoint_i] ) - ) + ), + expedition_dir, ) return diff --git a/virtual_ship/expedition/schedule.py b/virtual_ship/expedition/schedule.py index 9e4415a7..09609ab4 100644 --- a/virtual_ship/expedition/schedule.py +++ b/virtual_ship/expedition/schedule.py @@ -24,7 +24,12 @@ def to_yaml(self, file_path: str | Path) -> None: :param file_path: Path to the file to write to. """ with open(file_path, "w") as file: - yaml.dump(self.model_dump(by_alias=True), file) + yaml.dump( + self.model_dump( + by_alias=True, + ), + file, + ) @classmethod def from_yaml(cls, file_path: str | Path) -> Schedule: From e28b2e19c5e34d3ad085397eecd5c4df46bd13b4 Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Sat, 24 Aug 2024 02:43:42 +0200 Subject: [PATCH 19/35] codetools --- virtual_ship/expedition/checkpoint.py | 2 +- virtual_ship/expedition/do_expedition.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/virtual_ship/expedition/checkpoint.py b/virtual_ship/expedition/checkpoint.py index ea261b7f..f62e3edf 100644 --- a/virtual_ship/expedition/checkpoint.py +++ b/virtual_ship/expedition/checkpoint.py @@ -7,8 +7,8 @@ import yaml from pydantic import BaseModel -from .schedule import Schedule from .instrument_type import InstrumentType +from .schedule import Schedule class _YamlDumper(yaml.SafeDumper): diff --git a/virtual_ship/expedition/do_expedition.py b/virtual_ship/expedition/do_expedition.py index 6b0ffd82..6eb0e1fc 100644 --- a/virtual_ship/expedition/do_expedition.py +++ b/virtual_ship/expedition/do_expedition.py @@ -1,5 +1,7 @@ """do_expedition function.""" +import os +import shutil from pathlib import Path import pyproj @@ -12,8 +14,6 @@ from .simulate_measurements import simulate_measurements from .simulate_schedule import ScheduleProblem, simulate_schedule from .verify_schedule import verify_schedule -import os -import shutil def do_expedition(expedition_dir: str | Path) -> None: From 7237a1d67176e0eb92440fa8d2f5f77c62bb185b Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Sat, 24 Aug 2024 02:45:12 +0200 Subject: [PATCH 20/35] missing newline --- tests/expedition/expedition_dir/input_data/.gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/expedition/expedition_dir/input_data/.gitignore b/tests/expedition/expedition_dir/input_data/.gitignore index 8d6d7cf4..0f4bf3bf 100644 --- a/tests/expedition/expedition_dir/input_data/.gitignore +++ b/tests/expedition/expedition_dir/input_data/.gitignore @@ -6,4 +6,4 @@ !default_t.nc !default_uv.nc !drifter_t.nc -!drifter_uv.nc \ No newline at end of file +!drifter_uv.nc From ed25a0bcff9c36e36028d72cbb66fdd3763d9949 Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Sun, 25 Aug 2024 15:59:10 +0200 Subject: [PATCH 21/35] change download dir name to input_data --- scripts/download_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/download_data.py b/scripts/download_data.py index e388b9f2..d89edba8 100644 --- a/scripts/download_data.py +++ b/scripts/download_data.py @@ -8,7 +8,7 @@ import datetime if __name__ == "__main__": - datadir = "download" + datadir = "input_data" username = input("username: ") password = input("password: ") From bd78130f8db7d3aba2b8cd715649cc48d9d6e34f Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Sun, 25 Aug 2024 16:49:11 +0200 Subject: [PATCH 22/35] add units to config --- .../expedition_dir/ship_config.yaml | 18 +++++++-------- tests/expedition/test_simulate_schedule.py | 2 +- virtual_ship/expedition/ship_config.py | 18 +++++++-------- .../expedition/simulate_measurements.py | 2 +- virtual_ship/expedition/simulate_schedule.py | 23 +++++++++++-------- virtual_ship/expedition/verify_schedule.py | 4 +++- 6 files changed, 36 insertions(+), 31 deletions(-) diff --git a/tests/expedition/expedition_dir/ship_config.yaml b/tests/expedition/expedition_dir/ship_config.yaml index 0d8d6914..b3763f05 100644 --- a/tests/expedition/expedition_dir/ship_config.yaml +++ b/tests/expedition/expedition_dir/ship_config.yaml @@ -1,21 +1,21 @@ adcp_config: num_bins: 40 - max_depth: -1000.0 + max_depth_meter: -1000.0 period_minutes: 5.0 argo_float_config: cycle_days: 10.0 drift_days: 9.0 - drift_depth: -1000.0 - max_depth: -2000.0 - min_depth: 0.0 - vertical_speed: -0.1 + drift_depth_meter: -1000.0 + max_depth_meter: -2000.0 + min_depth_meter: 0.0 + vertical_speed_meter_per_second: -0.1 ctd_config: - max_depth: -2000.0 - min_depth: -11.0 + max_depth_meter: -2000.0 + min_depth_meter: -11.0 stationkeeping_time_minutes: 20.0 drifter_config: - depth: 0.0 + depth_meter: 0.0 lifetime_minutes: 40320.0 -ship_speed: 5.14 +ship_speed_meter_per_second: 5.14 ship_underwater_st_config: period_minutes: 5.0 diff --git a/tests/expedition/test_simulate_schedule.py b/tests/expedition/test_simulate_schedule.py index ed2cf324..e27fed3f 100644 --- a/tests/expedition/test_simulate_schedule.py +++ b/tests/expedition/test_simulate_schedule.py @@ -17,7 +17,7 @@ def test_simulate_schedule_feasible() -> None: projection = pyproj.Geod(ellps="WGS84") ship_config = ShipConfig.from_yaml("expedition_dir/ship_config.yaml") - ship_config.ship_speed = 5.14 + ship_config.ship_speed_meter_per_second = 5.14 schedule = Schedule( waypoints=[ Waypoint(Location(0, 0), base_time), diff --git a/virtual_ship/expedition/ship_config.py b/virtual_ship/expedition/ship_config.py index d228d21c..f6d4594f 100644 --- a/virtual_ship/expedition/ship_config.py +++ b/virtual_ship/expedition/ship_config.py @@ -12,10 +12,10 @@ class ArgoFloatConfig(BaseModel): """Configuration for argos floats.""" - min_depth: float = Field(le=0.0) - max_depth: float = Field(le=0.0) - drift_depth: float = Field(le=0.0) - vertical_speed: float = Field(lt=0.0) + min_depth_meter: float = Field(le=0.0) + max_depth_meter: float = Field(le=0.0) + drift_depth_meter: float = Field(le=0.0) + vertical_speed_meter_per_second: float = Field(lt=0.0) cycle_days: float = Field(gt=0.0) drift_days: float = Field(gt=0.0) @@ -23,7 +23,7 @@ class ArgoFloatConfig(BaseModel): class ADCPConfig(BaseModel): """Configuration for ADCP instrument.""" - max_depth: float = Field(le=0.0) + max_depth_meter: float = Field(le=0.0) num_bins: int = Field(gt=0.0) period: timedelta = Field( serialization_alias="period_minutes", @@ -46,8 +46,8 @@ class CTDConfig(BaseModel): validation_alias="stationkeeping_time_minutes", gt=timedelta(), ) - min_depth: float = Field(le=0.0) - max_depth: float = Field(le=0.0) + min_depth_meter: float = Field(le=0.0) + max_depth_meter: float = Field(le=0.0) model_config = ConfigDict(populate_by_name=True) @@ -75,7 +75,7 @@ def _serialize_period(self, value: timedelta, _info): class DrifterConfig(BaseModel): """Configuration for drifters.""" - depth: float = Field(le=0.0) + depth_meter: float = Field(le=0.0) lifetime: timedelta = Field( serialization_alias="lifetime_minutes", validation_alias="lifetime_minutes", @@ -92,7 +92,7 @@ def _serialize_lifetime(self, value: timedelta, _info): class ShipConfig(BaseModel): """Configuration of the virtual ship.""" - ship_speed: float = Field(gt=0.0) + ship_speed_meter_per_second: float = Field(gt=0.0) """ Velocity of the ship in meters per second. """ diff --git a/virtual_ship/expedition/simulate_measurements.py b/virtual_ship/expedition/simulate_measurements.py index d21dd477..d56d744a 100644 --- a/virtual_ship/expedition/simulate_measurements.py +++ b/virtual_ship/expedition/simulate_measurements.py @@ -55,7 +55,7 @@ def simulate_measurements( simulate_adcp( fieldset=input_data.adcp_fieldset, out_path=expedition_dir.joinpath("results", "adcp.zarr"), - max_depth=ship_config.adcp_config.max_depth, + max_depth=ship_config.adcp_config.max_depth_meter, min_depth=-5, num_bins=ship_config.adcp_config.num_bins, sample_points=measurements.adcps, diff --git a/virtual_ship/expedition/simulate_schedule.py b/virtual_ship/expedition/simulate_schedule.py index aea73776..85df8696 100644 --- a/virtual_ship/expedition/simulate_schedule.py +++ b/virtual_ship/expedition/simulate_schedule.py @@ -123,7 +123,8 @@ def _progress_time_traveling_towards(self, location: Location) -> None: azimuth1 = geodinv[0] distance_to_next_waypoint = geodinv[2] time_to_reach = timedelta( - seconds=distance_to_next_waypoint / self._ship_config.ship_speed + seconds=distance_to_next_waypoint + / self._ship_config.ship_speed_meter_per_second ) end_time = self._time + time_to_reach @@ -134,7 +135,8 @@ def _progress_time_traveling_towards(self, location: Location) -> None: while self._next_adcp_time <= end_time: time_to_sail = self._next_adcp_time - time distance_to_move = ( - self._ship_config.ship_speed * time_to_sail.total_seconds() + self._ship_config.ship_speed_meter_per_second + * time_to_sail.total_seconds() ) geodfwd: tuple[float, float, float] = self._projection.fwd( lons=location.lon, @@ -160,7 +162,8 @@ def _progress_time_traveling_towards(self, location: Location) -> None: while self._next_ship_underwater_st_time <= end_time: time_to_sail = self._next_ship_underwater_st_time - time distance_to_move = ( - self._ship_config.ship_speed * time_to_sail.total_seconds() + self._ship_config.ship_speed_meter_per_second + * time_to_sail.total_seconds() ) geodfwd: tuple[float, float, float] = self._projection.fwd( lons=location.lon, @@ -229,10 +232,10 @@ def _make_measurements(self, waypoint: Waypoint) -> timedelta: self._measurements_to_simulate.argo_floats.append( ArgoFloat( spacetime=Spacetime(self._location, self._time), - min_depth=self._ship_config.argo_float_config.min_depth, - max_depth=self._ship_config.argo_float_config.max_depth, - drift_depth=self._ship_config.argo_float_config.drift_depth, - vertical_speed=self._ship_config.argo_float_config.vertical_speed, + min_depth=self._ship_config.argo_float_config.min_depth_meter, + max_depth=self._ship_config.argo_float_config.max_depth_meter, + drift_depth=self._ship_config.argo_float_config.drift_depth_meter, + vertical_speed=self._ship_config.argo_float_config.vertical_speed_meter_per_second, cycle_days=self._ship_config.argo_float_config.cycle_days, drift_days=self._ship_config.argo_float_config.drift_days, ) @@ -241,8 +244,8 @@ def _make_measurements(self, waypoint: Waypoint) -> timedelta: self._measurements_to_simulate.ctds.append( CTD( spacetime=Spacetime(self._location, self._time), - min_depth=self._ship_config.ctd_config.min_depth, - max_depth=self._ship_config.ctd_config.max_depth, + min_depth=self._ship_config.ctd_config.min_depth_meter, + max_depth=self._ship_config.ctd_config.max_depth_meter, ) ) time_costs.append(timedelta(minutes=20)) @@ -250,7 +253,7 @@ def _make_measurements(self, waypoint: Waypoint) -> timedelta: self._measurements_to_simulate.drifters.append( Drifter( spacetime=Spacetime(self._location, self._time), - depth=self._ship_config.drifter_config.depth, + depth=self._ship_config.drifter_config.depth_meter, lifetime=self._ship_config.drifter_config.lifetime, ) ) diff --git a/virtual_ship/expedition/verify_schedule.py b/virtual_ship/expedition/verify_schedule.py index 06076d0f..fc2c7997 100644 --- a/virtual_ship/expedition/verify_schedule.py +++ b/virtual_ship/expedition/verify_schedule.py @@ -94,7 +94,9 @@ def verify_schedule( ) distance = geodinv[2] - time_to_reach = timedelta(seconds=distance / ship_config.ship_speed) + time_to_reach = timedelta( + seconds=distance / ship_config.ship_speed_meter_per_second + ) arrival_time = time + time_to_reach if wp_next.time is None: From 9b7afc7a2da65f4d0a218f594b0fdd5138a82762 Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Sun, 25 Aug 2024 17:35:16 +0200 Subject: [PATCH 23/35] cli tool dont use relative import --- virtual_ship/cli/do_expedition.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/virtual_ship/cli/do_expedition.py b/virtual_ship/cli/do_expedition.py index d224ca4f..868a1aa3 100644 --- a/virtual_ship/cli/do_expedition.py +++ b/virtual_ship/cli/do_expedition.py @@ -7,7 +7,7 @@ import argparse from pathlib import Path -from ..expedition.do_expedition import do_expedition +from virtual_ship.expedition.do_expedition import do_expedition def main() -> None: From 3c303118f302bdd4269c6d3955fe468de1750e75 Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Mon, 26 Aug 2024 18:56:39 +0200 Subject: [PATCH 24/35] sfdgsfdgiu --- virtual_ship/expedition/do_expedition.py | 1 + virtual_ship/expedition/ship_config.py | 8 ++--- virtual_ship/expedition/verify_schedule.py | 36 +++++++++++++--------- 3 files changed, 26 insertions(+), 19 deletions(-) diff --git a/virtual_ship/expedition/do_expedition.py b/virtual_ship/expedition/do_expedition.py index 6eb0e1fc..0b43ee8e 100644 --- a/virtual_ship/expedition/do_expedition.py +++ b/virtual_ship/expedition/do_expedition.py @@ -105,6 +105,7 @@ def do_expedition(expedition_dir: str | Path) -> None: print("Done simulating measurements.") print("Your expedition has concluded successfully!") + print("Your measurements can be found in the results directory.") def _get_ship_config(expedition_dir: Path) -> ShipConfig | None: diff --git a/virtual_ship/expedition/ship_config.py b/virtual_ship/expedition/ship_config.py index f6d4594f..ce197cc6 100644 --- a/virtual_ship/expedition/ship_config.py +++ b/virtual_ship/expedition/ship_config.py @@ -104,28 +104,28 @@ class ShipConfig(BaseModel): If None, no argo floats can be deployed. """ - adcp_config: ADCPConfig | None + adcp_config: ADCPConfig | None = None """ ADCP configuration. If None, no ADCP measurements will be performed. """ - ctd_config: CTDConfig | None + ctd_config: CTDConfig | None = None """ CTD configuration. If None, no CTDs can be cast. """ - ship_underwater_st_config: ShipUnderwaterSTConfig | None + ship_underwater_st_config: ShipUnderwaterSTConfig | None = None """ Ship underwater salinity temperature measurementconfiguration. If None, no ST measurements will be performed. """ - drifter_config: DrifterConfig | None + drifter_config: DrifterConfig | None = None """ Drifter configuration. diff --git a/virtual_ship/expedition/verify_schedule.py b/virtual_ship/expedition/verify_schedule.py index fc2c7997..5926eb5f 100644 --- a/virtual_ship/expedition/verify_schedule.py +++ b/virtual_ship/expedition/verify_schedule.py @@ -50,6 +50,8 @@ def verify_schedule( # check if all waypoints are in water # this is done by picking an arbitrary provided fieldset and checking if UV is not zero + print("Verifying all waypoints are on water..") + # get all available fieldsets available_fieldsets = [ fs @@ -62,25 +64,29 @@ def verify_schedule( ] if fs is not None ] + # check if there are any fieldsets, else its an error if len(available_fieldsets) == 0: - raise ValueError( - "No fieldsets provided to check if waypoints are on land. Assuming no provided fieldsets is an error." - ) - # pick any - fieldset = available_fieldsets[0] - # get waypoints with 0 UV - land_waypoints = [ - (wp_i, wp) - for wp_i, wp in enumerate(schedule.waypoints) - if _is_on_land_zero_uv(fieldset, wp) - ] - # raise an error if there are any - if len(land_waypoints) > 0: - raise PlanningError( - f"The following waypoints are on land: {['#' + str(wp_i) + ' ' + str(wp) for (wp_i, wp) in land_waypoints]}" + print( + "Cannot verify because no fieldsets have been loaded. This is probably because you are not using any instruments in your schedule. This is not a problem, but carefully check your waypoint locations manually.." ) + else: + # pick any + fieldset = available_fieldsets[0] + # get waypoints with 0 UV + land_waypoints = [ + (wp_i, wp) + for wp_i, wp in enumerate(schedule.waypoints) + if _is_on_land_zero_uv(fieldset, wp) + ] + # raise an error if there are any + if len(land_waypoints) > 0: + raise PlanningError( + f"The following waypoints are on land: {['#' + str(wp_i) + ' ' + str(wp) for (wp_i, wp) in land_waypoints]}" + ) + print("Good, all waypoints are on water.") + # check that ship will arrive on time at each waypoint (in case no unexpected event happen) time = schedule.waypoints[0].time for wp_i, (wp, wp_next) in enumerate( From 5bca508c901d3446a156912bffbe1f101be1dfc0 Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Mon, 26 Aug 2024 19:01:49 +0200 Subject: [PATCH 25/35] imrpoved error msg --- .../expedition/simulate_measurements.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/virtual_ship/expedition/simulate_measurements.py b/virtual_ship/expedition/simulate_measurements.py index d56d744a..81a56a12 100644 --- a/virtual_ship/expedition/simulate_measurements.py +++ b/virtual_ship/expedition/simulate_measurements.py @@ -35,10 +35,10 @@ def simulate_measurements( if len(measurements.ship_underwater_sts) > 0: print("Simulating onboard salinity and temperature measurements.") - if input_data.ship_underwater_st_fieldset is None: - raise RuntimeError("No fieldset for ship underwater ST provided.") if ship_config.ship_underwater_st_config is None: raise RuntimeError("No configuration for ship underwater ST provided.") + if input_data.ship_underwater_st_fieldset is None: + raise RuntimeError("No fieldset for ship underwater ST provided.") simulate_ship_underwater_st( fieldset=input_data.ship_underwater_st_fieldset, out_path=expedition_dir.joinpath("results", "ship_underwater_st.zarr"), @@ -48,10 +48,10 @@ def simulate_measurements( if len(measurements.adcps) > 0: print("Simulating onboard ADCP.") - if input_data.adcp_fieldset is None: - raise RuntimeError("No fieldset for ADCP provided.") if ship_config.adcp_config is None: raise RuntimeError("No configuration for ADCP provided.") + if input_data.adcp_fieldset is None: + raise RuntimeError("No fieldset for ADCP provided.") simulate_adcp( fieldset=input_data.adcp_fieldset, out_path=expedition_dir.joinpath("results", "adcp.zarr"), @@ -63,10 +63,10 @@ def simulate_measurements( if len(measurements.ctds) > 0: print("Simulating CTD casts.") - if input_data.ctd_fieldset is None: - raise RuntimeError("No fieldset for CTD provided.") if ship_config.ctd_config is None: raise RuntimeError("No configuration for CTD provided.") + if input_data.ctd_fieldset is None: + raise RuntimeError("No fieldset for CTD provided.") simulate_ctd( out_path=expedition_dir.joinpath("results", "ctd.zarr"), fieldset=input_data.ctd_fieldset, @@ -76,10 +76,10 @@ def simulate_measurements( if len(measurements.drifters) > 0: print("Simulating drifters") - if input_data.drifter_fieldset is None: - raise RuntimeError("No fieldset for drifters provided.") if ship_config.drifter_config is None: raise RuntimeError("No configuration for drifters provided.") + if input_data.drifter_fieldset is None: + raise RuntimeError("No fieldset for drifters provided.") simulate_drifters( out_path=expedition_dir.joinpath("results", "drifters.zarr"), fieldset=input_data.drifter_fieldset, @@ -91,10 +91,10 @@ def simulate_measurements( if len(measurements.argo_floats) > 0: print("Simulating argo floats") - if input_data.argo_float_fieldset is None: - raise RuntimeError("No fieldset for argo floats provided.") if ship_config.argo_float_config is None: raise RuntimeError("No configuration for argo floats provided.") + if input_data.argo_float_fieldset is None: + raise RuntimeError("No fieldset for argo floats provided.") simulate_argo_floats( out_path=expedition_dir.joinpath("results", "argo_floats.zarr"), argo_floats=measurements.argo_floats, From af1be5b5d1278d899aacecf69574844cbdb581d7 Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Mon, 26 Aug 2024 19:03:23 +0200 Subject: [PATCH 26/35] fix typo --- virtual_ship/expedition/checkpoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/virtual_ship/expedition/checkpoint.py b/virtual_ship/expedition/checkpoint.py index f62e3edf..a05d7bab 100644 --- a/virtual_ship/expedition/checkpoint.py +++ b/virtual_ship/expedition/checkpoint.py @@ -22,9 +22,9 @@ class _YamlDumper(yaml.SafeDumper): class Checkpoint(BaseModel): """ - Checkpoint is schedule simulation. + A checkpoint of schedule simulation. - Until where the schedule execution proceeded without troubles. + Copy of the schedule until where the simulation proceeded without troubles. """ past_schedule: Schedule From 78dab99b82d3e5852a9f052855fb71d42c6610fd Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Mon, 26 Aug 2024 19:10:50 +0200 Subject: [PATCH 27/35] =?UTF-8?q?codetools=20fixes=20and=20made=20pydantic?= =?UTF-8?q?=20imports=20clearer=20=F0=9F=8C=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- virtual_ship/expedition/checkpoint.py | 4 +- virtual_ship/expedition/input_data.py | 9 +-- virtual_ship/expedition/schedule.py | 6 +- virtual_ship/expedition/ship_config.py | 64 +++++++++++----------- virtual_ship/expedition/verify_schedule.py | 1 - 5 files changed, 40 insertions(+), 44 deletions(-) diff --git a/virtual_ship/expedition/checkpoint.py b/virtual_ship/expedition/checkpoint.py index a05d7bab..d3bb08a4 100644 --- a/virtual_ship/expedition/checkpoint.py +++ b/virtual_ship/expedition/checkpoint.py @@ -4,8 +4,8 @@ from pathlib import Path +import pydantic import yaml -from pydantic import BaseModel from .instrument_type import InstrumentType from .schedule import Schedule @@ -20,7 +20,7 @@ class _YamlDumper(yaml.SafeDumper): ) -class Checkpoint(BaseModel): +class Checkpoint(pydantic.BaseModel): """ A checkpoint of schedule simulation. diff --git a/virtual_ship/expedition/input_data.py b/virtual_ship/expedition/input_data.py index e039bbcb..a35304e6 100644 --- a/virtual_ship/expedition/input_data.py +++ b/virtual_ship/expedition/input_data.py @@ -97,8 +97,7 @@ def _load_default_fieldset(cls, directory: str | Path) -> FieldSet: # make depth negative for g in fieldset.gridset.grids: - if max(g.depth) > 0: - g.depth = -g.depth + g.depth = -g.depth # add bathymetry data bathymetry_file = directory.joinpath("bathymetry.nc") @@ -108,8 +107,7 @@ def _load_default_fieldset(cls, directory: str | Path) -> FieldSet: bathymetry_file, bathymetry_variables, bathymetry_dimensions ) # make depth negative - if max(bathymetry_field.data > 0): - bathymetry_field.data = -bathymetry_field.data + bathymetry_field.data = -bathymetry_field.data fieldset.add_field(bathymetry_field) # read in data already @@ -139,8 +137,7 @@ def _load_drifter_fieldset(cls, directory: str | Path) -> FieldSet: # make depth negative for g in fieldset.gridset.grids: - if max(g.depth) > 0: - g.depth = -g.depth + g.depth = -g.depth return fieldset diff --git a/virtual_ship/expedition/schedule.py b/virtual_ship/expedition/schedule.py index 09609ab4..a92ba55b 100644 --- a/virtual_ship/expedition/schedule.py +++ b/virtual_ship/expedition/schedule.py @@ -4,18 +4,18 @@ from pathlib import Path +import pydantic import yaml -from pydantic import BaseModel, ConfigDict from .waypoint import Waypoint -class Schedule(BaseModel): +class Schedule(pydantic.BaseModel): """Schedule of the virtual ship.""" waypoints: list[Waypoint] - model_config = ConfigDict(extra="forbid") + model_config = pydantic.ConfigDict(extra="forbid") def to_yaml(self, file_path: str | Path) -> None: """ diff --git a/virtual_ship/expedition/ship_config.py b/virtual_ship/expedition/ship_config.py index ce197cc6..8b28631f 100644 --- a/virtual_ship/expedition/ship_config.py +++ b/virtual_ship/expedition/ship_config.py @@ -5,94 +5,94 @@ from datetime import timedelta from pathlib import Path +import pydantic import yaml -from pydantic import BaseModel, ConfigDict, Field, field_serializer -class ArgoFloatConfig(BaseModel): +class ArgoFloatConfig(pydantic.BaseModel): """Configuration for argos floats.""" - min_depth_meter: float = Field(le=0.0) - max_depth_meter: float = Field(le=0.0) - drift_depth_meter: float = Field(le=0.0) - vertical_speed_meter_per_second: float = Field(lt=0.0) - cycle_days: float = Field(gt=0.0) - drift_days: float = Field(gt=0.0) + min_depth_meter: float = pydantic.Field(le=0.0) + max_depth_meter: float = pydantic.Field(le=0.0) + drift_depth_meter: float = pydantic.Field(le=0.0) + vertical_speed_meter_per_second: float = pydantic.Field(lt=0.0) + cycle_days: float = pydantic.Field(gt=0.0) + drift_days: float = pydantic.Field(gt=0.0) -class ADCPConfig(BaseModel): +class ADCPConfig(pydantic.BaseModel): """Configuration for ADCP instrument.""" - max_depth_meter: float = Field(le=0.0) - num_bins: int = Field(gt=0.0) - period: timedelta = Field( + max_depth_meter: float = pydantic.Field(le=0.0) + num_bins: int = pydantic.Field(gt=0.0) + period: timedelta = pydantic.Field( serialization_alias="period_minutes", validation_alias="period_minutes", gt=timedelta(), ) - model_config = ConfigDict(populate_by_name=True) + model_config = pydantic.ConfigDict(populate_by_name=True) - @field_serializer("period") + @pydantic.pydantic.field_serializer("period") def _serialize_period(self, value: timedelta, _info): return value.total_seconds() / 60.0 -class CTDConfig(BaseModel): +class CTDConfig(pydantic.BaseModel): """Configuration for CTD instrument.""" - stationkeeping_time: timedelta = Field( + stationkeeping_time: timedelta = pydantic.Field( serialization_alias="stationkeeping_time_minutes", validation_alias="stationkeeping_time_minutes", gt=timedelta(), ) - min_depth_meter: float = Field(le=0.0) - max_depth_meter: float = Field(le=0.0) + min_depth_meter: float = pydantic.Field(le=0.0) + max_depth_meter: float = pydantic.Field(le=0.0) - model_config = ConfigDict(populate_by_name=True) + model_config = pydantic.ConfigDict(populate_by_name=True) - @field_serializer("stationkeeping_time") + @pydantic.pydantic.field_serializer("stationkeeping_time") def _serialize_stationkeeping_time(self, value: timedelta, _info): return value.total_seconds() / 60.0 -class ShipUnderwaterSTConfig(BaseModel): +class ShipUnderwaterSTConfig(pydantic.BaseModel): """Configuration for underwater ST.""" - period: timedelta = Field( + period: timedelta = pydantic.Field( serialization_alias="period_minutes", validation_alias="period_minutes", gt=timedelta(), ) - model_config = ConfigDict(populate_by_name=True) + model_config = pydantic.ConfigDict(populate_by_name=True) - @field_serializer("period") + @pydantic.pydantic.field_serializer("period") def _serialize_period(self, value: timedelta, _info): return value.total_seconds() / 60.0 -class DrifterConfig(BaseModel): +class DrifterConfig(pydantic.BaseModel): """Configuration for drifters.""" - depth_meter: float = Field(le=0.0) - lifetime: timedelta = Field( + depth_meter: float = pydantic.Field(le=0.0) + lifetime: timedelta = pydantic.Field( serialization_alias="lifetime_minutes", validation_alias="lifetime_minutes", gt=timedelta(), ) - model_config = ConfigDict(populate_by_name=True) + model_config = pydantic.ConfigDict(populate_by_name=True) - @field_serializer("lifetime") + @pydantic.pydantic.field_serializer("lifetime") def _serialize_lifetime(self, value: timedelta, _info): return value.total_seconds() / 60.0 -class ShipConfig(BaseModel): +class ShipConfig(pydantic.BaseModel): """Configuration of the virtual ship.""" - ship_speed_meter_per_second: float = Field(gt=0.0) + ship_speed_meter_per_second: float = pydantic.Field(gt=0.0) """ Velocity of the ship in meters per second. """ @@ -132,7 +132,7 @@ class ShipConfig(BaseModel): If None, no drifters can be deployed. """ - model_config = ConfigDict(extra="forbid") + model_config = pydantic.ConfigDict(extra="forbid") def to_yaml(self, file_path: str | Path) -> None: """ diff --git a/virtual_ship/expedition/verify_schedule.py b/virtual_ship/expedition/verify_schedule.py index 5926eb5f..0a8ac4b1 100644 --- a/virtual_ship/expedition/verify_schedule.py +++ b/virtual_ship/expedition/verify_schedule.py @@ -26,7 +26,6 @@ def verify_schedule( :param schedule: The schedule to verify. :param input_data: Fieldsets that can be used to check for zero UV condition (is waypoint on land). :raises PlanningError: If waypoints are not feasible or incorrect. - :raises ValueError: If there are no fieldsets in the ship_config, which are needed to verify all waypoints are on water. """ if len(schedule.waypoints) == 0: raise PlanningError("At least one waypoint must be provided.") From a0bf9826d55b8caab0781420823b97f7df712b81 Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Mon, 26 Aug 2024 19:19:22 +0200 Subject: [PATCH 28/35] Update virtual_ship/expedition/simulate_measurements.py Co-authored-by: Erik van Sebille --- virtual_ship/expedition/simulate_measurements.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/virtual_ship/expedition/simulate_measurements.py b/virtual_ship/expedition/simulate_measurements.py index 81a56a12..1e4de863 100644 --- a/virtual_ship/expedition/simulate_measurements.py +++ b/virtual_ship/expedition/simulate_measurements.py @@ -22,7 +22,7 @@ def simulate_measurements( """ Simulate measurements using parcels. - Saves everything the $expedition_dir/results. + Saves everything in expedition_dir/results. :param expedition_dir: Base directory of the expedition. :param ship_config: Ship configuration. From d03916d741213a76558ac898663a48f6d62805a9 Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Mon, 26 Aug 2024 19:19:46 +0200 Subject: [PATCH 29/35] Update virtual_ship/expedition/simulate_measurements.py Co-authored-by: Erik van Sebille --- virtual_ship/expedition/simulate_measurements.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/virtual_ship/expedition/simulate_measurements.py b/virtual_ship/expedition/simulate_measurements.py index 1e4de863..7ff7868f 100644 --- a/virtual_ship/expedition/simulate_measurements.py +++ b/virtual_ship/expedition/simulate_measurements.py @@ -20,7 +20,7 @@ def simulate_measurements( measurements: MeasurementsToSimulate, ) -> None: """ - Simulate measurements using parcels. + Simulate measurements using Parcels. Saves everything in expedition_dir/results. From 0f0eeb7b39c30c0f1f176fef45958f67180bd4bc Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Mon, 26 Aug 2024 19:29:31 +0200 Subject: [PATCH 30/35] fixed pydantic errors --- virtual_ship/expedition/ship_config.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/virtual_ship/expedition/ship_config.py b/virtual_ship/expedition/ship_config.py index 8b28631f..8e4564fc 100644 --- a/virtual_ship/expedition/ship_config.py +++ b/virtual_ship/expedition/ship_config.py @@ -33,7 +33,7 @@ class ADCPConfig(pydantic.BaseModel): model_config = pydantic.ConfigDict(populate_by_name=True) - @pydantic.pydantic.field_serializer("period") + @pydantic.field_serializer("period") def _serialize_period(self, value: timedelta, _info): return value.total_seconds() / 60.0 @@ -51,7 +51,7 @@ class CTDConfig(pydantic.BaseModel): model_config = pydantic.ConfigDict(populate_by_name=True) - @pydantic.pydantic.field_serializer("stationkeeping_time") + @pydantic.field_serializer("stationkeeping_time") def _serialize_stationkeeping_time(self, value: timedelta, _info): return value.total_seconds() / 60.0 @@ -67,7 +67,7 @@ class ShipUnderwaterSTConfig(pydantic.BaseModel): model_config = pydantic.ConfigDict(populate_by_name=True) - @pydantic.pydantic.field_serializer("period") + @pydantic.field_serializer("period") def _serialize_period(self, value: timedelta, _info): return value.total_seconds() / 60.0 @@ -84,7 +84,7 @@ class DrifterConfig(pydantic.BaseModel): model_config = pydantic.ConfigDict(populate_by_name=True) - @pydantic.pydantic.field_serializer("lifetime") + @pydantic.field_serializer("lifetime") def _serialize_lifetime(self, value: timedelta, _info): return value.total_seconds() / 60.0 From 4799bfccc1e5ebf67178cf3c780654f01af92344 Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Mon, 26 Aug 2024 19:29:42 +0200 Subject: [PATCH 31/35] more reliable verify not on land sampling --- virtual_ship/expedition/verify_schedule.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/virtual_ship/expedition/verify_schedule.py b/virtual_ship/expedition/verify_schedule.py index 0a8ac4b1..f96e9acc 100644 --- a/virtual_ship/expedition/verify_schedule.py +++ b/virtual_ship/expedition/verify_schedule.py @@ -130,7 +130,7 @@ def _is_on_land_zero_uv(fieldset: FieldSet, waypoint: Waypoint) -> bool: """ return fieldset.UV.eval( 0, - -fieldset.U.depth[0], + fieldset.gridset.grids[0].depth[0], waypoint.location.lat, waypoint.location.lon, applyConversion=False, From 29b5b0f396474f96df6eb2d71d9a2f3c0816f8cf Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Mon, 26 Aug 2024 20:35:12 +0200 Subject: [PATCH 32/35] load in fieldsets preemtively and better error mesgs --- .../expedition_dir/ship_config.yaml | 2 +- virtual_ship/expedition/input_data.py | 6 ++++ virtual_ship/expedition/verify_schedule.py | 30 +++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/tests/expedition/expedition_dir/ship_config.yaml b/tests/expedition/expedition_dir/ship_config.yaml index b3763f05..c057d6b5 100644 --- a/tests/expedition/expedition_dir/ship_config.yaml +++ b/tests/expedition/expedition_dir/ship_config.yaml @@ -1,3 +1,4 @@ +ship_speed_meter_per_second: 5.14 adcp_config: num_bins: 40 max_depth_meter: -1000.0 @@ -16,6 +17,5 @@ ctd_config: drifter_config: depth_meter: 0.0 lifetime_minutes: 40320.0 -ship_speed_meter_per_second: 5.14 ship_underwater_st_config: period_minutes: 5.0 diff --git a/virtual_ship/expedition/input_data.py b/virtual_ship/expedition/input_data.py index a35304e6..6ee4f1c4 100644 --- a/virtual_ship/expedition/input_data.py +++ b/virtual_ship/expedition/input_data.py @@ -139,6 +139,9 @@ def _load_drifter_fieldset(cls, directory: str | Path) -> FieldSet: for g in fieldset.gridset.grids: g.depth = -g.depth + # read in data already + fieldset.computeTimeChunk(0, 1) + return fieldset @classmethod @@ -167,4 +170,7 @@ def _load_argo_float_fieldset(cls, directory: str | Path) -> FieldSet: if max(g.depth) > 0: g.depth = -g.depth + # read in data already + fieldset.computeTimeChunk(0, 1) + return fieldset diff --git a/virtual_ship/expedition/verify_schedule.py b/virtual_ship/expedition/verify_schedule.py index f96e9acc..2f0b39b7 100644 --- a/virtual_ship/expedition/verify_schedule.py +++ b/virtual_ship/expedition/verify_schedule.py @@ -113,6 +113,36 @@ def verify_schedule( else: time = wp_next.time + # verify instruments in schedule have configuration + for wp in schedule.waypoints: + if wp.instrument is not None: + for instrument in ( + wp.instrument if isinstance(wp.instrument, list) else [wp.instrument] + ): + if ( + instrument == InstrumentType.ARGO_FLOAT + and ship_config.argo_float_config is None + ): + raise PlanningError( + "Planning has waypoint with Argo float instrument, but configuration does not configure Argo floats." + ) + elif ( + instrument == InstrumentType.CTD + and ship_config.argo_float_config is None + ): + raise PlanningError( + "Planning has waypoint with CTD instrument, but configuration does not configure CTDs." + ) + elif ( + instrument == InstrumentType.DRIFTER + and ship_config.argo_float_config is None + ): + raise PlanningError( + "Planning has waypoint with drifter instrument, but configuration does not configure drifters." + ) + else: + raise RuntimeError("Instrument not supported.") + class PlanningError(RuntimeError): """An error in the schedule.""" From 473653fe248918a06c98de64f30fa08a7f0b59f1 Mon Sep 17 00:00:00 2001 From: Aart Stuurman Date: Mon, 26 Aug 2024 20:37:12 +0200 Subject: [PATCH 33/35] codetools --- virtual_ship/expedition/verify_schedule.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/virtual_ship/expedition/verify_schedule.py b/virtual_ship/expedition/verify_schedule.py index 2f0b39b7..80ea2d98 100644 --- a/virtual_ship/expedition/verify_schedule.py +++ b/virtual_ship/expedition/verify_schedule.py @@ -26,6 +26,7 @@ def verify_schedule( :param schedule: The schedule to verify. :param input_data: Fieldsets that can be used to check for zero UV condition (is waypoint on land). :raises PlanningError: If waypoints are not feasible or incorrect. + :raises NotImplementedError: If instrument is in schedule that is not implememented. """ if len(schedule.waypoints) == 0: raise PlanningError("At least one waypoint must be provided.") @@ -141,7 +142,7 @@ def verify_schedule( "Planning has waypoint with drifter instrument, but configuration does not configure drifters." ) else: - raise RuntimeError("Instrument not supported.") + raise NotImplementedError("Instrument not supported.") class PlanningError(RuntimeError): From b1b31d9c566ecd51ab3cdf4388ef87e205710c9c Mon Sep 17 00:00:00 2001 From: Vecko <36369090+VeckoTheGecko@users.noreply.github.com> Date: Wed, 25 Sep 2024 13:54:58 +0200 Subject: [PATCH 34/35] Update virtual_ship/expedition/input_data.py Co-authored-by: Erik van Sebille --- virtual_ship/expedition/input_data.py | 1 + 1 file changed, 1 insertion(+) diff --git a/virtual_ship/expedition/input_data.py b/virtual_ship/expedition/input_data.py index 6ee4f1c4..7af9ef72 100644 --- a/virtual_ship/expedition/input_data.py +++ b/virtual_ship/expedition/input_data.py @@ -164,6 +164,7 @@ def _load_argo_float_fieldset(cls, directory: str | Path) -> FieldSet: filenames, variables, dimensions, allow_time_extrapolation=False ) fieldset.T.interp_method = "linear_invdist_land_tracer" + fieldset.S.interp_method = "linear_invdist_land_tracer" # make depth negative for g in fieldset.gridset.grids: From 00df77ab67cba6ae85d78bbeeec62a9c4d4ea515 Mon Sep 17 00:00:00 2001 From: Vecko <36369090+VeckoTheGecko@users.noreply.github.com> Date: Wed, 25 Sep 2024 14:22:48 +0200 Subject: [PATCH 35/35] patch test --- virtual_ship/expedition/verify_schedule.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/virtual_ship/expedition/verify_schedule.py b/virtual_ship/expedition/verify_schedule.py index 80ea2d98..55c34587 100644 --- a/virtual_ship/expedition/verify_schedule.py +++ b/virtual_ship/expedition/verify_schedule.py @@ -120,6 +120,9 @@ def verify_schedule( for instrument in ( wp.instrument if isinstance(wp.instrument, list) else [wp.instrument] ): + if instrument not in InstrumentType: + raise NotImplementedError("Instrument not supported.") + if ( instrument == InstrumentType.ARGO_FLOAT and ship_config.argo_float_config is None @@ -127,22 +130,17 @@ def verify_schedule( raise PlanningError( "Planning has waypoint with Argo float instrument, but configuration does not configure Argo floats." ) - elif ( - instrument == InstrumentType.CTD - and ship_config.argo_float_config is None - ): + if instrument == InstrumentType.CTD and ship_config.ctd_config is None: raise PlanningError( "Planning has waypoint with CTD instrument, but configuration does not configure CTDs." ) - elif ( + if ( instrument == InstrumentType.DRIFTER - and ship_config.argo_float_config is None + and ship_config.drifter_config is None ): raise PlanningError( "Planning has waypoint with drifter instrument, but configuration does not configure drifters." ) - else: - raise NotImplementedError("Instrument not supported.") class PlanningError(RuntimeError):