File size: 19,721 Bytes
6628826
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
"""Stringman world-model data: task-free trajectories in the twin, the SkyJEPA recipe (scripts/arm_datagen.py's, on the
cable robot). No target, no cost, no task label in the files: only how the machine moves when commanded.

A NEW RANDOM ROOM PER TRAJECTORY (sim/scene.random_room; --fixed-room for a test set of one real room): the model is a
model of the machine, not of a place (2026-10-08), and a room enters its dynamics only through the line geometry,
which is stored with each trajectory (mounts, runs) and given to the model as an input.

    python scripts/cdpr_datagen.py --scenario configs/scenarios/clear_playroom.yaml --n 20 --out data/cdpr_v0_sample
    python scripts/cdpr_datagen.py --scenario configs/scenarios/clear_playroom.yaml --n 4000 --workers 7 --out data/cdpr_v0_playroom

Files (shards of 100):
  states   (N, T+1, 14)  p, v, swing s, s_dot, wrist, wrist_dot, closure, closure_dot (sim.kin), AS ESTIMATED (noise added)
  actions  (N, T, 5)     the command SENT at each record step: gantry velocity (3), wrist rate, closure target
  tensions (N, T+1, 4)   line tensions (N): an observation, NOT a model input (uncalibrated on the real robot)
  payload  (N, T+1)      kg held at the grasp point (bookkeeping: the model must infer it)
  eta      (N, P)        the trajectory's randomised physics (eta_names)
  mounts   (N, 4, 3)     the room's mount points (line order of the firmware), runs (N, 4) the indirect lines' fixed runs
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.

The machine is driven exactly as the real host drives it (sim.host.SpoolState: velocity -> line speeds by
finite-difference IK at the ESTIMATED position through MISCALIBRATED mounts, 50 Hz spool loop with the tension floor
and soft mute, acceleration-limited motors, 60 Hz gripper loop), with a command latency. Planning model = the
machine-only twin (no furniture, no contacts): the grasp point is kept >= 4 cm above the floor (floor contact is a task's
business, not the machine's).

Motion = a chain of segments chosen at random until the trajectory is full:
  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
  dwell    hold still 1-4 s (the robot mostly hovers and waits)
  stop     a fast goto cut to zero half way: swing excitation and its decay
  pump     horizontal sinusoid at 0.7-1.3 x the swing frequency, small amplitude: the swing's resonance
  descend  down to a grasp height (G 4-15 cm), hold, back up (the pick's vertical regime)
  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
Wrist: rate pulses (half the segments); fingers: a new closure target (40 % of segments).
Payload: attached (20-500 g) or dropped at 35 % of segment boundaries: the "weight changes" regime.
Randomised per trajectory (eta): gantry and gripper mass x0.85-1.15, line EA 2-15 kN (log), line damping x0.5-2,
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),
swing damping x0.3-3. Estimator noise as the sim estimator's (perception/oracle_estimator.StateEstimate).
"""
from __future__ import annotations

import argparse
import copy
import json
import sys
import time
from collections import deque
from pathlib import Path

import mujoco
import numpy as np
import torch

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))

from cablepilot.perception.oracle_estimator import StateEstimate  # noqa: E402
from cablepilot.sim.host import HostModel, SpoolState  # noqa: E402
from cablepilot.sim.kin import ccw_polygon, worst_hang_tensions  # noqa: E402
from cablepilot.sim.scene import (Indices, build_model, grasp_point_local, load_config, mounts, random_room, room_centre,  # noqa: E402
                                  set_room_geometry, settle_spools, total_mass)
from cablepilot.sim.state import to_state  # noqa: E402

SEGMENTS = {"goto": 0.25, "dwell": 0.10, "stop": 0.10, "pump": 0.10, "descend": 0.15, "plan": 0.30}
ETA_NAMES = ["gantry_mass_scale", "gripper_mass_scale", "EA_N", "line_damping_scale", "line_accel_mps2", "latency_s",
             "calib_sigma_m", "swing_damping_scale", "payload_max_kg"]
DT = 0.1
D_G = -0.5147          # grasp point on the pole, Stringman hand (tasks/pick_place.PickPlaceTask.dG); per hand: Generator.dG


class Generator:
    def __init__(self, scenario: str, seconds: float = 30.0, random_rooms: bool = True, hand: str | None = None):
        self.cfg = load_config(scenario=scenario, hand=hand)
        # grasp point's distance down the pole (SO-101: hand origin + gripperframe, -0.472; its 7.9 mm lateral offset
        # does not matter for keeping it off the floor)
        self.dG = float(self.cfg["body"]["gripper_origin_z_m"] + grasp_point_local(self.cfg)[2])
        self.base_cfg = self.cfg
        self.random_rooms = random_rooms
        self.m = build_model(self.cfg, world=False, payload_kg=1e-4)
        self.d = mujoco.MjData(self.m)
        self.idx = Indices(self.m)
        self.h = self.m.opt.timestep
        self.sub = int(round(DT / self.h))
        self.T = int(round(seconds / DT))
        self.M = mounts(self.cfg)
        self.poly = ccw_polygon(self.M[:, :2])
        self.pay = self.m.body("payload").id
        m = self.m
        self.orig = {k: getattr(m, k).copy() for k in ("body_mass", "body_inertia", "actuator_gainprm", "actuator_biasprm",
                                                        "dof_damping")}
        self.gripper, self.gantry = m.body("gripper").id, m.body("gantry").id
        self.swing_dofs = slice(self.idx.gv + 3, self.idx.gv + 6)

    # ------------------------------------------------------------------------------------- geometry
    def inside_by(self, xy):
        a, b = self.poly, np.roll(self.poly, -1, 0)
        e = b - a
        n = np.c_[-e[:, 1], e[:, 0]] / np.linalg.norm(e, axis=1)[:, None]
        return float(((np.asarray(xy) - a) * n).sum(1).min())

    def tension_ok(self, p, lim=26.0):
        t = worst_hang_tensions(torch.as_tensor(self.M, dtype=torch.float32), torch.as_tensor(p, dtype=torch.float32),
                                total_mass(self.m) + 0.5)
        return float(t.max()) <= lim

    def random_point(self, rng, z_lo=None):
        lo, hi = self.poly.min(0), self.poly.max(0)
        for _ in range(1000):
            xy = rng.uniform(lo, hi)
            if self.inside_by(xy) < 0.35:
                continue
            gz = rng.uniform(0.04, 0.15) if z_lo else rng.uniform(0.15, 1.2)
            p = np.r_[xy, gz - self.dG]
            if self.tension_ok(p):
                return p
        return np.r_[self.poly.mean(0), 1.0]

    # ------------------------------------------------------------------------------------- randomisation
    def randomise(self, rng) -> tuple[dict, HostModel]:
        m, o = self.m, self.orig
        for k, v in o.items():
            getattr(m, k)[:] = v
        if self.random_rooms:   # a new room every trajectory: the model learns the machine, not a place
            self.cfg = set_room_geometry(self.base_cfg, m, self.idx, random_room(rng))
            self.M = mounts(self.cfg)
            self.poly = ccw_polygon(self.M[:, :2])
        e = {"gantry_mass_scale": rng.uniform(0.85, 1.15), "gripper_mass_scale": rng.uniform(0.85, 1.15),
             "EA_N": float(np.exp(rng.uniform(np.log(2000), np.log(15000)))), "line_damping_scale": rng.uniform(0.5, 2.0),
             "line_accel_mps2": rng.uniform(0.10, 0.30), "latency_s": rng.uniform(0.0, 0.15),
             "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)))),
             "payload_max_kg": rng.uniform(0.0, 0.5)}
        m.body_mass[self.gantry] *= e["gantry_mass_scale"]
        m.body_inertia[self.gantry] *= e["gantry_mass_scale"]
        m.body_mass[self.gripper] *= e["gripper_mass_scale"]
        m.body_inertia[self.gripper] *= e["gripper_mass_scale"]
        m.dof_damping[self.swing_dofs] *= e["swing_damping_scale"]
        run = np.asarray(self.cfg["room"]["indirect_run_m"], float)
        span = np.linalg.norm(self.M - np.r_[room_centre(self.cfg), 1.2], axis=1)
        for i, a in enumerate(self.idx.spool):
            k = e["EA_N"] / (span[i] + run[i])
            m.actuator_gainprm[a, 0] = k
            m.actuator_biasprm[a, 1] = -k
            m.actuator_biasprm[a, 2] *= e["line_damping_scale"]
        cfg_h = copy.deepcopy(self.cfg)                     # the host's belief of where the mounts are
        cfg_h["room"]["mounts_xyz"] = (self.M + rng.normal(0, e["calib_sigma_m"], self.M.shape)).tolist()
        host = HostModel(cfg_h, self.h, self.idx.travel)
        host.acc = e["line_accel_mps2"]
        return e, host

    # ------------------------------------------------------------------------------------- commands
    def segments(self, rng):
        """Endless generator of (kind, function(state, t_in_segment) -> (action, done))."""
        sig = np.array([0.06, 0.06, 0.03, 0.4])
        omega = np.sqrt(9.81 / 0.3089)
        names, probs = list(SEGMENTS), np.array(list(SEGMENTS.values()))
        while True:
            kind = str(rng.choice(names, p=probs / probs.sum()))
            wrist_rate = rng.choice([0.0, 0.0, rng.uniform(-1.5, 1.5)])
            close = None if rng.random() > 0.4 else float(rng.choice([0.0, 1.0, rng.uniform(0, 1)]))
            if kind in ("goto", "stop", "descend"):
                tgt = self.random_point(rng, z_lo=(kind == "descend"))
                vmax = rng.uniform(0.1, 0.35)
                cut = rng.uniform(1.0, 4.0) if kind == "stop" else None
                hold = rng.uniform(0.5, 2.5)
                state = {"arrived": None}

                def f(x, t, tgt=tgt, vmax=vmax, cut=cut, hold=hold, state=state):
                    if cut is not None and t > cut:
                        return np.zeros(3), t > cut + rng.uniform(2.0, 5.0)
                    d = tgt - x[:3]
                    v = d * 0.8
                    n = np.linalg.norm(v)
                    v = v * min(1.0, vmax / max(n, 1e-9))
                    if np.linalg.norm(d) < 0.03:
                        state["arrived"] = state["arrived"] or t
                    done = state["arrived"] is not None and t - state["arrived"] > hold or t > 25
                    return v, done
            elif kind == "dwell":
                dur = rng.uniform(1.0, 4.0)

                def f(x, t, dur=dur):
                    return np.zeros(3), t > dur
            elif kind == "pump":
                w = omega * rng.uniform(0.7, 1.3)
                amp = rng.uniform(0.02, 0.08)
                ang = rng.uniform(0, 2 * np.pi)
                dur = rng.uniform(2.0, 5.0)

                def f(x, t, w=w, amp=amp, ang=ang, dur=dur):
                    s = amp * np.sin(w * t) if t < dur else 0.0
                    return np.array([s * np.cos(ang), s * np.sin(ang), 0.0]), t > dur + 3.0
            else:   # plan: AR(0.9) random walk, a new command every 0.2 s
                scale = rng.uniform(1, 3)
                st = {"eps": np.zeros(4), "base": None, "k": -1}
                steps = int(rng.integers(8, 30))

                def f(x, t, scale=scale, st=st, steps=steps):
                    k = int(round(t / 0.2))
                    if st["base"] is None:
                        st["base"] = np.r_[x[3:6], 0.0]
                    if k != st["k"]:
                        st["k"] = k
                        st["eps"] = 0.9 * st["eps"] + np.sqrt(1 - 0.81) * rng.normal(0, 1, 4) * sig * scale
                    v = st["base"][:3] * 0.9 ** k + st["eps"][:3]
                    return v, k >= steps
            yield kind, f, wrist_rate, close

    # ------------------------------------------------------------------------------------- one trajectory
    def trajectory(self, seed: int) -> dict | None:
        rng = np.random.default_rng(seed)
        m, d, idx = self.m, self.d, self.idx
        eta, host = self.randomise(rng)
        mujoco.mj_resetData(m, d)
        p0 = self.random_point(rng)
        d.qpos[idx.gq:idx.gq + 3] = p0
        d.qpos[idx.fq] = d.qpos[idx.frq] = -idx.travel
        m.body_mass[self.pay] = 1e-4
        settle_spools(m, d, idx, self.cfg)
        d.ctrl[idx.finger_ctrl] = -idx.travel
        for _ in range(int(1.0 / self.h)):
            mujoco.mj_step(m, d)
        x = to_state(m, d, idx)
        spools = SpoolState(host, self.cfg, x)
        noise = StateEstimate(np.random.default_rng(seed + 1))
        # command latency, in PHYSICS steps: a command sent at tick k reaches the host lat_sub physics steps later.
        # (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
        # followed its command at ~0.4 /s instead of ~2-3 /s and the model learned that commands barely move the robot.)
        lat_sub = int(round(eta["latency_s"] / self.h))
        pending = deque()                                    # (physics step at which it applies, action)
        applied = np.r_[np.zeros(3), 0.0, x[12]]
        n_phys = 0
        T = self.T
        S = np.empty((T + 1, 14))
        A = np.empty((T, 5))
        TEN = np.empty((T + 1, 4))
        PAY = np.empty(T + 1)
        kinds = {}
        segs = self.segments(rng)
        kind, f, wr, cl = next(segs)
        t_seg, close = 0.0, x[12]
        v_prev = np.zeros(3)

        def rec(k):
            S[k] = noise(to_state(m, d, idx))
            TEN[k] = -d.actuator_force[idx.spool]
            PAY[k] = m.body_mass[self.pay]

        rec(0)
        for k in range(T):
            xe = S[k]
            v, done = f(xe, t_seg)
            if done:
                kinds[kind] = kinds.get(kind, 0) + 1
                kind, f, wr, cl = next(segs)
                t_seg = 0.0
                if cl is not None:
                    close = cl
                if rng.random() < 0.35:                    # the payload changes at a segment boundary
                    on = m.body_mass[self.pay] > 1e-3
                    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)
                    m.body_inertia[self.pay] = 1e-6
                v, _ = f(xe, t_seg)
            # near the floor, descend no faster than the spools can stop: stopping distance v^2 / 2a with a = half the
            # line acceleration (a 0.35 m/s descent needs ~0.3 m; the first sample put G 23 cm below the floor). The fw
            # visual servo does the same ("descent speed as a function of how far there is left to fall").
            G = xe[:3] + self.dG * np.r_[xe[6:8], 1.0]
            v = v.copy()
            v[2] = max(v[2], -np.sqrt(2 * 0.5 * eta["line_accel_mps2"] * max(G[2] - 0.04, 0.0)))
            # keep inside: a command that would leave the reachable box is turned back toward the centre
            if self.inside_by(xe[:2]) < 0.25 or G[2] < 0.08 or not self.tension_ok(xe[:3], 30.0):
                c = np.r_[self.poly.mean(0), 1.0]
                v = 0.15 * (c - xe[:3]) / max(np.linalg.norm(c - xe[:3]), 1e-6)
            sp = np.linalg.norm(v)
            v = v * min(1.0, 0.35 / max(sp, 1e-9))
            a = np.r_[v, wr if t_seg < 3.0 else 0.0, close]
            A[k] = a
            pending.append((n_phys + lat_sub, a, xe[:3].copy()))
            for _ in range(self.sub):
                while pending and pending[0][0] <= n_phys:
                    _, applied, p_at_send = pending.popleft()
                    spools.command(p_at_send, applied)
                n_phys += 1
                u = spools.substep(-d.actuator_force[idx.spool], self.h)
                d.ctrl[idx.spool] = u[:4]
                d.ctrl[idx.wrist_ctrl], d.ctrl[idx.finger_ctrl] = u[4], u[5]
                mujoco.mj_step(m, d)
            if not np.isfinite(d.qpos).all() or np.abs(d.qvel[:6]).max() > 20:
                return None
            rec(k + 1)
            t_seg += DT
            v_prev = v
        return {"states": S.astype(np.float32), "actions": A.astype(np.float32), "tensions": TEN.astype(np.float32),
                "payload": PAY.astype(np.float32), "eta": np.array([eta[k] for k in ETA_NAMES], np.float32), "kinds": kinds,
                "mounts": self.M.astype(np.float32), "runs": np.asarray(self.cfg["room"]["indirect_run_m"], np.float32)}


def _shard_job(args):
    out, scenario, shard_i, seed0, n, seconds, random_rooms, hand = args
    gen = Generator(scenario, seconds, random_rooms, hand)
    rows, rejected, kinds = [], 0, {}
    t0 = time.perf_counter()
    for i in range(n):
        tr = gen.trajectory(seed0 + i)
        if tr is None:
            rejected += 1
            continue
        for k, v in tr["kinds"].items():
            kinds[k] = kinds.get(k, 0) + v
        rows.append(tr)
    shard = {k: np.stack([r[k] for r in rows]) for k in ("states", "actions", "tensions", "payload", "eta", "mounts", "runs")}
    np.savez_compressed(Path(out) / f"shard_{shard_i:04d}.npz", **shard, eta_names=np.array(ETA_NAMES), dt=DT)
    return len(rows), rejected, kinds, time.perf_counter() - t0


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--scenario", default="configs/scenarios/clear_playroom.yaml")
    ap.add_argument("--n", type=int, default=20)
    ap.add_argument("--seed", type=int, default=0)
    ap.add_argument("--seconds", type=float, default=30.0)
    ap.add_argument("--shard-size", type=int, default=100)
    ap.add_argument("--workers", type=int, default=1)
    ap.add_argument("--hand", default=None, help="stringman | so101 (default: configs/stringman.yaml `hand`)")
    ap.add_argument("--fixed-room", action="store_true", help="the scenario's room for every trajectory (test sets of a real room)")
    ap.add_argument("--out", default=str(ROOT / "data/cdpr_v0_sample"))
    args = ap.parse_args()
    out = Path(args.out)
    out.mkdir(parents=True, exist_ok=True)
    size = min(args.shard_size, args.n)
    jobs = [(str(out), args.scenario, i, args.seed + i * size, size, args.seconds, not args.fixed_room, args.hand)
            for i in range(args.n // size)]
    t0 = time.perf_counter()
    done, rej, kinds, cpu = 0, 0, {}, 0.0
    if args.workers > 1:
        from multiprocessing import Pool
        with Pool(args.workers) as pool:
            it = pool.imap_unordered(_shard_job, jobs)
            for k, (n, r, kd, s) in enumerate(it):
                done, rej, cpu = done + n, rej + r, cpu + s
                for a, v in kd.items():
                    kinds[a] = kinds.get(a, 0) + v
                print(f"  {k + 1}/{len(jobs)} shards, {done} trajectories, {time.perf_counter() - t0:.0f} s", flush=True)
    else:
        for job in jobs:
            n, r, kd, s = _shard_job(job)
            done, rej, cpu = done + n, rej + r, cpu + s
            for a, v in kd.items():
                kinds[a] = kinds.get(a, 0) + v
    meta = {"trajectories": done, "rejected": rej, "seconds_each": args.seconds, "rate_hz": 1 / DT,
            "scenario": args.scenario, "hand": Generator(args.scenario, 1.0, False, args.hand).cfg["hand"],
            "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,
            "cpu_s_per_trajectory": cpu / max(done, 1), "segments": kinds, "eta_names": ETA_NAMES,
            "model_state": "states (14): p, v, s, s_dot, wrist, wrist_dot, closure, closure_dot. tensions NOT a model input."}
    (out / "manifest.json").write_text(json.dumps(meta, indent=2))
    print(json.dumps(meta, indent=1))


if __name__ == "__main__":
    main()