Initial FlowMo-WM public code release
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- README.md +41 -3
- data/paper/dataset_card.md +47 -0
- data/paper/diagnostic_seen_flow.npz +3 -0
- data/paper/generation_config.json +49 -0
- data/paper/test_unseen_boat_params.npz +3 -0
- data/paper/test_unseen_flow.npz +3 -0
- data/paper/train.npz +3 -0
- driftwm/__init__.py +3 -0
- driftwm/data/__init__.py +1 -0
- driftwm/data/generate.py +260 -0
- driftwm/sim/__init__.py +1 -0
- driftwm/sim/boat.py +110 -0
- driftwm/sim/dynamics.py +73 -0
- driftwm/sim/env.py +142 -0
- driftwm/sim/flow.py +428 -0
- driftwm/sim/render.py +145 -0
- driftwm/sim/sanity.py +89 -0
- driftwm/utils.py +85 -0
- experiments/BASELINES.md +44 -0
- experiments/EXPERIMENT_MATRIX.md +151 -0
- experiments/METHOD_AUDIT.md +30 -0
- experiments/README.md +83 -0
- experiments/TASK_PLAN.md +118 -0
- experiments/__init__.py +1 -0
- experiments/current_estimator_mpc/README.md +3 -0
- experiments/current_estimator_mpc/__init__.py +1 -0
- experiments/current_estimator_mpc/checkpoint/.gitkeep +0 -0
- experiments/current_estimator_mpc/result/.gitkeep +0 -0
- experiments/current_estimator_mpc/src/__init__.py +1 -0
- experiments/current_estimator_mpc/src/config.py +5 -0
- experiments/current_estimator_mpc/src/estimator.py +9 -0
- experiments/current_estimator_mpc/src/evaluate.py +10 -0
- experiments/current_estimator_mpc/src/mpc.py +19 -0
- experiments/docs/EXPERIMENT_PROTOCOL.md +211 -0
- experiments/evaluate_flowmo_latent_probes.py +266 -0
- experiments/evaluate_image_planning.py +577 -0
- experiments/evaluate_image_world_models.py +269 -0
- experiments/figures/.gitkeep +0 -0
- experiments/figures/README.md +11 -0
- experiments/flowmo/README.md +9 -0
- experiments/flowmo/__init__.py +1 -0
- experiments/flowmo/checkpoint/.gitkeep +0 -0
- experiments/flowmo/result/.gitkeep +0 -0
- experiments/flowmo/src/__init__.py +1 -0
- experiments/flowmo/src/config.py +7 -0
- experiments/flowmo/src/model.py +18 -0
- experiments/flowmo/src/plan.py +6 -0
- experiments/flowmo/src/predict.py +7 -0
- experiments/flowmo/src/train.py +16 -0
- experiments/gifs/.gitkeep +0 -0
README.md
CHANGED
|
@@ -1,3 +1,41 @@
|
|
| 1 |
-
-
|
| 2 |
-
|
| 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
|