cccat6 commited on
Commit
604e535
·
verified ·
1 Parent(s): 6eaa6cd

Initial FlowMo-WM public code release

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +41 -3
  2. data/paper/dataset_card.md +47 -0
  3. data/paper/diagnostic_seen_flow.npz +3 -0
  4. data/paper/generation_config.json +49 -0
  5. data/paper/test_unseen_boat_params.npz +3 -0
  6. data/paper/test_unseen_flow.npz +3 -0
  7. data/paper/train.npz +3 -0
  8. driftwm/__init__.py +3 -0
  9. driftwm/data/__init__.py +1 -0
  10. driftwm/data/generate.py +260 -0
  11. driftwm/sim/__init__.py +1 -0
  12. driftwm/sim/boat.py +110 -0
  13. driftwm/sim/dynamics.py +73 -0
  14. driftwm/sim/env.py +142 -0
  15. driftwm/sim/flow.py +428 -0
  16. driftwm/sim/render.py +145 -0
  17. driftwm/sim/sanity.py +89 -0
  18. driftwm/utils.py +85 -0
  19. experiments/BASELINES.md +44 -0
  20. experiments/EXPERIMENT_MATRIX.md +151 -0
  21. experiments/METHOD_AUDIT.md +30 -0
  22. experiments/README.md +83 -0
  23. experiments/TASK_PLAN.md +118 -0
  24. experiments/__init__.py +1 -0
  25. experiments/current_estimator_mpc/README.md +3 -0
  26. experiments/current_estimator_mpc/__init__.py +1 -0
  27. experiments/current_estimator_mpc/checkpoint/.gitkeep +0 -0
  28. experiments/current_estimator_mpc/result/.gitkeep +0 -0
  29. experiments/current_estimator_mpc/src/__init__.py +1 -0
  30. experiments/current_estimator_mpc/src/config.py +5 -0
  31. experiments/current_estimator_mpc/src/estimator.py +9 -0
  32. experiments/current_estimator_mpc/src/evaluate.py +10 -0
  33. experiments/current_estimator_mpc/src/mpc.py +19 -0
  34. experiments/docs/EXPERIMENT_PROTOCOL.md +211 -0
  35. experiments/evaluate_flowmo_latent_probes.py +266 -0
  36. experiments/evaluate_image_planning.py +577 -0
  37. experiments/evaluate_image_world_models.py +269 -0
  38. experiments/figures/.gitkeep +0 -0
  39. experiments/figures/README.md +11 -0
  40. experiments/flowmo/README.md +9 -0
  41. experiments/flowmo/__init__.py +1 -0
  42. experiments/flowmo/checkpoint/.gitkeep +0 -0
  43. experiments/flowmo/result/.gitkeep +0 -0
  44. experiments/flowmo/src/__init__.py +1 -0
  45. experiments/flowmo/src/config.py +7 -0
  46. experiments/flowmo/src/model.py +18 -0
  47. experiments/flowmo/src/plan.py +6 -0
  48. experiments/flowmo/src/predict.py +7 -0
  49. experiments/flowmo/src/train.py +16 -0
  50. experiments/gifs/.gitkeep +0 -0
README.md CHANGED
@@ -1,3 +1,41 @@
1
- ---
2
- license: mit
3
- ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FlowMo: Flow-Momentum World Model
2
+
3
+ FlowMo is a clean-image world-model benchmark for surface vehicles under hidden water drift. The proposed model separates short-history endogenous state and momentum from long-history exogenous drift context, then evaluates whether that factorization improves rollout prediction and closed-loop planning.
4
+
5
+ ## Paper Pipeline
6
+
7
+ Run the complete paper-facing experiment:
8
+
9
+ ```bash
10
+ python -m experiments.run_paper_image_pipeline
11
+ ```
12
+
13
+ The default command trains all learned world models, evaluates prediction, runs FlowMo latent probes, evaluates planning on all configured tasks and boat morphologies, generates GIFs, and writes:
14
+
15
+ ```text
16
+ experiments/reports/paper_prediction_seen_flow_diagnostic.json
17
+ experiments/reports/paper_prediction_unseen_flow.json
18
+ experiments/reports/paper_prediction_unseen_boat_params.json
19
+ experiments/reports/paper_flowmo_latent_probes.json
20
+ experiments/reports/paper_planning/
21
+ experiments/reports/paper_report.md
22
+ ```
23
+
24
+ Images are rendered online from simulator states. Model inputs are clean top-down RGB frames with no flow arrows, no goal markers, no velocity vectors, and no trajectory overlays.
25
+
26
+ ## Compared Methods
27
+
28
+ - `flowmo`: proposed Flow-Momentum World Model.
29
+ - `leworldmodel`: LeWorldModel-style JEPA latent predictor.
30
+ - `planet`: PlaNet-style RSSM world model.
31
+ - `tdmpc2`: TD-MPC2-style latent dynamics world model.
32
+ - `pid_los_controller`, `physics_mpc_no_flow`, `current_estimator_mpc`, `oracle_flow_mpc`: traditional planning/control baselines.
33
+
34
+ Baseline fidelity and naming rules are documented in `experiments/BASELINES.md`.
35
+ The complete paper experiment matrix is documented in `experiments/EXPERIMENT_MATRIX.md`.
36
+
37
+ ## Tests
38
+
39
+ ```bash
40
+ python -m pytest -q
41
+ ```
data/paper/dataset_card.md ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FlowMo Paper Dataset
2
+
3
+ This directory contains the canonical datasets used by the paper-facing
4
+ experiments. File names are stable and intentionally do not include version
5
+ suffixes; when the dataset is regenerated, these files are replaced in place.
6
+
7
+ ## Splits
8
+
9
+ | File | Role |
10
+ | --- | --- |
11
+ | `train.npz` | Training split shared by all learned world models. |
12
+ | `test_unseen_flow.npz` | Primary split with unseen flow families. |
13
+ | `test_unseen_boat_params.npz` | Primary split with unseen boat dynamics. |
14
+ | `diagnostic_seen_flow.npz` | Seen-flow-family diagnostic split used only for optimization sanity checks. |
15
+
16
+ ## Sizes
17
+
18
+ | File | Episodes | Steps per episode |
19
+ | --- | ---: | ---: |
20
+ | `train.npz` | 2400 | 300 |
21
+ | `diagnostic_seen_flow.npz` | 480 | 300 |
22
+ | `test_unseen_flow.npz` | 480 | 300 |
23
+ | `test_unseen_boat_params.npz` | 480 | 300 |
24
+
25
+ ## Stored Arrays
26
+
27
+ Each `.npz` stores low-dimensional simulator state and metadata. Image-input
28
+ models receive clean rendered images generated online from the same states.
29
+ The image observation contains only the boat and clean workspace; flow vectors,
30
+ velocity arrows, and visualization overlays are not part of the model input.
31
+
32
+ All learned world models use the same split files, the same window sampling
33
+ rules, the same image renderer, and the same train/evaluation budgets.
34
+
35
+ ## Flow Families
36
+
37
+ The training split and seen-flow-family diagnostic split use `noflow`, `uniform`,
38
+ `slowly_varying`, `vortex_center`, `gradient`, and `turbulent_patch` flows.
39
+ The unseen-flow split uses `noflow`, `shear`, `moving_vortex`, and
40
+ `random_fourier` flows. The unseen-boat-dynamics split uses the training flow
41
+ families with held-out boat mass, drag, inertia, and actuator-delay ranges.
42
+
43
+ All splits use the fixed paper flow-strength constants in `driftwm/sim/flow.py`,
44
+ both boat morphologies, and clean image observations without flow overlays.
45
+ `flow_pool_size=80` means that each nonzero flow family is represented by 80
46
+ hidden flow conditions before trajectories are sampled; it is a dataset
47
+ diversity constant, not a model input or experiment mode.
data/paper/diagnostic_seen_flow.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e8366dd25e98b81b3b159a600e4e158b8b719e0ad41a0e3fbc915b6af34ce924
3
+ size 6601980
data/paper/generation_config.json ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "boats": [
3
+ "twin",
4
+ "triangle"
5
+ ],
6
+ "train_flow_types": [
7
+ "noflow",
8
+ "uniform",
9
+ "slowly_varying",
10
+ "vortex_center",
11
+ "gradient",
12
+ "turbulent_patch"
13
+ ],
14
+ "trajectory_types": [
15
+ "noflow_random_action",
16
+ "noflow_action_then_zero",
17
+ "flow_zero_action",
18
+ "flow_active_control",
19
+ "flow_waypoint_control"
20
+ ],
21
+ "episodes": {
22
+ "train": 2400,
23
+ "diagnostic_seen_flow": 480,
24
+ "test_unseen_flow": 480,
25
+ "test_unseen_boat_params": 480
26
+ },
27
+ "steps": 300,
28
+ "flow_pool_size": 80,
29
+ "boundary": "terminate",
30
+ "seeds": {
31
+ "train": 4301,
32
+ "diagnostic_seen_flow": 4302,
33
+ "test_unseen_flow": 4303,
34
+ "test_unseen_boat_params": 4304
35
+ },
36
+ "image_size": 160,
37
+ "visual_scale": 2.5,
38
+ "workspace": [
39
+ 0.0,
40
+ 10.0,
41
+ 0.0,
42
+ 10.0
43
+ ],
44
+ "unseen_flow_types": [
45
+ "shear",
46
+ "moving_vortex",
47
+ "random_fourier"
48
+ ]
49
+ }
data/paper/test_unseen_boat_params.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fa4161b8917935a7a159f3e1604d4551c455f55160d8dd83b234bd726fc154a4
3
+ size 6400836
data/paper/test_unseen_flow.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3beab06303edd2a81c802972d1055fed3ceffa54ac90f4686b11b08479682b87
3
+ size 6493086
data/paper/train.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3632528ec850fd526e028f49bf1641d8f9f9399dc6c07e495b9351df29f76f2a
3
+ size 32273806
driftwm/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """Passive-drift disentangled world model package."""
2
+
3
+ __version__ = "0.1.0"
driftwm/data/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Dataset generation and loading utilities."""
driftwm/data/generate.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import copy
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+
10
+ from driftwm.sim.env import SurfaceBoatEnv
11
+ from driftwm.sim.flow import sample_flow
12
+ from driftwm.utils import ensure_dir, pad_action
13
+
14
+
15
+ BOAT_TO_ID = {"twin": 0, "triangle": 1}
16
+ ID_TO_BOAT = {v: k for k, v in BOAT_TO_ID.items()}
17
+ FLOW_TO_ID = {
18
+ "noflow": 0,
19
+ "uniform": 1,
20
+ "slowly_varying": 2,
21
+ "vortex": 3,
22
+ "vortex_center": 4,
23
+ "gradient": 5,
24
+ "turbulent_patch": 6,
25
+ "shear": 7,
26
+ "moving_vortex": 8,
27
+ "random_fourier": 9,
28
+ }
29
+ ID_TO_FLOW = {v: k for k, v in FLOW_TO_ID.items()}
30
+ TRAJ_TO_ID = {
31
+ "noflow_random_action": 0,
32
+ "noflow_action_then_zero": 1,
33
+ "flow_zero_action": 2,
34
+ "flow_active_control": 3,
35
+ "flow_waypoint_control": 4,
36
+ }
37
+ ID_TO_TRAJ = {v: k for k, v in TRAJ_TO_ID.items()}
38
+ PAPER_FLOW_POOL_SIZE = 80
39
+
40
+
41
+ def smooth_random_actions(rng: np.random.Generator, steps: int, action_dim: int, scale: float = 1.0) -> np.ndarray:
42
+ actions = np.zeros((steps, action_dim), dtype=np.float32)
43
+ current = rng.uniform(-0.2, 0.2, size=action_dim).astype(np.float32)
44
+ for t in range(steps):
45
+ if t % 12 == 0:
46
+ target = rng.uniform(-1.0, 1.0, size=action_dim).astype(np.float32)
47
+ current = 0.82 * current + 0.18 * target
48
+ actions[t] = np.clip(scale * current, -1.0, 1.0)
49
+ return actions
50
+
51
+
52
+ def simple_goal_policy(env: SurfaceBoatEnv, goal: np.ndarray, rng: np.random.Generator) -> np.ndarray:
53
+ pos = env.state[:2]
54
+ theta = float(env.state[2])
55
+ delta = goal - pos
56
+ target_angle = float(np.arctan2(delta[1], delta[0]))
57
+ err = float(np.arctan2(np.sin(target_angle - theta), np.cos(target_angle - theta)))
58
+ if env.spec.name == "twin":
59
+ forward = np.clip(0.35 + 0.55 * np.cos(err), -0.4, 0.9)
60
+ turn = np.clip(-0.75 * np.sin(err), -0.8, 0.8)
61
+ action = np.array([forward + turn, forward - turn], dtype=np.float32)
62
+ else:
63
+ base = np.array([np.sin(err), -0.5 * np.sin(err) + 0.35, -0.5 * np.sin(err) - 0.35], dtype=np.float32)
64
+ action = 0.7 * base
65
+ action += rng.normal(0.0, 0.08, size=env.action_dim).astype(np.float32)
66
+ return np.clip(action, -1.0, 1.0)
67
+
68
+
69
+ def choose_trajectory_type(rng: np.random.Generator) -> str:
70
+ keys = list(TRAJ_TO_ID)
71
+ weights = np.array([0.14, 0.14, 0.14, 0.28, 0.30], dtype=np.float64)
72
+ weights = weights[: len(keys)]
73
+ weights = weights / weights.sum()
74
+ return str(rng.choice(keys, p=weights))
75
+
76
+
77
+ def make_flow_pool(
78
+ flow_types: list[str],
79
+ rng: np.random.Generator,
80
+ workspace: tuple[float, float, float, float],
81
+ pool_size: int = PAPER_FLOW_POOL_SIZE,
82
+ ) -> dict[str, list]:
83
+ pool = {ft: [] for ft in flow_types}
84
+ fid = 1
85
+ for ft in flow_types:
86
+ if ft == "noflow":
87
+ pool[ft] = [sample_flow("noflow", rng, flow_id=0, workspace=workspace)]
88
+ continue
89
+ for _ in range(pool_size):
90
+ pool[ft].append(sample_flow(ft, rng, flow_id=fid, workspace=workspace))
91
+ fid += 1
92
+ return pool
93
+
94
+
95
+ def generate_dataset(
96
+ boats: list[str],
97
+ flow_types: list[str],
98
+ episodes: int,
99
+ steps: int,
100
+ out: str | Path,
101
+ seed: int = 0,
102
+ workspace: tuple[float, float, float, float] = (0.0, 10.0, 0.0, 10.0),
103
+ boundary: str = "terminate",
104
+ randomize_params: bool = True,
105
+ flow_pool_size: int = PAPER_FLOW_POOL_SIZE,
106
+ unseen_boat_params: bool = False,
107
+ ) -> None:
108
+ rng = np.random.default_rng(seed)
109
+ out = Path(out)
110
+ ensure_dir(out.parent)
111
+ flow_pool = make_flow_pool(flow_types, rng, workspace, flow_pool_size)
112
+
113
+ obs = np.zeros((episodes, steps + 1, 4), dtype=np.float32)
114
+ actions = np.zeros((episodes, steps, 3), dtype=np.float32)
115
+ states = np.zeros((episodes, steps + 1, 9), dtype=np.float32)
116
+ true_flow = np.zeros((episodes, steps + 1, 2), dtype=np.float32)
117
+ boat_ids = np.zeros((episodes,), dtype=np.int64)
118
+ action_dims = np.zeros((episodes,), dtype=np.int64)
119
+ flow_type_ids = np.zeros((episodes,), dtype=np.int64)
120
+ flow_ids = np.zeros((episodes,), dtype=np.int64)
121
+ traj_type_ids = np.zeros((episodes,), dtype=np.int64)
122
+
123
+ env = SurfaceBoatEnv(
124
+ seed=seed,
125
+ episode_steps=steps,
126
+ workspace=workspace,
127
+ boundary=boundary,
128
+ randomize_params=randomize_params,
129
+ )
130
+
131
+ for ep in range(episodes):
132
+ traj_type = choose_trajectory_type(rng)
133
+ boat = boats[int(rng.integers(0, len(boats)))]
134
+ if traj_type.startswith("noflow"):
135
+ flow_type = "noflow"
136
+ else:
137
+ available = [ft for ft in flow_types if ft != "noflow"] or ["uniform"]
138
+ flow_type = available[int(rng.integers(0, len(available)))]
139
+ flow_template = flow_pool[flow_type][int(rng.integers(0, len(flow_pool[flow_type])))]
140
+ flow = copy.deepcopy(flow_template)
141
+ random_velocity = traj_type != "flow_zero_action"
142
+ env.reset(
143
+ boat=boat,
144
+ flow_type=flow_type,
145
+ flow=flow,
146
+ random_velocity=random_velocity,
147
+ randomize_params=randomize_params,
148
+ )
149
+ if unseen_boat_params:
150
+ for key in list(env.params):
151
+ if key in {"mass", "inertia", "actuator_tau"}:
152
+ factor = rng.choice([rng.uniform(0.55, 0.72), rng.uniform(1.45, 1.85)])
153
+ else:
154
+ factor = rng.choice([rng.uniform(0.45, 0.70), rng.uniform(1.45, 1.90)])
155
+ env.params[key] = float(env.params[key] * factor)
156
+
157
+ if traj_type == "noflow_random_action":
158
+ planned_actions = smooth_random_actions(rng, steps, env.action_dim, scale=1.0)
159
+ elif traj_type == "noflow_action_then_zero":
160
+ planned_actions = np.zeros((steps, env.action_dim), dtype=np.float32)
161
+ push_len = max(12, min(50, steps // 4))
162
+ planned_actions[:push_len] = smooth_random_actions(rng, push_len, env.action_dim, scale=0.95)
163
+ elif traj_type == "flow_zero_action":
164
+ planned_actions = np.zeros((steps, env.action_dim), dtype=np.float32)
165
+ env.state[3:6] = 0.0
166
+ else:
167
+ planned_actions = smooth_random_actions(rng, steps, env.action_dim, scale=0.7)
168
+ goal = rng.uniform([1.5, 1.5], [8.5, 8.5]).astype(np.float32)
169
+ waypoints = rng.uniform([1.5, 1.5], [8.5, 8.5], size=(4, 2)).astype(np.float32)
170
+ waypoint_idx = 0
171
+
172
+ boat_ids[ep] = BOAT_TO_ID[boat]
173
+ action_dims[ep] = env.action_dim
174
+ flow_type_ids[ep] = FLOW_TO_ID[flow_type]
175
+ flow_ids[ep] = int(env.flow.flow_id)
176
+ traj_type_ids[ep] = TRAJ_TO_ID[traj_type]
177
+ obs[ep, 0] = env.observation()
178
+ states[ep, 0, : 6 + env.action_dim] = env.full_state()
179
+ true_flow[ep, 0] = env.flow_at(env.state[:2])
180
+
181
+ done = False
182
+ for t in range(steps):
183
+ if traj_type == "flow_waypoint_control":
184
+ goal = waypoints[min(waypoint_idx, len(waypoints) - 1)]
185
+ if np.linalg.norm(env.state[:2] - goal) < 0.75 and waypoint_idx < len(waypoints) - 1:
186
+ waypoint_idx += 1
187
+ goal = waypoints[waypoint_idx]
188
+ if rng.random() < 0.88:
189
+ action = simple_goal_policy(env, goal, rng)
190
+ else:
191
+ action = planned_actions[t]
192
+ elif traj_type == "flow_active_control" and rng.random() < 0.75:
193
+ action = simple_goal_policy(env, goal, rng)
194
+ else:
195
+ action = planned_actions[t]
196
+ actions[ep, t] = pad_action(action)
197
+ ob, _, done, _ = env.step(action)
198
+ obs[ep, t + 1] = ob
199
+ states[ep, t + 1, : 6 + env.action_dim] = env.full_state()
200
+ true_flow[ep, t + 1] = env.flow_at(env.state[:2])
201
+ if done:
202
+ obs[ep, t + 2 :] = obs[ep, t + 1]
203
+ states[ep, t + 2 :] = states[ep, t + 1]
204
+ true_flow[ep, t + 2 :] = true_flow[ep, t + 1]
205
+ break
206
+
207
+ metadata = {
208
+ "boats": BOAT_TO_ID,
209
+ "flows": FLOW_TO_ID,
210
+ "trajectories": TRAJ_TO_ID,
211
+ "steps": steps,
212
+ "workspace": list(workspace),
213
+ "boundary": boundary,
214
+ "seed": seed,
215
+ "max_action_dim": 3,
216
+ "flow_pool_size": flow_pool_size,
217
+ "unseen_boat_params": bool(unseen_boat_params),
218
+ }
219
+ np.savez_compressed(
220
+ out,
221
+ obs=obs,
222
+ actions=actions,
223
+ states=states,
224
+ true_flow=true_flow,
225
+ boat_ids=boat_ids,
226
+ action_dims=action_dims,
227
+ flow_type_ids=flow_type_ids,
228
+ flow_ids=flow_ids,
229
+ traj_type_ids=traj_type_ids,
230
+ metadata=json.dumps(metadata),
231
+ )
232
+
233
+
234
+ def main() -> None:
235
+ parser = argparse.ArgumentParser()
236
+ parser.add_argument("--boats", nargs="+", choices=list(BOAT_TO_ID), default=["twin"])
237
+ parser.add_argument("--flow-types", nargs="+", choices=list(FLOW_TO_ID), default=["noflow", "uniform"])
238
+ parser.add_argument("--episodes", type=int, default=2000)
239
+ parser.add_argument("--steps", type=int, default=200)
240
+ parser.add_argument("--out", required=True)
241
+ parser.add_argument("--seed", type=int, default=0)
242
+ parser.add_argument("--boundary", choices=["terminate", "bounce", "clip"], default="terminate")
243
+ parser.add_argument("--no-randomize-params", action="store_true")
244
+ parser.add_argument("--unseen-boat-params", action="store_true")
245
+ args = parser.parse_args()
246
+ generate_dataset(
247
+ boats=args.boats,
248
+ flow_types=args.flow_types,
249
+ episodes=args.episodes,
250
+ steps=args.steps,
251
+ out=args.out,
252
+ seed=args.seed,
253
+ boundary=args.boundary,
254
+ randomize_params=not args.no_randomize_params,
255
+ unseen_boat_params=args.unseen_boat_params,
256
+ )
257
+
258
+
259
+ if __name__ == "__main__":
260
+ main()
driftwm/sim/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Simulation components for 2D surface vehicles."""
driftwm/sim/boat.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class BoatSpec:
11
+ name: str
12
+ action_dim: int
13
+ thruster_positions: np.ndarray
14
+ thruster_dirs: np.ndarray
15
+ hull_vertices: np.ndarray
16
+
17
+
18
+ def get_boat_spec(name: str) -> BoatSpec:
19
+ name = name.lower()
20
+ if name == "twin":
21
+ length = 0.62
22
+ beam = 0.34
23
+ positions = np.array(
24
+ [[-length / 2.0, beam / 2.0], [-length / 2.0, -beam / 2.0]],
25
+ dtype=np.float32,
26
+ )
27
+ dirs = np.array([[1.0, 0.0], [1.0, 0.0]], dtype=np.float32)
28
+ hull = np.array(
29
+ [
30
+ [length / 2.0, 0.0],
31
+ [0.12, beam / 2.0],
32
+ [-length / 2.0, beam / 2.0],
33
+ [-length / 2.0, -beam / 2.0],
34
+ [0.12, -beam / 2.0],
35
+ ],
36
+ dtype=np.float32,
37
+ )
38
+ return BoatSpec("twin", 2, positions, dirs, hull)
39
+ if name == "triangle":
40
+ radius = 0.34
41
+ phis = np.array([0.0, 2.0 * np.pi / 3.0, 4.0 * np.pi / 3.0], dtype=np.float32)
42
+ positions = np.stack([radius * np.cos(phis), radius * np.sin(phis)], axis=-1)
43
+ dirs = np.stack([-np.sin(phis), np.cos(phis)], axis=-1).astype(np.float32)
44
+ hull = np.stack([0.44 * np.cos(phis), 0.44 * np.sin(phis)], axis=-1).astype(np.float32)
45
+ return BoatSpec("triangle", 3, positions.astype(np.float32), dirs, hull)
46
+ raise ValueError(f"unknown boat type: {name}")
47
+
48
+
49
+ def default_boat_params(name: str) -> dict[str, float]:
50
+ if name == "triangle":
51
+ return {
52
+ "mass": 1.15,
53
+ "inertia": 0.18,
54
+ "t_max": 1.25,
55
+ "drag_linear_x": 0.62,
56
+ "drag_linear_y": 0.72,
57
+ "drag_quad_x": 0.18,
58
+ "drag_quad_y": 0.22,
59
+ "drag_angular": 0.32,
60
+ "drag_angular_quad": 0.08,
61
+ "actuator_tau": 0.22,
62
+ }
63
+ return {
64
+ "mass": 1.0,
65
+ "inertia": 0.13,
66
+ "t_max": 1.1,
67
+ "drag_linear_x": 0.55,
68
+ "drag_linear_y": 0.88,
69
+ "drag_quad_x": 0.16,
70
+ "drag_quad_y": 0.25,
71
+ "drag_angular": 0.28,
72
+ "drag_angular_quad": 0.06,
73
+ "actuator_tau": 0.18,
74
+ }
75
+
76
+
77
+ def sample_boat_params(name: str, rng: np.random.Generator, randomize: bool = True) -> dict[str, float]:
78
+ params = default_boat_params(name)
79
+ if not randomize:
80
+ return params
81
+ sampled: dict[str, float] = {}
82
+ for key, value in params.items():
83
+ if key == "actuator_tau":
84
+ factor = rng.uniform(0.75, 1.35)
85
+ elif key in {"mass", "inertia"}:
86
+ factor = rng.uniform(0.85, 1.25)
87
+ else:
88
+ factor = rng.uniform(0.75, 1.30)
89
+ sampled[key] = float(value * factor)
90
+ return sampled
91
+
92
+
93
+ def thruster_wrench_body(
94
+ spec: BoatSpec,
95
+ actuator_state: np.ndarray,
96
+ params: dict[str, float],
97
+ ) -> tuple[np.ndarray, float]:
98
+ u = np.asarray(actuator_state, dtype=np.float32)
99
+ forces = params["t_max"] * u[:, None] * spec.thruster_dirs
100
+ force_body = forces.sum(axis=0)
101
+ torques = spec.thruster_positions[:, 0] * forces[:, 1] - spec.thruster_positions[:, 1] * forces[:, 0]
102
+ return force_body.astype(np.float32), float(torques.sum())
103
+
104
+
105
+ def boat_metadata(name: str, params: dict[str, float]) -> dict[str, Any]:
106
+ return {
107
+ "boat_type": name,
108
+ "action_dim": get_boat_spec(name).action_dim,
109
+ "params": {k: float(v) for k, v in params.items()},
110
+ }
driftwm/sim/dynamics.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+
5
+ from driftwm.sim.boat import BoatSpec, thruster_wrench_body
6
+ from driftwm.utils import wrap_angle
7
+
8
+
9
+ def rot_body_to_world(theta: float) -> np.ndarray:
10
+ c = np.cos(theta)
11
+ s = np.sin(theta)
12
+ return np.array([[c, -s], [s, c]], dtype=np.float32)
13
+
14
+
15
+ def step_dynamics(
16
+ state: np.ndarray,
17
+ action: np.ndarray,
18
+ spec: BoatSpec,
19
+ params: dict[str, float],
20
+ flow_velocity: np.ndarray,
21
+ dt: float,
22
+ workspace: tuple[float, float, float, float],
23
+ boundary: str = "terminate",
24
+ ) -> tuple[np.ndarray, bool]:
25
+ action = np.clip(np.asarray(action, dtype=np.float32), -1.0, 1.0)
26
+ state = np.asarray(state, dtype=np.float32).copy()
27
+ x, y, theta, vx, vy, omega = state[:6]
28
+ u = state[6 : 6 + spec.action_dim]
29
+
30
+ tau_a = max(params["actuator_tau"], 1e-3)
31
+ alpha = min(1.0, dt / tau_a)
32
+ u_next = u + alpha * (action - u)
33
+
34
+ rot = rot_body_to_world(float(theta))
35
+ vel_world = np.array([vx, vy], dtype=np.float32)
36
+ rel_world = vel_world - np.asarray(flow_velocity, dtype=np.float32)
37
+ rel_body = rot.T @ rel_world
38
+
39
+ d1 = np.array([params["drag_linear_x"], params["drag_linear_y"]], dtype=np.float32)
40
+ d2 = np.array([params["drag_quad_x"], params["drag_quad_y"]], dtype=np.float32)
41
+ drag_body = -d1 * rel_body - d2 * np.abs(rel_body) * rel_body
42
+
43
+ thrust_body, tau_thr = thruster_wrench_body(spec, u_next, params)
44
+ force_world = rot @ (thrust_body + drag_body)
45
+
46
+ tau_drag = -params["drag_angular"] * omega - params["drag_angular_quad"] * abs(float(omega)) * omega
47
+ tau_total = tau_thr + tau_drag
48
+
49
+ vel_next = vel_world + dt * force_world / params["mass"]
50
+ pos_next = np.array([x, y], dtype=np.float32) + dt * vel_next
51
+ omega_next = omega + dt * tau_total / params["inertia"]
52
+ theta_next = wrap_angle(theta + dt * omega_next)
53
+
54
+ xmin, xmax, ymin, ymax = workspace
55
+ done = bool(pos_next[0] < xmin or pos_next[0] > xmax or pos_next[1] < ymin or pos_next[1] > ymax)
56
+ if done and boundary == "bounce":
57
+ restitution = 0.45
58
+ if pos_next[0] < xmin or pos_next[0] > xmax:
59
+ vel_next[0] *= -restitution
60
+ if pos_next[1] < ymin or pos_next[1] > ymax:
61
+ vel_next[1] *= -restitution
62
+ pos_next[0] = np.clip(pos_next[0], xmin, xmax)
63
+ pos_next[1] = np.clip(pos_next[1], ymin, ymax)
64
+ done = False
65
+ elif done and boundary == "clip":
66
+ pos_next[0] = np.clip(pos_next[0], xmin, xmax)
67
+ pos_next[1] = np.clip(pos_next[1], ymin, ymax)
68
+ done = False
69
+
70
+ next_state = state.copy()
71
+ next_state[:6] = np.array([pos_next[0], pos_next[1], theta_next, vel_next[0], vel_next[1], omega_next], dtype=np.float32)
72
+ next_state[6 : 6 + spec.action_dim] = u_next
73
+ return next_state.astype(np.float32), done
driftwm/sim/env.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+
8
+ from driftwm.sim.boat import BoatSpec, get_boat_spec, sample_boat_params
9
+ from driftwm.sim.dynamics import step_dynamics
10
+ from driftwm.sim.flow import Flow, sample_flow
11
+ from driftwm.utils import obs_from_state
12
+
13
+
14
+ @dataclass
15
+ class EnvConfig:
16
+ boat: str = "twin"
17
+ flow_type: str = "noflow"
18
+ dt: float = 0.05
19
+ episode_steps: int = 200
20
+ workspace: tuple[float, float, float, float] = (0.0, 10.0, 0.0, 10.0)
21
+ boundary: str = "terminate"
22
+ randomize_params: bool = True
23
+
24
+
25
+ class SurfaceBoatEnv:
26
+ def __init__(
27
+ self,
28
+ boat: str = "twin",
29
+ flow_type: str = "noflow",
30
+ dt: float = 0.05,
31
+ episode_steps: int = 200,
32
+ workspace: tuple[float, float, float, float] = (0.0, 10.0, 0.0, 10.0),
33
+ boundary: str = "terminate",
34
+ randomize_params: bool = True,
35
+ seed: int | None = None,
36
+ ):
37
+ self.config = EnvConfig(boat, flow_type, dt, episode_steps, workspace, boundary, randomize_params)
38
+ self.rng = np.random.default_rng(seed)
39
+ self.spec: BoatSpec = get_boat_spec(boat)
40
+ self.params: dict[str, float] = sample_boat_params(boat, self.rng, randomize_params)
41
+ self.flow: Flow = sample_flow(flow_type, self.rng, flow_id=1, workspace=workspace)
42
+ self.state = np.zeros(6 + self.spec.action_dim, dtype=np.float32)
43
+ self.t = 0
44
+ self.time = 0.0
45
+ self.last_flow_velocity = np.zeros(2, dtype=np.float32)
46
+
47
+ @property
48
+ def action_dim(self) -> int:
49
+ return self.spec.action_dim
50
+
51
+ @property
52
+ def workspace(self) -> tuple[float, float, float, float]:
53
+ return self.config.workspace
54
+
55
+ def reset(
56
+ self,
57
+ *,
58
+ boat: str | None = None,
59
+ flow_type: str | None = None,
60
+ flow: Flow | None = None,
61
+ flow_id: int | None = None,
62
+ random_velocity: bool = True,
63
+ initial_state: np.ndarray | None = None,
64
+ randomize_params: bool | None = None,
65
+ ) -> tuple[np.ndarray, dict[str, Any]]:
66
+ if boat is not None:
67
+ self.config.boat = boat
68
+ if flow_type is not None:
69
+ self.config.flow_type = flow_type
70
+ if randomize_params is not None:
71
+ self.config.randomize_params = randomize_params
72
+
73
+ self.spec = get_boat_spec(self.config.boat)
74
+ self.params = sample_boat_params(self.config.boat, self.rng, self.config.randomize_params)
75
+ if flow is not None:
76
+ self.flow = flow
77
+ else:
78
+ fid = int(flow_id if flow_id is not None else self.rng.integers(1, 2_000_000))
79
+ self.flow = sample_flow(self.config.flow_type, self.rng, fid, self.config.workspace)
80
+
81
+ if initial_state is not None:
82
+ self.state = np.asarray(initial_state, dtype=np.float32).copy()
83
+ else:
84
+ xmin, xmax, ymin, ymax = self.config.workspace
85
+ margin = 1.0
86
+ pos = np.array(
87
+ [self.rng.uniform(xmin + margin, xmax - margin), self.rng.uniform(ymin + margin, ymax - margin)],
88
+ dtype=np.float32,
89
+ )
90
+ theta = self.rng.uniform(-np.pi, np.pi)
91
+ vel = self.rng.uniform(-0.12, 0.12, size=2).astype(np.float32) if random_velocity else np.zeros(2, dtype=np.float32)
92
+ omega = float(self.rng.uniform(-0.15, 0.15)) if random_velocity else 0.0
93
+ self.state = np.zeros(6 + self.spec.action_dim, dtype=np.float32)
94
+ self.state[:6] = np.array([pos[0], pos[1], theta, vel[0], vel[1], omega], dtype=np.float32)
95
+
96
+ self.t = 0
97
+ self.time = 0.0
98
+ self.last_flow_velocity = self.flow.velocity(self.state[:2], self.time)
99
+ return self.observation(), self.info()
100
+
101
+ def observation(self) -> np.ndarray:
102
+ return obs_from_state(self.state[:6])
103
+
104
+ def full_state(self) -> np.ndarray:
105
+ return self.state.copy()
106
+
107
+ def flow_at(self, pos: np.ndarray) -> np.ndarray:
108
+ return self.flow.velocity(np.asarray(pos, dtype=np.float32), self.time)
109
+
110
+ def step(self, action: np.ndarray) -> tuple[np.ndarray, float, bool, dict[str, Any]]:
111
+ action = np.asarray(action, dtype=np.float32)[: self.action_dim]
112
+ flow_velocity = self.flow.velocity(self.state[:2], self.time)
113
+ self.last_flow_velocity = flow_velocity.astype(np.float32)
114
+ self.state, boundary_done = step_dynamics(
115
+ self.state,
116
+ action,
117
+ self.spec,
118
+ self.params,
119
+ flow_velocity,
120
+ self.config.dt,
121
+ self.config.workspace,
122
+ self.config.boundary,
123
+ )
124
+ self.flow.step(self.config.dt, self.rng)
125
+ self.t += 1
126
+ self.time += self.config.dt
127
+ timeout = self.t >= self.config.episode_steps
128
+ done = boundary_done or timeout
129
+ reward = 0.0
130
+ return self.observation(), reward, done, self.info()
131
+
132
+ def info(self) -> dict[str, Any]:
133
+ meta = {
134
+ "t": self.t,
135
+ "time": self.time,
136
+ "boat_type": self.spec.name,
137
+ "action_dim": self.action_dim,
138
+ "flow_velocity": self.last_flow_velocity.astype(float).tolist(),
139
+ "params": {k: float(v) for k, v in self.params.items()},
140
+ }
141
+ meta.update(self.flow.metadata())
142
+ return meta
driftwm/sim/flow.py ADDED
@@ -0,0 +1,428 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+
8
+
9
+ PAPER_FLOW = {
10
+ "uniform_min": 0.03,
11
+ "uniform_max": 0.24,
12
+ "slow_max": 0.26,
13
+ "slow_noise": 0.0035,
14
+ "vortex_base_max": 0.12,
15
+ "vortex_gamma": 0.14,
16
+ "vortex_max": 0.34,
17
+ "gradient_base_min": 0.01,
18
+ "gradient_base_max": 0.16,
19
+ "gradient_matrix_std": 0.022,
20
+ "gradient_max": 0.34,
21
+ "turbulent_base_max": 0.12,
22
+ "turbulent_vector_std": 0.075,
23
+ "turbulent_max": 0.34,
24
+ "shear_max": 0.38,
25
+ "moving_vortex_max": 0.38,
26
+ "random_fourier_max": 0.38,
27
+ }
28
+
29
+
30
+ @dataclass
31
+ class Flow:
32
+ name: str
33
+ flow_id: int
34
+
35
+ def velocity(self, pos: np.ndarray, t: float = 0.0) -> np.ndarray:
36
+ raise NotImplementedError
37
+
38
+ def step(self, dt: float, rng: np.random.Generator) -> None:
39
+ return None
40
+
41
+ def metadata(self) -> dict[str, Any]:
42
+ return {"flow_type": self.name, "flow_id": int(self.flow_id)}
43
+
44
+
45
+ @dataclass
46
+ class NoFlow(Flow):
47
+ def __init__(self, flow_id: int = 0):
48
+ super().__init__("noflow", flow_id)
49
+
50
+ def velocity(self, pos: np.ndarray, t: float = 0.0) -> np.ndarray:
51
+ return np.zeros_like(np.asarray(pos, dtype=np.float32))
52
+
53
+
54
+ @dataclass
55
+ class UniformFlow(Flow):
56
+ vector: np.ndarray
57
+
58
+ def __init__(self, vector: np.ndarray, flow_id: int):
59
+ super().__init__("uniform", flow_id)
60
+ self.vector = np.asarray(vector, dtype=np.float32)
61
+
62
+ def velocity(self, pos: np.ndarray, t: float = 0.0) -> np.ndarray:
63
+ pos = np.asarray(pos, dtype=np.float32)
64
+ return np.broadcast_to(self.vector, pos.shape).astype(np.float32)
65
+
66
+ def metadata(self) -> dict[str, Any]:
67
+ out = super().metadata()
68
+ out["vector"] = self.vector.astype(float).tolist()
69
+ return out
70
+
71
+
72
+ @dataclass
73
+ class SlowlyVaryingFlow(Flow):
74
+ vector: np.ndarray
75
+ rho: float
76
+ noise_std: float
77
+ max_speed: float
78
+
79
+ def __init__(
80
+ self,
81
+ vector: np.ndarray,
82
+ flow_id: int,
83
+ rho: float = 0.995,
84
+ noise_std: float = 0.005,
85
+ max_speed: float = 0.35,
86
+ ):
87
+ super().__init__("slowly_varying", flow_id)
88
+ self.vector = np.asarray(vector, dtype=np.float32)
89
+ self.rho = float(rho)
90
+ self.noise_std = float(noise_std)
91
+ self.max_speed = float(max_speed)
92
+
93
+ def velocity(self, pos: np.ndarray, t: float = 0.0) -> np.ndarray:
94
+ pos = np.asarray(pos, dtype=np.float32)
95
+ return np.broadcast_to(self.vector, pos.shape).astype(np.float32)
96
+
97
+ def step(self, dt: float, rng: np.random.Generator) -> None:
98
+ noise = rng.normal(0.0, self.noise_std, size=2).astype(np.float32)
99
+ self.vector = self.rho * self.vector + np.sqrt(max(0.0, 1.0 - self.rho**2)) * noise
100
+ speed = float(np.linalg.norm(self.vector))
101
+ if speed > self.max_speed:
102
+ self.vector = self.vector / speed * self.max_speed
103
+
104
+ def metadata(self) -> dict[str, Any]:
105
+ out = super().metadata()
106
+ out.update({"vector": self.vector.astype(float).tolist(), "rho": self.rho})
107
+ return out
108
+
109
+
110
+ @dataclass
111
+ class VortexFlow(Flow):
112
+ base: np.ndarray
113
+ center: np.ndarray
114
+ gamma: float
115
+ radius_eps: float
116
+ max_speed: float
117
+
118
+ def __init__(
119
+ self,
120
+ base: np.ndarray,
121
+ center: np.ndarray,
122
+ gamma: float,
123
+ flow_id: int,
124
+ radius_eps: float = 0.30,
125
+ max_speed: float = 0.50,
126
+ name: str = "vortex",
127
+ ):
128
+ super().__init__(name, flow_id)
129
+ self.base = np.asarray(base, dtype=np.float32)
130
+ self.center = np.asarray(center, dtype=np.float32)
131
+ self.gamma = float(gamma)
132
+ self.radius_eps = float(radius_eps)
133
+ self.max_speed = float(max_speed)
134
+
135
+ def velocity(self, pos: np.ndarray, t: float = 0.0) -> np.ndarray:
136
+ pos = np.asarray(pos, dtype=np.float32)
137
+ rel = pos - self.center
138
+ denom = np.sum(rel * rel, axis=-1, keepdims=True) + self.radius_eps**2
139
+ swirl = self.gamma * np.concatenate([-rel[..., 1:2], rel[..., 0:1]], axis=-1) / denom
140
+ vel = self.base + swirl
141
+ speed = np.linalg.norm(vel, axis=-1, keepdims=True)
142
+ scale = np.minimum(1.0, self.max_speed / np.maximum(speed, 1e-6))
143
+ return (vel * scale).astype(np.float32)
144
+
145
+ def metadata(self) -> dict[str, Any]:
146
+ out = super().metadata()
147
+ out.update(
148
+ {
149
+ "base": self.base.astype(float).tolist(),
150
+ "center": self.center.astype(float).tolist(),
151
+ "gamma": self.gamma,
152
+ }
153
+ )
154
+ return out
155
+
156
+
157
+ @dataclass
158
+ class GradientFlow(Flow):
159
+ base: np.ndarray
160
+ center: np.ndarray
161
+ matrix: np.ndarray
162
+ max_speed: float
163
+
164
+ def __init__(self, base: np.ndarray, center: np.ndarray, matrix: np.ndarray, flow_id: int, max_speed: float = 0.55):
165
+ super().__init__("gradient", flow_id)
166
+ self.base = np.asarray(base, dtype=np.float32)
167
+ self.center = np.asarray(center, dtype=np.float32)
168
+ self.matrix = np.asarray(matrix, dtype=np.float32)
169
+ self.max_speed = float(max_speed)
170
+
171
+ def velocity(self, pos: np.ndarray, t: float = 0.0) -> np.ndarray:
172
+ pos = np.asarray(pos, dtype=np.float32)
173
+ rel = pos - self.center
174
+ vel = self.base + rel @ self.matrix.T
175
+ speed = np.linalg.norm(vel, axis=-1, keepdims=True)
176
+ scale = np.minimum(1.0, self.max_speed / np.maximum(speed, 1e-6))
177
+ return (vel * scale).astype(np.float32)
178
+
179
+ def metadata(self) -> dict[str, Any]:
180
+ out = super().metadata()
181
+ out.update({"base": self.base.astype(float).tolist(), "matrix": self.matrix.astype(float).tolist()})
182
+ return out
183
+
184
+
185
+ @dataclass
186
+ class TurbulentPatchFlow(Flow):
187
+ base: np.ndarray
188
+ centers: np.ndarray
189
+ vectors: np.ndarray
190
+ sigma: float
191
+ max_speed: float
192
+
193
+ def __init__(
194
+ self,
195
+ base: np.ndarray,
196
+ centers: np.ndarray,
197
+ vectors: np.ndarray,
198
+ flow_id: int,
199
+ sigma: float = 1.15,
200
+ max_speed: float = 0.55,
201
+ ):
202
+ super().__init__("turbulent_patch", flow_id)
203
+ self.base = np.asarray(base, dtype=np.float32)
204
+ self.centers = np.asarray(centers, dtype=np.float32)
205
+ self.vectors = np.asarray(vectors, dtype=np.float32)
206
+ self.sigma = float(sigma)
207
+ self.max_speed = float(max_speed)
208
+
209
+ def velocity(self, pos: np.ndarray, t: float = 0.0) -> np.ndarray:
210
+ pos = np.asarray(pos, dtype=np.float32)
211
+ rel = pos[..., None, :] - self.centers
212
+ weights = np.exp(-np.sum(rel * rel, axis=-1, keepdims=True) / (2.0 * self.sigma**2))
213
+ perturb = np.sum(weights * self.vectors, axis=-2)
214
+ vel = self.base + perturb
215
+ speed = np.linalg.norm(vel, axis=-1, keepdims=True)
216
+ scale = np.minimum(1.0, self.max_speed / np.maximum(speed, 1e-6))
217
+ return (vel * scale).astype(np.float32)
218
+
219
+ def metadata(self) -> dict[str, Any]:
220
+ out = super().metadata()
221
+ out.update({"base": self.base.astype(float).tolist(), "centers": self.centers.astype(float).tolist()})
222
+ return out
223
+
224
+
225
+ @dataclass
226
+ class ShearFlow(Flow):
227
+ base: np.ndarray
228
+ center_y: float
229
+ shear: float
230
+ max_speed: float
231
+
232
+ def __init__(self, base: np.ndarray, center_y: float, shear: float, flow_id: int, max_speed: float = 0.60):
233
+ super().__init__("shear", flow_id)
234
+ self.base = np.asarray(base, dtype=np.float32)
235
+ self.center_y = float(center_y)
236
+ self.shear = float(shear)
237
+ self.max_speed = float(max_speed)
238
+
239
+ def velocity(self, pos: np.ndarray, t: float = 0.0) -> np.ndarray:
240
+ pos = np.asarray(pos, dtype=np.float32)
241
+ vel = np.broadcast_to(self.base, pos.shape).copy()
242
+ vel[..., 0] += self.shear * (pos[..., 1] - self.center_y)
243
+ speed = np.linalg.norm(vel, axis=-1, keepdims=True)
244
+ scale = np.minimum(1.0, self.max_speed / np.maximum(speed, 1e-6))
245
+ return (vel * scale).astype(np.float32)
246
+
247
+ def metadata(self) -> dict[str, Any]:
248
+ out = super().metadata()
249
+ out.update({"base": self.base.astype(float).tolist(), "center_y": self.center_y, "shear": self.shear})
250
+ return out
251
+
252
+
253
+ @dataclass
254
+ class MovingVortexFlow(VortexFlow):
255
+ center_velocity: np.ndarray
256
+ workspace: tuple[float, float, float, float]
257
+
258
+ def __init__(
259
+ self,
260
+ base: np.ndarray,
261
+ center: np.ndarray,
262
+ gamma: float,
263
+ center_velocity: np.ndarray,
264
+ flow_id: int,
265
+ workspace: tuple[float, float, float, float],
266
+ radius_eps: float = 0.35,
267
+ max_speed: float = 0.60,
268
+ ):
269
+ super().__init__(base=base, center=center, gamma=gamma, flow_id=flow_id, radius_eps=radius_eps, max_speed=max_speed, name="moving_vortex")
270
+ self.center_velocity = np.asarray(center_velocity, dtype=np.float32)
271
+ self.workspace = workspace
272
+
273
+ def step(self, dt: float, rng: np.random.Generator) -> None:
274
+ del rng
275
+ self.center = self.center + dt * self.center_velocity
276
+ xmin, xmax, ymin, ymax = self.workspace
277
+ if self.center[0] < xmin + 1.0 or self.center[0] > xmax - 1.0:
278
+ self.center_velocity[0] *= -1.0
279
+ if self.center[1] < ymin + 1.0 or self.center[1] > ymax - 1.0:
280
+ self.center_velocity[1] *= -1.0
281
+ self.center[0] = np.clip(self.center[0], xmin + 1.0, xmax - 1.0)
282
+ self.center[1] = np.clip(self.center[1], ymin + 1.0, ymax - 1.0)
283
+
284
+ def metadata(self) -> dict[str, Any]:
285
+ out = super().metadata()
286
+ out["center_velocity"] = self.center_velocity.astype(float).tolist()
287
+ return out
288
+
289
+
290
+ @dataclass
291
+ class RandomFourierFlow(Flow):
292
+ base: np.ndarray
293
+ k: np.ndarray
294
+ amp: np.ndarray
295
+ phase: np.ndarray
296
+ temporal: np.ndarray
297
+ max_speed: float
298
+
299
+ def __init__(
300
+ self,
301
+ base: np.ndarray,
302
+ k: np.ndarray,
303
+ amp: np.ndarray,
304
+ phase: np.ndarray,
305
+ temporal: np.ndarray,
306
+ flow_id: int,
307
+ max_speed: float = 0.60,
308
+ ):
309
+ super().__init__("random_fourier", flow_id)
310
+ self.base = np.asarray(base, dtype=np.float32)
311
+ self.k = np.asarray(k, dtype=np.float32)
312
+ self.amp = np.asarray(amp, dtype=np.float32)
313
+ self.phase = np.asarray(phase, dtype=np.float32)
314
+ self.temporal = np.asarray(temporal, dtype=np.float32)
315
+ self.max_speed = float(max_speed)
316
+
317
+ def velocity(self, pos: np.ndarray, t: float = 0.0) -> np.ndarray:
318
+ pos = np.asarray(pos, dtype=np.float32)
319
+ flat = pos.reshape(-1, 2)
320
+ arg = flat @ self.k.T + self.phase[None, :] + float(t) * self.temporal[None, :]
321
+ # Divergence-free field via stream function psi: v=(dpsi/dy, -dpsi/dx).
322
+ coeff = self.amp[None, :] * np.cos(arg)
323
+ vx = np.sum(coeff * self.k[None, :, 1], axis=1)
324
+ vy = -np.sum(coeff * self.k[None, :, 0], axis=1)
325
+ vel = np.stack([vx, vy], axis=-1).reshape(pos.shape) + self.base
326
+ speed = np.linalg.norm(vel, axis=-1, keepdims=True)
327
+ scale = np.minimum(1.0, self.max_speed / np.maximum(speed, 1e-6))
328
+ return (vel * scale).astype(np.float32)
329
+
330
+ def metadata(self) -> dict[str, Any]:
331
+ out = super().metadata()
332
+ out.update({"base": self.base.astype(float).tolist(), "num_modes": int(self.k.shape[0])})
333
+ return out
334
+
335
+
336
+ def _sample_uniform_vector(rng: np.random.Generator, min_speed: float = 0.05, max_speed: float = 0.35) -> np.ndarray:
337
+ speed = rng.uniform(min_speed, max_speed)
338
+ direction = rng.uniform(0.0, 2.0 * np.pi)
339
+ return np.array([speed * np.cos(direction), speed * np.sin(direction)], dtype=np.float32)
340
+
341
+
342
+ def sample_flow(
343
+ flow_type: str,
344
+ rng: np.random.Generator,
345
+ flow_id: int,
346
+ workspace: tuple[float, float, float, float] = (0.0, 10.0, 0.0, 10.0),
347
+ ) -> Flow:
348
+ flow_type = flow_type.lower()
349
+ profile = PAPER_FLOW
350
+ if flow_type == "noflow":
351
+ return NoFlow(flow_id=0)
352
+ if flow_type == "uniform":
353
+ return UniformFlow(_sample_uniform_vector(rng, profile["uniform_min"], profile["uniform_max"]), flow_id=flow_id)
354
+ if flow_type in {"slow", "slowly_varying", "ou"}:
355
+ return SlowlyVaryingFlow(
356
+ _sample_uniform_vector(rng, profile["uniform_min"], profile["uniform_max"]),
357
+ flow_id=flow_id,
358
+ noise_std=profile["slow_noise"],
359
+ max_speed=profile["slow_max"],
360
+ )
361
+ if flow_type in {"vortex", "vortex_center"}:
362
+ xmin, xmax, ymin, ymax = workspace
363
+ if flow_type == "vortex_center":
364
+ center = np.array([(xmin + xmax) / 2.0, (ymin + ymax) / 2.0], dtype=np.float32)
365
+ else:
366
+ center = np.array([rng.uniform(xmin + 2.0, xmax - 2.0), rng.uniform(ymin + 2.0, ymax - 2.0)], dtype=np.float32)
367
+ base = _sample_uniform_vector(rng, 0.0, profile["vortex_base_max"])
368
+ gamma = float(rng.uniform(-profile["vortex_gamma"], profile["vortex_gamma"]))
369
+ return VortexFlow(base=base, center=center, gamma=gamma, flow_id=flow_id, max_speed=profile["vortex_max"], name=flow_type)
370
+ if flow_type in {"gradient", "gradient_flow"}:
371
+ xmin, xmax, ymin, ymax = workspace
372
+ center = np.array([(xmin + xmax) / 2.0, (ymin + ymax) / 2.0], dtype=np.float32)
373
+ base = _sample_uniform_vector(rng, profile["gradient_base_min"], profile["gradient_base_max"])
374
+ mat = rng.normal(0.0, profile["gradient_matrix_std"], size=(2, 2)).astype(np.float32)
375
+ return GradientFlow(base=base, center=center, matrix=mat, flow_id=flow_id, max_speed=profile["gradient_max"])
376
+ if flow_type in {"turbulent", "turbulent_patch", "patch"}:
377
+ xmin, xmax, ymin, ymax = workspace
378
+ base = _sample_uniform_vector(rng, 0.0, profile["turbulent_base_max"])
379
+ centers = np.stack(
380
+ [
381
+ rng.uniform([xmin + 1.0, ymin + 1.0], [xmax - 1.0, ymax - 1.0])
382
+ for _ in range(5)
383
+ ],
384
+ axis=0,
385
+ ).astype(np.float32)
386
+ vectors = rng.normal(0.0, profile["turbulent_vector_std"], size=(5, 2)).astype(np.float32)
387
+ return TurbulentPatchFlow(base=base, centers=centers, vectors=vectors, flow_id=flow_id, max_speed=profile["turbulent_max"])
388
+ if flow_type in {"shear", "shear_flow"}:
389
+ xmin, xmax, ymin, ymax = workspace
390
+ base = _sample_uniform_vector(rng, 0.0, profile["turbulent_base_max"])
391
+ center_y = 0.5 * (ymin + ymax)
392
+ shear = float(rng.uniform(-0.08, 0.08))
393
+ return ShearFlow(base=base, center_y=center_y, shear=shear, flow_id=flow_id, max_speed=profile["shear_max"])
394
+ if flow_type in {"moving_vortex", "moving-vortex"}:
395
+ xmin, xmax, ymin, ymax = workspace
396
+ center = np.array([rng.uniform(xmin + 2.0, xmax - 2.0), rng.uniform(ymin + 2.0, ymax - 2.0)], dtype=np.float32)
397
+ base = _sample_uniform_vector(rng, 0.0, profile["vortex_base_max"])
398
+ gamma = float(rng.uniform(-profile["vortex_gamma"], profile["vortex_gamma"]))
399
+ center_velocity = _sample_uniform_vector(rng, 0.02, 0.08)
400
+ return MovingVortexFlow(
401
+ base=base,
402
+ center=center,
403
+ gamma=gamma,
404
+ center_velocity=center_velocity,
405
+ flow_id=flow_id,
406
+ workspace=workspace,
407
+ max_speed=profile["moving_vortex_max"],
408
+ )
409
+ if flow_type in {"random_fourier", "fourier", "divfree"}:
410
+ base = _sample_uniform_vector(rng, 0.0, profile["turbulent_base_max"])
411
+ modes = 8
412
+ k = rng.integers(1, 5, size=(modes, 2)).astype(np.float32)
413
+ signs = rng.choice([-1.0, 1.0], size=(modes, 2)).astype(np.float32)
414
+ k = signs * k * (2.0 * np.pi / 10.0)
415
+ amp_std = 0.028
416
+ amp = rng.normal(0.0, amp_std, size=(modes,)).astype(np.float32)
417
+ phase = rng.uniform(0.0, 2.0 * np.pi, size=(modes,)).astype(np.float32)
418
+ temporal = rng.normal(0.0, 0.12, size=(modes,)).astype(np.float32)
419
+ return RandomFourierFlow(
420
+ base=base,
421
+ k=k,
422
+ amp=amp,
423
+ phase=phase,
424
+ temporal=temporal,
425
+ flow_id=flow_id,
426
+ max_speed=profile["random_fourier_max"],
427
+ )
428
+ raise ValueError(f"unknown flow type: {flow_type}")
driftwm/sim/render.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from pathlib import Path
5
+ from typing import Iterable
6
+
7
+ import numpy as np
8
+ from PIL import Image, ImageDraw
9
+
10
+ from driftwm.sim.boat import BoatSpec, get_boat_spec
11
+ from driftwm.sim.flow import Flow
12
+ from driftwm.sim.dynamics import rot_body_to_world
13
+ from driftwm.utils import ensure_dir
14
+
15
+
16
+ Color = tuple[int, int, int]
17
+
18
+
19
+ def _world_to_px(point: np.ndarray, workspace: tuple[float, float, float, float], size: int, pad: int) -> tuple[int, int]:
20
+ xmin, xmax, ymin, ymax = workspace
21
+ x = (point[0] - xmin) / max(1e-6, xmax - xmin)
22
+ y = (point[1] - ymin) / max(1e-6, ymax - ymin)
23
+ px = int(pad + x * (size - 2 * pad))
24
+ py = int(size - pad - y * (size - 2 * pad))
25
+ return px, py
26
+
27
+
28
+ def _draw_arrow(draw: ImageDraw.ImageDraw, p0: tuple[int, int], p1: tuple[int, int], color: Color, width: int = 2) -> None:
29
+ draw.line([p0, p1], fill=color, width=width)
30
+ dx = p1[0] - p0[0]
31
+ dy = p1[1] - p0[1]
32
+ angle = math.atan2(dy, dx)
33
+ head = 8
34
+ for sign in (-1, 1):
35
+ a = angle + sign * 2.55
36
+ p = (int(p1[0] + head * math.cos(a)), int(p1[1] + head * math.sin(a)))
37
+ draw.line([p1, p], fill=color, width=width)
38
+
39
+
40
+ def draw_flow_field(
41
+ draw: ImageDraw.ImageDraw,
42
+ flow: Flow,
43
+ workspace: tuple[float, float, float, float],
44
+ size: int,
45
+ pad: int,
46
+ t: float = 0.0,
47
+ grid: int = 9,
48
+ ) -> None:
49
+ xmin, xmax, ymin, ymax = workspace
50
+ xs = np.linspace(xmin + 0.7, xmax - 0.7, grid)
51
+ ys = np.linspace(ymin + 0.7, ymax - 0.7, grid)
52
+ for x in xs:
53
+ for y in ys:
54
+ p = np.array([x, y], dtype=np.float32)
55
+ v = flow.velocity(p, t)
56
+ speed = float(np.linalg.norm(v))
57
+ if speed < 1e-4:
58
+ continue
59
+ q = p + 0.75 * v / max(0.15, speed)
60
+ p0 = _world_to_px(p, workspace, size, pad)
61
+ p1 = _world_to_px(q, workspace, size, pad)
62
+ _draw_arrow(draw, p0, p1, (160, 190, 218), width=1)
63
+
64
+
65
+ def draw_boat(
66
+ draw: ImageDraw.ImageDraw,
67
+ state: np.ndarray,
68
+ spec: BoatSpec,
69
+ workspace: tuple[float, float, float, float],
70
+ size: int,
71
+ pad: int,
72
+ fill: Color = (38, 89, 133),
73
+ outline: Color = (15, 37, 61),
74
+ ) -> None:
75
+ pos = state[:2]
76
+ theta = float(state[2])
77
+ rot = rot_body_to_world(theta)
78
+ hull = (spec.hull_vertices @ rot.T) + pos
79
+ pts = [_world_to_px(p, workspace, size, pad) for p in hull]
80
+ draw.polygon(pts, fill=fill, outline=outline)
81
+ nose = pos + rot @ np.array([0.56, 0.0], dtype=np.float32)
82
+ _draw_arrow(draw, _world_to_px(pos, workspace, size, pad), _world_to_px(nose, workspace, size, pad), (230, 242, 255), width=2)
83
+ for r, d in zip(spec.thruster_positions, spec.thruster_dirs):
84
+ p = pos + rot @ r
85
+ q = p + rot @ (r - 0.16 * d)
86
+ _draw_arrow(draw, _world_to_px(q, workspace, size, pad), _world_to_px(p, workspace, size, pad), (215, 108, 71), width=2)
87
+
88
+
89
+ def render_frame(
90
+ state: np.ndarray,
91
+ boat: str | BoatSpec,
92
+ flow: Flow,
93
+ workspace: tuple[float, float, float, float] = (0.0, 10.0, 0.0, 10.0),
94
+ trajectory: np.ndarray | None = None,
95
+ goal: np.ndarray | None = None,
96
+ planned: Iterable[np.ndarray] | None = None,
97
+ size: int = 512,
98
+ pad: int = 28,
99
+ t: float = 0.0,
100
+ ) -> Image.Image:
101
+ spec = get_boat_spec(boat) if isinstance(boat, str) else boat
102
+ img = Image.new("RGB", (size, size), (247, 250, 252))
103
+ draw = ImageDraw.Draw(img, "RGBA")
104
+ draw.rectangle([pad, pad, size - pad, size - pad], outline=(35, 54, 72, 255), width=2)
105
+ draw_flow_field(draw, flow, workspace, size, pad, t=t)
106
+ if planned is not None:
107
+ for rollout in planned:
108
+ pts = [_world_to_px(p[:2], workspace, size, pad) for p in rollout]
109
+ if len(pts) > 1:
110
+ draw.line(pts, fill=(110, 138, 183, 45), width=1)
111
+ if trajectory is not None and len(trajectory) > 1:
112
+ pts = [_world_to_px(p[:2], workspace, size, pad) for p in trajectory]
113
+ draw.line(pts, fill=(22, 131, 105, 230), width=3)
114
+ if goal is not None:
115
+ gx, gy = _world_to_px(np.asarray(goal, dtype=np.float32), workspace, size, pad)
116
+ r = 8
117
+ draw.ellipse([gx - r, gy - r, gx + r, gy + r], fill=(211, 67, 78, 230), outline=(120, 25, 33, 255), width=2)
118
+ draw_boat(draw, state, spec, workspace, size, pad)
119
+ return img
120
+
121
+
122
+ def save_gif(frames: list[Image.Image], path: str | Path, duration_ms: int = 40) -> None:
123
+ path = Path(path)
124
+ ensure_dir(path.parent)
125
+ if not frames:
126
+ raise ValueError("no frames to save")
127
+ frames[0].save(path, save_all=True, append_images=frames[1:], duration=duration_ms, loop=0)
128
+
129
+
130
+ def save_boat_geometry(boat: str, path: str | Path, size: int = 420) -> None:
131
+ from driftwm.sim.flow import NoFlow
132
+
133
+ state = np.array([5.0, 5.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float32)
134
+ img = render_frame(state, boat, NoFlow(), trajectory=None, size=size)
135
+ path = Path(path)
136
+ ensure_dir(path.parent)
137
+ img.save(path)
138
+
139
+
140
+ def save_flow_quiver(flow: Flow, path: str | Path, workspace: tuple[float, float, float, float] = (0.0, 10.0, 0.0, 10.0)) -> None:
141
+ state = np.array([5.0, 5.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float32)
142
+ img = render_frame(state, "twin", flow, workspace=workspace, trajectory=None)
143
+ path = Path(path)
144
+ ensure_dir(path.parent)
145
+ img.save(path)
driftwm/sim/sanity.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+
8
+ from driftwm.sim.env import SurfaceBoatEnv
9
+ from driftwm.sim.render import render_frame, save_boat_geometry, save_flow_quiver, save_gif
10
+ from driftwm.utils import ensure_dir
11
+
12
+
13
+ def _action_for_scenario(boat: str, scenario: str, step: int) -> np.ndarray:
14
+ if scenario == "thruster":
15
+ if boat == "twin":
16
+ return np.array([0.9, 0.15], dtype=np.float32) if step < 80 else np.array([0.2, 0.8], dtype=np.float32)
17
+ return np.array([0.8, -0.2, 0.35], dtype=np.float32) if step < 80 else np.array([-0.35, 0.75, 0.1], dtype=np.float32)
18
+ if scenario == "random":
19
+ raise RuntimeError("random scenario is handled separately")
20
+ return np.zeros(3 if boat == "triangle" else 2, dtype=np.float32)
21
+
22
+
23
+ def run_sanity(
24
+ boat: str,
25
+ flow_type: str,
26
+ out: str | Path,
27
+ scenario: str = "auto",
28
+ steps: int = 200,
29
+ seed: int = 0,
30
+ boundary: str = "bounce",
31
+ ) -> None:
32
+ if scenario == "auto":
33
+ scenario = "slide" if flow_type == "noflow" else "drift"
34
+ env = SurfaceBoatEnv(boat=boat, flow_type=flow_type, episode_steps=steps, boundary=boundary, randomize_params=False, seed=seed)
35
+ obs, _ = env.reset(random_velocity=False)
36
+ if scenario == "slide":
37
+ env.state[3:5] = np.array([0.95, 0.22], dtype=np.float32)
38
+ env.state[5] = 0.25
39
+ elif scenario == "drift":
40
+ env.state[3:6] = 0.0
41
+ elif scenario == "thruster":
42
+ env.state[3:6] = 0.0
43
+
44
+ frames = []
45
+ traj = [env.full_state()[:6].copy()]
46
+ rng = np.random.default_rng(seed + 17)
47
+ for k in range(steps):
48
+ if scenario == "random":
49
+ action = rng.uniform(-1.0, 1.0, size=env.action_dim).astype(np.float32)
50
+ else:
51
+ action = _action_for_scenario(boat, scenario, k)[: env.action_dim]
52
+ env.step(action)
53
+ traj.append(env.full_state()[:6].copy())
54
+ if k % 2 == 0:
55
+ frames.append(
56
+ render_frame(
57
+ env.full_state()[:6],
58
+ env.spec,
59
+ env.flow,
60
+ env.workspace,
61
+ trajectory=np.asarray(traj),
62
+ t=env.time,
63
+ )
64
+ )
65
+ save_gif(frames, out)
66
+
67
+
68
+ def main() -> None:
69
+ parser = argparse.ArgumentParser()
70
+ parser.add_argument("--boat", choices=["twin", "triangle"], default="twin")
71
+ parser.add_argument("--flow", choices=["noflow", "uniform", "slowly_varying", "vortex"], default="noflow")
72
+ parser.add_argument("--scenario", choices=["auto", "slide", "drift", "thruster", "random"], default="auto")
73
+ parser.add_argument("--steps", type=int, default=200)
74
+ parser.add_argument("--seed", type=int, default=0)
75
+ parser.add_argument("--out", required=True)
76
+ parser.add_argument("--make-static", action="store_true")
77
+ args = parser.parse_args()
78
+ run_sanity(args.boat, args.flow, args.out, args.scenario, args.steps, args.seed)
79
+ if args.make_static:
80
+ out_dir = ensure_dir(Path(args.out).parent)
81
+ save_boat_geometry("twin", out_dir / "boat_geometry_twin.png")
82
+ save_boat_geometry("triangle", out_dir / "boat_geometry_triangle.png")
83
+ env = SurfaceBoatEnv(flow_type=args.flow, boundary="bounce", seed=args.seed)
84
+ env.reset()
85
+ save_flow_quiver(env.flow, out_dir / "flow_field_quiver.png")
86
+
87
+
88
+ if __name__ == "__main__":
89
+ main()
driftwm/utils.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import math
5
+ import os
6
+ import random
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+ import torch
12
+ import yaml
13
+
14
+
15
+ def ensure_dir(path: str | os.PathLike[str]) -> Path:
16
+ p = Path(path)
17
+ p.mkdir(parents=True, exist_ok=True)
18
+ return p
19
+
20
+
21
+ def load_yaml(path: str | os.PathLike[str]) -> dict[str, Any]:
22
+ with open(path, "r", encoding="utf-8") as f:
23
+ return yaml.safe_load(f)
24
+
25
+
26
+ def save_json(obj: Any, path: str | os.PathLike[str]) -> None:
27
+ path = Path(path)
28
+ ensure_dir(path.parent)
29
+ with open(path, "w", encoding="utf-8") as f:
30
+ json.dump(obj, f, indent=2, sort_keys=True)
31
+
32
+
33
+ def set_seed(seed: int) -> None:
34
+ random.seed(seed)
35
+ np.random.seed(seed)
36
+ torch.manual_seed(seed)
37
+ if torch.cuda.is_available():
38
+ torch.cuda.manual_seed_all(seed)
39
+
40
+
41
+ def wrap_angle(theta: float | np.ndarray) -> float | np.ndarray:
42
+ return (theta + math.pi) % (2.0 * math.pi) - math.pi
43
+
44
+
45
+ def angle_error(pred: np.ndarray, target: np.ndarray) -> np.ndarray:
46
+ return np.arctan2(np.sin(pred - target), np.cos(pred - target))
47
+
48
+
49
+ def obs_from_state(state: np.ndarray) -> np.ndarray:
50
+ theta = state[..., 2]
51
+ return np.stack(
52
+ [state[..., 0], state[..., 1], np.cos(theta), np.sin(theta)],
53
+ axis=-1,
54
+ ).astype(np.float32)
55
+
56
+
57
+ def state_dim_for_action_dim(action_dim: int) -> int:
58
+ return 6 + action_dim
59
+
60
+
61
+ def pad_action(action: np.ndarray, max_dim: int = 3) -> np.ndarray:
62
+ out = np.zeros(max_dim, dtype=np.float32)
63
+ out[: action.shape[-1]] = action
64
+ return out
65
+
66
+
67
+ def unpad_action(action: np.ndarray, action_dim: int) -> np.ndarray:
68
+ return np.asarray(action[..., :action_dim], dtype=np.float32)
69
+
70
+
71
+ def device_from_arg(device: str | None = None) -> torch.device:
72
+ if device:
73
+ return torch.device(device)
74
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
75
+
76
+
77
+ def configure_torch_runtime() -> None:
78
+ """Prefer stable kernels on local ROCm builds.
79
+
80
+ The local rocm712 environment can expose RDNA targets where MIOpen's GRU
81
+ kernel compilation fails. Disabling the cuDNN/MIOpen RNN backend keeps the
82
+ same ROCm device while using PyTorch's native kernels.
83
+ """
84
+ if torch.cuda.is_available() and getattr(torch.version, "hip", None):
85
+ torch.backends.cudnn.enabled = False
experiments/BASELINES.md ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Baseline Scope
2
+
3
+ The public benchmark has two formal comparison groups.
4
+
5
+ ## A. Learned World Models
6
+
7
+ Purpose: compare image-input world-model architectures under the same data, optimizer budget, rollout target, and planning interface.
8
+
9
+ | Directory | Report Name | Why It Is Included |
10
+ |---|---|---|
11
+ | `flowmo` | FlowMo | Proposed flow-momentum WM. Separates short object motion state from long ambient drift context. |
12
+ | `leworldmodel` | LeWorldModel | Simple JEPA-style latent prediction baseline. Tests whether current-image latent dynamics are enough. |
13
+ | `planet` | PlaNet RSSM | Recurrent state-space baseline. Tests whether generic recurrent memory can absorb momentum and drift. |
14
+ | `tdmpc2` | TD-MPC2 Dynamics | Compact latent-dynamics baseline. Tests action-conditioned latent rollout with a task-oriented architecture. |
15
+
16
+ Comparison outputs:
17
+
18
+ ```text
19
+ rollout prediction error
20
+ heading prediction error
21
+ context ablation for FlowMo
22
+ planning metrics when the learned WM is used inside the shared planner
23
+ ```
24
+
25
+ ## B. Traditional Non-WM Controllers
26
+
27
+ Purpose: compare downstream behavior against hand-designed controllers that do not train a neural world model.
28
+
29
+ | Directory | Report Name | Why It Is Included |
30
+ |---|---|---|
31
+ | `pid_los_controller` | PID/LOS controller | Simple classical waypoint tracking baseline. |
32
+ | `physics_mpc_no_flow` | Physics MPC No-Flow | Nominal dynamics controller that ignores ambient current. |
33
+ | `current_estimator_mpc` | Current-Estimator MPC | Strong classical baseline that estimates current from recent drift. |
34
+ | `oracle_flow_mpc` | Oracle-Flow MPC | Reference bound using true local flow from the simulator. |
35
+
36
+ Comparison outputs:
37
+
38
+ ```text
39
+ success rate
40
+ final distance
41
+ trajectory length over successful episodes
42
+ energy / thrust work over successful episodes
43
+ time to goal over successful episodes
44
+ ```
experiments/EXPERIMENT_MATRIX.md ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FlowMo Paper Experiment Matrix
2
+
3
+ This document defines the paper-facing experiments. File and run names avoid version suffixes; regenerated artifacts replace the same public paths.
4
+
5
+ ## Shared Data And Observation Protocol
6
+
7
+ All learned world models use the same simulator data and clean-image observation pipeline.
8
+
9
+ ```text
10
+ Image input: clean top-down RGB boat image
11
+ Image size: 160 x 160
12
+ Visual scale: 2.5
13
+ Forbidden image cues: flow arrows, velocity vectors, trajectory overlays, goal marker
14
+ Train split: data/paper/train.npz
15
+ Primary unseen-flow split: data/paper/test_unseen_flow.npz
16
+ Primary unseen-boat-dynamics split: data/paper/test_unseen_boat_params.npz
17
+ Diagnostic seen-flow-family split: data/paper/diagnostic_seen_flow.npz
18
+ Config: experiments/shared/config/paper_image.json
19
+ Checkpoint: paper.pt
20
+ Intermediate checkpoints: paper_step_XXXXXX.pt
21
+ ```
22
+
23
+ Formal training budget:
24
+
25
+ ```text
26
+ train_episodes: 2400
27
+ test_episodes: 480
28
+ train_windows: 393216
29
+ test_windows: 24576
30
+ batch_size: 256
31
+ steps: 20000
32
+ checkpoint_interval: 2000
33
+ num_workers: 4
34
+ render_mode: device
35
+ ```
36
+
37
+ Precision policy:
38
+
39
+ ```text
40
+ training: bf16 model autocast, fp32 losses and metrics
41
+ prediction_eval: bf16 model autocast, fp32 metrics
42
+ planning_eval: fp32
43
+ ```
44
+
45
+ ## A. Learned World-Model Comparison
46
+
47
+ Purpose: measure world-model quality directly. The key question is whether FlowMo's short object-motion state plus long ambient-drift context improves rollout prediction under hidden currents and momentum.
48
+
49
+ | Method | Comparison Role | What It Tests |
50
+ |---|---|---|
51
+ | `flowmo` | Proposed WM | Explicit flow-momentum factorization: short state/momentum latent, long drift context, zero-context residual transition. |
52
+ | `leworldmodel` | JEPA-style WM baseline | Whether simple image-latent prediction without explicit history/context can handle boat momentum and flow. |
53
+ | `planet` | RSSM WM baseline | Whether generic recurrent latent memory can represent momentum and drift without a separate context factor. |
54
+ | `tdmpc2` | Compact latent-dynamics WM baseline | Whether a compact action-conditioned latent transition matches FlowMo under equal supervision. |
55
+
56
+ Prediction datasets:
57
+
58
+ ```text
59
+ test_unseen_flow
60
+ test_unseen_boat_params
61
+ diagnostic_seen_flow
62
+ ```
63
+
64
+ Prediction metrics:
65
+
66
+ ```text
67
+ pos@1, pos@5, pos@10, pos@20, pos@40, pos@60
68
+ heading@20, heading@60
69
+ zero-action drift error
70
+ no-flow momentum decay error
71
+ same-action different-flow error
72
+ ```
73
+
74
+ FlowMo context diagnostics:
75
+
76
+ | Diagnostic | Operation | Evidence Sought |
77
+ |---|---|---|
78
+ | Inferred context | Normal rollout with inferred `c_t` | Best prediction under flow. |
79
+ | Zero context | Set `c_t=0` | Degraded flow prediction, smaller change in no-flow. |
80
+ | Shuffled context | Use context from another episode | Worse rollout when hidden flow differs. |
81
+ | Same-flow transfer | Use context from another episode with the same hidden flow | Better than wrong-flow context transfer. |
82
+ | No-flow context norm | Measure `||c_t||` on no-flow data | Smaller than flow context norm. |
83
+ | Context PCA | Plot `c_t` by flow family / flow id | Flow-related organization. |
84
+
85
+ FlowMo latent probes:
86
+
87
+ | Probe Target | Feature Sets | Purpose |
88
+ |---|---|---|
89
+ | Object momentum `(vx, vy, omega)` | `z_t`, `c_t`, `[z_t,c_t]` | Tests whether short-history state contains object motion. |
90
+ | Local flow vector | `z_t`, `c_t`, `[z_t,c_t]` | Tests whether state plus context exposes local ambient drift. |
91
+ | Episode drift vector | `z_t`, `c_t`, `[z_t,c_t]` | Tests whether long context contains environment-level drift. |
92
+
93
+ ## B. Traditional Non-WM Control Comparison
94
+
95
+ Purpose: provide downstream control references and report practical task behavior. The central WM claim still comes from A; B shows whether prediction differences matter for planning and control.
96
+
97
+ Learned WM planners:
98
+
99
+ ```text
100
+ flowmo
101
+ leworldmodel
102
+ planet
103
+ tdmpc2
104
+ ```
105
+
106
+ Traditional non-WM controllers:
107
+
108
+ | Method | Comparison Role | What It Tests |
109
+ |---|---|---|
110
+ | `pid_los_controller` | Simple classical controller | Baseline waypoint tracking without learned dynamics. |
111
+ | `physics_mpc_no_flow` | Nominal physics MPC | Effect of ignoring hidden current. |
112
+ | `current_estimator_mpc` | Current-compensated classical MPC | Strength of a hand-designed drift estimator. |
113
+ | `oracle_flow_mpc` | Oracle reference | Reference performance when true local flow is available. |
114
+
115
+ Planning tasks:
116
+
117
+ ```text
118
+ reach_uniform
119
+ counterflow
120
+ station_keeping
121
+ passive_to_active
122
+ waypoint_square
123
+ waypoint_zigzag
124
+ ```
125
+
126
+ Boats:
127
+
128
+ ```text
129
+ twin
130
+ triangle
131
+ ```
132
+
133
+ Planning metrics:
134
+
135
+ ```text
136
+ success rate
137
+ final distance
138
+ trajectory length over successful episodes
139
+ energy / thrust work over successful episodes
140
+ time to goal over successful episodes
141
+ ```
142
+
143
+ Formal commands:
144
+
145
+ ```bash
146
+ python -m experiments.run_paper_image_pipeline --stages train
147
+ python -m experiments.run_paper_image_pipeline --stages prediction
148
+ python -m experiments.run_paper_image_pipeline --stages probe
149
+ python -m experiments.run_paper_image_pipeline --stages planning
150
+ python -m experiments.run_paper_image_pipeline --stages report
151
+ ```
experiments/METHOD_AUDIT.md ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FlowMo Method And Baseline Audit
2
+
3
+ This document records what each paper-facing method implements and what comparison it supports.
4
+
5
+ ## A. Learned World Models
6
+
7
+ | Method | Implemented Architecture | Rollout State | Transition Form | Comparison Purpose |
8
+ |---|---|---|---|---|
9
+ | FlowMo | Shared image encoder; short state GRU; long strided context GRU; base transition plus zero-context residual | `z_t` plus `c_t` | `z + F0(z,a) + R(z,c) - R(z,0)` | Tests whether separating short object motion from long ambient drift improves prediction and planning. |
10
+ | LeWorldModel | Image encoder; current-frame latent state; action-conditioned residual transition | `z_t` | `z + F(z,a)` | Tests a simple JEPA-style image-latent predictor without temporal drift context. |
11
+ | PlaNet RSSM | Image encoder; deterministic recurrent state; stochastic latent posterior/prior; decoder from recurrent plus stochastic state | deterministic recurrent state plus stochastic latent | recurrent transition plus prior/posterior latent | Tests generic recurrent state-space memory under the shared rollout protocol. |
12
+ | TD-MPC2 Dynamics | Image encoder; short action-conditioned history encoder; residual latent dynamics | `z_t` | `z + F(z,a)` after history encoding | Tests compact action-conditioned latent dynamics without explicit drift context. |
13
+
14
+ ## B. Traditional Non-WM Controllers
15
+
16
+ | Method | Input | Controller Type | Comparison Purpose |
17
+ |---|---|---|---|
18
+ | PID/LOS controller | Clean image pose estimate | Line-of-sight waypoint tracking | Simple hand-designed tracking baseline. |
19
+ | Physics MPC No-Flow | Clean image pose estimate | MPC with nominal boat dynamics | Measures the cost of ignoring external current. |
20
+ | Current-Estimator MPC | Clean image pose estimate and recent drift | MPC with estimated current | Strong classical current-compensation baseline. |
21
+ | Oracle-Flow MPC | Clean image pose estimate and simulator local flow | MPC with true local current | Reference bound for downstream planning. |
22
+
23
+ ## Architecture Difference Checklist
24
+
25
+ | Method | Uses History | Has Long Context | Has Explicit Drift Context | Distinct From FlowMo |
26
+ |---|---|---|---|---|
27
+ | FlowMo | Yes, short state history | Yes, strided long context | Yes, `c_t` | Proposed factorization. |
28
+ | LeWorldModel | No, current image only | No | No | Tests no-history latent prediction. |
29
+ | PlaNet RSSM | Yes | No | No | Tests generic recurrent memory. |
30
+ | TD-MPC2 Dynamics | Yes, short history | No | No | Tests compact latent dynamics. |
experiments/README.md ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Experiments
2
+
3
+ This directory contains the paper-facing experiment code, checkpoints, results, figures, GIFs, tables, and reports.
4
+
5
+ This directory contains two formal experiment categories:
6
+
7
+ - **A. Learned world models**: trainable image-input WMs evaluated on rollout prediction and WM-based planning.
8
+ - **B. Traditional non-WM controllers**: hand-designed control baselines evaluated on the same downstream tasks.
9
+
10
+ Main method:
11
+
12
+ - **FlowMo**: Flow-Momentum World Model, the proposed drift-aware world model for surface vehicles.
13
+
14
+ Category A learned WM comparisons:
15
+
16
+ - **LeWorldModel**: JEPA-style latent predictor under the shared clean-image protocol.
17
+ - **PlaNet RSSM**: recurrent state-space world-model baseline under the shared clean-image protocol.
18
+ - **TD-MPC2 Dynamics**: task-oriented latent dynamics baseline under the shared clean-image protocol.
19
+
20
+ Purpose of Category A: compare world-model architectures under identical image data, optimizer budget, parameter budget, rollout target, and evaluation protocol.
21
+
22
+ Category B traditional controllers:
23
+
24
+ - **PID/LOS controller**
25
+ - **Physics MPC No-Flow**
26
+ - **Current-Estimator MPC**
27
+ - **Oracle-Flow MPC**
28
+
29
+ Purpose of Category B: compare downstream task behavior against non-neural controllers that do not train a world model.
30
+
31
+ Baseline details are documented in `BASELINES.md`; the full experiment protocol is documented in `docs/EXPERIMENT_PROTOCOL.md`.
32
+
33
+ Design principles:
34
+
35
+ - Shared simulator, datasets, planning utilities, metrics, and visualization live in `shared/`.
36
+ - Each method has its own directory with `src/`, `checkpoint/`, and `result/`.
37
+ - Paper artifacts are collected in top-level `figures/`, `gifs/`, `tables/`, and `reports/`.
38
+ - Method names should be explicit and readable. Avoid cryptic suffixes in paper-facing file names.
39
+
40
+ Standard method interface:
41
+
42
+ ```text
43
+ src/model.py # build_model(), load_model()
44
+ src/train.py # train(config)
45
+ src/predict.py # rollout(model, batch)
46
+ src/plan.py # plan(model, env, task)
47
+ src/config.py # default_config()
48
+ ```
49
+
50
+ Traditional controllers use:
51
+
52
+ ```text
53
+ src/controller.py or src/mpc.py
54
+ src/evaluate.py
55
+ src/config.py
56
+ ```
57
+
58
+ Formal clean-image configuration:
59
+
60
+ ```text
61
+ image_size=160
62
+ visual_scale=2.5
63
+ train=data/paper/train.npz
64
+ test=data/paper/test_unseen_flow.npz and data/paper/test_unseen_boat_params.npz
65
+ ```
66
+
67
+ Full paper-facing image pipeline:
68
+
69
+ ```bash
70
+ python -m experiments.run_paper_image_pipeline
71
+ ```
72
+
73
+ The default command runs the paper configuration end to end: train all learned world models, evaluate long rollout prediction, run FlowMo latent probes, evaluate closed-loop planning against traditional controllers, generate GIFs, and write the final report. Images are rendered online from simulator states, so no separate image-cache preparation step is required.
74
+
75
+ Manual image training:
76
+
77
+ ```bash
78
+ python -m experiments.train_image_world_models
79
+ python -m experiments.evaluate_image_world_models
80
+ python -m experiments.evaluate_flowmo_latent_probes
81
+ python -m experiments.evaluate_image_planning --task reach_uniform --boat twin
82
+ python -m experiments.summarize_paper_image_results
83
+ ```
experiments/TASK_PLAN.md ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Paper Task Plan
2
+
3
+ This is the execution plan for the public FlowMo experiments. The plan has two parts: A evaluates world models directly, and B evaluates downstream control behavior with traditional non-WM references.
4
+
5
+ ## A. Learned World Models
6
+
7
+ Purpose: test whether the FlowMo world-model architecture improves image-based prediction under hidden flow, boat momentum, actuator delay, and drag.
8
+
9
+ Shared setup:
10
+
11
+ ```text
12
+ Input: clean top-down boat images plus action history
13
+ No image cues: no flow arrows, no velocity vector, no goal marker
14
+ Training data: data/paper/train.npz
15
+ Primary evaluation data: data/paper/test_unseen_flow.npz, data/paper/test_unseen_boat_params.npz
16
+ Diagnostic data: data/paper/diagnostic_seen_flow.npz
17
+ Training budget: shared optimizer, batch size, rollout horizon, step count, and checkpoint schedule
18
+ Training precision: BF16 model autocast, FP32 losses and metrics
19
+ Prediction precision: BF16 model autocast, FP32 metrics
20
+ ```
21
+
22
+ Compared methods:
23
+
24
+ | Method | Purpose |
25
+ |---|---|
26
+ | `flowmo` | Proposed flow-momentum WM. Tests explicit separation of short object-motion state and long ambient-drift context. |
27
+ | `leworldmodel` | JEPA-style latent predictor. Tests whether a simple current-image latent transition is sufficient. |
28
+ | `planet` | RSSM recurrent state-space WM. Tests whether generic recurrent latent memory can absorb momentum and flow effects without FlowMo's explicit context. |
29
+ | `tdmpc2` | Compact latent-dynamics WM. Tests whether a task-oriented latent transition architecture matches FlowMo under the same rollout supervision. |
30
+
31
+ Primary A metrics:
32
+
33
+ ```text
34
+ pos@1, pos@5, pos@10, pos@20, pos@40, pos@60
35
+ heading@20, heading@60
36
+ zero-action drift prediction error
37
+ no-flow momentum decay prediction error
38
+ same-action different-flow prediction error
39
+ FlowMo inferred-context vs c=0 vs shuffled-context error
40
+ ```
41
+
42
+ Required A outputs:
43
+
44
+ ```text
45
+ experiments/<method>/checkpoint/paper.pt
46
+ experiments/<method>/checkpoint/paper_step_*.pt
47
+ experiments/<method>/result/parameter_count.json
48
+ experiments/<method>/result/paper_training.json
49
+ experiments/reports/paper_prediction_seen_flow_diagnostic.json
50
+ experiments/reports/paper_prediction_unseen_flow.json
51
+ experiments/reports/paper_prediction_unseen_boat_params.json
52
+ experiments/reports/paper_flowmo_latent_probes.json
53
+ ```
54
+
55
+ Core A conclusions:
56
+
57
+ ```text
58
+ 1. Whether FlowMo has lower long-horizon rollout error.
59
+ 2. Whether the gain is strongest under unseen flow families and unseen boat dynamics.
60
+ 3. Whether explicit drift context helps beyond ordinary recurrent history.
61
+ 4. Whether the same architecture works for both twin and triangle boats.
62
+ 5. Whether frozen linear probes recover object momentum from `z_t` and ambient drift from `c_t`.
63
+ ```
64
+
65
+ ## B. Traditional Non-WM Controllers
66
+
67
+ Purpose: evaluate downstream control behavior and provide non-neural-control reference points. These methods do not train a world model.
68
+
69
+ Shared setup:
70
+
71
+ ```text
72
+ Input: clean top-down images converted to pose for classical control
73
+ Tasks: same simulator, same boats, same goals, same flow settings
74
+ Metrics: success, final distance, successful-episode trajectory length, successful-episode thrust energy, successful-episode time-to-goal
75
+ Planning precision: FP32
76
+ ```
77
+
78
+ Compared methods:
79
+
80
+ | Method | Purpose |
81
+ |---|---|
82
+ | `pid_los_controller` | Classical line-of-sight waypoint tracking. Tests a simple hand-designed controller. |
83
+ | `physics_mpc_no_flow` | Physics MPC without external-current compensation. Tests how much hidden flow hurts a nominal dynamics controller. |
84
+ | `current_estimator_mpc` | MPC with recent-drift current estimation. Tests a strong classical current-compensation baseline. |
85
+ | `oracle_flow_mpc` | MPC with simulator true local flow. Provides a reference upper bound for classical planning. |
86
+
87
+ Planning tasks:
88
+
89
+ ```text
90
+ reach_uniform
91
+ counterflow
92
+ station_keeping
93
+ passive_to_active
94
+ waypoint_square
95
+ waypoint_zigzag
96
+ ```
97
+
98
+ Boats:
99
+
100
+ ```text
101
+ twin
102
+ triangle
103
+ ```
104
+
105
+ Required B outputs:
106
+
107
+ ```text
108
+ experiments/reports/paper_planning/*.json
109
+ experiments/reports/paper_planning/gifs/*.gif
110
+ ```
111
+
112
+ Core B conclusions:
113
+
114
+ ```text
115
+ 1. Whether WM-based planning is competitive with classical non-WM control.
116
+ 2. Whether FlowMo improves success, final distance, energy, and path length versus other learned WMs.
117
+ 3. How far FlowMo remains from the oracle-flow classical reference.
118
+ ```
experiments/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Paper-facing experiment package."""
experiments/current_estimator_mpc/README.md ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # Current-Estimator MPC Baseline
2
+
3
+ Traditional MPC baseline with an online estimator for approximately uniform current drift.
experiments/current_estimator_mpc/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Current-estimator MPC experiment package."""
experiments/current_estimator_mpc/checkpoint/.gitkeep ADDED
File without changes
experiments/current_estimator_mpc/result/.gitkeep ADDED
File without changes
experiments/current_estimator_mpc/src/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Current-estimator MPC source package."""
experiments/current_estimator_mpc/src/config.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Current-estimator MPC config."""
2
+
3
+
4
+ def default_config():
5
+ return {"goal": [8.0, 8.0], "gain": 0.65, "current_gain": 0.5}
experiments/current_estimator_mpc/src/estimator.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """Online current estimator."""
2
+
3
+ import numpy as np
4
+
5
+ from experiments.shared.src.vision.pose_from_image import estimate_pose_from_clean_image
6
+
7
+ def estimate_current(history, config):
8
+ poses = np.stack([estimate_pose_from_clean_image(img) for img in history], axis=0)
9
+ return poses[-1, :2] - poses[0, :2]
experiments/current_estimator_mpc/src/evaluate.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """Current-estimator MPC evaluation."""
2
+
3
+ from experiments.current_estimator_mpc.src.config import default_config
4
+ from experiments.current_estimator_mpc.src.estimator import estimate_current
5
+ from experiments.current_estimator_mpc.src.mpc import plan
6
+
7
+ def evaluate(config):
8
+ cfg = default_config() | config
9
+ current = estimate_current(config["history"], cfg)
10
+ return plan(config["history"][-1], current, {"goal": cfg["goal"]}, cfg)
experiments/current_estimator_mpc/src/mpc.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MPC with online current estimate."""
2
+
3
+ import numpy as np
4
+
5
+ from experiments.shared.src.control.geometric import goal_action
6
+ from experiments.shared.src.vision.pose_from_image import estimate_pose_from_clean_image
7
+
8
+ def plan(state, current_estimate, task, config):
9
+ pose = estimate_pose_from_clean_image(state)
10
+ goal = np.asarray(task["goal"], dtype=np.float32)
11
+ return goal_action(
12
+ pose,
13
+ goal,
14
+ int(config["action_dim"]),
15
+ float(config["gain"]),
16
+ 0.6,
17
+ drift=np.asarray(current_estimate, dtype=np.float32),
18
+ drift_gain=float(config["current_gain"]),
19
+ )
experiments/docs/EXPERIMENT_PROTOCOL.md ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FlowMo Experiment Protocol
2
+
3
+ This document is the single paper-facing record for the public FlowMo experiments. Regenerated artifacts should replace the same paths instead of introducing version suffixes.
4
+
5
+ ## Scope
6
+
7
+ The project has two formal comparison groups.
8
+
9
+ ### A. Learned World Models
10
+
11
+ Purpose: compare image-input world-model architectures under the same simulator data, optimizer budget, rollout target, parameter scale, and planning interface.
12
+
13
+ | Directory | Report Name | Architecture | Comparison Purpose |
14
+ |---|---|---|---|
15
+ | `flowmo` | FlowMo | Shared image encoder; short object-motion state encoder; long strided ambient-drift context encoder; base transition plus zero-context residual | Proposed flow-momentum factorization. Tests whether separating endogenous motion from exogenous drift improves prediction and planning. |
16
+ | `leworldmodel` | LeWorldModel | JEPA-style image-latent predictor with action-conditioned residual transition | Tests whether simple current-image latent prediction is sufficient. |
17
+ | `planet` | RSSM | Recurrent state-space latent model with deterministic memory and stochastic latent state | Tests whether generic recurrent memory can absorb momentum and drift without a separate context factor. |
18
+ | `tdmpc2` | TD-MPC2 Dynamics | Compact action-conditioned latent dynamics with shared image encoder and rollout heads | Tests task-oriented latent dynamics under equal supervision. |
19
+
20
+ All learned methods receive clean top-down RGB boat images and action history. They do not receive flow labels, flow arrows, velocity vectors, trajectory overlays, or goal markers in the image.
21
+
22
+ ### B. Traditional Non-WM Controllers
23
+
24
+ Purpose: compare downstream behavior against non-neural controllers that do not train a world model.
25
+
26
+ | Directory | Report Name | Input | Comparison Purpose |
27
+ |---|---|---|---|
28
+ | `pid_los_controller` | PID/LOS | Clean image pose estimate | Simple hand-designed waypoint tracking baseline. |
29
+ | `physics_mpc_no_flow` | Physics MPC No-Flow | Clean image pose estimate | Measures the cost of ignoring ambient current. |
30
+ | `current_estimator_mpc` | Current-Estimator MPC | Clean image pose estimate and recent drift | Strong classical current-compensation baseline. |
31
+ | `oracle_flow_mpc` | Oracle-Flow MPC | Clean image pose estimate and simulator local flow | Reference bound for control when true local flow is available. |
32
+
33
+ ## Data
34
+
35
+ All methods use the same splits:
36
+
37
+ ```text
38
+ train: data/paper/train.npz
39
+ unseen_flow_test: data/paper/test_unseen_flow.npz
40
+ unseen_boat_dynamics_test: data/paper/test_unseen_boat_params.npz
41
+ seen_flow_diagnostic: data/paper/diagnostic_seen_flow.npz
42
+ dataset_card: data/paper/dataset_card.md
43
+ generation_config: data/paper/generation_config.json
44
+ ```
45
+
46
+ Observation protocol:
47
+
48
+ ```text
49
+ image_size: 160 x 160
50
+ visual_scale: 2.5
51
+ rendering: online clean top-down RGB images
52
+ forbidden cues: flow arrows, velocity vectors, trajectory overlays, goal markers
53
+ ```
54
+
55
+ Training budget:
56
+
57
+ ```text
58
+ train_episodes: 2400
59
+ test_episodes: 480
60
+ train_windows: 393216
61
+ test_windows: 24576
62
+ batch_size: 256
63
+ steps: 20000
64
+ checkpoint_interval: 2000
65
+ num_workers: 4
66
+ render_mode: device
67
+ ```
68
+
69
+ Precision policy:
70
+
71
+ ```text
72
+ training: bf16 model autocast, fp32 losses and metrics
73
+ prediction_eval: bf16 model autocast, fp32 metrics
74
+ planning_eval: fp32
75
+ ```
76
+
77
+ The precision split is intentional: BF16 speeds up image encoding and latent rollout on the RTX 5090 without measurable short-run loss drift, while CEM planning is dominated by small control tensors and did not improve under BF16.
78
+
79
+ ## Prediction Evaluation
80
+
81
+ Datasets:
82
+
83
+ ```text
84
+ test_unseen_flow
85
+ test_unseen_boat_params
86
+ diagnostic_seen_flow
87
+ ```
88
+
89
+ Metrics:
90
+
91
+ ```text
92
+ pos@1, pos@5, pos@10, pos@20, pos@40, pos@60
93
+ heading@20, heading@60
94
+ zero-action drift prediction error
95
+ no-flow momentum decay prediction error
96
+ same-action different-flow prediction error
97
+ ```
98
+
99
+ FlowMo-only context diagnostics:
100
+
101
+ | Diagnostic | Operation | Evidence Sought |
102
+ |---|---|---|
103
+ | Inferred context | Normal rollout with inferred `c_t` | Best prediction under flow. |
104
+ | Zero context | Set `c_t=0` | Degraded flow prediction and limited change in no-flow. |
105
+ | Shuffled context | Use context from another episode | Worse rollout when hidden flow differs. |
106
+ | Same-flow transfer | Use context from another episode with the same hidden flow | Better transfer than wrong-flow context. |
107
+ | Context norm | Compare no-flow and flow `||c_t||` | Flow context should be larger than no-flow context. |
108
+
109
+ FlowMo latent probes:
110
+
111
+ ```text
112
+ Train frozen linear probes from z_t, c_t, and [z_t,c_t].
113
+ Targets: object momentum (vx, vy, omega), local flow vector, episode drift vector.
114
+ Purpose: verify which latent carries object-motion information and which latent carries ambient-drift information.
115
+ ```
116
+
117
+ ## Planning Evaluation
118
+
119
+ Learned WM planners:
120
+
121
+ ```text
122
+ flowmo
123
+ leworldmodel
124
+ planet
125
+ tdmpc2
126
+ ```
127
+
128
+ Traditional non-WM controllers:
129
+
130
+ ```text
131
+ pid_los_controller
132
+ physics_mpc_no_flow
133
+ current_estimator_mpc
134
+ oracle_flow_mpc
135
+ ```
136
+
137
+ Tasks:
138
+
139
+ ```text
140
+ reach_uniform
141
+ counterflow
142
+ station_keeping
143
+ passive_to_active
144
+ waypoint_square
145
+ waypoint_zigzag
146
+ ```
147
+
148
+ Boats:
149
+
150
+ ```text
151
+ twin
152
+ triangle
153
+ ```
154
+
155
+ Metrics:
156
+
157
+ ```text
158
+ success rate
159
+ final distance
160
+ trajectory length over successful episodes
161
+ energy / thrust work over successful episodes
162
+ time to goal over successful episodes
163
+ ```
164
+
165
+ ## Required Outputs
166
+
167
+ Training outputs:
168
+
169
+ ```text
170
+ experiments/<method>/checkpoint/paper.pt
171
+ experiments/<method>/checkpoint/paper_step_*.pt
172
+ experiments/<method>/result/parameter_count.json
173
+ experiments/<method>/result/paper_training.json
174
+ experiments/<method>/result/paper_training_trace.jsonl
175
+ ```
176
+
177
+ Evaluation outputs:
178
+
179
+ ```text
180
+ experiments/reports/paper_prediction_unseen_flow.json
181
+ experiments/reports/paper_prediction_unseen_boat_params.json
182
+ experiments/reports/paper_prediction_seen_flow_diagnostic.json
183
+ experiments/reports/paper_flowmo_latent_probes.json
184
+ experiments/reports/paper_planning/*.json
185
+ experiments/reports/paper_planning/gifs/*.gif
186
+ experiments/reports/paper_report.md
187
+ ```
188
+
189
+ ## Commands
190
+
191
+ Run the complete paper pipeline:
192
+
193
+ ```bash
194
+ python -m experiments.run_paper_image_pipeline
195
+ ```
196
+
197
+ Run stages separately:
198
+
199
+ ```bash
200
+ python -m experiments.run_paper_image_pipeline --stages train
201
+ python -m experiments.run_paper_image_pipeline --stages prediction
202
+ python -m experiments.run_paper_image_pipeline --stages probe
203
+ python -m experiments.run_paper_image_pipeline --stages planning
204
+ python -m experiments.run_paper_image_pipeline --stages report
205
+ ```
206
+
207
+ Run smoke tests:
208
+
209
+ ```bash
210
+ python -m pytest -q
211
+ ```
experiments/evaluate_flowmo_latent_probes.py ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Linear probes for FlowMo latent state and drift context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib
7
+ import json
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+ import torch
13
+ from torch.utils.data import DataLoader, Dataset
14
+
15
+ from experiments.shared.src.vision.clean_renderer import render_clean_boat_history_tensor
16
+ from experiments.train_image_world_models import autocast_context, configure_training_runtime, selected_history_indices
17
+
18
+
19
+ @dataclass
20
+ class ProbeSet:
21
+ z: np.ndarray
22
+ c: np.ndarray
23
+ momentum: np.ndarray
24
+ local_flow: np.ndarray
25
+ episode_drift: np.ndarray
26
+
27
+
28
+ class FlowMoProbeDataset(Dataset):
29
+ def __init__(
30
+ self,
31
+ source_npz: str,
32
+ history_len: int,
33
+ episodes: int,
34
+ max_windows: int,
35
+ seed: int,
36
+ ):
37
+ src = np.load(source_npz, allow_pickle=False)
38
+ self.states = src["states"][:episodes].astype(np.float32)
39
+ self.actions = src["actions"][:episodes].astype(np.float32)
40
+ self.true_flow = src["true_flow"][:episodes].astype(np.float32)
41
+ self.boat_ids = src["boat_ids"][:episodes].astype(np.int64)
42
+ self.history_len = int(history_len)
43
+ steps = self.actions.shape[1]
44
+ all_indices = [(ep, t) for ep in range(episodes) for t in range(self.history_len - 1, steps)]
45
+ rng = np.random.default_rng(seed)
46
+ selected = rng.choice(len(all_indices), size=min(max_windows, len(all_indices)), replace=False)
47
+ self.indices = [all_indices[int(i)] for i in selected]
48
+ self.episode_drift = self.true_flow.mean(axis=1).astype(np.float32)
49
+
50
+ def __len__(self) -> int:
51
+ return len(self.indices)
52
+
53
+ def __getitem__(self, index: int):
54
+ ep, t = self.indices[index]
55
+ state_hist = self.states[ep, t - self.history_len + 1 : t + 1, :6].copy()
56
+ padded_actions = np.zeros((self.actions.shape[1] + 1, self.actions.shape[2]), dtype=np.float32)
57
+ padded_actions[1:] = self.actions[ep]
58
+ action_hist = padded_actions[t - self.history_len + 1 : t + 1].copy()
59
+ return (
60
+ torch.from_numpy(state_hist),
61
+ torch.from_numpy(action_hist),
62
+ torch.tensor(self.boat_ids[ep], dtype=torch.long),
63
+ torch.from_numpy(self.states[ep, t, 3:6].copy()),
64
+ torch.from_numpy(self.true_flow[ep, t].copy()),
65
+ torch.from_numpy(self.episode_drift[ep].copy()),
66
+ )
67
+
68
+
69
+ def build_flowmo():
70
+ config_module = importlib.import_module("experiments.flowmo.src.config")
71
+ model_module = importlib.import_module("experiments.flowmo.src.model")
72
+ cfg = config_module.default_config()
73
+ return cfg, model_module.build_model(cfg)
74
+
75
+
76
+ def loader_kwargs(num_workers: int) -> dict:
77
+ if num_workers <= 0:
78
+ return {}
79
+ return {"multiprocessing_context": "spawn", "persistent_workers": True, "prefetch_factor": 4}
80
+
81
+
82
+ @torch.no_grad()
83
+ def collect_probe_set(model, source: str, episodes: int, max_windows: int, args, seed: int) -> ProbeSet:
84
+ dataset = FlowMoProbeDataset(source, args.history_len, episodes, max_windows, seed)
85
+ loader = DataLoader(
86
+ dataset,
87
+ batch_size=args.batch_size,
88
+ shuffle=False,
89
+ num_workers=args.num_workers,
90
+ pin_memory=torch.device(args.device).type == "cuda",
91
+ **loader_kwargs(args.num_workers),
92
+ )
93
+ device = torch.device(args.device)
94
+ history_indices = selected_history_indices(model, args.history_len)
95
+ zs, cs, momentum, local_flow, episode_drift = [], [], [], [], []
96
+ model.eval()
97
+ for states, actions, boat_ids, batch_momentum, batch_flow, batch_drift in loader:
98
+ states = states[:, history_indices].to(device, non_blocking=True)
99
+ actions = actions[:, history_indices].to(device, non_blocking=True)
100
+ boat_ids = boat_ids.to(device, non_blocking=True)
101
+ images = render_clean_boat_history_tensor(
102
+ states,
103
+ boat_ids,
104
+ image_size=args.image_size,
105
+ visual_scale=args.visual_scale,
106
+ )
107
+ with autocast_context(device, args.precision):
108
+ z, c = model.encode(images, actions)
109
+ zs.append(z.float().cpu().numpy())
110
+ cs.append(c.float().cpu().numpy())
111
+ momentum.append(batch_momentum.numpy())
112
+ local_flow.append(batch_flow.numpy())
113
+ episode_drift.append(batch_drift.numpy())
114
+ return ProbeSet(
115
+ z=np.concatenate(zs, axis=0),
116
+ c=np.concatenate(cs, axis=0),
117
+ momentum=np.concatenate(momentum, axis=0),
118
+ local_flow=np.concatenate(local_flow, axis=0),
119
+ episode_drift=np.concatenate(episode_drift, axis=0),
120
+ )
121
+
122
+
123
+ def fit_ridge(features: np.ndarray, targets: np.ndarray, alpha: float) -> dict[str, np.ndarray]:
124
+ x_mean = features.mean(axis=0, keepdims=True)
125
+ x_std = features.std(axis=0, keepdims=True) + 1.0e-6
126
+ x = (features - x_mean) / x_std
127
+ x = np.concatenate([x, np.ones((x.shape[0], 1), dtype=x.dtype)], axis=1)
128
+ penalty = np.eye(x.shape[1], dtype=np.float64) * float(alpha)
129
+ penalty[-1, -1] = 0.0
130
+ weights = np.linalg.solve(x.T @ x + penalty, x.T @ targets)
131
+ return {"x_mean": x_mean, "x_std": x_std, "weights": weights}
132
+
133
+
134
+ def predict_ridge(model: dict[str, np.ndarray], features: np.ndarray) -> np.ndarray:
135
+ x = (features - model["x_mean"]) / model["x_std"]
136
+ x = np.concatenate([x, np.ones((x.shape[0], 1), dtype=x.dtype)], axis=1)
137
+ return x @ model["weights"]
138
+
139
+
140
+ def regression_metrics(prediction: np.ndarray, target: np.ndarray, dims: list[str]) -> dict:
141
+ error = prediction - target
142
+ mse_per_dim = np.mean(error * error, axis=0)
143
+ rmse_per_dim = np.sqrt(mse_per_dim)
144
+ baseline_mse = np.mean((target - target.mean(axis=0, keepdims=True)) ** 2, axis=0) + 1.0e-12
145
+ r2_per_dim = 1.0 - mse_per_dim / baseline_mse
146
+ return {
147
+ "rmse": float(np.sqrt(np.mean(error * error))),
148
+ "r2_mean": float(np.mean(r2_per_dim)),
149
+ "rmse_by_dim": {dim: float(value) for dim, value in zip(dims, rmse_per_dim)},
150
+ "r2_by_dim": {dim: float(value) for dim, value in zip(dims, r2_per_dim)},
151
+ }
152
+
153
+
154
+ def feature_matrix(probe_set: ProbeSet, feature_name: str) -> np.ndarray:
155
+ if feature_name == "z":
156
+ return probe_set.z
157
+ if feature_name == "c":
158
+ return probe_set.c
159
+ if feature_name == "z_c":
160
+ return np.concatenate([probe_set.z, probe_set.c], axis=1)
161
+ raise ValueError(f"unknown feature set: {feature_name}")
162
+
163
+
164
+ def target_matrix(probe_set: ProbeSet, target_name: str) -> tuple[np.ndarray, list[str]]:
165
+ if target_name == "momentum":
166
+ return probe_set.momentum, ["vx", "vy", "omega"]
167
+ if target_name == "local_flow":
168
+ return probe_set.local_flow, ["flow_x", "flow_y"]
169
+ if target_name == "episode_drift":
170
+ return probe_set.episode_drift, ["drift_x", "drift_y"]
171
+ raise ValueError(f"unknown target: {target_name}")
172
+
173
+
174
+ def train_probe_bank(train_set: ProbeSet, alpha: float) -> dict:
175
+ bank = {}
176
+ for target_name in ["momentum", "local_flow", "episode_drift"]:
177
+ targets, _dims = target_matrix(train_set, target_name)
178
+ bank[target_name] = {}
179
+ for feature_name in ["z", "c", "z_c"]:
180
+ bank[target_name][feature_name] = fit_ridge(feature_matrix(train_set, feature_name), targets, alpha)
181
+ return bank
182
+
183
+
184
+ def evaluate_probe_bank(bank: dict, probe_set: ProbeSet) -> dict:
185
+ payload = {}
186
+ for target_name in ["momentum", "local_flow", "episode_drift"]:
187
+ targets, dims = target_matrix(probe_set, target_name)
188
+ payload[target_name] = {}
189
+ for feature_name in ["z", "c", "z_c"]:
190
+ prediction = predict_ridge(bank[target_name][feature_name], feature_matrix(probe_set, feature_name))
191
+ payload[target_name][feature_name] = regression_metrics(prediction, targets, dims)
192
+ return payload
193
+
194
+
195
+ def parse_eval_split(text: str) -> tuple[str, str, int]:
196
+ name, source, episodes = text.split(":", maxsplit=2)
197
+ return name, source, int(episodes)
198
+
199
+
200
+ def main() -> None:
201
+ parser = argparse.ArgumentParser()
202
+ parser.add_argument("--train-source", default="data/paper/train.npz")
203
+ parser.add_argument("--train-episodes", type=int, default=2400)
204
+ parser.add_argument("--train-windows", type=int, default=32768)
205
+ parser.add_argument("--eval-splits", nargs="+", default=[
206
+ "unseen_flow:data/paper/test_unseen_flow.npz:480",
207
+ "unseen_boat_params:data/paper/test_unseen_boat_params.npz:480",
208
+ "seen_flow_diagnostic:data/paper/diagnostic_seen_flow.npz:480",
209
+ ])
210
+ parser.add_argument("--eval-windows", type=int, default=8192)
211
+ parser.add_argument("--history-len", type=int, default=32)
212
+ parser.add_argument("--batch-size", type=int, default=256)
213
+ parser.add_argument("--ridge-alpha", type=float, default=1.0e-3)
214
+ parser.add_argument("--checkpoint-name", default="paper.pt")
215
+ parser.add_argument("--image-size", type=int, default=160)
216
+ parser.add_argument("--visual-scale", type=float, default=2.5)
217
+ parser.add_argument("--num-workers", type=int, default=4)
218
+ parser.add_argument("--seed", type=int, default=909)
219
+ parser.add_argument("--device", default="cuda")
220
+ parser.add_argument("--precision", choices=["fp32", "bf16", "fp16"], default="bf16")
221
+ parser.add_argument("--out", default="experiments/reports/paper_flowmo_latent_probes.json")
222
+ args = parser.parse_args()
223
+
224
+ device = torch.device(args.device)
225
+ configure_training_runtime(device)
226
+ _cfg, model = build_flowmo()
227
+ state = torch.load(Path("experiments/flowmo/checkpoint") / args.checkpoint_name, map_location="cpu")
228
+ model.load_state_dict(state)
229
+ model.to(device)
230
+ if device.type == "cuda":
231
+ model.to(memory_format=torch.channels_last)
232
+
233
+ train_set = collect_probe_set(model, args.train_source, args.train_episodes, args.train_windows, args, args.seed)
234
+ bank = train_probe_bank(train_set, args.ridge_alpha)
235
+ split_results = {}
236
+ for index, split in enumerate(args.eval_splits):
237
+ name, source, episodes = parse_eval_split(split)
238
+ eval_set = collect_probe_set(model, source, episodes, args.eval_windows, args, args.seed + 100 + index)
239
+ split_results[name] = evaluate_probe_bank(bank, eval_set)
240
+ payload = {
241
+ "method": "flowmo",
242
+ "checkpoint": args.checkpoint_name,
243
+ "train_source": args.train_source,
244
+ "train_windows": args.train_windows,
245
+ "eval_windows": args.eval_windows,
246
+ "ridge_alpha": args.ridge_alpha,
247
+ "feature_sets": {
248
+ "z": "short-history object-motion latent",
249
+ "c": "long-history ambient-drift context",
250
+ "z_c": "concatenated state and context",
251
+ },
252
+ "targets": {
253
+ "momentum": ["vx", "vy", "omega"],
254
+ "local_flow": ["flow_x", "flow_y"],
255
+ "episode_drift": ["mean_flow_x", "mean_flow_y"],
256
+ },
257
+ "splits": split_results,
258
+ }
259
+ out = Path(args.out)
260
+ out.parent.mkdir(parents=True, exist_ok=True)
261
+ out.write_text(json.dumps(payload, indent=2))
262
+ print(json.dumps(payload, indent=2))
263
+
264
+
265
+ if __name__ == "__main__":
266
+ main()
experiments/evaluate_image_planning.py ADDED
@@ -0,0 +1,577 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Closed-loop planning evaluation for clean-image world models and controllers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib
7
+ import json
8
+ from collections import deque
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+ import torch
13
+ import torch.nn.functional as F
14
+
15
+ from driftwm.sim.env import SurfaceBoatEnv
16
+ from driftwm.sim.flow import UniformFlow, sample_flow
17
+ from driftwm.sim.render import render_frame, save_gif
18
+ from experiments.shared.src.methods import PAPER_LEARNED_METHODS, TRADITIONAL_METHODS
19
+ from experiments.shared.src.vision.clean_renderer import render_clean_boat_array
20
+ from experiments.train_image_world_models import autocast_context
21
+
22
+
23
+ LEARNED_METHODS = PAPER_LEARNED_METHODS
24
+
25
+ POSITION_SCALE = 5.0
26
+
27
+
28
+ def build_method(method: str):
29
+ config_module = importlib.import_module(f"experiments.{method}.src.config")
30
+ model_module = importlib.import_module(f"experiments.{method}.src.model")
31
+ cfg = config_module.default_config()
32
+ return cfg, model_module.build_model(cfg)
33
+
34
+
35
+ def decode_absolute(prediction: torch.Tensor) -> torch.Tensor:
36
+ xy = prediction[..., :2] * POSITION_SCALE + POSITION_SCALE
37
+ return torch.cat([xy, prediction[..., 2:4]], dim=-1)
38
+
39
+
40
+ def clean_observation(env: SurfaceBoatEnv, image_size: int, visual_scale: float) -> np.ndarray:
41
+ image = render_clean_boat_array(env.full_state()[:6], env.spec, image_size=image_size, visual_scale=visual_scale)
42
+ return np.transpose(image, (2, 0, 1))
43
+
44
+
45
+ def pad_action(action: np.ndarray, action_dim: int) -> np.ndarray:
46
+ out = np.zeros((action_dim,), dtype=np.float32)
47
+ action = np.asarray(action, dtype=np.float32)
48
+ out[: min(len(action), action_dim)] = action[: min(len(action), action_dim)]
49
+ return out
50
+
51
+
52
+ def task_goals(task: str, rng: np.random.Generator) -> np.ndarray:
53
+ if task == "waypoint_square":
54
+ return np.array([[2.5, 2.5], [7.5, 2.5], [7.5, 7.5], [2.5, 7.5]], dtype=np.float32)
55
+ if task == "waypoint_zigzag":
56
+ return np.array([[2.5, 7.0], [4.2, 3.0], [5.8, 7.0], [7.5, 3.0]], dtype=np.float32)
57
+ if task == "station_keeping":
58
+ return np.array([[5.0, 5.0]], dtype=np.float32)
59
+ if task == "counterflow":
60
+ return np.array([[8.4, 5.0]], dtype=np.float32)
61
+ return np.array([[8.0, 8.0]], dtype=np.float32)
62
+
63
+
64
+ def reset_task(env: SurfaceBoatEnv, task: str, flow_type: str, rng: np.random.Generator) -> None:
65
+ if task == "counterflow":
66
+ env.reset(
67
+ flow_type="uniform",
68
+ flow=UniformFlow(np.array([-0.22, 0.0], dtype=np.float32), flow_id=7001),
69
+ random_velocity=False,
70
+ )
71
+ env.state[:6] = np.array([2.0, 5.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float32)
72
+ return
73
+ if task == "station_keeping":
74
+ env.reset(
75
+ flow_type="uniform",
76
+ flow=UniformFlow(np.array([0.16, 0.10], dtype=np.float32), flow_id=7002),
77
+ random_velocity=False,
78
+ )
79
+ env.state[:6] = np.array([5.0, 5.0, 0.3, 0.0, 0.0, 0.0], dtype=np.float32)
80
+ return
81
+ flow = sample_flow(flow_type, rng, flow_id=10_000 + int(rng.integers(1, 1_000_000)), workspace=env.workspace)
82
+ env.reset(flow_type=flow_type, flow=flow, random_velocity=False)
83
+ env.state[:6] = np.array([2.0, 2.0, float(rng.uniform(-np.pi, np.pi)), 0.0, 0.0, 0.0], dtype=np.float32)
84
+
85
+
86
+ def rollout_latent(model, z: torch.Tensor, c: torch.Tensor, actions: torch.Tensor) -> torch.Tensor:
87
+ cur = z.repeat(actions.shape[0], 1)
88
+ ctx = c.repeat(actions.shape[0], 1) if c.numel() else c
89
+ preds = []
90
+ for t in range(actions.shape[1]):
91
+ cur = model.step(cur, actions[:, t], ctx)
92
+ preds.append(model.decoder(cur))
93
+ return decode_absolute(torch.stack(preds, dim=1)).float()
94
+
95
+
96
+ def warm_start_mean(
97
+ previous_mean: np.ndarray | None,
98
+ horizon: int,
99
+ action_dim: int,
100
+ active_action_dim: int,
101
+ device: torch.device,
102
+ ) -> torch.Tensor:
103
+ mean = torch.zeros((horizon, action_dim), dtype=torch.float32, device=device)
104
+ if previous_mean is None:
105
+ return mean
106
+ previous = torch.as_tensor(previous_mean, dtype=torch.float32, device=device)
107
+ steps = min(horizon, max(0, previous.shape[0] - 1))
108
+ if steps > 0:
109
+ mean[:steps, :active_action_dim] = previous[1 : 1 + steps, :active_action_dim]
110
+ if previous.shape[0] > 0 and steps < horizon:
111
+ mean[steps:, :active_action_dim] = previous[-1, :active_action_dim]
112
+ return mean.clamp(-1.0, 1.0)
113
+
114
+
115
+ def sample_action_sequences(mean: torch.Tensor, std: torch.Tensor, population: int, knots: int) -> torch.Tensor:
116
+ horizon, action_dim = mean.shape
117
+ if knots >= horizon:
118
+ noise = torch.randn(population, horizon, action_dim, device=mean.device)
119
+ return mean.unsqueeze(0) + std.unsqueeze(0) * noise
120
+ knots = max(2, knots)
121
+ knot_idx = torch.linspace(0, horizon - 1, knots, device=mean.device).round().long()
122
+ knot_mean = mean[knot_idx]
123
+ knot_std = std[knot_idx]
124
+ knot_samples = knot_mean.unsqueeze(0) + knot_std.unsqueeze(0) * torch.randn(
125
+ population,
126
+ knots,
127
+ action_dim,
128
+ device=mean.device,
129
+ )
130
+ samples = F.interpolate(
131
+ knot_samples.permute(0, 2, 1),
132
+ size=horizon,
133
+ mode="linear",
134
+ align_corners=True,
135
+ ).permute(0, 2, 1)
136
+ return samples
137
+
138
+
139
+ def learned_plan(
140
+ model,
141
+ image_history: deque,
142
+ action_history: deque,
143
+ goal: np.ndarray,
144
+ active_action_dim: int,
145
+ args,
146
+ prev_action: np.ndarray,
147
+ previous_mean: np.ndarray | None,
148
+ context_mode: str,
149
+ donor_context: torch.Tensor | None,
150
+ ) -> tuple[np.ndarray, np.ndarray | None, np.ndarray]:
151
+ device = next(model.parameters()).device
152
+ images = torch.as_tensor(np.asarray(image_history, dtype=np.uint8), device=device).unsqueeze(0)
153
+ actions = torch.as_tensor(np.asarray(action_history, dtype=np.float32), device=device).unsqueeze(0)
154
+ with torch.no_grad(), autocast_context(device, args.precision):
155
+ z, c = model.encode(images, actions)
156
+ if c.numel() and context_mode == "zero":
157
+ c = torch.zeros_like(c)
158
+ if c.numel() and context_mode == "shuffled" and donor_context is not None:
159
+ c = donor_context.to(device=device, dtype=torch.float32)
160
+ z = z.detach()
161
+ c = c.detach()
162
+ goal_t = torch.as_tensor(goal, dtype=torch.float32, device=device).view(1, 2)
163
+ with torch.no_grad(), autocast_context(device, args.precision):
164
+ current_pos = decode_absolute(model.decoder(z)).float().detach()[..., :2]
165
+ mean = warm_start_mean(
166
+ previous_mean,
167
+ args.cem_horizon,
168
+ model.config.action_dim,
169
+ active_action_dim,
170
+ device,
171
+ )
172
+ std = torch.full_like(mean, args.cem_action_std)
173
+ prev = torch.zeros((model.config.action_dim,), dtype=torch.float32, device=device)
174
+ prev[:active_action_dim] = torch.as_tensor(prev_action, dtype=torch.float32, device=device)
175
+ best_candidates = None
176
+ if args.planner == "gradient":
177
+ action, best_candidates, mean = gradient_plan(
178
+ model,
179
+ z,
180
+ c,
181
+ mean,
182
+ goal_t,
183
+ current_pos,
184
+ prev,
185
+ active_action_dim,
186
+ args,
187
+ )
188
+ return action, best_candidates, mean
189
+ with torch.no_grad():
190
+ action, best_candidates, mean = cem_plan(
191
+ model,
192
+ z,
193
+ c,
194
+ mean,
195
+ std,
196
+ goal_t,
197
+ current_pos,
198
+ prev,
199
+ active_action_dim,
200
+ args,
201
+ )
202
+ return action, best_candidates, mean
203
+
204
+
205
+ def planning_cost(
206
+ pred: torch.Tensor,
207
+ samples: torch.Tensor,
208
+ goal_t: torch.Tensor,
209
+ current_pos: torch.Tensor,
210
+ prev: torch.Tensor,
211
+ active_action_dim: int,
212
+ args,
213
+ ) -> torch.Tensor:
214
+ pos = pred[..., :2]
215
+ goal_delta = goal_t - current_pos
216
+ goal_dir = goal_delta / torch.linalg.norm(goal_delta, dim=-1, keepdim=True).clamp_min(1.0e-6)
217
+ progress = ((pos - current_pos[:, None]) * goal_dir[:, None]).sum(dim=-1).amax(dim=-1)
218
+ alpha = torch.linspace(1.0 / pos.shape[1], 1.0, pos.shape[1], device=pos.device, dtype=pos.dtype)
219
+ route = current_pos[:, None] + alpha.view(1, -1, 1) * goal_delta[:, None]
220
+ route_error = ((pos - route) ** 2).sum(dim=-1).mean(dim=-1)
221
+ goal_from_pos = goal_t[:, None] - pos
222
+ goal_from_pos = goal_from_pos / torch.linalg.norm(goal_from_pos, dim=-1, keepdim=True).clamp_min(1.0e-6)
223
+ heading = pred[..., 2:4]
224
+ heading = heading / torch.linalg.norm(heading, dim=-1, keepdim=True).clamp_min(1.0e-6)
225
+ heading_error = (1.0 - (heading * goal_from_pos).sum(dim=-1)).mean(dim=-1)
226
+ terminal = ((pos[:, -1] - goal_t) ** 2).sum(dim=-1)
227
+ path = ((pos - goal_t[:, None]) ** 2).sum(dim=-1).mean(dim=-1)
228
+ energy = (samples[..., :active_action_dim] ** 2).mean(dim=(1, 2))
229
+ smooth_prev = torch.cat([prev.view(1, 1, -1).repeat(samples.shape[0], 1, 1), samples[:, :-1]], dim=1)
230
+ smooth = ((samples - smooth_prev) ** 2).mean(dim=(1, 2))
231
+ boundary = (
232
+ torch.relu(-pos[..., 0])
233
+ + torch.relu(pos[..., 0] - 10.0)
234
+ + torch.relu(-pos[..., 1])
235
+ + torch.relu(pos[..., 1] - 10.0)
236
+ ).mean(dim=-1)
237
+ return (
238
+ args.cem_w_goal * terminal
239
+ + args.cem_w_path * path
240
+ + args.cem_w_route * route_error
241
+ + args.cem_w_heading_goal * heading_error
242
+ + args.cem_w_action * energy
243
+ + args.cem_w_smooth * smooth
244
+ + args.cem_w_boundary * boundary
245
+ - args.cem_w_progress * progress
246
+ )
247
+
248
+
249
+ def cem_plan(
250
+ model,
251
+ z: torch.Tensor,
252
+ c: torch.Tensor,
253
+ mean: torch.Tensor,
254
+ std: torch.Tensor,
255
+ goal_t: torch.Tensor,
256
+ current_pos: torch.Tensor,
257
+ prev: torch.Tensor,
258
+ active_action_dim: int,
259
+ args,
260
+ ) -> tuple[np.ndarray, np.ndarray | None, np.ndarray]:
261
+ best_candidates = None
262
+ for _ in range(args.cem_iterations):
263
+ samples = sample_action_sequences(mean, std, args.cem_population, args.cem_knots)
264
+ samples[0] = mean
265
+ samples = samples.clamp(-1.0, 1.0)
266
+ if active_action_dim < model.config.action_dim:
267
+ samples[:, :, active_action_dim:] = 0.0
268
+ with autocast_context(mean.device, args.precision):
269
+ pred = rollout_latent(model, z, c, samples)
270
+ cost = planning_cost(pred, samples, goal_t, current_pos, prev, active_action_dim, args)
271
+ elite_idx = torch.topk(cost, k=args.cem_elites, largest=False).indices
272
+ elites = samples[elite_idx]
273
+ mean = elites.mean(dim=0)
274
+ std = elites.std(dim=0).clamp_min(0.05)
275
+ if args.make_gifs:
276
+ pos = pred[..., :2]
277
+ best_candidates = pos[elite_idx[:12]].detach().cpu().numpy()
278
+ action = mean[0, :active_action_dim].detach().cpu().numpy()
279
+ return (
280
+ np.clip(action, -1.0, 1.0).astype(np.float32),
281
+ best_candidates,
282
+ mean.detach().cpu().numpy(),
283
+ )
284
+
285
+
286
+ def gradient_plan(
287
+ model,
288
+ z: torch.Tensor,
289
+ c: torch.Tensor,
290
+ mean: torch.Tensor,
291
+ goal_t: torch.Tensor,
292
+ current_pos: torch.Tensor,
293
+ prev: torch.Tensor,
294
+ active_action_dim: int,
295
+ args,
296
+ ) -> tuple[np.ndarray, np.ndarray | None, np.ndarray]:
297
+ init = mean.clamp(-0.95, 0.95)
298
+ logits = torch.atanh(init).detach().requires_grad_(True)
299
+ optimizer = torch.optim.Adam([logits], lr=args.planner_lr)
300
+ inactive = None
301
+ if active_action_dim < mean.shape[-1]:
302
+ inactive = torch.zeros_like(mean)
303
+ inactive[:, :active_action_dim] = 1.0
304
+ for _ in range(args.planner_iterations):
305
+ seq = torch.tanh(logits)
306
+ if inactive is not None:
307
+ seq = seq * inactive
308
+ with autocast_context(mean.device, args.precision):
309
+ pred = rollout_latent(model, z, c, seq.unsqueeze(0))
310
+ loss = planning_cost(pred, seq.unsqueeze(0), goal_t, current_pos, prev, active_action_dim, args).mean()
311
+ optimizer.zero_grad(set_to_none=True)
312
+ loss.backward()
313
+ optimizer.step()
314
+ with torch.no_grad():
315
+ seq = torch.tanh(logits)
316
+ if inactive is not None:
317
+ seq = seq * inactive
318
+ with autocast_context(mean.device, args.precision):
319
+ pred = rollout_latent(model, z, c, seq.unsqueeze(0))
320
+ candidates = pred[0, :, :2].detach().cpu().numpy()[None, ...] if args.make_gifs else None
321
+ action = seq[0, :active_action_dim].detach().cpu().numpy()
322
+ return (
323
+ np.clip(action, -1.0, 1.0).astype(np.float32),
324
+ candidates,
325
+ seq.detach().cpu().numpy(),
326
+ )
327
+
328
+
329
+ @torch.no_grad()
330
+ def donor_context_for_flowmo(model, env: SurfaceBoatEnv, args, seed: int) -> torch.Tensor | None:
331
+ if not hasattr(model, "to_c"):
332
+ return None
333
+ rng = np.random.default_rng(seed + 99_999)
334
+ donor = SurfaceBoatEnv(
335
+ boat=env.config.boat,
336
+ flow_type=env.config.flow_type,
337
+ boundary="terminate",
338
+ episode_steps=model.config.context_len + 8,
339
+ seed=seed + 99,
340
+ )
341
+ donor.reset(flow_type=env.config.flow_type, random_velocity=False)
342
+ image_history = deque(maxlen=args.history_len)
343
+ action_history = deque(maxlen=args.history_len)
344
+ action = np.zeros((model.config.action_dim,), dtype=np.float32)
345
+ for _ in range(args.history_len):
346
+ image_history.append(clean_observation(donor, args.image_size, args.visual_scale))
347
+ action_history.append(action.copy())
348
+ raw = rng.uniform(-0.5, 0.5, size=donor.action_dim).astype(np.float32)
349
+ donor.step(raw)
350
+ action = pad_action(raw, model.config.action_dim)
351
+ device = next(model.parameters()).device
352
+ images = torch.as_tensor(np.asarray(image_history, dtype=np.uint8), device=device).unsqueeze(0)
353
+ actions = torch.as_tensor(np.asarray(action_history, dtype=np.float32), device=device).unsqueeze(0)
354
+ with autocast_context(device, args.precision):
355
+ return model.encode(images, actions)[1].detach()
356
+
357
+
358
+ def traditional_action(method: str, image_history: deque, env: SurfaceBoatEnv, goal: np.ndarray) -> np.ndarray:
359
+ evaluate_module = importlib.import_module(f"experiments.{method}.src.evaluate")
360
+ image = np.transpose(image_history[-1], (1, 2, 0))
361
+ history = [np.transpose(x, (1, 2, 0)) for x in image_history]
362
+ cfg = {
363
+ "image": image,
364
+ "history": history,
365
+ "true_flow": env.last_flow_velocity.copy(),
366
+ "goal": goal.astype(float).tolist(),
367
+ "action_dim": env.action_dim,
368
+ "boat": env.config.boat,
369
+ }
370
+ return evaluate_module.evaluate(cfg)[: env.action_dim].astype(np.float32)
371
+
372
+
373
+ def evaluate_one_method(method: str, args) -> dict:
374
+ torch.manual_seed(args.seed)
375
+ learned = method in LEARNED_METHODS
376
+ model = None
377
+ if learned:
378
+ _cfg, model = build_method(method)
379
+ state = torch.load(Path("experiments") / method / "checkpoint" / args.checkpoint_name, map_location="cpu")
380
+ model.load_state_dict(state)
381
+ model.to(torch.device(args.device))
382
+ if torch.device(args.device).type == "cuda":
383
+ model.to(memory_format=torch.channels_last)
384
+ model.eval()
385
+ for param in model.parameters():
386
+ param.requires_grad_(False)
387
+ results = []
388
+ gif_dir = Path(args.out) / "gifs"
389
+ gif_dir.mkdir(parents=True, exist_ok=True)
390
+ context_modes = args.context_modes if method == "flowmo" else ["inferred"]
391
+ for context_mode in context_modes:
392
+ for ep in range(args.episodes):
393
+ episode_seed = int(args.seed + ep)
394
+ rng = np.random.default_rng(episode_seed)
395
+ env = SurfaceBoatEnv(
396
+ boat=args.boat,
397
+ flow_type=args.flow_type,
398
+ boundary="terminate",
399
+ episode_steps=args.max_steps,
400
+ seed=episode_seed,
401
+ )
402
+ reset_task(env, args.task, args.flow_type, rng)
403
+ goals = task_goals(args.task, rng)
404
+ goal_idx = 0
405
+ image_history = deque(maxlen=args.history_len)
406
+ action_history = deque(maxlen=args.history_len)
407
+ zero = np.zeros((model.config.action_dim if learned else 3,), dtype=np.float32)
408
+ first = clean_observation(env, args.image_size, args.visual_scale)
409
+ for _ in range(args.history_len):
410
+ image_history.append(first.copy())
411
+ action_history.append(zero.copy())
412
+ donor_context = donor_context_for_flowmo(model, env, args, episode_seed) if learned and context_mode == "shuffled" else None
413
+ trajectory = [env.full_state()[:6].copy()]
414
+ frames = []
415
+ prev_action = np.zeros((env.action_dim,), dtype=np.float32)
416
+ energy = 0.0
417
+ reached_times: list[int] = []
418
+ min_goal_dists = np.full((len(goals),), np.inf, dtype=np.float32)
419
+ passive_steps = args.passive_steps if args.task == "passive_to_active" else 0
420
+ planned = None
421
+ learned_plan_mean = None
422
+ for t in range(args.max_steps):
423
+ goal = goals[goal_idx]
424
+ if t < passive_steps:
425
+ action = np.zeros((env.action_dim,), dtype=np.float32)
426
+ planned = None
427
+ learned_plan_mean = None
428
+ elif learned:
429
+ action, planned, learned_plan_mean = learned_plan(
430
+ model,
431
+ image_history,
432
+ action_history,
433
+ goal,
434
+ env.action_dim,
435
+ args,
436
+ prev_action,
437
+ learned_plan_mean,
438
+ context_mode,
439
+ donor_context,
440
+ )
441
+ else:
442
+ action = traditional_action(method, image_history, env, goal)
443
+ planned = None
444
+ prev_action = action.copy()
445
+ _obs, _reward, done, _info = env.step(action)
446
+ energy += float(np.sum(action * action))
447
+ trajectory.append(env.full_state()[:6].copy())
448
+ image_history.append(clean_observation(env, args.image_size, args.visual_scale))
449
+ action_history.append(pad_action(action, len(action_history[-1])))
450
+ dists = np.linalg.norm(goals - env.state[:2], axis=1)
451
+ min_goal_dists = np.minimum(min_goal_dists, dists)
452
+ if ep < args.make_gifs and t % args.gif_stride == 0:
453
+ frames.append(
454
+ render_frame(
455
+ env.full_state()[:6],
456
+ env.spec,
457
+ env.flow,
458
+ env.workspace,
459
+ trajectory=np.asarray(trajectory),
460
+ goal=goal,
461
+ planned=planned,
462
+ t=env.time,
463
+ )
464
+ )
465
+ if float(dists[goal_idx]) < args.success_radius:
466
+ reached_times.append(t + 1)
467
+ if args.task == "station_keeping":
468
+ if t >= max(40, args.max_steps // 3):
469
+ break
470
+ else:
471
+ goal_idx += 1
472
+ learned_plan_mean = None
473
+ if goal_idx >= len(goals):
474
+ break
475
+ if done:
476
+ break
477
+ path = np.asarray(trajectory)[:, :2]
478
+ final_goal = goals[min(goal_idx, len(goals) - 1)]
479
+ record = {
480
+ "method": method,
481
+ "context_mode": context_mode,
482
+ "episode": ep,
483
+ "success": bool(goal_idx >= len(goals) or (args.task == "station_keeping" and np.linalg.norm(env.state[:2] - goals[0]) < args.success_radius)),
484
+ "final_distance": float(np.linalg.norm(env.state[:2] - final_goal)),
485
+ "mean_min_goal_distance": float(min_goal_dists.mean()),
486
+ "path_length": float(np.linalg.norm(np.diff(path, axis=0), axis=-1).sum()) if len(path) > 1 else 0.0,
487
+ "energy": energy,
488
+ "steps": len(trajectory) - 1,
489
+ "reached_times": reached_times,
490
+ }
491
+ results.append(record)
492
+ if ep < args.make_gifs and frames:
493
+ name = f"image_planning_{method}_{context_mode}_{args.boat}_{args.task}_ep{ep:03d}.gif"
494
+ save_gif(frames, gif_dir / name, duration_ms=args.gif_duration_ms)
495
+ return summarize(method, args, results)
496
+
497
+
498
+ def summarize(method: str, args, results: list[dict]) -> dict:
499
+ groups = sorted({r["context_mode"] for r in results})
500
+ by_context = {}
501
+ def success_mean(items: list[dict], key: str) -> float | None:
502
+ successful = [r[key] for r in items if r["success"]]
503
+ return float(np.mean(successful)) if successful else None
504
+
505
+ for context in groups:
506
+ items = [r for r in results if r["context_mode"] == context]
507
+ by_context[context] = {
508
+ "episodes": len(items),
509
+ "successes": len([r for r in items if r["success"]]),
510
+ "success_rate": float(np.mean([r["success"] for r in items])),
511
+ "final_distance_mean": float(np.mean([r["final_distance"] for r in items])),
512
+ "mean_min_goal_distance": float(np.mean([r["mean_min_goal_distance"] for r in items])),
513
+ "path_length_success_mean": success_mean(items, "path_length"),
514
+ "energy_success_mean": success_mean(items, "energy"),
515
+ "steps_success_mean": success_mean(items, "steps"),
516
+ }
517
+ return {
518
+ "method": method,
519
+ "task": args.task,
520
+ "boat": args.boat,
521
+ "flow_type": args.flow_type,
522
+ "by_context": by_context,
523
+ "results": results,
524
+ }
525
+
526
+
527
+ def main() -> None:
528
+ parser = argparse.ArgumentParser()
529
+ parser.add_argument("--methods", nargs="+", default=LEARNED_METHODS + TRADITIONAL_METHODS)
530
+ parser.add_argument("--task", choices=["reach_uniform", "counterflow", "station_keeping", "passive_to_active", "waypoint_square", "waypoint_zigzag"], default="reach_uniform")
531
+ parser.add_argument("--boat", choices=["twin", "triangle"], default="twin")
532
+ parser.add_argument("--flow-type", choices=["uniform", "slowly_varying", "vortex_center", "gradient", "turbulent_patch"], default="uniform")
533
+ parser.add_argument("--episodes", type=int, default=50)
534
+ parser.add_argument("--max-steps", type=int, default=240)
535
+ parser.add_argument("--planner", choices=["gradient", "cem"], default="gradient")
536
+ parser.add_argument("--passive-steps", type=int, default=25)
537
+ parser.add_argument("--history-len", type=int, default=32)
538
+ parser.add_argument("--image-size", type=int, default=160)
539
+ parser.add_argument("--visual-scale", type=float, default=2.5)
540
+ parser.add_argument("--checkpoint-name", default="image_local.pt")
541
+ parser.add_argument("--context-modes", nargs="+", default=["inferred", "zero", "shuffled"])
542
+ parser.add_argument("--cem-horizon", type=int, default=15)
543
+ parser.add_argument("--cem-population", type=int, default=128)
544
+ parser.add_argument("--cem-elites", type=int, default=16)
545
+ parser.add_argument("--cem-iterations", type=int, default=3)
546
+ parser.add_argument("--cem-action-std", type=float, default=0.5)
547
+ parser.add_argument("--cem-knots", type=int, default=5)
548
+ parser.add_argument("--planner-iterations", type=int, default=30)
549
+ parser.add_argument("--planner-lr", type=float, default=0.08)
550
+ parser.add_argument("--cem-w-goal", type=float, default=8.0)
551
+ parser.add_argument("--cem-w-path", type=float, default=0.45)
552
+ parser.add_argument("--cem-w-route", type=float, default=1.0)
553
+ parser.add_argument("--cem-w-heading-goal", type=float, default=0.0)
554
+ parser.add_argument("--cem-w-action", type=float, default=0.04)
555
+ parser.add_argument("--cem-w-smooth", type=float, default=0.08)
556
+ parser.add_argument("--cem-w-boundary", type=float, default=10.0)
557
+ parser.add_argument("--cem-w-progress", type=float, default=2.0)
558
+ parser.add_argument("--success-radius", type=float, default=0.65)
559
+ parser.add_argument("--make-gifs", type=int, default=3)
560
+ parser.add_argument("--gif-stride", type=int, default=1)
561
+ parser.add_argument("--gif-duration-ms", type=int, default=55)
562
+ parser.add_argument("--seed", type=int, default=33)
563
+ parser.add_argument("--device", default="cuda")
564
+ parser.add_argument("--precision", choices=["fp32", "bf16", "fp16"], default="fp32")
565
+ parser.add_argument("--out", default="experiments/reports/image_planning")
566
+ args = parser.parse_args()
567
+
568
+ out_dir = Path(args.out)
569
+ out_dir.mkdir(parents=True, exist_ok=True)
570
+ payload = [evaluate_one_method(method, args) for method in args.methods]
571
+ out_path = out_dir / f"{args.task}_{args.boat}_{args.flow_type}.json"
572
+ out_path.write_text(json.dumps(payload, indent=2))
573
+ print(json.dumps(payload, indent=2))
574
+
575
+
576
+ if __name__ == "__main__":
577
+ main()
experiments/evaluate_image_world_models.py ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluate trained image-input world models on long open-loop rollouts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib
7
+ import json
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+ import torch
12
+ from torch.utils.data import DataLoader
13
+
14
+ from experiments.shared.src.data.image_dataset import ImageTrajectoryDataset
15
+ from experiments.shared.src.methods import PAPER_LEARNED_METHODS
16
+ from experiments.shared.src.vision.clean_renderer import render_clean_boat_history_tensor
17
+ from experiments.train_image_world_models import configure_training_runtime
18
+ from experiments.train_image_world_models import autocast_context
19
+ from experiments.train_image_world_models import decode_predictions
20
+ from experiments.train_image_world_models import required_model_history
21
+ from experiments.train_image_world_models import selected_history_indices
22
+
23
+
24
+ METHODS = PAPER_LEARNED_METHODS
25
+
26
+
27
+ def loader_kwargs(num_workers: int) -> dict:
28
+ if num_workers <= 0:
29
+ return {}
30
+ return {
31
+ "multiprocessing_context": "spawn",
32
+ "persistent_workers": True,
33
+ "prefetch_factor": 4,
34
+ }
35
+
36
+
37
+ def prepare_batch(batch, args, device: torch.device):
38
+ observation_hist, actions, future_actions, targets, origin, prev_origin, flow_type_id, boat_id = batch
39
+ history_indices = getattr(args, "history_indices", None)
40
+ if history_indices is None:
41
+ model_history_len = int(getattr(args, "model_history_len", observation_hist.shape[1]))
42
+ observation_hist = observation_hist[:, -model_history_len:]
43
+ actions = actions[:, -model_history_len:]
44
+ else:
45
+ observation_hist = observation_hist[:, history_indices]
46
+ actions = actions[:, history_indices]
47
+ actions = actions.to(device, non_blocking=True)
48
+ future_actions = future_actions.to(device, non_blocking=True)
49
+ targets = targets.to(device, non_blocking=True)
50
+ origin = origin.to(device, non_blocking=True)
51
+ if args.render_mode == "device":
52
+ states = observation_hist.to(device, non_blocking=True)
53
+ boat_id_device = boat_id.to(device, non_blocking=True)
54
+ images = render_clean_boat_history_tensor(
55
+ states,
56
+ boat_id_device,
57
+ image_size=args.image_size,
58
+ visual_scale=args.visual_scale,
59
+ )
60
+ else:
61
+ images = observation_hist.to(device, non_blocking=True)
62
+ return images, actions, future_actions, targets, origin, prev_origin, flow_type_id, boat_id
63
+
64
+
65
+ def build_method(method: str):
66
+ config_module = importlib.import_module(f"experiments.{method}.src.config")
67
+ model_module = importlib.import_module(f"experiments.{method}.src.model")
68
+ cfg = config_module.default_config()
69
+ return cfg, model_module.build_model(cfg)
70
+
71
+
72
+ def load_flow_names(source_npz: str) -> dict[int, str]:
73
+ src = np.load(source_npz, allow_pickle=False)
74
+ metadata = json.loads(str(src["metadata"]))
75
+ return {int(v): str(k) for k, v in metadata["flows"].items()}
76
+
77
+
78
+ def load_group_names(source_npz: str, key: str) -> dict[int, str]:
79
+ src = np.load(source_npz, allow_pickle=False)
80
+ metadata = json.loads(str(src["metadata"]))
81
+ return {int(v): str(k) for k, v in metadata[key].items()}
82
+
83
+
84
+ @torch.no_grad()
85
+ def rollout_with_context(model, images: torch.Tensor, actions: torch.Tensor, future_actions: torch.Tensor, mode: str) -> torch.Tensor:
86
+ z, c = model.encode(images, actions)
87
+ if mode == "zero":
88
+ c = torch.zeros_like(c)
89
+ elif mode == "shuffled":
90
+ c = c.roll(shifts=1, dims=0)
91
+ if hasattr(model, "rollout_with_context"):
92
+ return model.rollout_with_context(z, c, future_actions)
93
+ preds = []
94
+ cur = z
95
+ for t in range(future_actions.shape[1]):
96
+ cur = model.step(cur, future_actions[:, t], c)
97
+ preds.append(model.decoder(cur))
98
+ return torch.stack(preds, dim=1)
99
+
100
+
101
+ @torch.no_grad()
102
+ def evaluate_model(
103
+ model,
104
+ loader,
105
+ device: torch.device,
106
+ horizon: int,
107
+ target_mode: str,
108
+ flow_names: dict[int, str],
109
+ traj_names: dict[int, str],
110
+ boat_names: dict[int, str],
111
+ context_mode: str,
112
+ args,
113
+ ) -> dict:
114
+ model.eval()
115
+ steps = [s for s in [1, 3, 6, 8, 10, 20, 30, 40, 60] if s <= horizon]
116
+ pos_sum = np.zeros(horizon, dtype=np.float64)
117
+ heading_sum = np.zeros(horizon, dtype=np.float64)
118
+ flow_pos: dict[int, np.ndarray] = {}
119
+ flow_heading: dict[int, np.ndarray] = {}
120
+ flow_count: dict[int, int] = {}
121
+ traj_pos: dict[int, np.ndarray] = {}
122
+ traj_heading: dict[int, np.ndarray] = {}
123
+ traj_count: dict[int, int] = {}
124
+ boat_pos: dict[int, np.ndarray] = {}
125
+ boat_heading: dict[int, np.ndarray] = {}
126
+ boat_count: dict[int, int] = {}
127
+ count = 0
128
+ cursor = 0
129
+ for batch in loader:
130
+ images, actions, future_actions, targets, origin, _prev_origin, flow_type_id, _boat_id = prepare_batch(batch, args, device)
131
+ with autocast_context(device, args.precision):
132
+ if context_mode == "inferred":
133
+ encoded = model.rollout(images, actions, future_actions)
134
+ else:
135
+ encoded = rollout_with_context(model, images, actions, future_actions, context_mode)
136
+ pred = decode_predictions(encoded.float(), origin, target_mode)
137
+ pos = torch.linalg.norm(pred[..., :2] - targets[..., :2], dim=-1)
138
+ pred_angle = torch.atan2(pred[..., 3], pred[..., 2])
139
+ target_angle = torch.atan2(targets[..., 3], targets[..., 2])
140
+ heading = torch.atan2(torch.sin(pred_angle - target_angle), torch.cos(pred_angle - target_angle)).abs()
141
+ pos_np = pos.cpu().numpy()
142
+ heading_np = heading.cpu().numpy()
143
+ pos_sum += pos_np.sum(axis=0)
144
+ heading_sum += heading_np.sum(axis=0)
145
+ count += int(pos_np.shape[0])
146
+ flow_np = flow_type_id.numpy()
147
+ batch_indices = loader.dataset.indices[cursor : cursor + int(pos_np.shape[0])]
148
+ cursor += int(pos_np.shape[0])
149
+ traj_np = np.array([loader.dataset.traj_type_ids[ep] for ep, _t in batch_indices], dtype=np.int64)
150
+ boat_np = np.array([loader.dataset.boat_ids[ep] for ep, _t in batch_indices], dtype=np.int64)
151
+ for flow_id in np.unique(flow_np):
152
+ mask = flow_np == flow_id
153
+ fid = int(flow_id)
154
+ flow_pos.setdefault(fid, np.zeros(horizon, dtype=np.float64))
155
+ flow_heading.setdefault(fid, np.zeros(horizon, dtype=np.float64))
156
+ flow_count[fid] = flow_count.get(fid, 0) + int(mask.sum())
157
+ flow_pos[fid] += pos_np[mask].sum(axis=0)
158
+ flow_heading[fid] += heading_np[mask].sum(axis=0)
159
+ for traj_id in np.unique(traj_np):
160
+ mask = traj_np == traj_id
161
+ tid = int(traj_id)
162
+ traj_pos.setdefault(tid, np.zeros(horizon, dtype=np.float64))
163
+ traj_heading.setdefault(tid, np.zeros(horizon, dtype=np.float64))
164
+ traj_count[tid] = traj_count.get(tid, 0) + int(mask.sum())
165
+ traj_pos[tid] += pos_np[mask].sum(axis=0)
166
+ traj_heading[tid] += heading_np[mask].sum(axis=0)
167
+ for boat_id in np.unique(boat_np):
168
+ mask = boat_np == boat_id
169
+ bid = int(boat_id)
170
+ boat_pos.setdefault(bid, np.zeros(horizon, dtype=np.float64))
171
+ boat_heading.setdefault(bid, np.zeros(horizon, dtype=np.float64))
172
+ boat_count[bid] = boat_count.get(bid, 0) + int(mask.sum())
173
+ boat_pos[bid] += pos_np[mask].sum(axis=0)
174
+ boat_heading[bid] += heading_np[mask].sum(axis=0)
175
+ result = summarize(pos_sum / count, heading_sum / count, steps)
176
+ by_flow = {}
177
+ for fid, n in sorted(flow_count.items()):
178
+ by_flow[flow_names.get(fid, str(fid))] = summarize(flow_pos[fid] / n, flow_heading[fid] / n, steps)
179
+ result["by_flow"] = by_flow
180
+ result["by_trajectory"] = {
181
+ traj_names.get(tid, str(tid)): summarize(traj_pos[tid] / n, traj_heading[tid] / n, steps)
182
+ for tid, n in sorted(traj_count.items())
183
+ }
184
+ result["by_boat"] = {
185
+ boat_names.get(bid, str(bid)): summarize(boat_pos[bid] / n, boat_heading[bid] / n, steps)
186
+ for bid, n in sorted(boat_count.items())
187
+ }
188
+ return result
189
+
190
+
191
+ def summarize(pos_mean: np.ndarray, heading_mean: np.ndarray, steps: list[int]) -> dict[str, float]:
192
+ result: dict[str, float] = {}
193
+ for step in steps:
194
+ result[f"pos{step}"] = float(pos_mean[step - 1])
195
+ result[f"heading{step}"] = float(heading_mean[step - 1])
196
+ return result
197
+
198
+
199
+ def main() -> None:
200
+ parser = argparse.ArgumentParser()
201
+ parser.add_argument("--methods", nargs="+", default=METHODS)
202
+ parser.add_argument("--test-source", default="data/paper/test_unseen_flow.npz")
203
+ parser.add_argument("--test-episodes", type=int, default=256)
204
+ parser.add_argument("--history-len", type=int, default=32)
205
+ parser.add_argument("--horizon", type=int, default=60)
206
+ parser.add_argument("--test-windows", type=int, default=4096)
207
+ parser.add_argument("--batch-size", type=int, default=64)
208
+ parser.add_argument("--seed", type=int, default=20)
209
+ parser.add_argument("--device", default="cuda")
210
+ parser.add_argument("--target-mode", choices=["absolute_normalized", "relative_motion"], default="absolute_normalized")
211
+ parser.add_argument("--checkpoint-name", default="image_local.pt")
212
+ parser.add_argument("--out", default="experiments/reports/image_long_rollout_eval.json")
213
+ parser.add_argument("--num-workers", type=int, default=4)
214
+ parser.add_argument("--image-size", type=int, default=160)
215
+ parser.add_argument("--visual-scale", type=float, default=2.5)
216
+ parser.add_argument("--render-mode", choices=["device", "dataset"], default="device")
217
+ parser.add_argument("--precision", choices=["fp32", "bf16", "fp16"], default="fp32")
218
+ args = parser.parse_args()
219
+ device = torch.device(args.device)
220
+ configure_training_runtime(device)
221
+ flow_names = load_flow_names(args.test_source)
222
+ traj_names = load_group_names(args.test_source, "trajectories")
223
+ boat_names = load_group_names(args.test_source, "boats")
224
+ ds = ImageTrajectoryDataset(
225
+ args.test_source,
226
+ history_len=args.history_len,
227
+ horizon=args.horizon,
228
+ episodes=args.test_episodes,
229
+ max_windows=args.test_windows,
230
+ seed=args.seed,
231
+ image_size=args.image_size,
232
+ visual_scale=args.visual_scale,
233
+ return_aux=True,
234
+ render_images=args.render_mode == "dataset",
235
+ )
236
+ loader = DataLoader(
237
+ ds,
238
+ batch_size=args.batch_size,
239
+ shuffle=False,
240
+ num_workers=args.num_workers,
241
+ pin_memory=device.type == "cuda",
242
+ **loader_kwargs(args.num_workers),
243
+ )
244
+ payload = []
245
+ for method in args.methods:
246
+ _cfg, model = build_method(method)
247
+ state = torch.load(Path("experiments") / method / "checkpoint" / args.checkpoint_name, map_location="cpu")
248
+ model.load_state_dict(state)
249
+ model.to(device)
250
+ if device.type == "cuda":
251
+ model.to(memory_format=torch.channels_last)
252
+ args.model_history_len = required_model_history(model, args.history_len)
253
+ args.history_indices = selected_history_indices(model, args.history_len)
254
+ item = {
255
+ "method": method,
256
+ "inferred": evaluate_model(model, loader, device, args.horizon, args.target_mode, flow_names, traj_names, boat_names, "inferred", args),
257
+ }
258
+ if method == "flowmo":
259
+ item["context_zero"] = evaluate_model(model, loader, device, args.horizon, args.target_mode, flow_names, traj_names, boat_names, "zero", args)
260
+ item["context_shuffled"] = evaluate_model(model, loader, device, args.horizon, args.target_mode, flow_names, traj_names, boat_names, "shuffled", args)
261
+ payload.append(item)
262
+ out = Path(args.out)
263
+ out.parent.mkdir(parents=True, exist_ok=True)
264
+ out.write_text(json.dumps(payload, indent=2))
265
+ print(json.dumps(payload, indent=2))
266
+
267
+
268
+ if __name__ == "__main__":
269
+ main()
experiments/figures/.gitkeep ADDED
File without changes
experiments/figures/README.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Figures
2
+
3
+ Paper-facing figures generated from method results.
4
+
5
+ Subdirectories:
6
+
7
+ - `prediction/`
8
+ - `planning/`
9
+ - `context/`
10
+ - `energy/`
11
+ - `trajectory/`
experiments/flowmo/README.md ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # FlowMo: Flow-Momentum World Model
2
+
3
+ FlowMo is the proposed Flow-Momentum World Model for surface vehicles under hidden external drift.
4
+
5
+ Core idea:
6
+
7
+ - Short-history latent state for endogenous state, momentum, and actuator response.
8
+ - Long-history drift context for exogenous flow effects.
9
+ - Context-conditioned residual transition for drift-aware rollout and planning.
experiments/flowmo/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """FlowMo experiment package."""
experiments/flowmo/checkpoint/.gitkeep ADDED
File without changes
experiments/flowmo/result/.gitkeep ADDED
File without changes
experiments/flowmo/src/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """FlowMo source package."""
experiments/flowmo/src/config.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """FlowMo default config."""
2
+
3
+ from experiments.shared.src.models.image_world_models import ImageWorldModelConfig
4
+
5
+ def default_config():
6
+ """Return the default FlowMo image-input config."""
7
+ return ImageWorldModelConfig(emb_dim=64, z_dim=112, c_dim=8, hidden_dim=128, history_len=8, context_len=32)
experiments/flowmo/src/model.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FlowMo model definition."""
2
+
3
+ import torch
4
+
5
+ from experiments.flowmo.src.config import default_config
6
+ from experiments.shared.src.models.image_world_models import FlowMoImageWorldModel
7
+
8
+ def build_model(config):
9
+ """Build the FlowMo world model."""
10
+ return FlowMoImageWorldModel(config)
11
+
12
+
13
+ def load_model(checkpoint_path, config=None):
14
+ """Load a trained FlowMo checkpoint."""
15
+ cfg = default_config() if config is None else config
16
+ model = build_model(cfg)
17
+ model.load_state_dict(torch.load(checkpoint_path, map_location="cpu"))
18
+ return model
experiments/flowmo/src/plan.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """FlowMo closed-loop planning interface."""
2
+
3
+
4
+ def plan(model, env, task, config):
5
+ """Run closed-loop planning with FlowMo."""
6
+ return config["planner"](model, env, task, config)
experiments/flowmo/src/predict.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """FlowMo rollout interface."""
2
+
3
+
4
+ def rollout(model, batch, horizon):
5
+ """Run open-loop FlowMo prediction."""
6
+ images, actions, future_actions = batch
7
+ return model.rollout(images, actions, future_actions[:, :horizon])
experiments/flowmo/src/train.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FlowMo training entry point."""
2
+
3
+ import torch.nn.functional as F
4
+
5
+
6
+ def train(config):
7
+ """Train FlowMo on the configured dataset."""
8
+ model = config["model"]
9
+ optimizer = config["optimizer"]
10
+ images, actions, future_actions, targets = config["batch"]
11
+ pred = model.rollout(images, actions, future_actions)
12
+ loss = F.mse_loss(pred, targets)
13
+ optimizer.zero_grad(set_to_none=True)
14
+ loss.backward()
15
+ optimizer.step()
16
+ return float(loss.detach())
experiments/gifs/.gitkeep ADDED
File without changes