diff --git a/src/gesim/server/dashboard.py b/src/gesim/server/dashboard.py
index a03ffe4..f24cc90 100644
--- a/src/gesim/server/dashboard.py
+++ b/src/gesim/server/dashboard.py
@@ -99,7 +99,7 @@
- Action vs predicted state (commanded action vs world-model prediction, last frame)
+ Action vs predicted state (commanded action vs PE prediction, last frame; grippers in mm)
@@ -117,12 +117,19 @@
}
const LABELS = ["L1","L2","L3","L4","L5","L6","L7","L grip","R1","R2","R3","R4","R5","R6","R7","R grip"];
+// Match gesim.types GRIPPER_APERTURE_* — WM action grippers are [0,1], PE state is mm.
+const GRIP_MIN = 35, GRIP_SPAN = 120 - 35, GRIP_DIMS = new Set([7, 15]);
+
+function actionForCompare(i, v) {
+ return GRIP_DIMS.has(i) ? v * GRIP_SPAN + GRIP_MIN : v;
+}
function tableFor(title, action, state, lo, hi) {
let rows = "";
for (let i = lo; i < hi; i++) {
- const a = action[i], p = state[i], d = p - a;
- const cls = Math.abs(d) < 1e-4 ? "" : (d > 0 ? "pos" : "neg");
+ const a = actionForCompare(i, action[i]), p = state[i], d = p - a;
+ const tol = GRIP_DIMS.has(i) ? 2.0 : 1e-4;
+ const cls = Math.abs(d) < tol ? "" : (d > 0 ? "pos" : "neg");
rows += "
| " + LABELS[i] + " | " + a.toFixed(3) +
" | " + p.toFixed(3) + " | " +
(d >= 0 ? "+" : "") + d.toFixed(3) + " |
";
diff --git a/src/gesim/types.py b/src/gesim/types.py
index c2146b5..a1a2060 100644
--- a/src/gesim/types.py
+++ b/src/gesim/types.py
@@ -18,6 +18,26 @@
STATE_DIM = 16
ACTION_DIM = 16
+# Genie-01 OmniPicker: WM actions use normalised gripper commands in [0, 1]; PE
+# state uses physical aperture in mm. Keep in sync with conditioning/band.py.
+GRIPPER_APERTURE_MIN_MM = 35.0
+GRIPPER_APERTURE_MAX_MM = 120.0
+WM_GRIPPER_DIMS = (7, 15)
+
+
+def wm_action_gripper_to_mm(norm: float) -> float:
+ """Map a normalised WM gripper command to OmniPicker aperture in mm."""
+ span = GRIPPER_APERTURE_MAX_MM - GRIPPER_APERTURE_MIN_MM
+ return float(norm) * span + GRIPPER_APERTURE_MIN_MM
+
+
+def wm_action_row_for_state_compare(action: np.ndarray) -> np.ndarray:
+ """Return a WM action row with gripper channels converted to mm for PE comparison."""
+ out = np.asarray(action, dtype=np.float32).reshape(-1).copy()
+ for dim in WM_GRIPPER_DIMS:
+ out[dim] = wm_action_gripper_to_mm(out[dim])
+ return out
+
@dataclass(frozen=True)
class Observation:
diff --git a/src/gesim/video.py b/src/gesim/video.py
index f23fdf0..3f3afbe 100644
--- a/src/gesim/video.py
+++ b/src/gesim/video.py
@@ -10,6 +10,18 @@
logger = logging.getLogger("gesim.video")
+def _resolve_ffmpeg() -> str | None:
+ """Return an ffmpeg binary, preferring PATH then optional imageio-ffmpeg."""
+ ffmpeg = shutil.which("ffmpeg")
+ if ffmpeg:
+ return ffmpeg
+ try:
+ import imageio_ffmpeg
+ except ImportError:
+ return None
+ return imageio_ffmpeg.get_ffmpeg_exe()
+
+
def save_video(frames: np.ndarray, path: str, fps: int = 16):
"""Save ``(T, 3, V, H, W)`` float video to mp4. Views are tiled horizontally."""
T, C, V, H, W = frames.shape
@@ -22,7 +34,7 @@ def save_video(frames: np.ndarray, path: str, fps: int = 16):
rows = np.ascontiguousarray(rows)
height, width = rows.shape[1], rows.shape[2]
- ffmpeg = shutil.which("ffmpeg")
+ ffmpeg = _resolve_ffmpeg()
if ffmpeg:
cmd = [
ffmpeg,
diff --git a/tests/test_gripper_units.py b/tests/test_gripper_units.py
new file mode 100644
index 0000000..d13e90a
--- /dev/null
+++ b/tests/test_gripper_units.py
@@ -0,0 +1,23 @@
+import numpy as np
+import pytest
+
+from gesim.types import (
+ GRIPPER_APERTURE_MAX_MM,
+ GRIPPER_APERTURE_MIN_MM,
+ wm_action_gripper_to_mm,
+ wm_action_row_for_state_compare,
+)
+
+
+def test_wm_action_gripper_to_mm_endpoints():
+ assert wm_action_gripper_to_mm(0.0) == pytest.approx(GRIPPER_APERTURE_MIN_MM)
+ assert wm_action_gripper_to_mm(1.0) == pytest.approx(GRIPPER_APERTURE_MAX_MM)
+
+
+def test_wm_action_row_for_state_compare_only_touches_grippers():
+ row = np.arange(16, dtype=np.float32)
+ out = wm_action_row_for_state_compare(row)
+ assert np.array_equal(out[:7], row[:7])
+ assert np.array_equal(out[8:15], row[8:15])
+ assert out[7] == pytest.approx(wm_action_gripper_to_mm(row[7]))
+ assert out[15] == pytest.approx(wm_action_gripper_to_mm(row[15]))