Ethgar commited on
Commit
6628826
路
verified 路
1 Parent(s): d8b0475

Open to the Stringman community: training data, datagen/trainer/room importer, avoid_furniture, [home] reports

Browse files
AGENTS.md CHANGED
@@ -98,8 +98,57 @@ This opens one pull request on `MacroSwan/pilot-tasks` containing the result, yo
98
  page is the discussion: people and agents comment there, the maintainers re-run it, and the best merged tuning becomes
99
  the next person's starting point. Ask your person before submitting. It publishes under their Hugging Face account.
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  ## Where things are
102
 
103
  `README.md` covers the model and the numbers. `cablepilot/cli.py` has the commands. `cablepilot/core/cards.py` defines
104
  the card schema: the predicates, the skills and `signature()`. `cablepilot/cardrun.py` maps cards to the cable skills
105
- and writes the result format. `cablepilot/stringman.py` is the real robot adapter.
 
98
  page is the discussion: people and agents comment there, the maintainers re-run it, and the best merged tuning becomes
99
  the next person's starting point. Ask your person before submitting. It publishes under their Hugging Face account.
100
 
101
+ ## Step 6: tell everyone how it went in your home
102
+
103
+ The maintainers do not have a Stringman. Your report is how anyone learns whether this works in a real home, so write
104
+ one even when (above all when) it went badly. Open a **Discussion** on this repository, titled
105
+ `[home] <room> 路 <card> 路 <what happened in five words>`, filled from [`HOME_REPORT.md`](HOME_REPORT.md). It covers the
106
+ room, the rig, what you changed to integrate it, what the robot did, what broke, and what you would change. Attach
107
+ `doctor.json`, `rig.yaml` and the run's `result.json`. If you have a real-robot log, open a PR on the dataset
108
+ `MacroSwan/real-runs` with it as well (format in its README; blur faces first). Read the other `[home]` threads
109
+ before you start: someone may already have fixed your problem. Answer them when you can.
110
+
111
+ ## Step 7: change the model itself
112
+
113
+ Everything the model learned from is public, so you can regenerate it, retrain it and replace it without asking anyone.
114
+
115
+ | what | where |
116
+ |---|---|
117
+ | the training data: 12,000 task-free trajectories (100 h of simulated motion, each in its own random room) | dataset `MacroSwan/cablepilot-motion` |
118
+ | how that data is made (the motion recipe, randomised physics, the host's real spool loop) | `scripts/cdpr_datagen.py` (docstring) |
119
+ | training, then test 1 (open-loop error at 2 s on held-out rooms, next to MuJoCo and to the physics alone) | `scripts/train_cdpr_wm.py` |
120
+ | a real Stringman room from a LeRobot recording (anchors, named places, floor image) | `scripts/import_room.py` |
121
+
122
+ ```bash
123
+ hf download MacroSwan/cablepilot-motion --repo-type dataset --local-dir data/motion # ~280 MB, or regenerate:
124
+ python scripts/cdpr_datagen.py --scenario configs/scenarios/clear_playroom.yaml --n 12000 --seed 0 --workers 4 --out data/motion
125
+ python scripts/train_cdpr_wm.py --data data/motion --out weights/mine.pt # minutes on a laptop CPU, no GPU
126
+ python scripts/train_cdpr_wm.py --eval-only --data data/motion --out weights/stringman_g1.pt # the bar to beat
127
+ ```
128
+
129
+ The released `weights/stringman_g1.pt` scores **19.6 mm** median grasp-point error at 2 s on 500 held-out windows
130
+ (the MuJoCo oracle scores 21.0 mm). A new model has to beat that on the same test, and then score at least as well on a
131
+ task (`cablepilot run`, 3 episodes, both rooms), before it replaces the old one. Open a PR here with the weights, the
132
+ test-1 JSON written next to them, the run's `result.json` and one line of `why`. To use your model in a run, point
133
+ `configs/planner_learned.yaml` at it locally (a tuning file may never change the model).
134
+
135
+ **Your own recordings are the best test set there is.** Any Stringman LeRobot dataset with `anchor_poses`, for example
136
+ `naavox/stringman_cleans`, holds the real state and the real commands. Converting a few of its episodes into the
137
+ `cdpr_datagen` format and running `--eval-only --test-all` on them gives the model's first error measured on a real
138
+ machine. Nobody has done that yet: it is the most useful thing on the list below.
139
+
140
+ ## Open jobs (pick one, say so in a Discussion first)
141
+
142
+ 1. **The model on real data.** Test 1 on real Stringman episodes (see Step 7). Then fine-tune on them, if it helps.
143
+ 2. **`avoid_furniture`.** The card is in `MacroSwan/pilot-tasks`. `cablepilot check` says what is missing: a `near`
144
+ skill (a runner around `clear_floor.GotoTask`) and a furnished room (`scripts/import_room.py` plus the furniture
145
+ boxes that `runner.obstacles` reads).
146
+ 3. **`stringman.py` on hardware.** Check each `ASSUME` against `nf_robot`, then `hover()` in `dry_run`, then for real.
147
+ 4. **Your room.** Import it with `scripts/import_room.py` and add it as a `--world`, so that everyone's cards are scored in
148
+ a third real room.
149
+
150
  ## Where things are
151
 
152
  `README.md` covers the model and the numbers. `cablepilot/cli.py` has the commands. `cablepilot/core/cards.py` defines
153
  the card schema: the predicates, the skills and `signature()`. `cablepilot/cardrun.py` maps cards to the cable skills
154
+ and writes the result format. `cablepilot/stringman.py` is the real robot adapter. `scripts/` holds the data generator, the trainer and the room importer.
HOME_REPORT.md ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # [home] report template
2
+
3
+ Copy this into a new Discussion on `MacroSwan/cablepilot`, titled `[home] <room> 路 <card> 路 <five words>`.
4
+ Write what happened, including what went wrong. A failed integration, well described, is worth more than a good score.
5
+
6
+ **Who:** `<agent> for @<person>` 路 cablepilot commit `<sha>` 路 date
7
+
8
+ **Room:** kind (bedroom, playroom, ...), rough size, floor (rug, wood, tiles), what is in the way (bed, desk, chair
9
+ legs, cables on the floor), light. `doctor.json` attached.
10
+
11
+ **Rig:** robot and firmware version, gripper, cameras, the computer that plans (CPU, threads, `doctor` speed line).
12
+ `rig.yaml` attached.
13
+
14
+ **How we integrated it:** what you had to change or write to make it run here, in order: places bound, adapter
15
+ `ASSUME`s checked or fixed, tunings, code. Link the commits or PRs.
16
+
17
+ **What ran:** simulation (`--world`, seeds, episodes) and/or the real robot (`dry_run`, `hover()`, task). `result.json`
18
+ attached. Real log: link to the `MacroSwan/real-runs` PR.
19
+
20
+ **What the robot did:** the score, s per item, furniture contacts, max tension, plan time. Then in words: where it hesitated,
21
+ swung, missed, touched something.
22
+
23
+ **What broke, and the guess why:**
24
+
25
+ **Compared with what you use now (VLA, ACT, scripted):** success, time per item, compute, and the setup effort for a
26
+ new task. Only what you measured.
27
+
28
+ **What we would change next:**
README.md CHANGED
@@ -11,6 +11,7 @@ tags:
11
  - task-cards
12
  datasets:
13
  - MacroSwan/pilot-tasks
 
14
  ---
15
 
16
  # cablepilot
@@ -119,6 +120,14 @@ State (14): gantry position and velocity, pole swing and its rate, wrist angle a
119
  Command (5): gantry velocity (what `move_direction_speed` takes), wrist rate, closure target. Model step 0.1 s,
120
  1 s of history.
121
 
 
 
 
 
 
 
 
 
122
  ## Status
123
 
124
  - **Simulation:** proven. Model test on 500 held-out windows in 310 unseen rooms: grasp point 19.6 mm median error at
 
11
  - task-cards
12
  datasets:
13
  - MacroSwan/pilot-tasks
14
+ - MacroSwan/cablepilot-motion
15
  ---
16
 
17
  # cablepilot
 
120
  Command (5): gantry velocity (what `move_direction_speed` takes), wrist rate, closure target. Model step 0.1 s,
121
  1 s of history.
122
 
123
+ ## Train it yourself
124
+
125
+ The model was trained on the public dataset [`MacroSwan/cablepilot-motion`](https://huggingface.co/datasets/MacroSwan/cablepilot-motion):
126
+ 12,000 trajectories, about 100 hours of task-free simulated motion, each in its own random room. It can be regenerated
127
+ in about 15 minutes on 4 cores (`scripts/cdpr_datagen.py`) and retrained on a laptop CPU without a GPU
128
+ (`scripts/train_cdpr_wm.py`). `scripts/import_room.py` turns a Stringman LeRobot recording into a simulated room.
129
+ AGENTS.md, Step 7, gives the commands and the bar a new model has to beat.
130
+
131
  ## Status
132
 
133
  - **Simulation:** proven. Model test on 500 held-out windows in 310 unseen rooms: grasp point 19.6 mm median error at
scripts/cdpr_datagen.py ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stringman world-model data: task-free trajectories in the twin, the SkyJEPA recipe (scripts/arm_datagen.py's, on the
2
+ cable robot). No target, no cost, no task label in the files: only how the machine moves when commanded.
3
+
4
+ A NEW RANDOM ROOM PER TRAJECTORY (sim/scene.random_room; --fixed-room for a test set of one real room): the model is a
5
+ model of the machine, not of a place (2026-10-08), and a room enters its dynamics only through the line geometry,
6
+ which is stored with each trajectory (mounts, runs) and given to the model as an input.
7
+
8
+ python scripts/cdpr_datagen.py --scenario configs/scenarios/clear_playroom.yaml --n 20 --out data/cdpr_v0_sample
9
+ python scripts/cdpr_datagen.py --scenario configs/scenarios/clear_playroom.yaml --n 4000 --workers 7 --out data/cdpr_v0_playroom
10
+
11
+ Files (shards of 100):
12
+ states (N, T+1, 14) p, v, swing s, s_dot, wrist, wrist_dot, closure, closure_dot (sim.kin), AS ESTIMATED (noise added)
13
+ actions (N, T, 5) the command SENT at each record step: gantry velocity (3), wrist rate, closure target
14
+ tensions (N, T+1, 4) line tensions (N): an observation, NOT a model input (uncalibrated on the real robot)
15
+ payload (N, T+1) kg held at the grasp point (bookkeeping: the model must infer it)
16
+ eta (N, P) the trajectory's randomised physics (eta_names)
17
+ mounts (N, 4, 3) the room's mount points (line order of the firmware), runs (N, 4) the indirect lines' fixed runs
18
+ Recorded at 10 Hz (dt 0.1 s = half a planner step: the planner's 0.2 s is 2 model steps), 30 s per trajectory.
19
+
20
+ The machine is driven exactly as the real host drives it (sim.host.SpoolState: velocity -> line speeds by
21
+ finite-difference IK at the ESTIMATED position through MISCALIBRATED mounts, 50 Hz spool loop with the tension floor
22
+ and soft mute, acceleration-limited motors, 60 Hz gripper loop), with a command latency. Planning model = the
23
+ machine-only twin (no furniture, no contacts): the grasp point is kept >= 4 cm above the floor (floor contact is a task's
24
+ business, not the machine's).
25
+
26
+ Motion = a chain of segments chosen at random until the trajectory is full:
27
+ goto to a random reachable point (inside the mount polygon by 0.35 m, 3-line worst tension <= 26 N), 0.1-0.35 m/s
28
+ dwell hold still 1-4 s (the robot mostly hovers and waits)
29
+ stop a fast goto cut to zero half way: swing excitation and its decay
30
+ pump horizontal sinusoid at 0.7-1.3 x the swing frequency, small amplitude: the swing's resonance
31
+ descend down to a grasp height (G 4-15 cm), hold, back up (the pick's vertical regime)
32
+ plan what MPPI asks: a new command every planner step (0.2 s), AR(0.9) random walk, 1-3 x the planner's sigma
33
+ Wrist: rate pulses (half the segments); fingers: a new closure target (40 % of segments).
34
+ Payload: attached (20-500 g) or dropped at 35 % of segment boundaries: the "weight changes" regime.
35
+ Randomised per trajectory (eta): gantry and gripper mass x0.85-1.15, line EA 2-15 kN (log), line damping x0.5-2,
36
+ spool acceleration 0.10-0.30 m/s^2, command latency 0-0.15 s, mount calibration error sigma 0-4 cm (the host's IK only),
37
+ swing damping x0.3-3. Estimator noise as the sim estimator's (perception/oracle_estimator.StateEstimate).
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import argparse
42
+ import copy
43
+ import json
44
+ import sys
45
+ import time
46
+ from collections import deque
47
+ from pathlib import Path
48
+
49
+ import mujoco
50
+ import numpy as np
51
+ import torch
52
+
53
+ ROOT = Path(__file__).resolve().parents[1]
54
+ sys.path.insert(0, str(ROOT))
55
+
56
+ from cablepilot.perception.oracle_estimator import StateEstimate # noqa: E402
57
+ from cablepilot.sim.host import HostModel, SpoolState # noqa: E402
58
+ from cablepilot.sim.kin import ccw_polygon, worst_hang_tensions # noqa: E402
59
+ from cablepilot.sim.scene import (Indices, build_model, grasp_point_local, load_config, mounts, random_room, room_centre, # noqa: E402
60
+ set_room_geometry, settle_spools, total_mass)
61
+ from cablepilot.sim.state import to_state # noqa: E402
62
+
63
+ SEGMENTS = {"goto": 0.25, "dwell": 0.10, "stop": 0.10, "pump": 0.10, "descend": 0.15, "plan": 0.30}
64
+ ETA_NAMES = ["gantry_mass_scale", "gripper_mass_scale", "EA_N", "line_damping_scale", "line_accel_mps2", "latency_s",
65
+ "calib_sigma_m", "swing_damping_scale", "payload_max_kg"]
66
+ DT = 0.1
67
+ D_G = -0.5147 # grasp point on the pole, Stringman hand (tasks/pick_place.PickPlaceTask.dG); per hand: Generator.dG
68
+
69
+
70
+ class Generator:
71
+ def __init__(self, scenario: str, seconds: float = 30.0, random_rooms: bool = True, hand: str | None = None):
72
+ self.cfg = load_config(scenario=scenario, hand=hand)
73
+ # grasp point's distance down the pole (SO-101: hand origin + gripperframe, -0.472; its 7.9 mm lateral offset
74
+ # does not matter for keeping it off the floor)
75
+ self.dG = float(self.cfg["body"]["gripper_origin_z_m"] + grasp_point_local(self.cfg)[2])
76
+ self.base_cfg = self.cfg
77
+ self.random_rooms = random_rooms
78
+ self.m = build_model(self.cfg, world=False, payload_kg=1e-4)
79
+ self.d = mujoco.MjData(self.m)
80
+ self.idx = Indices(self.m)
81
+ self.h = self.m.opt.timestep
82
+ self.sub = int(round(DT / self.h))
83
+ self.T = int(round(seconds / DT))
84
+ self.M = mounts(self.cfg)
85
+ self.poly = ccw_polygon(self.M[:, :2])
86
+ self.pay = self.m.body("payload").id
87
+ m = self.m
88
+ self.orig = {k: getattr(m, k).copy() for k in ("body_mass", "body_inertia", "actuator_gainprm", "actuator_biasprm",
89
+ "dof_damping")}
90
+ self.gripper, self.gantry = m.body("gripper").id, m.body("gantry").id
91
+ self.swing_dofs = slice(self.idx.gv + 3, self.idx.gv + 6)
92
+
93
+ # ------------------------------------------------------------------------------------- geometry
94
+ def inside_by(self, xy):
95
+ a, b = self.poly, np.roll(self.poly, -1, 0)
96
+ e = b - a
97
+ n = np.c_[-e[:, 1], e[:, 0]] / np.linalg.norm(e, axis=1)[:, None]
98
+ return float(((np.asarray(xy) - a) * n).sum(1).min())
99
+
100
+ def tension_ok(self, p, lim=26.0):
101
+ t = worst_hang_tensions(torch.as_tensor(self.M, dtype=torch.float32), torch.as_tensor(p, dtype=torch.float32),
102
+ total_mass(self.m) + 0.5)
103
+ return float(t.max()) <= lim
104
+
105
+ def random_point(self, rng, z_lo=None):
106
+ lo, hi = self.poly.min(0), self.poly.max(0)
107
+ for _ in range(1000):
108
+ xy = rng.uniform(lo, hi)
109
+ if self.inside_by(xy) < 0.35:
110
+ continue
111
+ gz = rng.uniform(0.04, 0.15) if z_lo else rng.uniform(0.15, 1.2)
112
+ p = np.r_[xy, gz - self.dG]
113
+ if self.tension_ok(p):
114
+ return p
115
+ return np.r_[self.poly.mean(0), 1.0]
116
+
117
+ # ------------------------------------------------------------------------------------- randomisation
118
+ def randomise(self, rng) -> tuple[dict, HostModel]:
119
+ m, o = self.m, self.orig
120
+ for k, v in o.items():
121
+ getattr(m, k)[:] = v
122
+ if self.random_rooms: # a new room every trajectory: the model learns the machine, not a place
123
+ self.cfg = set_room_geometry(self.base_cfg, m, self.idx, random_room(rng))
124
+ self.M = mounts(self.cfg)
125
+ self.poly = ccw_polygon(self.M[:, :2])
126
+ e = {"gantry_mass_scale": rng.uniform(0.85, 1.15), "gripper_mass_scale": rng.uniform(0.85, 1.15),
127
+ "EA_N": float(np.exp(rng.uniform(np.log(2000), np.log(15000)))), "line_damping_scale": rng.uniform(0.5, 2.0),
128
+ "line_accel_mps2": rng.uniform(0.10, 0.30), "latency_s": rng.uniform(0.0, 0.15),
129
+ "calib_sigma_m": rng.uniform(0.0, 0.04), "swing_damping_scale": float(np.exp(rng.uniform(np.log(0.3), np.log(3.0)))),
130
+ "payload_max_kg": rng.uniform(0.0, 0.5)}
131
+ m.body_mass[self.gantry] *= e["gantry_mass_scale"]
132
+ m.body_inertia[self.gantry] *= e["gantry_mass_scale"]
133
+ m.body_mass[self.gripper] *= e["gripper_mass_scale"]
134
+ m.body_inertia[self.gripper] *= e["gripper_mass_scale"]
135
+ m.dof_damping[self.swing_dofs] *= e["swing_damping_scale"]
136
+ run = np.asarray(self.cfg["room"]["indirect_run_m"], float)
137
+ span = np.linalg.norm(self.M - np.r_[room_centre(self.cfg), 1.2], axis=1)
138
+ for i, a in enumerate(self.idx.spool):
139
+ k = e["EA_N"] / (span[i] + run[i])
140
+ m.actuator_gainprm[a, 0] = k
141
+ m.actuator_biasprm[a, 1] = -k
142
+ m.actuator_biasprm[a, 2] *= e["line_damping_scale"]
143
+ cfg_h = copy.deepcopy(self.cfg) # the host's belief of where the mounts are
144
+ cfg_h["room"]["mounts_xyz"] = (self.M + rng.normal(0, e["calib_sigma_m"], self.M.shape)).tolist()
145
+ host = HostModel(cfg_h, self.h, self.idx.travel)
146
+ host.acc = e["line_accel_mps2"]
147
+ return e, host
148
+
149
+ # ------------------------------------------------------------------------------------- commands
150
+ def segments(self, rng):
151
+ """Endless generator of (kind, function(state, t_in_segment) -> (action, done))."""
152
+ sig = np.array([0.06, 0.06, 0.03, 0.4])
153
+ omega = np.sqrt(9.81 / 0.3089)
154
+ names, probs = list(SEGMENTS), np.array(list(SEGMENTS.values()))
155
+ while True:
156
+ kind = str(rng.choice(names, p=probs / probs.sum()))
157
+ wrist_rate = rng.choice([0.0, 0.0, rng.uniform(-1.5, 1.5)])
158
+ close = None if rng.random() > 0.4 else float(rng.choice([0.0, 1.0, rng.uniform(0, 1)]))
159
+ if kind in ("goto", "stop", "descend"):
160
+ tgt = self.random_point(rng, z_lo=(kind == "descend"))
161
+ vmax = rng.uniform(0.1, 0.35)
162
+ cut = rng.uniform(1.0, 4.0) if kind == "stop" else None
163
+ hold = rng.uniform(0.5, 2.5)
164
+ state = {"arrived": None}
165
+
166
+ def f(x, t, tgt=tgt, vmax=vmax, cut=cut, hold=hold, state=state):
167
+ if cut is not None and t > cut:
168
+ return np.zeros(3), t > cut + rng.uniform(2.0, 5.0)
169
+ d = tgt - x[:3]
170
+ v = d * 0.8
171
+ n = np.linalg.norm(v)
172
+ v = v * min(1.0, vmax / max(n, 1e-9))
173
+ if np.linalg.norm(d) < 0.03:
174
+ state["arrived"] = state["arrived"] or t
175
+ done = state["arrived"] is not None and t - state["arrived"] > hold or t > 25
176
+ return v, done
177
+ elif kind == "dwell":
178
+ dur = rng.uniform(1.0, 4.0)
179
+
180
+ def f(x, t, dur=dur):
181
+ return np.zeros(3), t > dur
182
+ elif kind == "pump":
183
+ w = omega * rng.uniform(0.7, 1.3)
184
+ amp = rng.uniform(0.02, 0.08)
185
+ ang = rng.uniform(0, 2 * np.pi)
186
+ dur = rng.uniform(2.0, 5.0)
187
+
188
+ def f(x, t, w=w, amp=amp, ang=ang, dur=dur):
189
+ s = amp * np.sin(w * t) if t < dur else 0.0
190
+ return np.array([s * np.cos(ang), s * np.sin(ang), 0.0]), t > dur + 3.0
191
+ else: # plan: AR(0.9) random walk, a new command every 0.2 s
192
+ scale = rng.uniform(1, 3)
193
+ st = {"eps": np.zeros(4), "base": None, "k": -1}
194
+ steps = int(rng.integers(8, 30))
195
+
196
+ def f(x, t, scale=scale, st=st, steps=steps):
197
+ k = int(round(t / 0.2))
198
+ if st["base"] is None:
199
+ st["base"] = np.r_[x[3:6], 0.0]
200
+ if k != st["k"]:
201
+ st["k"] = k
202
+ st["eps"] = 0.9 * st["eps"] + np.sqrt(1 - 0.81) * rng.normal(0, 1, 4) * sig * scale
203
+ v = st["base"][:3] * 0.9 ** k + st["eps"][:3]
204
+ return v, k >= steps
205
+ yield kind, f, wrist_rate, close
206
+
207
+ # ------------------------------------------------------------------------------------- one trajectory
208
+ def trajectory(self, seed: int) -> dict | None:
209
+ rng = np.random.default_rng(seed)
210
+ m, d, idx = self.m, self.d, self.idx
211
+ eta, host = self.randomise(rng)
212
+ mujoco.mj_resetData(m, d)
213
+ p0 = self.random_point(rng)
214
+ d.qpos[idx.gq:idx.gq + 3] = p0
215
+ d.qpos[idx.fq] = d.qpos[idx.frq] = -idx.travel
216
+ m.body_mass[self.pay] = 1e-4
217
+ settle_spools(m, d, idx, self.cfg)
218
+ d.ctrl[idx.finger_ctrl] = -idx.travel
219
+ for _ in range(int(1.0 / self.h)):
220
+ mujoco.mj_step(m, d)
221
+ x = to_state(m, d, idx)
222
+ spools = SpoolState(host, self.cfg, x)
223
+ noise = StateEstimate(np.random.default_rng(seed + 1))
224
+ # command latency, in PHYSICS steps: a command sent at tick k reaches the host lat_sub physics steps later.
225
+ # (v0 counted it in physics steps but delayed it by that many 0.1 s ticks: up to 3.7 s; the data's velocity
226
+ # followed its command at ~0.4 /s instead of ~2-3 /s and the model learned that commands barely move the robot.)
227
+ lat_sub = int(round(eta["latency_s"] / self.h))
228
+ pending = deque() # (physics step at which it applies, action)
229
+ applied = np.r_[np.zeros(3), 0.0, x[12]]
230
+ n_phys = 0
231
+ T = self.T
232
+ S = np.empty((T + 1, 14))
233
+ A = np.empty((T, 5))
234
+ TEN = np.empty((T + 1, 4))
235
+ PAY = np.empty(T + 1)
236
+ kinds = {}
237
+ segs = self.segments(rng)
238
+ kind, f, wr, cl = next(segs)
239
+ t_seg, close = 0.0, x[12]
240
+ v_prev = np.zeros(3)
241
+
242
+ def rec(k):
243
+ S[k] = noise(to_state(m, d, idx))
244
+ TEN[k] = -d.actuator_force[idx.spool]
245
+ PAY[k] = m.body_mass[self.pay]
246
+
247
+ rec(0)
248
+ for k in range(T):
249
+ xe = S[k]
250
+ v, done = f(xe, t_seg)
251
+ if done:
252
+ kinds[kind] = kinds.get(kind, 0) + 1
253
+ kind, f, wr, cl = next(segs)
254
+ t_seg = 0.0
255
+ if cl is not None:
256
+ close = cl
257
+ if rng.random() < 0.35: # the payload changes at a segment boundary
258
+ on = m.body_mass[self.pay] > 1e-3
259
+ m.body_mass[self.pay] = 1e-4 if on else max(1e-4, rng.uniform(0.02, 0.5) * eta["payload_max_kg"] / 0.5)
260
+ m.body_inertia[self.pay] = 1e-6
261
+ v, _ = f(xe, t_seg)
262
+ # near the floor, descend no faster than the spools can stop: stopping distance v^2 / 2a with a = half the
263
+ # line acceleration (a 0.35 m/s descent needs ~0.3 m; the first sample put G 23 cm below the floor). The fw
264
+ # visual servo does the same ("descent speed as a function of how far there is left to fall").
265
+ G = xe[:3] + self.dG * np.r_[xe[6:8], 1.0]
266
+ v = v.copy()
267
+ v[2] = max(v[2], -np.sqrt(2 * 0.5 * eta["line_accel_mps2"] * max(G[2] - 0.04, 0.0)))
268
+ # keep inside: a command that would leave the reachable box is turned back toward the centre
269
+ if self.inside_by(xe[:2]) < 0.25 or G[2] < 0.08 or not self.tension_ok(xe[:3], 30.0):
270
+ c = np.r_[self.poly.mean(0), 1.0]
271
+ v = 0.15 * (c - xe[:3]) / max(np.linalg.norm(c - xe[:3]), 1e-6)
272
+ sp = np.linalg.norm(v)
273
+ v = v * min(1.0, 0.35 / max(sp, 1e-9))
274
+ a = np.r_[v, wr if t_seg < 3.0 else 0.0, close]
275
+ A[k] = a
276
+ pending.append((n_phys + lat_sub, a, xe[:3].copy()))
277
+ for _ in range(self.sub):
278
+ while pending and pending[0][0] <= n_phys:
279
+ _, applied, p_at_send = pending.popleft()
280
+ spools.command(p_at_send, applied)
281
+ n_phys += 1
282
+ u = spools.substep(-d.actuator_force[idx.spool], self.h)
283
+ d.ctrl[idx.spool] = u[:4]
284
+ d.ctrl[idx.wrist_ctrl], d.ctrl[idx.finger_ctrl] = u[4], u[5]
285
+ mujoco.mj_step(m, d)
286
+ if not np.isfinite(d.qpos).all() or np.abs(d.qvel[:6]).max() > 20:
287
+ return None
288
+ rec(k + 1)
289
+ t_seg += DT
290
+ v_prev = v
291
+ return {"states": S.astype(np.float32), "actions": A.astype(np.float32), "tensions": TEN.astype(np.float32),
292
+ "payload": PAY.astype(np.float32), "eta": np.array([eta[k] for k in ETA_NAMES], np.float32), "kinds": kinds,
293
+ "mounts": self.M.astype(np.float32), "runs": np.asarray(self.cfg["room"]["indirect_run_m"], np.float32)}
294
+
295
+
296
+ def _shard_job(args):
297
+ out, scenario, shard_i, seed0, n, seconds, random_rooms, hand = args
298
+ gen = Generator(scenario, seconds, random_rooms, hand)
299
+ rows, rejected, kinds = [], 0, {}
300
+ t0 = time.perf_counter()
301
+ for i in range(n):
302
+ tr = gen.trajectory(seed0 + i)
303
+ if tr is None:
304
+ rejected += 1
305
+ continue
306
+ for k, v in tr["kinds"].items():
307
+ kinds[k] = kinds.get(k, 0) + v
308
+ rows.append(tr)
309
+ shard = {k: np.stack([r[k] for r in rows]) for k in ("states", "actions", "tensions", "payload", "eta", "mounts", "runs")}
310
+ np.savez_compressed(Path(out) / f"shard_{shard_i:04d}.npz", **shard, eta_names=np.array(ETA_NAMES), dt=DT)
311
+ return len(rows), rejected, kinds, time.perf_counter() - t0
312
+
313
+
314
+ def main():
315
+ ap = argparse.ArgumentParser()
316
+ ap.add_argument("--scenario", default="configs/scenarios/clear_playroom.yaml")
317
+ ap.add_argument("--n", type=int, default=20)
318
+ ap.add_argument("--seed", type=int, default=0)
319
+ ap.add_argument("--seconds", type=float, default=30.0)
320
+ ap.add_argument("--shard-size", type=int, default=100)
321
+ ap.add_argument("--workers", type=int, default=1)
322
+ ap.add_argument("--hand", default=None, help="stringman | so101 (default: configs/stringman.yaml `hand`)")
323
+ ap.add_argument("--fixed-room", action="store_true", help="the scenario's room for every trajectory (test sets of a real room)")
324
+ ap.add_argument("--out", default=str(ROOT / "data/cdpr_v0_sample"))
325
+ args = ap.parse_args()
326
+ out = Path(args.out)
327
+ out.mkdir(parents=True, exist_ok=True)
328
+ size = min(args.shard_size, args.n)
329
+ jobs = [(str(out), args.scenario, i, args.seed + i * size, size, args.seconds, not args.fixed_room, args.hand)
330
+ for i in range(args.n // size)]
331
+ t0 = time.perf_counter()
332
+ done, rej, kinds, cpu = 0, 0, {}, 0.0
333
+ if args.workers > 1:
334
+ from multiprocessing import Pool
335
+ with Pool(args.workers) as pool:
336
+ it = pool.imap_unordered(_shard_job, jobs)
337
+ for k, (n, r, kd, s) in enumerate(it):
338
+ done, rej, cpu = done + n, rej + r, cpu + s
339
+ for a, v in kd.items():
340
+ kinds[a] = kinds.get(a, 0) + v
341
+ print(f" {k + 1}/{len(jobs)} shards, {done} trajectories, {time.perf_counter() - t0:.0f} s", flush=True)
342
+ else:
343
+ for job in jobs:
344
+ n, r, kd, s = _shard_job(job)
345
+ done, rej, cpu = done + n, rej + r, cpu + s
346
+ for a, v in kd.items():
347
+ kinds[a] = kinds.get(a, 0) + v
348
+ meta = {"trajectories": done, "rejected": rej, "seconds_each": args.seconds, "rate_hz": 1 / DT,
349
+ "scenario": args.scenario, "hand": Generator(args.scenario, 1.0, False, args.hand).cfg["hand"],
350
+ "dG": Generator(args.scenario, 1.0, False, args.hand).dG, "random_rooms": not args.fixed_room, "wall_s": time.perf_counter() - t0, "cpu_s": cpu,
351
+ "cpu_s_per_trajectory": cpu / max(done, 1), "segments": kinds, "eta_names": ETA_NAMES,
352
+ "model_state": "states (14): p, v, s, s_dot, wrist, wrist_dot, closure, closure_dot. tensions NOT a model input."}
353
+ (out / "manifest.json").write_text(json.dumps(meta, indent=2))
354
+ print(json.dumps(meta, indent=1))
355
+
356
+
357
+ if __name__ == "__main__":
358
+ main()
scripts/import_room.py ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Import a real Stringman room from a naavox LeRobot dataset into the twin: geometry, named places, the floor image.
2
+
3
+ hf download naavox/playroom-sep29-2 --repo-type dataset --local-dir data/hf/playroom-sep29-2
4
+ python scripts/import_room.py --dataset data/hf/playroom-sep29-2 --conf conf_playroom --calibrations <fw>/src/nf_robot/ml/calibrations --name playroom
5
+ python scripts/import_room.py --dataset data/hf/bedroom-sep6 --conf conf_bedroom_move_clutter_2 --name bedroom
6
+ uv run python scripts/import_room.py --building commercial_real_estate --office r5 --name cre_r5
7
+
8
+ A BUILDING (the room layer, milestone R, 2026-10-13): one office cut out of a scanned building
9
+ (~/robot-lab/buildings/<name>/, shared by every robot; see import_building's docstring). Writes configs/rooms/<name>.yaml
10
+ and data/rooms/<name>/ (visual meshes, the fixed map's points; outside git, rebuilt by this command).
11
+
12
+ Writes configs/rooms/<name>.yaml and assets/rooms/<name>/floor.png. Everything comes from the recording itself, in its
13
+ own room frame (fw ml/calibrations/readme.md: a config is only valid for the sessions it was recorded with):
14
+ - anchors: the `anchor_poses` feature (datasets from August 2026 on carry it per frame; constant within a session)
15
+ - named places: observation.state `<place>_bearing` / `_distance`, which are in the GRIPPER frame:
16
+ place = gripper_pos_xy + distance * (sin(a), cos(a)), a = bearing + spin
17
+ (found 2026-10-08 by trying the conventions: this one puts every frame of playroom-sep29-2 on the same point, std
18
+ 0.00 m; the others scatter by 0.6-1.8 m). A place whose solution is the origin is unset.
19
+ - floor: the median of the episodes' first overhead frames. The overhead camera is the firmware's orthographic floor
20
+ view (host/floor_view.py: room origin at the image centre, +y up, 5 m across), so it drops onto the twin's floor
21
+ at its true scale. Static clutter stays in it: it is the room's look, not something to pick.
22
+ - eyelets (the indirect lines' far corners) are NOT in the recording: taken from the room's calibration file
23
+ (fw ml/calibrations) through the 2D similarity that maps that file's two anchors onto the recording's, z = the
24
+ recording's anchor height. APPROXIMATE (that file is an older calibration of the same room).
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import json
30
+ import os
31
+ from pathlib import Path
32
+
33
+ import sys
34
+
35
+ import numpy as np
36
+ import yaml
37
+
38
+ ROOT = Path(__file__).resolve().parents[1]
39
+ sys.path.insert(0, str(ROOT))
40
+ # the firmware's calibration files (cranebot3-firmware src/nf_robot/ml/calibrations): --calibrations or $NF_CALIBRATIONS
41
+ FW_CAL = Path(os.environ.get("NF_CALIBRATIONS", "nf_robot/ml/calibrations"))
42
+ PLACES = ("hamper", "toybox", "trashcan", "gamepad", "parking_location")
43
+ EXTENT_M = 5.0 # fw host/floor_view.EXTENT_M
44
+
45
+
46
+ def similarity_2d(src: np.ndarray, dst: np.ndarray):
47
+ """Scale, rotation, translation mapping 2 src points onto 2 dst points (complex-number form)."""
48
+ s0, s1 = complex(*src[0]), complex(*src[1])
49
+ d0, d1 = complex(*dst[0]), complex(*dst[1])
50
+ a = (d1 - d0) / (s1 - s0)
51
+ b = d0 - a * s0
52
+ return lambda p: np.array([(a * complex(*p) + b).real, (a * complex(*p) + b).imag]), abs(a)
53
+
54
+
55
+ def floor_median(ds: Path, n_max: int = 60) -> np.ndarray:
56
+ import av
57
+ import pandas as pd
58
+ ep = pd.read_parquet(ds / "meta/episodes/chunk-000/file-000.parquet")
59
+ col = "videos/observation.images.overhead_camera/from_timestamp"
60
+ fidx = "videos/observation.images.overhead_camera/file_index"
61
+ ts = sorted(ep.loc[ep[fidx] == 0, col].values[:n_max] + 0.5)
62
+ c = av.open(str(ds / "videos/observation.images.overhead_camera/chunk-000/file-000.mp4"))
63
+ s = c.streams.video[0]
64
+ s.thread_type = "AUTO"
65
+ out, i = [], 0
66
+ for f in c.decode(s):
67
+ t = float(f.pts * s.time_base)
68
+ while i < len(ts) and t >= ts[i]:
69
+ out.append(f.to_ndarray(format="rgb24"))
70
+ i += 1
71
+ if i >= len(ts):
72
+ break
73
+ return np.median(np.stack(out), 0).astype(np.uint8), len(out)
74
+
75
+
76
+ def main():
77
+ ap = argparse.ArgumentParser()
78
+ ap.add_argument("--dataset")
79
+ ap.add_argument("--conf", help="fw ml/calibrations/<conf>.json for the eyelets")
80
+ ap.add_argument("--calibrations", help="the firmware's ml/calibrations folder (default $NF_CALIBRATIONS)")
81
+ ap.add_argument("--building", help="a scanned building (~/robot-lab/buildings/<name>): import one office of it")
82
+ ap.add_argument("--office", help="the office's id in building.yaml (e.g. r5)")
83
+ ap.add_argument("--anchor-z", type=float, default=2.65, help="mount height: ceiling 2.85 less the winch block")
84
+ ap.add_argument("--name", required=True)
85
+ args = ap.parse_args()
86
+ if args.building:
87
+ return import_building(args)
88
+ import pandas as pd
89
+ ds = Path(args.dataset)
90
+ info = json.loads((ds / "meta/info.json").read_text())
91
+ df = pd.read_parquet(ds / "data/chunk-000/file-000.parquet")
92
+ names = info["features"]["observation.state"]["names"]
93
+ S = np.stack(df["observation.state"].values)
94
+ col = lambda k: S[:, names.index(k)] # noqa: E731
95
+ ap_ = np.stack(df["anchor_poses"].values)
96
+ assert len(np.unique(ap_.round(4), axis=0)) == 1, "anchors moved within the recording"
97
+ a = ap_[0].reshape(2, 6) # rx, ry, rz, x, y, z per anchor
98
+ anchors = a[:, 3:6]
99
+
100
+ places = {}
101
+ g = np.c_[col("gripper_pos_x"), col("gripper_pos_y")]
102
+ for p in PLACES:
103
+ ang = col(f"{p}_bearing") + col("spin")
104
+ P = g + col(f"{p}_distance")[:, None] * np.c_[np.sin(ang), np.cos(ang)]
105
+ med, sd = np.median(P, 0), P.std(0).max()
106
+ if sd < 0.02 and np.linalg.norm(med) > 0.05:
107
+ places[p] = [round(float(med[0]), 3), round(float(med[1]), 3)]
108
+
109
+ conf = json.loads((Path(args.calibrations or FW_CAL) / f"{args.conf}.json").read_text())
110
+ ca = np.array([[x["pose"]["position"][k] if "pose" in x else x["position"][k] for k in "xy"] for x in conf["anchors"]])
111
+ ce = np.array([[x["indirectLine"]["eyeletPos"][k] for k in "xy"] for x in conf["anchors"]])
112
+ f, scale = similarity_2d(ca, anchors[:, :2])
113
+ eyelets = np.array([f(e) for e in ce])
114
+ z = anchors[:, 2]
115
+ # line order (fw-sim): 0 anchor0 direct, 1 anchor0 indirect via its eyelet, 2 anchor1 direct, 3 anchor1 indirect
116
+ mounts = [anchors[0, :2], eyelets[0], anchors[1, :2], eyelets[1]]
117
+ mz = [z[0], z[0], z[1], z[1]]
118
+ runs = [0.0, float(np.linalg.norm(eyelets[0] - anchors[0, :2])), 0.0, float(np.linalg.norm(eyelets[1] - anchors[1, :2]))]
119
+
120
+ img, n = floor_median(ds)
121
+ out_dir = ROOT / "assets/rooms" / args.name
122
+ out_dir.mkdir(parents=True, exist_ok=True)
123
+ from PIL import Image
124
+ Image.fromarray(img).save(out_dir / "floor.png")
125
+ gan = np.c_[col("gantry_position_x"), col("gantry_position_y"), col("gantry_position_z")]
126
+ room = {
127
+ "source": {"dataset": f"naavox/{ds.name}", "frames": int(len(df)), "conf_for_eyelets": args.conf,
128
+ "eyelet_similarity_scale": round(float(scale), 3), "floor_median_of": int(n)},
129
+ "mounts_xyz": [[round(float(m[0]), 3), round(float(m[1]), 3), round(float(zz), 3)] for m, zz in zip(mounts, mz)],
130
+ "indirect_run_m": [round(r, 3) for r in runs],
131
+ "ceiling_m": round(float(z.max()) + 0.13, 3), # docs: anchors ~13 cm below the ceiling
132
+ "named_places": places,
133
+ "floor_image": {"path": f"assets/rooms/{args.name}/floor.png", "extent_m": EXTENT_M, "centre": [0.0, 0.0]},
134
+ "recorded_gantry_box": [[round(float(gan[:, i].min()), 2), round(float(gan[:, i].max()), 2)] for i in range(3)],
135
+ "anchor_yaw_rad": [round(float(a[0, 2]), 3), round(float(a[1, 2]), 3)],
136
+ }
137
+ path = ROOT / "configs/rooms" / f"{args.name}.yaml"
138
+ path.parent.mkdir(parents=True, exist_ok=True)
139
+ path.write_text(f"# Imported by scripts/import_room.py from naavox/{ds.name} (see its docstring for each field's source).\n"
140
+ + yaml.safe_dump(room, sort_keys=False))
141
+ print(path.read_text())
142
+
143
+
144
+ # ----------------------------------------------------------------------------------------------- a scanned building
145
+ def floor_field(P, box, fz, cell=0.25):
146
+ """The scanned floor's height as a smooth field f(x, y): the scan is not flat. r5's floor points wander from -6.7 to
147
+ +1.0 cm (0.6 m cell medians, 2026-10-13; a RANSAC plane came out 1.05 deg tilted and left the open floor at
148
+ +1.7 cm median): Polycam drift, not a sloping office. Per 25 cm cell: the median of the points within 4 cm of
149
+ the cell's 10th percentile (the floor, not a chair leg), cells with < 20 such points filled from the nearest
150
+ measured ones (under the table), then smoothed (sigma 2 cells). Returns f (callable on (n, 2)), its grid."""
151
+ from scipy import ndimage
152
+ from scipy.interpolate import NearestNDInterpolator, RegularGridInterpolator
153
+ x0, x1, y0, y1 = box
154
+ Q = P[(P[:, 0] > x0) & (P[:, 0] < x1) & (P[:, 1] > y0) & (P[:, 1] < y1) & (np.abs(P[:, 2] - fz) < 0.12)]
155
+ nx, ny = int(np.ceil((x1 - x0) / cell)), int(np.ceil((y1 - y0) / cell))
156
+ ij = np.minimum(((Q[:, :2] - [x0, y0]) / cell).astype(int), [nx - 1, ny - 1])
157
+ G = np.full((nx, ny), np.nan)
158
+ key = ij[:, 0] * ny + ij[:, 1]
159
+ order = np.argsort(key)
160
+ ks, starts = np.unique(key[order], return_index=True)
161
+ for k, a, b in zip(ks, starts, np.r_[starts[1:], len(order)]):
162
+ z = Q[order[a:b], 2]
163
+ lo = np.percentile(z, 10)
164
+ z = z[z < lo + 0.04]
165
+ if len(z) >= 20:
166
+ G[k // ny, k % ny] = np.median(z)
167
+ ok = ~np.isnan(G)
168
+ ii, jj = np.nonzero(ok)
169
+ fill = NearestNDInterpolator(np.c_[ii, jj], G[ok])
170
+ ai, aj = np.nonzero(~ok)
171
+ if len(ai):
172
+ G[~ok] = fill(np.c_[ai, aj])
173
+ G = ndimage.gaussian_filter(G, 2.0, mode="nearest")
174
+ xs, ys = x0 + (np.arange(nx) + 0.5) * cell, y0 + (np.arange(ny) + 0.5) * cell
175
+ f = RegularGridInterpolator((xs, ys), G, bounds_error=False, fill_value=None)
176
+ return lambda xy: f(np.clip(xy, [xs[0], ys[0]], [xs[-1], ys[-1]])), G, float(ok.mean())
177
+
178
+
179
+ def import_building(args):
180
+ """One office of a scanned building -> a room for the cable robot.
181
+
182
+ - mounts: the office's four corners at anchor height, found from the scan, not typed in: the free space in the
183
+ band 1.0 m .. anchor_z + 0.15 m (wall points occupy a 4 cm grid; the anchor band alone leaked out of r5
184
+ through two scan holes in its walls, 2026-10-13), the connected part around the office centre,
185
+ eroded 5 cm (fw definitions: eyelets 5 cm off the walls); the corner = the free cell furthest along
186
+ (+-1, +-1). Order as scripts/make_room3d.py (CCW from +x+y; lines 0, 2 direct, 1, 3 via eyelets along a wall).
187
+ - the room frame: origin on the floor under the mounts' centre, axes = the building's (pose in building.yaml)
188
+ - the visual mesh: the OBJ's faces in the office box (+30 cm: its walls), the ceiling dropped (the overhead
189
+ camera hangs under it), one mesh per texture. VISUAL ONLY in the plant (contype 0).
190
+ - the fixed map's points: the PLY in the same crop (data/rooms/<name>/fixed_points.npy, xyz + rgb).
191
+ - supports: the floor and each large horizontal surface (upward mesh faces 0.3-1.5 m, clustered by height and
192
+ place, >= 0.25 m^2): a table top's height and box. The plant gets a thin collision box under each so waste
193
+ can lie on it (the mesh itself has no contacts).
194
+ - the bin (named place `hamper`): free floor near a wall, >= 0.6 m inside the mount polygon, nothing of the
195
+ scan 0.1-1.5 m above within 0.45 m."""
196
+ from scipy import ndimage
197
+ from cablepilot.roommap.scan import load_building, load_mtl, load_obj, load_ply, obj_to_frame, write_obj
198
+ bdir, B = load_building(args.building)
199
+ off = B["offices"][args.office]
200
+ x0, x1, y0, y1 = off["box"]
201
+ fz = float(B["floor_z"])
202
+ P, C = load_ply(bdir / B["files"]["points"])
203
+ pad = 0.3
204
+ near = (P[:, 0] > x0 - pad) & (P[:, 0] < x1 + pad) & (P[:, 1] > y0 - pad) & (P[:, 1] < y1 + pad)
205
+ # the office's own floor, FOUND (armpilot scan_furniture: RANSAC, "found, not given"): the building's floor_z is
206
+ # the whole floor's histogram peak, and r5's floor lies 2.2 cm under it (median, 2026-10-13); a level-floor
207
+ # assumption left a speckle of "obstacle" voxels 2-4 cm up all over the room
208
+ ffield, G, measured = floor_field(P, (x0 - pad, x1 + pad, y0 - pad, y1 + pad), fz)
209
+ P = P.copy()
210
+ P[near, 2] -= ffield(P[near, :2]) # flatten (the same correction for the mesh below)
211
+ fz_mean = float(np.mean(G))
212
+
213
+ # mounts: free space at anchor height
214
+ res = 0.04
215
+ lo = np.array([x0 - pad, y0 - pad])
216
+ shape = (np.array([x1 - x0, y1 - y0]) + 2 * pad) / res
217
+ shape = shape.astype(int) + 1
218
+ band = near & (P[:, 2] > 1.0) & (P[:, 2] < args.anchor_z + 0.15)
219
+ occ = np.zeros(shape, bool)
220
+ ij = ((P[band, :2] - lo) / res).astype(int)
221
+ occ[ij[:, 0], ij[:, 1]] = True
222
+ occ = ndimage.binary_closing(occ, iterations=2) # the scan's 2.2 cm points leave pinholes in a wall
223
+ lab, _ = ndimage.label(~occ)
224
+ c_ij = ((np.array([(x0 + x1) / 2, (y0 + y1) / 2]) - lo) / res).astype(int)
225
+ free = ndimage.binary_erosion(lab == lab[tuple(c_ij)], iterations=2) # 2 cells = 8 cm from a wall point's cell
226
+ fi, fj = np.nonzero(free)
227
+ cells = lo + (np.c_[fi, fj] + 0.5) * res
228
+ M = []
229
+ for d in ((1, 1), (-1, 1), (-1, -1), (1, -1)):
230
+ M.append(cells[np.argmax(cells @ np.array(d, float))])
231
+ M = np.array(M)
232
+ O = M.mean(0).round(3) # the room frame's origin (floor under the mounts' centre)
233
+ mounts = np.c_[M - O, np.full(4, args.anchor_z)]
234
+ runs = [0.0, float(np.linalg.norm(M[1] - M[0])), 0.0, float(np.linalg.norm(M[3] - M[2]))]
235
+
236
+ out = ROOT / "data/rooms" / args.name
237
+ out.mkdir(parents=True, exist_ok=True)
238
+ keep = near & (P[:, 2] > -0.05) & (P[:, 2] < off["ceiling_m"] - 0.1)
239
+ pts = np.c_[P[keep] - [*O, 0], C[keep]].astype(np.float32)
240
+ np.save(out / "fixed_points.npy", pts)
241
+
242
+ V, T, Fc, Mt = load_obj(bdir / B["files"]["mesh"])
243
+ V = obj_to_frame(V) # the mesh shares the PLY's frame (same capture)
244
+ V[:, 2] -= ffield(V[:, :2])
245
+ V = V - [*O, 0]
246
+ A = V[Fc[:, :, 0]]
247
+ nrm = np.cross(A[:, 1] - A[:, 0], A[:, 2] - A[:, 0])
248
+ area = np.linalg.norm(nrm, axis=1) / 2
249
+ up = nrm[:, 2] / (2 * area + 1e-12)
250
+ cen = A.mean(1)
251
+ bx0, bx1, by0, by1 = x0 - O[0] - pad, x1 - O[0] + pad, y0 - O[1] - pad, y1 - O[1] + pad
252
+ inbox = (cen[:, 0] > bx0) & (cen[:, 0] < bx1) & (cen[:, 1] > by0) & (cen[:, 1] < by1)
253
+ keepF = inbox & ~((cen[:, 2] > off["ceiling_m"] - 0.25) & (up < -0.5))
254
+ mtl = load_mtl(bdir / B["files"]["mesh"].replace(" ", "_").replace(".obj", ".mtl"))
255
+ meshes = []
256
+ for k, mat in enumerate(sorted(set(Mt[keepF]))):
257
+ sel = keepF & (Mt == mat)
258
+ n = write_obj(out / f"mesh_{k}.obj", V, T, Fc[sel])
259
+ from PIL import Image # MuJoCo reads PNG textures, not the scan's JPEGs
260
+ Image.open(bdir / mtl[mat]).convert("RGB").save(out / f"mesh_{k}.png")
261
+ meshes.append({"obj": f"mesh_{k}.obj", "texture": f"mesh_{k}.png", "faces": int(n)})
262
+
263
+ # supports: large horizontal surfaces above the floor
264
+ hz = keepF & (up > 0.95) & (cen[:, 2] > 0.3) & (cen[:, 2] < 1.5)
265
+ supports = [{"name": "floor", "z": 0.0}]
266
+ g = 0.05
267
+ zb = np.round(cen[hz, 2] / 0.02).astype(int)
268
+ for zk in np.unique(zb):
269
+ s = np.nonzero(hz)[0][np.abs(zb - zk) <= 1]
270
+ if area[s].sum() < 0.25:
271
+ continue
272
+ q = ((cen[s, :2] - [bx0, by0]) / g).astype(int)
273
+ grid = np.zeros((int((bx1 - bx0) / g) + 1, int((by1 - by0) / g) + 1), bool)
274
+ grid[q[:, 0], q[:, 1]] = True
275
+ grid = ndimage.binary_closing(grid, iterations=2)
276
+ lab, nl = ndimage.label(grid)
277
+ for L in range(1, nl + 1):
278
+ mem = lab[q[:, 0], q[:, 1]] == L
279
+ if area[s[mem]].sum() < 0.25 or any(abs(sp["z"] - np.average(cen[s[mem], 2], weights=area[s[mem]])) < 0.03
280
+ and sp.get("_l") == (zk, L) for sp in supports):
281
+ continue
282
+ xy = cen[s[mem], :2]
283
+ zz = float(np.average(cen[s[mem], 2], weights=area[s[mem]]))
284
+ if any(sp["name"] != "floor" and abs(sp["z"] - zz) < 0.03 and np.all(np.abs(np.mean(xy, 0) - np.mean(sp["box"], 1)) < 0.3)
285
+ for sp in supports):
286
+ continue # the same table seen in the neighbouring height bin
287
+ bxy = np.percentile(xy, [3, 97], axis=0)
288
+ supports.append({"name": f"top{len(supports)}", "z": round(zz, 3),
289
+ "box": [[round(float(bxy[0, 0]), 3), round(float(bxy[1, 0]), 3)],
290
+ [round(float(bxy[0, 1]), 3), round(float(bxy[1, 1]), 3)]],
291
+ "area_m2": round(float(area[s[mem]].sum()), 2)})
292
+
293
+ # one table, one support: the height bins overlap (r5's table came out three times, 0.710 / 0.717 / 0.718 m,
294
+ # boxes inside each other, 2026-10-13): merge same-height (3 cm) supports whose boxes overlap into their union
295
+ tops = [sp for sp in supports if "box" in sp]
296
+ merged = True
297
+ while merged:
298
+ merged = False
299
+ for i in range(len(tops)):
300
+ for j in range(i + 1, len(tops)):
301
+ a, b = tops[i], tops[j]
302
+ (ax0, ax1), (ay0, ay1) = a["box"]
303
+ (bx0_, bx1_), (by0_, by1_) = b["box"]
304
+ if abs(a["z"] - b["z"]) < 0.03 and ax0 < bx1_ and bx0_ < ax1 and ay0 < by1_ and by0_ < ay1:
305
+ w = a["area_m2"] + b["area_m2"]
306
+ a["z"] = round((a["z"] * a["area_m2"] + b["z"] * b["area_m2"]) / w, 3)
307
+ a["box"] = [[min(ax0, bx0_), max(ax1, bx1_)], [min(ay0, by0_), max(ay1, by1_)]]
308
+ a["area_m2"] = round(max(a["area_m2"], b["area_m2"]), 2) # nested bins: the larger, not the sum
309
+ tops.pop(j)
310
+ merged = True
311
+ break
312
+ if merged:
313
+ break
314
+ supports = [supports[0]] + [{**t, "name": f"table{k}"} for k, t in enumerate(tops)]
315
+
316
+ # the bin: free floor near a wall
317
+ from cablepilot.sim.kin import ccw_polygon
318
+ poly = ccw_polygon(mounts[:, :2])
319
+ e = np.roll(poly, -1, 0) - poly
320
+ nr = np.c_[-e[:, 1], e[:, 0]] / np.linalg.norm(e, axis=1)[:, None]
321
+ tall = pts[(pts[:, 2] > 0.1) & (pts[:, 2] < 1.5), :2]
322
+ from scipy.spatial import cKDTree
323
+ tree = cKDTree(tall)
324
+ best = None
325
+ for x in np.arange(poly[:, 0].min(), poly[:, 0].max(), 0.05):
326
+ for y in np.arange(poly[:, 1].min(), poly[:, 1].max(), 0.05):
327
+ q = np.array([x, y])
328
+ ins = ((q - poly) * nr).sum(1).min()
329
+ if ins < 0.6 or tree.query(q)[0] < 0.45:
330
+ continue
331
+ if best is None or ins < best[0]: # nearest a wall (yet >= 0.6 m in)
332
+ best = (ins, q)
333
+ hamper = best[1].round(3).tolist()
334
+
335
+ room = {"source": {"building": str(bdir).replace(str(Path.home()), "~"), "office": args.office, "imported_by": "scripts/import_room.py --building",
336
+ "scan_points_kept": int(keep.sum())},
337
+ "floor_flattened": {"note": "scan z - f(x, y), f = the scanned floor's smooth height (floor_field)",
338
+ "f_range_m": [round(float(G.min()), 4), round(float(G.max()), 4)],
339
+ "cells_measured": round(measured, 2)},
340
+ "building_pose": {"xyz": [float(O[0]), float(O[1]), round(fz_mean, 4)], "yaw_rad": 0.0,
341
+ "note": "room frame = building frame shifted by xyz (floor at z 0); same axes"},
342
+ "size_m": [round(float(np.ptp(M[:, 0])), 3), round(float(np.ptp(M[:, 1])), 3)],
343
+ "mounts_xyz": mounts.round(3).tolist(), "indirect_run_m": [round(r, 3) for r in runs],
344
+ "ceiling_m": float(off["ceiling_m"]),
345
+ "named_places": {"hamper": hamper},
346
+ "scan": {"dir": f"data/rooms/{args.name}", "meshes": meshes, "points": "fixed_points.npy",
347
+ "box": [[round(float(bx0), 3), round(float(bx1), 3)], [round(float(by0), 3), round(float(by1), 3)]]},
348
+ "supports": supports}
349
+ path = ROOT / "configs/rooms" / f"{args.name}.yaml"
350
+ path.write_text(f"# One office of a scanned building ({B['name']}, office {args.office}), imported by "
351
+ f"scripts/import_room.py --building (see import_building's docstring for each field's source).\n"
352
+ + yaml.safe_dump(room, sort_keys=False))
353
+ print(path.read_text())
354
+
355
+
356
+ if __name__ == "__main__":
357
+ main()
scripts/train_cdpr_wm.py ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train the Stringman SkyJEPA (cablepilot/model/learned.py) on task-free data, then test 1: open-loop prediction.
2
+ (armpilot's scripts/train_arm_wm.py on the cable robot.)
3
+
4
+ python scripts/train_cdpr_wm.py --data data/cdpr_v0_playroom --out checkpoints/cdpr_wm_v0.pt
5
+ python scripts/train_cdpr_wm.py --eval-only --out checkpoints/cdpr_wm_v0.pt
6
+
7
+ Stage 1 (task-free JEPA): predict the next 2 s of state latents from 1 s of history and the commands,
8
+ loss = MSE(pred, target) + 0.02 SIGReg(pred), as SkyJEPA. Stage 2: the prober on frozen latents, loss = weighted MSE of
9
+ the integrated states (normalised) against the recorded ones, the end of the horizon weighted most. The prober's last
10
+ layer starts at zero: training starts exactly at the hand-written backbone and learns only the correction.
11
+
12
+ Test 1 (docs/MILESTONES.md M1, the bar set before training): on held-out windows, after 20 steps (2.0 s): grasp point
13
+ error <= 2 cm median (also on payload windows), pole tilt error <= 2 deg median; next to the nominal oracle (MuJoCo, no
14
+ eta, no payload) and the backbone alone (nothing learned).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import math
21
+ import sys
22
+ import time
23
+ from pathlib import Path
24
+
25
+ import numpy as np
26
+ import torch
27
+ import torch.nn as nn
28
+
29
+ ROOT = Path(__file__).resolve().parents[1]
30
+ sys.path.insert(0, str(ROOT))
31
+
32
+ from cablepilot.model.learned import JEPA, Normalizer, Prober, features, integrate # noqa: E402
33
+
34
+ H, L = 10, 20
35
+ L_SWING_DATA = 0.3089 # the data's pendulum (fw, Stringman hand); load() sets it from the manifest's hand
36
+ D_G = -0.5147 # grasp point down the pole; load() sets it from the data's manifest (SO-101 hand: -0.472)
37
+ # the machine the data came from (manifest "machine"; Stringman when absent) and its backbone priors: the person lift's
38
+ # swing runs over a 2.4x range of lengths (empty harness .. a person), its spools ramp at 0.8 m/s^2 (P1, 2026-10-12)
39
+ MACHINE, OMEGA_SPAN, A_MAX0 = "configs/stringman.yaml", 0.3, 0.25
40
+ # Stage-2 error scales per state dim, in METRES / radians: an error of one scale costs 1. In normalised units (v0) a 5 cm
41
+ # position error cost ~0.003 next to the swing and rate terms, and the grasp-point error at 2 s ROSE during stage 2
42
+ # (53 -> 68 mm) while the loss fell; near-still windows came out at 80 mm against 33 mm for "integrate the command"
43
+ # (2026-10-08). p 2 cm, v 5 cm/s, s 0.01 (~0.6 deg), s_dot 0.1 /s, wrist 0.1 rad, wrist rate 0.5, closure 0.05, its rate 0.5.
44
+ E_SCALE = torch.tensor([0.02, 0.02, 0.02, 0.05, 0.05, 0.05, 0.01, 0.01, 0.1, 0.1, 0.1, 0.5, 0.05, 0.5])
45
+
46
+
47
+ class SIGReg(nn.Module):
48
+ """LeJEPA's Sketched Isotropic Gaussian Regularisation (copied from armpilot, from LeDrone/skyjepa)."""
49
+
50
+ def __init__(self, n_directions=64, n_knots=17):
51
+ super().__init__()
52
+ nodes, weights = np.polynomial.hermite.hermgauss(n_knots)
53
+ self.n = n_directions
54
+ self.register_buffer("nodes", torch.tensor(nodes, dtype=torch.float32))
55
+ self.register_buffer("weights", torch.tensor(weights, dtype=torch.float32))
56
+ self.register_buffer("gauss_cf", torch.tensor(np.exp(-nodes ** 2 / 2.0), dtype=torch.float32))
57
+
58
+ def forward(self, lat): # (T, B, D)
59
+ T, B, D = lat.shape
60
+ u = torch.randn(self.n, D)
61
+ u = u / (u.norm(dim=1, keepdim=True) + 1e-8)
62
+ stat = lat.new_zeros(())
63
+ for t in range(T):
64
+ arg = (lat[t] @ u.t()).unsqueeze(-1) * self.nodes.view(1, 1, -1)
65
+ C, S = torch.cos(arg).mean(0), torch.sin(arg).mean(0)
66
+ stat = stat + B * (((C - self.gauss_cf) ** 2 + S ** 2) * self.weights).sum(1).mean()
67
+ return stat / T
68
+
69
+
70
+ def load(data):
71
+ """One dataset directory, or several joined with commas: (X, A, P, M, R, dt). M (N, 4, 3) mounts and R (N, 4) runs
72
+ per trajectory (shards from a fixed room without them: the scenario's)."""
73
+ dirs = [Path(d) for d in str(data).split(",")]
74
+ parts = []
75
+ for d in dirs:
76
+ shards = sorted(d.glob("shard_*.npz"))
77
+ X, A, P, M, R = [], [], [], [], []
78
+ man = json.loads((d / "manifest.json").read_text())
79
+ global D_G, L_SWING_DATA, MACHINE, OMEGA_SPAN, A_MAX0
80
+ D_G = float(man.get("dG", D_G))
81
+ from cablepilot.sim.scene import load_config as _lc
82
+ if man.get("machine"): # another machine (P1: the person lift): its own pendulum and spools
83
+ MACHINE = man["machine"]
84
+ mc = _lc(machine=MACHINE)
85
+ L_SWING_DATA = float(mc["body"]["swing_length_m"])
86
+ A_MAX0 = float(mc["spool"]["line_accel_mps2"])
87
+ OMEGA_SPAN = 1.0 if mc.get("hand") == "harness" else 0.3
88
+ elif man.get("hand"): # the hand's pendulum, to start the backbone's swing from (Prober l_swing)
89
+ L_SWING_DATA = float(_lc(hand=man["hand"])["body"]["swing_length_m"])
90
+ for f in shards:
91
+ z = np.load(f)
92
+ X.append(z["states"])
93
+ A.append(z["actions"])
94
+ P.append(z["payload"])
95
+ if "mounts" in z:
96
+ M.append(z["mounts"])
97
+ R.append(z["runs"])
98
+ else:
99
+ from cablepilot.sim.scene import load_config, mounts
100
+ c = load_config(machine=MACHINE, scenario=man["scenario"])
101
+ M.append(np.repeat(mounts(c)[None].astype(np.float32), len(z["states"]), 0))
102
+ R.append(np.repeat(np.asarray(c["room"]["indirect_run_m"], np.float32)[None], len(z["states"]), 0))
103
+ parts.append([np.concatenate(v) for v in (X, A, P, M, R)] + [float(np.load(shards[0])["dt"])])
104
+ if len(parts) == 1:
105
+ return tuple(parts[0])
106
+ k = min(len(p[0]) for p in parts) # interleave so that the held-out tail holds trajectories of every set
107
+ return tuple(np.stack([p[j][:k] for p in parts], 1).reshape(-1, *parts[0][j].shape[1:]) for j in range(5)) + (parts[0][5],)
108
+
109
+
110
+ def windows(X, A, idx, rng, B, M, P=None):
111
+ i = rng.choice(idx, B)
112
+ t = rng.integers(0, A.shape[1] - (H + L) + 1, B)
113
+ ar = np.arange(H + 1 + L)
114
+ out = [torch.as_tensor(X[i[:, None], t[:, None] + ar]), torch.as_tensor(A[i[:, None], t[:, None] + ar[:-1]]),
115
+ torch.as_tensor(M[i])]
116
+ if P is not None:
117
+ out += [P[i[:, None], t[:, None] + ar], i]
118
+ return out
119
+
120
+
121
+ def lr_at(step, total, peak, final=1e-4, warm=200):
122
+ if step < warm:
123
+ return peak * step / warm
124
+ p = (step - warm) / max(1, total - warm)
125
+ return final + (peak - final) * 0.5 * (1 + math.cos(math.pi * p))
126
+
127
+
128
+ def grasp_point(x):
129
+ s = x[..., 6:8]
130
+ z = np.sqrt(np.clip(1 - (s ** 2).sum(-1, keepdims=True), 1e-6, None))
131
+ return x[..., 0:3] + D_G * np.concatenate([s, z], -1)
132
+
133
+
134
+ def train(args):
135
+ X, A, P, M, R, dt = load(args.data)
136
+ n = len(X)
137
+ rng = np.random.default_rng(0)
138
+ test_idx, train_idx = np.arange(n - args.test, n), np.arange(0, n - args.test)
139
+ with torch.no_grad():
140
+ sub = features(torch.as_tensor(X[train_idx[:2000], ::5]), torch.as_tensor(M[train_idx[:2000]])[:, None]).reshape(-1, 28).numpy()
141
+ asub = A[train_idx[:2000]].reshape(-1, 5)
142
+ norm = Normalizer(sub.mean(0), sub.std(0) + 1e-3, asub.mean(0), asub.std(0) + 1e-3)
143
+ cfg = {"latent": 24, "action_latent": 12, "hidden": 48, "prober_hidden": 64, "H": H, "L": L, "dt": dt,
144
+ "data": str(args.data), "trajectories_train": int(len(train_idx)), "l_swing": L_SWING_DATA,
145
+ "omega_span": OMEGA_SPAN, "a_max0": A_MAX0, "machine": MACHINE}
146
+ torch.manual_seed(0)
147
+ jepa, prober, sig = JEPA(cfg["latent"], cfg["action_latent"], cfg["hidden"]), Prober(cfg["latent"], cfg["prober_hidden"], cfg["l_swing"], cfg["omega_span"], cfg["a_max0"]), SIGReg()
148
+ n_j, n_p = sum(p.numel() for p in jepa.parameters()), sum(p.numel() for p in prober.parameters())
149
+ print(f"{n} trajectories ({len(train_idx)} train, {len(test_idx)} test), dt {dt * 1000:.0f} ms; "
150
+ f"JEPA {n_j} params, prober {n_p}", flush=True)
151
+ t0 = time.perf_counter()
152
+ opt = torch.optim.Adam(jepa.parameters(), lr=args.lr, weight_decay=1e-5)
153
+ if args.stage1_from: # reuse stage 1 (frozen latents) from an earlier checkpoint
154
+ old = torch.load(args.stage1_from, weights_only=False)
155
+ jepa.load_state_dict(old["jepa"])
156
+ norm = Normalizer(**old["norm"])
157
+ print(f"stage 1 from {args.stage1_from}", flush=True)
158
+ for step in range(0 if args.stage1_from else args.steps1):
159
+ for g in opt.param_groups:
160
+ g["lr"] = lr_at(step, args.steps1, args.lr)
161
+ xs, acts, Mw = windows(X, A, train_idx, rng, args.batch, M)
162
+ pred, tgt = jepa(norm.s(features(xs, Mw[:, None])), norm.a(acts), H)
163
+ l_pred = ((pred - tgt) ** 2).mean()
164
+ l_sig = sig(pred.transpose(0, 1)[::4])
165
+ loss = l_pred + 0.02 * l_sig
166
+ opt.zero_grad()
167
+ loss.backward()
168
+ nn.utils.clip_grad_norm_(jepa.parameters(), 0.5)
169
+ opt.step()
170
+ if step % 250 == 0 or step == args.steps1 - 1:
171
+ std = pred.detach().reshape(-1, pred.shape[-1]).std(0).min().item()
172
+ print(f" stage 1 step {step:5d} L_pred {l_pred.item():.4f} L_sig {l_sig.item():.3f} "
173
+ f"latent min std {std:.3f} {time.perf_counter() - t0:.0f} s", flush=True)
174
+ t1 = time.perf_counter() - t0
175
+ jepa.eval()
176
+ for p in jepa.parameters():
177
+ p.requires_grad_(False)
178
+ opt = torch.optim.Adam(prober.parameters(), lr=args.lr2, weight_decay=1e-5)
179
+ tw = torch.linspace(0.3, 1.0, L)[None, :, None]
180
+ for step in range(args.steps2):
181
+ for g in opt.param_groups:
182
+ g["lr"] = lr_at(step, args.steps2, args.lr2)
183
+ xs, acts, Mw = windows(X, A, train_idx, rng, args.batch, M)
184
+ with torch.no_grad():
185
+ pred, _ = jepa(norm.s(features(xs, Mw[:, None])), norm.a(acts), H)
186
+ out = integrate(xs[:, H], acts[:, H:], acts[:, H - 1], pred, prober, dt, norm, Mw)
187
+ err = nn.functional.huber_loss((out - xs[:, H + 1:]) / E_SCALE, torch.zeros_like(out), reduction="none", delta=3.0)
188
+ loss = (err * tw).mean()
189
+ opt.zero_grad()
190
+ loss.backward()
191
+ nn.utils.clip_grad_norm_(prober.parameters(), 0.5)
192
+ opt.step()
193
+ if step % 250 == 0 or step == args.steps2 - 1:
194
+ g_err = np.linalg.norm(grasp_point(out[:, -1].detach().numpy()) - grasp_point(xs[:, -1].numpy()), axis=-1)
195
+ print(f" stage 2 step {step:5d} loss {loss.item():.4f} grasp-point error at 2 s median "
196
+ f"{1000 * np.median(g_err):.1f} mm {time.perf_counter() - t0:.0f} s", flush=True)
197
+ ckpt = {"config": cfg, "jepa": jepa.state_dict(), "prober": prober.state_dict(), "norm": norm.state_dict(),
198
+ "train_s": time.perf_counter() - t0, "stage1_s": t1, "params": {"jepa": n_j, "prober": n_p}}
199
+ Path(args.out).parent.mkdir(parents=True, exist_ok=True)
200
+ torch.save(ckpt, args.out)
201
+ print(f"saved {args.out} ({time.perf_counter() - t0:.0f} s)")
202
+ return X, A, P, M, R, test_idx
203
+
204
+
205
+ def tilt_angle_err(a, b):
206
+ def axis(x):
207
+ s = x[..., 6:8]
208
+ return np.concatenate([s, np.sqrt(np.clip(1 - (s ** 2).sum(-1, keepdims=True), 1e-6, None))], -1)
209
+ c = np.clip((axis(a) * axis(b)).sum(-1), -1, 1)
210
+ return np.degrees(np.arccos(c))
211
+
212
+
213
+ def test1(args, data=None, test_idx=None):
214
+ from cablepilot.model.learned import LearnedCDPRModel
215
+ from cablepilot.model.oracle import OracleWorldModel
216
+ from cablepilot.sim.scene import load_config
217
+ if data is None:
218
+ data = load(args.data)
219
+ test_idx = np.arange(len(data[0]) - args.test, len(data[0]))
220
+ X, A, P, M, R, dt = data
221
+ manifest = json.loads((Path(str(args.data).split(",")[0]) / "manifest.json").read_text())
222
+ cfg = load_config(machine=manifest.get("machine", "configs/stringman.yaml"), scenario=manifest["scenario"],
223
+ hand=manifest.get("hand"))
224
+ person = cfg.get("hand") == "harness"
225
+ ck = torch.load(args.out, weights_only=False)
226
+ model = LearnedCDPRModel(ck, dt=ck["config"]["dt"])
227
+ rng = np.random.default_rng(123)
228
+ xs, acts, Mw, pay, traj = windows(X, A, test_idx, rng, args.n_eval, M, P)
229
+ xs, acts = xs.numpy(), acts.numpy()
230
+ payload_w = (pay > 1e-3).any(1)
231
+ event_w = np.ptp(pay, axis=1) > 1.0 # the person's weight coming on / going off / shifting
232
+ t0 = time.perf_counter()
233
+ learned = []
234
+ for b in range(len(xs)):
235
+ model.set_room(M[traj[b]])
236
+ learned.append(model.predict(xs[b, :H + 1], acts[b, :H], acts[b:b + 1, H:]).numpy())
237
+ learned = np.concatenate(learned)
238
+ t_l = (time.perf_counter() - t0) / len(xs)
239
+ # the person lift's stiff lines need the 2 ms step; its oracle is told 75 kg (the range's middle), never the truth
240
+ orc = OracleWorldModel(cfg, dt=ck["config"]["dt"], timestep=0.002 if person else 0.01, nthread=4,
241
+ payload_kg=75.0 if person else 0.0)
242
+ t0 = time.perf_counter()
243
+ oracle, last = [], None
244
+ for b in range(len(xs)):
245
+ j = traj[b]
246
+ if last is None or not np.allclose(M[j], last): # the oracle in this window's room
247
+ orc.set_room({"mounts_xyz": M[j].tolist(), "indirect_run_m": R[j].tolist()})
248
+ last = M[j]
249
+ oracle.append(orc.rollout(xs[b, H][None], acts[b, H - 1][None], acts[b:b + 1, H:]).numpy())
250
+ oracle = np.concatenate(oracle)
251
+ t_o = (time.perf_counter() - t0) / len(xs)
252
+ fresh = Prober(ck["config"]["latent"], ck["config"]["prober_hidden"], ck["config"].get("l_swing", 0.3089),
253
+ ck["config"].get("omega_span", 0.3), ck["config"].get("a_max0", 0.25)) # the hand-written backbone alone
254
+ with torch.no_grad():
255
+ bb = integrate(torch.as_tensor(xs[:, H]), torch.as_tensor(acts[:, H:]), torch.as_tensor(acts[:, H - 1]),
256
+ torch.zeros(len(xs), L, ck["config"]["latent"]), fresh, ck["config"]["dt"], model.norm,
257
+ torch.as_tensor(M[traj])).numpy()
258
+ truth = xs[:, -1]
259
+ res = {}
260
+ for name, pr in (("learned", learned[:, -1]), ("oracle", oracle[:, -1]), ("backbone", bb[:, -1])):
261
+ e = np.linalg.norm(grasp_point(pr) - grasp_point(truth), axis=-1) * 1000
262
+ ea = tilt_angle_err(pr, truth)
263
+ eh = np.linalg.norm(pr[:, 0:3] - truth[:, 0:3], axis=-1) * 1000
264
+ res[name] = {"g_med_mm": float(np.median(e)), "g_p90_mm": float(np.percentile(e, 90)),
265
+ "g_payload_med_mm": float(np.median(e[payload_w])), "tilt_med_deg": float(np.median(ea)),
266
+ "tilt_p90_deg": float(np.percentile(ea, 90)),
267
+ "hook_med_mm": float(np.median(eh)), "hook_event_med_mm": float(np.median(eh[event_w])) if event_w.any() else None,
268
+ "tilt_event_med_deg": float(np.median(ea[event_w])) if event_w.any() else None}
269
+ print(f"\nTEST 1 路 {args.data} 路 open loop, {len(xs)} held-out windows ({payload_w.sum()} with payload on or changing), "
270
+ f"{len(set(map(tuple, M[traj].reshape(len(traj), -1).round(3))))} rooms, after {L} steps ({L * ck['config']['dt']:.1f} s)")
271
+ print("(backbone = hand-written physics alone, nothing learned; oracle = MuJoCo in the same room, nominal physics, no payload)")
272
+ print(f"{'model':9s} {'grasp pt median':>15s} {'p90':>8s} {'payload median':>15s} {'tilt median':>12s} {'p90':>7s}")
273
+ for k, r in res.items():
274
+ print(f"{k:9s} {r['g_med_mm']:12.1f} mm {r['g_p90_mm']:5.1f} mm {r['g_payload_med_mm']:12.1f} mm "
275
+ f"{r['tilt_med_deg']:9.2f} deg {r['tilt_p90_deg']:4.2f}")
276
+ r = res["learned"]
277
+ if person: # P1 (docs/MILESTONES.md): hook <= 20 mm, also on attach / set-down windows; swing <= 1.5 deg
278
+ f1 = lambda v: "n/a" if v is None else f"{v:.1f}"
279
+ print(f"\nP1 路 {event_w.sum()} windows where the person's weight changes. At 2 s:")
280
+ for k, v in res.items():
281
+ print(f" {k:9s} hook {v['hook_med_mm']:.1f} mm (events {f1(v['hook_event_med_mm'])}), swing "
282
+ f"{v['tilt_med_deg']:.2f} deg (events {f1(v['tilt_event_med_deg'])}), person COM {v['g_med_mm']:.1f} mm")
283
+ ok = (r["hook_med_mm"] <= 20 and (r["hook_event_med_mm"] is None or r["hook_event_med_mm"] <= 20)
284
+ and r["tilt_med_deg"] <= 1.5)
285
+ print(f"P1 model bar (hook <= 20 mm, also on events; swing <= 1.5 deg): {'YES' if ok else 'NO'}")
286
+ res["weigh"] = weigh_test(args, cfg, test_idx)
287
+ else:
288
+ ok = r["g_med_mm"] <= 20 and r["g_payload_med_mm"] <= 20 and r["tilt_med_deg"] <= 2
289
+ print(f"pass (<= 20 mm, <= 20 mm on payload, <= 2 deg): {'YES' if ok else 'NO'}")
290
+ print(f"learned {t_l * 1000:.1f} ms per window (S = 1), oracle {t_o * 1000:.1f} ms")
291
+ Path(args.out).with_suffix(f".test1.{Path(str(args.data).split(',')[0]).name}.json").write_text(json.dumps(res, indent=1))
292
+
293
+
294
+ def weigh_test(args, cfg, test_idx):
295
+ """P1's weighing bar: 1 s after every attach ends in the held-out trajectories, the lines' weighing
296
+ (perception/weigh.py: mean vertical pull over that second / g - the rig's nominal mass) within 10 % of the person."""
297
+ from cablepilot.perception.weigh import rig_mass, weigh_kg
298
+ shards = sorted(Path(str(args.data).split(",")[0]).glob("shard_*.npz"))
299
+ S, TEN, PAY, MO = [], [], [], []
300
+ for f in shards:
301
+ z = np.load(f)
302
+ S.append(z["states"]), TEN.append(z["tensions"]), PAY.append(z["payload"]), MO.append(z["mounts"])
303
+ S, TEN, PAY, MO = (np.concatenate(v) for v in (S, TEN, PAY, MO))
304
+ rig, errs = rig_mass(cfg), []
305
+ for j in test_idx:
306
+ p = PAY[j]
307
+ k = 1
308
+ while k < len(p):
309
+ if p[k - 1] < 1.0 <= p[k]: # an attach starts
310
+ e = k
311
+ while e + 1 < len(p) and abs(p[e + 1] - p[e]) > 1e-3:
312
+ e += 1 # ... and ends at e
313
+ # an attach that ENDS with a person on (2026-10-12: one attach in the held-out set was cut off by a
314
+ # set-down back to 0 kg, and the first run divided by that 0)
315
+ if e + 10 < len(p) and p[e] >= 1.0 and abs(p[e + 10] - p[e]) < 1e-3:
316
+ w = slice(e + 1, e + 11) # the second after it ends
317
+ est = weigh_kg(TEN[j, w], S[j, w, 0:3], MO[j], rig)
318
+ errs.append(abs(est - p[e + 10]) / p[e + 10])
319
+ k = e + 1
320
+ k += 1
321
+ errs = np.array(errs) * 100
322
+ if not len(errs):
323
+ print("weighing: no complete attach in the held-out set")
324
+ return None
325
+ r = {"attaches": int(len(errs)), "median_pct": float(np.median(errs)), "p90_pct": float(np.percentile(errs, 90)),
326
+ "max_pct": float(errs.max()), "within_10pct": float((errs <= 10).mean())}
327
+ print(f"P1 weighing bar (within 10 % 1 s after an attach ends): {r['attaches']} attaches, median {r['median_pct']:.1f} %, "
328
+ f"p90 {r['p90_pct']:.1f} %, max {r['max_pct']:.1f} %, within 10 %: {100 * r['within_10pct']:.0f} % "
329
+ f"{'YES' if r['within_10pct'] == 1.0 else 'NO'}")
330
+ return r
331
+
332
+
333
+ def main():
334
+ ap = argparse.ArgumentParser()
335
+ ap.add_argument("--data", default=str(ROOT / "data/cdpr_v0_playroom"))
336
+ ap.add_argument("--out", default=str(ROOT / "checkpoints/cdpr_wm_v0.pt"))
337
+ ap.add_argument("--steps1", type=int, default=3000)
338
+ ap.add_argument("--steps2", type=int, default=3000)
339
+ ap.add_argument("--batch", type=int, default=256)
340
+ ap.add_argument("--lr", type=float, default=3e-3)
341
+ ap.add_argument("--test", type=int, default=500, help="last N trajectories held out")
342
+ ap.add_argument("--n-eval", type=int, default=500)
343
+ ap.add_argument("--test-all", action="store_true", help="eval-only: every trajectory of --data is held out (an unseen room)")
344
+ ap.add_argument("--eval-only", action="store_true")
345
+ ap.add_argument("--stage1-from", default=None, help="checkpoint whose stage 1 (JEPA, normaliser) is reused")
346
+ ap.add_argument("--lr2", type=float, default=1e-3)
347
+ args = ap.parse_args()
348
+ if args.eval_only:
349
+ if args.test_all:
350
+ d = load(args.data)
351
+ return test1(args, d, np.arange(len(d[0])))
352
+ return test1(args)
353
+ X, A, P, M, R, test_idx = train(args)
354
+ test1(args, (X, A, P, M, R, float(np.load(sorted(Path(str(args.data).split(",")[0]).glob("shard_*.npz"))[0])["dt"])), test_idx)
355
+
356
+
357
+ if __name__ == "__main__":
358
+ main()